mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 03:06:38 +08:00
224 lines
6.3 KiB
Go
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
|
|
}
|
|
}
|