Files
MeBox/internal/service/scanner_prune.go
T
2026-09-05 12:34:17 +08:00

224 lines
6.3 KiB
Go

package service
import (
"context"
"os"
"path/filepath"
"strings"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/model"
)
// RemovePath 物理删除磁盘上已不存在的媒体记录。
func (s *ScannerService) RemovePath(ctx context.Context, path string) (int64, error) {
if _, err := os.Stat(path); err == nil {
return 0, nil // still exists; nothing to remove
}
// 目录整体消失(删除/改名离开):连同其子树下的媒体行一并移除。
// 此前只删 path 精确匹配的行——目录本身通常没有 media 行,导致目录
// 改名后旧子树记录全部失联,只有全量扫描才能修复。
prefix := filepath.Clean(path) + string(filepath.Separator)
var rows []struct {
ID string
Path string
}
if err := s.repo.DB.WithContext(ctx).Model(&model.Media{}).
Select("id, path").
Where("path = ? OR path LIKE ?", path, prefix+"%").
Find(&rows).Error; err != nil {
return 0, err
}
ids := make([]string, 0, len(rows))
for _, row := range rows {
// LIKE 里的 % _ 是通配符(候选集只会偏大),用 Go 前缀精确过滤,
// 避免对含 % / _ 的路径误删。
if row.Path == path || strings.HasPrefix(filepath.Clean(row.Path), prefix) {
ids = append(ids, row.ID)
}
}
if len(ids) == 0 {
return 0, nil
}
res := s.repo.DB.WithContext(ctx).Unscoped().
Where("id IN ?", ids).
Delete(&model.Media{})
if res.Error == nil && res.RowsAffected > 0 {
s.invalidateMediaCache(ctx)
}
return res.RowsAffected, res.Error
}
func (s *ScannerService) pruneMissingMedia(ctx context.Context, libraryID string, seen map[string]struct{}) (int64, error) {
// 只取 id/path,并把删除按批提交:此前整表载入完整 Media 结构体、
// 每行一条 DELETE,大库 prune 既费内存又长期占用写锁。
var rows []struct {
ID string
Path string
}
if err := s.repo.DB.WithContext(ctx).
Model(&model.Media{}).
Select("id, path").
Where("library_id = ?", libraryID).
Find(&rows).Error; err != nil {
return 0, err
}
stale := make([]string, 0)
for _, row := range rows {
if row.Path == "" {
continue
}
if _, ok := seen[filepath.Clean(row.Path)]; ok {
continue
}
if _, err := os.Stat(row.Path); err == nil {
continue
} else if !os.IsNotExist(err) {
continue
}
stale = append(stale, row.ID)
}
return s.deleteMediaByIDs(ctx, stale, true)
}
func (s *ScannerService) pruneMissingMediaForRoot(ctx context.Context, libraryID, rootID, rootPath string, seen map[string]struct{}) (int64, error) {
var rows []struct {
ID string
Path string
LibraryRootID string
}
q := s.repo.DB.WithContext(ctx).
Model(&model.Media{}).
Select("id, path, library_root_id").
Where("library_id = ? AND path NOT LIKE ?", libraryID, "cloud://%")
if strings.TrimSpace(rootID) != "" {
q = q.Where("library_root_id = ? OR library_root_id = '' OR library_root_id IS NULL", rootID)
}
if err := q.Find(&rows).Error; err != nil {
return 0, err
}
stale := make([]string, 0)
for _, row := range rows {
if row.Path == "" {
continue
}
if row.LibraryRootID == "" && !pathBelongsToRoot(row.Path, rootPath) {
continue
}
if _, ok := seen[filepath.Clean(row.Path)]; ok {
continue
}
if _, err := os.Stat(row.Path); err == nil {
continue
} else if !os.IsNotExist(err) {
continue
}
stale = append(stale, row.ID)
}
return s.deleteMediaByIDs(ctx, stale, true)
}
func pathBelongsToRoot(pathValue, rootPath string) bool {
pathValue = filepath.Clean(strings.TrimSpace(pathValue))
rootPath = filepath.Clean(strings.TrimSpace(rootPath))
if pathValue == "" || rootPath == "" || pathValue == "." || rootPath == "." {
return false
}
return strings.EqualFold(pathValue, rootPath) || pathWithin(pathValue, rootPath)
}
// deleteMediaByIDs removes media rows in fixed-size batches so each write
// transaction stays short and the global write gate is released frequently.
func (s *ScannerService) deleteMediaByIDs(ctx context.Context, ids []string, hard bool) (int64, error) {
const batch = 500
var removed int64
for i := 0; i < len(ids); i += batch {
end := i + batch
if end > len(ids) {
end = len(ids)
}
q := s.repo.DB.WithContext(ctx)
if hard {
q = q.Unscoped()
}
res := q.Where("id IN ?", ids[i:end]).Delete(&model.Media{})
if res.Error != nil {
return removed, res.Error
}
removed += res.RowsAffected
}
return removed, nil
}
func (s *ScannerService) pruneMissingCloudMedia(ctx context.Context, libraryID string, seen map[string]struct{}) (int64, error) {
return s.pruneMissingCloudMediaForLibraries(ctx, []string{libraryID}, seen)
}
func (s *ScannerService) pruneMissingCloudMediaForRoot(ctx context.Context, libraryID, rootID string, seen map[string]struct{}) (int64, error) {
if strings.TrimSpace(libraryID) == "" || strings.TrimSpace(rootID) == "" {
return 0, nil
}
var rows []struct {
ID string
Path string
}
if err := s.repo.DB.WithContext(ctx).
Model(&model.Media{}).
Select("id, path").
Where("library_id = ? AND library_root_id = ? AND path LIKE ?", libraryID, rootID, "cloud://%").
Find(&rows).Error; err != nil {
return 0, err
}
stale := make([]string, 0)
for _, row := range rows {
if _, ok := seen[row.Path]; ok {
continue
}
stale = append(stale, row.ID)
}
return s.deleteMediaByIDs(ctx, stale, true)
}
func (s *ScannerService) pruneMissingCloudMediaForLibraries(ctx context.Context, libraryIDs []string, seen map[string]struct{}) (int64, error) {
if len(libraryIDs) == 0 {
return 0, nil
}
var rows []struct {
ID string
Path string
}
if err := s.repo.DB.WithContext(ctx).
Model(&model.Media{}).
Select("id, path").
Where("library_id IN ? AND path LIKE ?", libraryIDs, "cloud://%").
Find(&rows).Error; err != nil {
return 0, err
}
stale := make([]string, 0)
for _, row := range rows {
if _, ok := seen[row.Path]; ok {
continue
}
stale = append(stale, row.ID)
}
return s.deleteMediaByIDs(ctx, stale, true)
}
func (s *ScannerService) autoScrapeEnabled(ctx context.Context) bool {
if s.repo == nil || s.repo.Setting == nil {
return false
}
value, err := s.repo.Setting.Get(ctx, "scrape.auto_on_scan")
if err != nil {
s.log.Warn("read scrape.auto_on_scan failed", zap.Error(err))
return false
}
switch strings.ToLower(strings.TrimSpace(value)) {
case "1", "true", "yes", "on", "enabled":
return true
default:
return false
}
}