fix(scanner): reconcile deleted files promptly and honor file_removal_grace (#312)
* fix(scanner): reconcile deleted files promptly and honor file_removal_grace Upgraded/replaced media files left dead playable versions until the next full library scan: autoscan file-scope changes for deleted paths failed resolution (ClassifyPath stats the path) and were silently dropped, and scanner.file_removal_grace was dead config — empty_trash_after_scan purged missing-marked rows folder-wide immediately. - Add scantrigger.Resolver.ResolveVanishedPath: maps a vanished video file to a subtree scan of its parent dir, a vanished dir to itself, and an entry directly under a library root to a guarded library scan. Rejects still-existing paths and requires the matched library root to exist on disk so an unmounted share never triggers reconciliation. - Autoscan falls back to it for file-scope and legacy parent-dir changes that fail with a RequestError, closing the dead-version window from ~24h (daily full scan) to roughly one poll interval. - DeleteMissingByFolder now takes the removal grace and only deletes rows missing longer than it (default 24h, restart-required setting); missing rows are already hidden from every client surface, so the grace only preserves per-file state (probe/match/markers) in case the file returns. Remove the uncalled DeleteMissing sweeper. Part of #311 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(scanner): only treat ENOENT as a vanished path in ResolveVanishedPath Permission or other stat failures must not reconcile still-existing files as missing; reject them with a RequestError instead. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(scanner): address PR 312 review nitpicks - Extract shared matchEnabledFolder/normalizeTrigger helpers so ResolveMissingSubtree and ResolveVanishedPath cannot drift. - Warn when a negative scanner.file_removal_grace is clamped to 0. - Add disabled-library test for ResolveVanishedPath. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
committed by
GitHub
co-authored by
Claude Fable 5
parent
28340ad6f5
commit
ba2bac7f55
@@ -37,6 +37,7 @@ type Store interface {
|
||||
type Resolver interface {
|
||||
Resolve(ctx context.Context, req scantrigger.Request) (*scantrigger.Target, error)
|
||||
ResolveMissingSubtree(ctx context.Context, subtreePath, trigger string) (*scantrigger.Target, error)
|
||||
ResolveVanishedPath(ctx context.Context, path, trigger string) (*scantrigger.Target, error)
|
||||
}
|
||||
|
||||
// Queuer enqueues resolved scan targets.
|
||||
@@ -399,6 +400,13 @@ func (s *Service) resolveAndClaim(ctx context.Context, changes []Change, ttl tim
|
||||
|
||||
for _, dir := range uniqueParentDirs(legacyPaths) {
|
||||
target, rerr := s.resolver.Resolve(ctx, scantrigger.Request{Path: dir, Trigger: scanTrigger})
|
||||
if isRequestError(rerr) {
|
||||
// The directory may have been removed (e.g. a deleted movie
|
||||
// folder). Fall back to a reconciling scan of the vanished path so
|
||||
// its files are marked missing promptly. Paths outside Silo's
|
||||
// media folders still resolve to nothing and are skipped below.
|
||||
target, rerr = s.resolver.ResolveVanishedPath(ctx, dir, scanTrigger)
|
||||
}
|
||||
if rerr != nil {
|
||||
var reqErr *scantrigger.RequestError
|
||||
if errors.As(rerr, &reqErr) {
|
||||
@@ -445,6 +453,14 @@ func collapseTargetsToLibraryScans(targets []scantrigger.Target) []scantrigger.T
|
||||
return collapsed
|
||||
}
|
||||
|
||||
// isRequestError reports whether err is a scantrigger.RequestError — the
|
||||
// resolver's "this path is not scannable as-is" signal, as opposed to an
|
||||
// internal failure.
|
||||
func isRequestError(err error) bool {
|
||||
var reqErr *scantrigger.RequestError
|
||||
return errors.As(err, &reqErr)
|
||||
}
|
||||
|
||||
func (s *Service) resolveChange(ctx context.Context, change Change) (*scantrigger.Target, bool) {
|
||||
if change.SourcePath == "" {
|
||||
return nil, false
|
||||
@@ -461,6 +477,12 @@ func (s *Service) resolveChange(ctx context.Context, change Change) (*scantrigge
|
||||
if err == nil && target != nil && target.Mode != scantrigger.ModeFile {
|
||||
return nil, false
|
||||
}
|
||||
if isRequestError(err) {
|
||||
// The file may have been deleted (upgrade/replacement). Fall back
|
||||
// to a reconciling scan so the stale row is marked missing
|
||||
// promptly instead of lingering until the next full library scan.
|
||||
target, err = s.resolver.ResolveVanishedPath(ctx, change.SourcePath, scanTrigger)
|
||||
}
|
||||
default:
|
||||
target, err = s.resolver.Resolve(ctx, scantrigger.Request{Path: change.SourcePath, Trigger: scanTrigger})
|
||||
}
|
||||
|
||||
@@ -156,6 +156,10 @@ func (passthroughConnRes) Resolve(context.Context, Connection) (ResolvedConnecti
|
||||
type fakeResolver struct{}
|
||||
|
||||
func (fakeResolver) Resolve(_ context.Context, req scantrigger.Request) (*scantrigger.Target, error) {
|
||||
if strings.Contains(req.Path, "vanished") {
|
||||
// Mimic the real resolver's stat failure for deleted paths.
|
||||
return nil, &scantrigger.RequestError{Status: 400, Code: "bad_request", Message: "Path does not exist"}
|
||||
}
|
||||
if strings.HasPrefix(req.Path, "/mnt/media/") {
|
||||
mode := scantrigger.ModeSubtree
|
||||
if filepath.Ext(req.Path) == ".mkv" {
|
||||
@@ -173,6 +177,19 @@ func (fakeResolver) ResolveMissingSubtree(_ context.Context, subtreePath, trigge
|
||||
return nil, &scantrigger.RequestError{Status: 400, Code: "bad_request", Message: "outside media folders"}
|
||||
}
|
||||
|
||||
func (fakeResolver) ResolveVanishedPath(_ context.Context, path, trigger string) (*scantrigger.Target, error) {
|
||||
if !strings.HasPrefix(path, "/mnt/media/") {
|
||||
return nil, &scantrigger.RequestError{Status: 400, Code: "bad_request", Message: "outside media folders"}
|
||||
}
|
||||
// Mimic the real resolver: vanished video files reconcile via their parent
|
||||
// directory; other vanished paths reconcile as themselves.
|
||||
scope := path
|
||||
if filepath.Ext(path) == ".mkv" {
|
||||
scope = filepath.Dir(path)
|
||||
}
|
||||
return &scantrigger.Target{Folder: &models.MediaFolder{ID: 7}, Mode: scantrigger.ModeSubtree, Path: scope, Trigger: trigger}, nil
|
||||
}
|
||||
|
||||
type distinctFolderResolver struct{}
|
||||
|
||||
func (distinctFolderResolver) Resolve(_ context.Context, req scantrigger.Request) (*scantrigger.Target, error) {
|
||||
@@ -205,6 +222,10 @@ func (distinctFolderResolver) ResolveMissingSubtree(_ context.Context, subtreePa
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (distinctFolderResolver) ResolveVanishedPath(_ context.Context, path, trigger string) (*scantrigger.Target, error) {
|
||||
return nil, &scantrigger.RequestError{Status: 400, Code: "bad_request", Message: "outside media folders"}
|
||||
}
|
||||
|
||||
// unresolvableResolver treats every path as outside Silo's media folders,
|
||||
// returning a RequestError — the "none resolved → misconfiguration" signal.
|
||||
type unresolvableResolver struct{}
|
||||
@@ -215,6 +236,9 @@ func (unresolvableResolver) Resolve(_ context.Context, req scantrigger.Request)
|
||||
func (unresolvableResolver) ResolveMissingSubtree(context.Context, string, string) (*scantrigger.Target, error) {
|
||||
return nil, &scantrigger.RequestError{Status: 400, Code: "bad_request", Message: "outside media folders"}
|
||||
}
|
||||
func (unresolvableResolver) ResolveVanishedPath(context.Context, string, string) (*scantrigger.Target, error) {
|
||||
return nil, &scantrigger.RequestError{Status: 400, Code: "bad_request", Message: "outside media folders"}
|
||||
}
|
||||
|
||||
// denySuppressor resolves paths normally but denies every claim, simulating a
|
||||
// recently-scanned / debounced target (resolved but suppressed).
|
||||
@@ -488,6 +512,71 @@ func TestPollOnceStructuredSubtreeChangeEnqueuesExactSubtree(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPollOnceFileChangeForDeletedPathFallsBackToSubtreeScan(t *testing.T) {
|
||||
store := &fakeStore{
|
||||
settings: Settings{Enabled: true, DefaultPollIntervalSeconds: 600, DebounceSeconds: 60},
|
||||
sources: []Source{{
|
||||
ID: "s1", PluginID: "silo.autoscan.cephfs", CapabilityID: "cephfs", Enabled: true,
|
||||
PathRewrites: []PathRewrite{{From: "/ceph/movies", To: "/mnt/media/movies"}},
|
||||
}},
|
||||
}
|
||||
prov := &fakeProvider{changes: map[string][]Change{
|
||||
"cephfs": {{
|
||||
SourcePath: "/ceph/movies/Movie-vanished (2026)/Movie-vanished (2026).mkv",
|
||||
Scope: ChangeScopeFile,
|
||||
}},
|
||||
}, nextMarker: "m1"}
|
||||
q := &recordingQueuer{}
|
||||
svc := newService(store, prov, q, allowSuppressor{})
|
||||
if err := svc.PollOnce(context.Background()); err != nil {
|
||||
t.Fatalf("PollOnce: %v", err)
|
||||
}
|
||||
if len(q.enqueued) != 1 {
|
||||
t.Fatalf("expected 1 fallback subtree scan, got %d: %+v", len(q.enqueued), q.enqueued)
|
||||
}
|
||||
if got := q.enqueued[0].Mode; got != scantrigger.ModeSubtree {
|
||||
t.Fatalf("mode = %q, want %q", got, scantrigger.ModeSubtree)
|
||||
}
|
||||
if got := q.enqueued[0].Path; got != "/mnt/media/movies/Movie-vanished (2026)" {
|
||||
t.Fatalf("subtree path = %q", got)
|
||||
}
|
||||
if _, ok := store.advanced["s1"]; !ok {
|
||||
t.Fatalf("expected marker advanced for s1")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPollOnceLegacyChangeForDeletedDirFallsBackToSubtreeScan(t *testing.T) {
|
||||
store := &fakeStore{
|
||||
settings: Settings{Enabled: true, DefaultPollIntervalSeconds: 600, DebounceSeconds: 60},
|
||||
sources: []Source{{
|
||||
ID: "s1", PluginID: "silo.autoscan.cephfs", CapabilityID: "cephfs", Enabled: true,
|
||||
PathRewrites: []PathRewrite{{From: "/ceph/movies", To: "/mnt/media/movies"}},
|
||||
}},
|
||||
}
|
||||
// Legacy/auto changes collapse to the parent dir before resolving; a
|
||||
// removed movie folder makes that dir vanish too.
|
||||
prov := &fakeProvider{changes: map[string][]Change{
|
||||
"cephfs": {{
|
||||
SourcePath: "/ceph/movies/Movie-vanished (2026)/Movie-vanished (2026).mkv",
|
||||
Scope: ChangeScopeAuto,
|
||||
}},
|
||||
}, nextMarker: "m1"}
|
||||
q := &recordingQueuer{}
|
||||
svc := newService(store, prov, q, allowSuppressor{})
|
||||
if err := svc.PollOnce(context.Background()); err != nil {
|
||||
t.Fatalf("PollOnce: %v", err)
|
||||
}
|
||||
if len(q.enqueued) != 1 {
|
||||
t.Fatalf("expected 1 fallback subtree scan, got %d: %+v", len(q.enqueued), q.enqueued)
|
||||
}
|
||||
if got := q.enqueued[0].Mode; got != scantrigger.ModeSubtree {
|
||||
t.Fatalf("mode = %q, want %q", got, scantrigger.ModeSubtree)
|
||||
}
|
||||
if got := q.enqueued[0].Path; got != "/mnt/media/movies/Movie-vanished (2026)" {
|
||||
t.Fatalf("subtree path = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPollOnceCollapsesLargeTargetBatchToLibraryScans(t *testing.T) {
|
||||
store := &fakeStore{
|
||||
settings: Settings{Enabled: true, DefaultPollIntervalSeconds: 600, DebounceSeconds: 60},
|
||||
|
||||
@@ -127,15 +127,16 @@ type userDBConfigRaw struct {
|
||||
|
||||
// ScannerConfig holds media scanner settings.
|
||||
type ScannerConfig struct {
|
||||
Workers int `yaml:"workers"`
|
||||
MaxConcurrentLibraries int `yaml:"max_concurrent_libraries"`
|
||||
MaxConcurrentScoped int `yaml:"max_concurrent_scoped"`
|
||||
EmptyTrashAfterScan bool `yaml:"-"`
|
||||
Workers int `yaml:"workers"`
|
||||
MaxConcurrentLibraries int `yaml:"max_concurrent_libraries"`
|
||||
MaxConcurrentScoped int `yaml:"max_concurrent_scoped"`
|
||||
EmptyTrashAfterScan bool `yaml:"-"`
|
||||
FileRemovalGrace time.Duration `yaml:"-"`
|
||||
}
|
||||
|
||||
// scannerConfigRaw is the raw YAML representation with duration strings.
|
||||
type scannerConfigRaw struct {
|
||||
FileRemovalGrace string `yaml:"file_removal_grace"` // legacy; preserved on YAML import only
|
||||
FileRemovalGrace string `yaml:"file_removal_grace"`
|
||||
Workers int `yaml:"workers"`
|
||||
MaxConcurrentLibraries int `yaml:"max_concurrent_libraries"`
|
||||
MaxConcurrentScoped int `yaml:"max_concurrent_scoped"`
|
||||
|
||||
@@ -273,6 +273,16 @@ func LoadFromDB(m map[string]string) (*Config, error) {
|
||||
return nil, err
|
||||
}
|
||||
cfg.Scanner.EmptyTrashAfterScan = emptyTrash
|
||||
fileRemovalGrace, err := durationOr(m, "scanner.file_removal_grace", 24*time.Hour)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if fileRemovalGrace < 0 {
|
||||
slog.Warn("negative scanner.file_removal_grace setting; missing files will be deleted immediately",
|
||||
"value", m["scanner.file_removal_grace"])
|
||||
fileRemovalGrace = 0
|
||||
}
|
||||
cfg.Scanner.FileRemovalGrace = fileRemovalGrace
|
||||
|
||||
// Matcher
|
||||
matcherWorkers, err := intOr(m, "matcher.workers", 8)
|
||||
|
||||
@@ -47,6 +47,7 @@ var restartRequiredKeys = map[string]bool{
|
||||
"scanner.max_concurrent_libraries": true,
|
||||
"scanner.max_concurrent_scoped": true,
|
||||
"scanner.empty_trash_after_scan": true,
|
||||
"scanner.file_removal_grace": true,
|
||||
"matcher.enable_tv_series_root_queue": true,
|
||||
"matcher.enable_tv_series_group_queue": true,
|
||||
|
||||
|
||||
@@ -501,7 +501,7 @@ func (s *Scanner) reconcileAudiobookMissingFiles(ctx context.Context, folder *mo
|
||||
}
|
||||
|
||||
if s.emptyTrashAfterScan {
|
||||
trashed, err := s.fileRepo.DeleteMissingByFolder(ctx, folder.ID)
|
||||
trashed, err := s.fileRepo.DeleteMissingByFolder(ctx, folder.ID, s.fileRemovalGrace)
|
||||
if err != nil {
|
||||
return fmt.Errorf("emptying trash for folder %d: %w", folder.ID, err)
|
||||
}
|
||||
|
||||
@@ -376,7 +376,7 @@ func (s *Scanner) reconcileMissingEbookFiles(ctx context.Context, folder *models
|
||||
}
|
||||
|
||||
if s.emptyTrashAfterScan {
|
||||
trashed, err := s.fileRepo.DeleteMissingByFolder(ctx, folder.ID)
|
||||
trashed, err := s.fileRepo.DeleteMissingByFolder(ctx, folder.ID, s.fileRemovalGrace)
|
||||
if err != nil {
|
||||
return fmt.Errorf("emptying trash for folder %d: %w", folder.ID, err)
|
||||
}
|
||||
|
||||
@@ -2498,26 +2498,17 @@ func (r *FileRepository) MarkMissing(ctx context.Context, id int, since time.Tim
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteMissing deletes media files that have been missing longer than the grace period.
|
||||
// DeleteMissingByFolder deletes media files in the given folder that have been
|
||||
// marked missing for longer than the grace period. Missing files are already
|
||||
// hidden from clients; the grace only delays deleting the row so a file that
|
||||
// reappears within the window restores without re-probing or re-matching.
|
||||
// A zero grace deletes all missing-marked rows immediately.
|
||||
// Returns the number of rows deleted.
|
||||
func (r *FileRepository) DeleteMissing(ctx context.Context, gracePeriod time.Duration) (int, error) {
|
||||
func (r *FileRepository) DeleteMissingByFolder(ctx context.Context, folderID int, gracePeriod time.Duration) (int, error) {
|
||||
cutoff := time.Now().UTC().Add(-gracePeriod)
|
||||
tag, err := r.pool.Exec(ctx,
|
||||
"DELETE FROM media_files WHERE missing_since IS NOT NULL AND missing_since < $1",
|
||||
cutoff,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("deleting missing files: %w", err)
|
||||
}
|
||||
return int(tag.RowsAffected()), nil
|
||||
}
|
||||
|
||||
// DeleteMissingByFolder deletes all media files marked as missing in the given folder.
|
||||
// Returns the number of rows deleted.
|
||||
func (r *FileRepository) DeleteMissingByFolder(ctx context.Context, folderID int) (int, error) {
|
||||
tag, err := r.pool.Exec(ctx,
|
||||
"DELETE FROM media_files WHERE media_folder_id = $1 AND missing_since IS NOT NULL",
|
||||
folderID,
|
||||
"DELETE FROM media_files WHERE media_folder_id = $1 AND missing_since IS NOT NULL AND missing_since < $2",
|
||||
folderID, cutoff,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("deleting missing files for folder %d: %w", folderID, err)
|
||||
|
||||
@@ -271,7 +271,7 @@ func (s *Scanner) reconcilePodcastMissingFiles(ctx context.Context, folder *mode
|
||||
}
|
||||
|
||||
if s.emptyTrashAfterScan {
|
||||
trashed, err := s.fileRepo.DeleteMissingByFolder(ctx, folder.ID)
|
||||
trashed, err := s.fileRepo.DeleteMissingByFolder(ctx, folder.ID, s.fileRemovalGrace)
|
||||
if err != nil {
|
||||
return fmt.Errorf("emptying trash for folder %d: %w", folder.ID, err)
|
||||
}
|
||||
|
||||
+21
-11
@@ -126,11 +126,16 @@ type Scanner struct {
|
||||
// worker pool while a scan is running (applies to the next scan).
|
||||
workers atomic.Int32
|
||||
emptyTrashAfterScan bool
|
||||
markerFetcher func(context.Context, string) *IntroCreditsMarkers
|
||||
metadataQueue MetadataQueueProducer
|
||||
movieQueueSyncer MovieQueueSyncer
|
||||
seriesQueueSyncer SeriesQueueSyncer
|
||||
literaryWorkLinker LiteraryWorkLinker
|
||||
// fileRemovalGrace is how long a file must have been marked missing before
|
||||
// emptying trash hard-deletes its row. Missing files are hidden from
|
||||
// clients immediately; the grace only delays losing per-file state so a
|
||||
// file that reappears (flapping mount, reverted upgrade) restores cheaply.
|
||||
fileRemovalGrace time.Duration
|
||||
markerFetcher func(context.Context, string) *IntroCreditsMarkers
|
||||
metadataQueue MetadataQueueProducer
|
||||
movieQueueSyncer MovieQueueSyncer
|
||||
seriesQueueSyncer SeriesQueueSyncer
|
||||
literaryWorkLinker LiteraryWorkLinker
|
||||
}
|
||||
|
||||
// SetImageCacher installs the imagecache.Cacher used by book scanners to push
|
||||
@@ -189,10 +194,13 @@ type SeriesQueueSyncer interface {
|
||||
}
|
||||
|
||||
// NewScanner creates a new Scanner with the given dependencies.
|
||||
func NewScanner(fileRepo *FileRepository, ffprobePath string, s3Client *s3client.Client, workers int, emptyTrashAfterScan bool) *Scanner {
|
||||
func NewScanner(fileRepo *FileRepository, ffprobePath string, s3Client *s3client.Client, workers int, emptyTrashAfterScan bool, fileRemovalGrace time.Duration) *Scanner {
|
||||
if workers < 1 {
|
||||
workers = 8
|
||||
}
|
||||
if fileRemovalGrace < 0 {
|
||||
fileRemovalGrace = 0
|
||||
}
|
||||
s := &Scanner{
|
||||
fileRepo: fileRepo,
|
||||
rootSnapshotRepo: NewScannedRootRepository(fileRepo.Pool()),
|
||||
@@ -210,6 +218,7 @@ func NewScanner(fileRepo *FileRepository, ffprobePath string, s3Client *s3client
|
||||
ffprobePath: ffprobePath,
|
||||
s3Client: s3Client,
|
||||
emptyTrashAfterScan: emptyTrashAfterScan,
|
||||
fileRemovalGrace: fileRemovalGrace,
|
||||
markerFetcher: nil,
|
||||
}
|
||||
s.SetWorkers(workers)
|
||||
@@ -832,11 +841,12 @@ func (s *Scanner) scanPaths(
|
||||
result.Missing++
|
||||
}
|
||||
|
||||
// Empty trash: delete all files marked as missing for this folder.
|
||||
// Safe because the empty-root guard (above) returns early when 0 files
|
||||
// are found on disk, so we only reach here when the root is populated.
|
||||
// Empty trash: delete files marked as missing for longer than the removal
|
||||
// grace for this folder. Safe because the empty-root guard (above) returns
|
||||
// early when 0 files are found on disk, so we only reach here when the
|
||||
// root is populated.
|
||||
if s.emptyTrashAfterScan {
|
||||
trashed, err := s.fileRepo.DeleteMissingByFolder(ctx, folder.ID)
|
||||
trashed, err := s.fileRepo.DeleteMissingByFolder(ctx, folder.ID, s.fileRemovalGrace)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("emptying trash for folder %d: %w", folder.ID, err)
|
||||
}
|
||||
@@ -1043,7 +1053,7 @@ func (s *Scanner) scanFolderByRoots(
|
||||
}
|
||||
|
||||
if s.emptyTrashAfterScan {
|
||||
trashed, err := s.fileRepo.DeleteMissingByFolder(ctx, folder.ID)
|
||||
trashed, err := s.fileRepo.DeleteMissingByFolder(ctx, folder.ID, s.fileRemovalGrace)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("emptying trash for folder %d: %w", folder.ID, err)
|
||||
}
|
||||
|
||||
@@ -106,24 +106,86 @@ func (r *Resolver) ResolveMissingSubtree(ctx context.Context, subtreePath, trigg
|
||||
if strings.TrimSpace(subtreePath) == "" || cleanPath == "." {
|
||||
return nil, &RequestError{Status: http.StatusBadRequest, Code: "bad_request", Message: "Path is required"}
|
||||
}
|
||||
folders, err := r.folders.List(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("listing libraries for scan: %w", err)
|
||||
}
|
||||
folder, matchedRoot, err := MatchFolderForPath(cleanPath, folders)
|
||||
folder, matchedRoot, err := r.matchEnabledFolder(ctx, cleanPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if folder != nil && !folder.Enabled {
|
||||
return nil, &RequestError{Status: http.StatusConflict, Code: "conflict", Message: "Library is disabled"}
|
||||
}
|
||||
if filepath.Clean(cleanPath) == filepath.Clean(matchedRoot) {
|
||||
return nil, &RequestError{Status: http.StatusBadRequest, Code: "bad_request", Message: "Subtree path must be below a library root"}
|
||||
}
|
||||
if trigger = strings.TrimSpace(trigger); trigger == "" {
|
||||
trigger = "path"
|
||||
return &Target{Folder: folder, Mode: ModeSubtree, Path: cleanPath, Trigger: normalizeTrigger(trigger)}, nil
|
||||
}
|
||||
|
||||
// matchEnabledFolder lists the configured libraries and returns the enabled
|
||||
// folder (and its matched root) that owns the given path. Shared by the
|
||||
// resolvers that accept paths which may no longer exist on disk.
|
||||
func (r *Resolver) matchEnabledFolder(ctx context.Context, cleanPath string) (*models.MediaFolder, string, error) {
|
||||
folders, err := r.folders.List(ctx)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("listing libraries for scan: %w", err)
|
||||
}
|
||||
return &Target{Folder: folder, Mode: ModeSubtree, Path: cleanPath, Trigger: trigger}, nil
|
||||
folder, matchedRoot, err := MatchFolderForPath(cleanPath, folders)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if folder != nil && !folder.Enabled {
|
||||
return nil, "", &RequestError{Status: http.StatusConflict, Code: "conflict", Message: "Library is disabled"}
|
||||
}
|
||||
return folder, matchedRoot, nil
|
||||
}
|
||||
|
||||
func normalizeTrigger(trigger string) string {
|
||||
if trigger = strings.TrimSpace(trigger); trigger == "" {
|
||||
return "path"
|
||||
}
|
||||
return trigger
|
||||
}
|
||||
|
||||
// ResolveVanishedPath resolves a change for a path that no longer exists on
|
||||
// disk (a file deleted by an upgrade/replacement, or a removed directory) to a
|
||||
// reconciling scan target. Paths with a supported video extension map to a
|
||||
// subtree scan of their parent directory; other paths map to a subtree scan of
|
||||
// the path itself. The scoped scan marks the vanished files missing so stale
|
||||
// versions stop being offered for playback.
|
||||
//
|
||||
// Two guards keep this from turning transient storage loss into cleanup:
|
||||
// the path must actually be gone (a still-existing path is rejected — use
|
||||
// Resolve), and the matched library root must still exist on disk so an
|
||||
// unmounted share never resolves to a reconciling scan.
|
||||
func (r *Resolver) ResolveVanishedPath(ctx context.Context, path, trigger string) (*Target, error) {
|
||||
if r == nil || r.folders == nil {
|
||||
return nil, &RequestError{Status: http.StatusServiceUnavailable, Code: "unavailable", Message: "Scanner not available"}
|
||||
}
|
||||
cleanPath := filepath.Clean(path)
|
||||
if strings.TrimSpace(path) == "" || cleanPath == "." {
|
||||
return nil, &RequestError{Status: http.StatusBadRequest, Code: "bad_request", Message: "Path is required"}
|
||||
}
|
||||
if _, err := os.Lstat(cleanPath); err == nil {
|
||||
return nil, &RequestError{Status: http.StatusBadRequest, Code: "bad_request", Message: "Path still exists"}
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
// Only a confirmed ENOENT counts as vanished. Permission or other
|
||||
// stat failures must not reconcile still-existing files as missing.
|
||||
return nil, &RequestError{Status: http.StatusBadRequest, Code: "bad_request", Message: "Path could not be inspected"}
|
||||
}
|
||||
folder, matchedRoot, err := r.matchEnabledFolder(ctx, cleanPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if info, statErr := os.Stat(matchedRoot); statErr != nil || !info.IsDir() {
|
||||
return nil, &RequestError{Status: http.StatusConflict, Code: "conflict", Message: "Library root is not available"}
|
||||
}
|
||||
trigger = normalizeTrigger(trigger)
|
||||
|
||||
scope := cleanPath
|
||||
if scanner.SupportsVideoFile(cleanPath) {
|
||||
scope = filepath.Dir(cleanPath)
|
||||
}
|
||||
if filepath.Clean(scope) == filepath.Clean(matchedRoot) {
|
||||
// A vanished entry directly under the root reconciles via a full
|
||||
// library scan, which keeps the empty-root guard in play.
|
||||
return &Target{Folder: folder, Mode: ModeLibrary, Trigger: trigger}, nil
|
||||
}
|
||||
return &Target{Folder: folder, Mode: ModeSubtree, Path: scope, Trigger: trigger}, nil
|
||||
}
|
||||
|
||||
func (r *Resolver) resolve(ctx context.Context, req Request, pathFolders []*models.MediaFolder, usePathFolders bool) (*Target, error) {
|
||||
|
||||
@@ -109,6 +109,158 @@ func TestResolverRejectsMissingSubtreeAtLibraryRoot(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolverResolvesVanishedFileToParentSubtree(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
movieDir := filepath.Join(root, "Movie (2026)")
|
||||
if err := os.Mkdir(movieDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
vanished := filepath.Join(movieDir, "Movie (2026).mkv")
|
||||
repo := &fakeFolderRepo{folders: []*models.MediaFolder{{
|
||||
ID: 20,
|
||||
Name: "Movies",
|
||||
Enabled: true,
|
||||
Paths: []string{root},
|
||||
}}}
|
||||
|
||||
target, err := NewResolver(repo).ResolveVanishedPath(context.Background(), vanished, "autoscan")
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveVanishedPath returned error: %v", err)
|
||||
}
|
||||
if target.Folder == nil || target.Folder.ID != 20 || target.Mode != ModeSubtree || target.Path != movieDir {
|
||||
t.Fatalf("unexpected target: %#v", target)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolverResolvesVanishedDirToItself(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
vanishedDir := filepath.Join(root, "Removed Movie (2026)")
|
||||
repo := &fakeFolderRepo{folders: []*models.MediaFolder{{
|
||||
ID: 21,
|
||||
Name: "Movies",
|
||||
Enabled: true,
|
||||
Paths: []string{root},
|
||||
}}}
|
||||
|
||||
target, err := NewResolver(repo).ResolveVanishedPath(context.Background(), vanishedDir, "autoscan")
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveVanishedPath returned error: %v", err)
|
||||
}
|
||||
if target.Folder == nil || target.Folder.ID != 21 || target.Mode != ModeSubtree || target.Path != vanishedDir {
|
||||
t.Fatalf("unexpected target: %#v", target)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolverResolvesVanishedFileDirectlyUnderRootToLibraryScan(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
vanished := filepath.Join(root, "Movie (2026).mkv")
|
||||
repo := &fakeFolderRepo{folders: []*models.MediaFolder{{
|
||||
ID: 22,
|
||||
Name: "Movies",
|
||||
Enabled: true,
|
||||
Paths: []string{root},
|
||||
}}}
|
||||
|
||||
target, err := NewResolver(repo).ResolveVanishedPath(context.Background(), vanished, "autoscan")
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveVanishedPath returned error: %v", err)
|
||||
}
|
||||
if target.Folder == nil || target.Folder.ID != 22 || target.Mode != ModeLibrary || target.Path != "" {
|
||||
t.Fatalf("unexpected target: %#v", target)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolverRejectsVanishedPathWhenRootIsGone(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
// Simulate an unmounted share: the configured root itself is gone.
|
||||
goneRoot := filepath.Join(root, "mount")
|
||||
vanished := filepath.Join(goneRoot, "Movie (2026)", "Movie (2026).mkv")
|
||||
repo := &fakeFolderRepo{folders: []*models.MediaFolder{{
|
||||
ID: 23,
|
||||
Name: "Movies",
|
||||
Enabled: true,
|
||||
Paths: []string{goneRoot},
|
||||
}}}
|
||||
|
||||
_, err := NewResolver(repo).ResolveVanishedPath(context.Background(), vanished, "autoscan")
|
||||
var reqErr *RequestError
|
||||
if !errors.As(err, &reqErr) {
|
||||
t.Fatalf("expected RequestError, got %T: %v", err, err)
|
||||
}
|
||||
if reqErr.Status != http.StatusConflict || reqErr.Code != "conflict" {
|
||||
t.Fatalf("unexpected error: %#v", reqErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolverRejectsVanishedPathInDisabledLibrary(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
vanished := filepath.Join(root, "Movie (2026)", "Movie (2026).mkv")
|
||||
repo := &fakeFolderRepo{folders: []*models.MediaFolder{{
|
||||
ID: 26,
|
||||
Name: "Movies",
|
||||
Enabled: false,
|
||||
Paths: []string{root},
|
||||
}}}
|
||||
|
||||
_, err := NewResolver(repo).ResolveVanishedPath(context.Background(), vanished, "autoscan")
|
||||
var reqErr *RequestError
|
||||
if !errors.As(err, &reqErr) {
|
||||
t.Fatalf("expected RequestError, got %T: %v", err, err)
|
||||
}
|
||||
if reqErr.Status != http.StatusConflict || reqErr.Code != "conflict" {
|
||||
t.Fatalf("unexpected error: %#v", reqErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolverRejectsVanishedPathOnNonNotExistStatError(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
// A regular file where a directory is expected makes Lstat on a child
|
||||
// path fail with ENOTDIR — a stat failure that is not ENOENT.
|
||||
notADir := filepath.Join(root, "Movie (2026)")
|
||||
if err := os.WriteFile(notADir, []byte("file, not dir"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
child := filepath.Join(notADir, "Movie (2026).mkv")
|
||||
repo := &fakeFolderRepo{folders: []*models.MediaFolder{{
|
||||
ID: 25,
|
||||
Name: "Movies",
|
||||
Enabled: true,
|
||||
Paths: []string{root},
|
||||
}}}
|
||||
|
||||
_, err := NewResolver(repo).ResolveVanishedPath(context.Background(), child, "autoscan")
|
||||
var reqErr *RequestError
|
||||
if !errors.As(err, &reqErr) {
|
||||
t.Fatalf("expected RequestError, got %T: %v", err, err)
|
||||
}
|
||||
if reqErr.Status != http.StatusBadRequest {
|
||||
t.Fatalf("unexpected error: %#v", reqErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolverRejectsVanishedPathThatStillExists(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
filePath := filepath.Join(root, "Movie (2026).mkv")
|
||||
if err := os.WriteFile(filePath, []byte("test"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repo := &fakeFolderRepo{folders: []*models.MediaFolder{{
|
||||
ID: 24,
|
||||
Name: "Movies",
|
||||
Enabled: true,
|
||||
Paths: []string{root},
|
||||
}}}
|
||||
|
||||
_, err := NewResolver(repo).ResolveVanishedPath(context.Background(), filePath, "autoscan")
|
||||
var reqErr *RequestError
|
||||
if !errors.As(err, &reqErr) {
|
||||
t.Fatalf("expected RequestError, got %T: %v", err, err)
|
||||
}
|
||||
if reqErr.Status != http.StatusBadRequest {
|
||||
t.Fatalf("unexpected error: %#v", reqErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolverClassifiesVideoFile(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
filePath := filepath.Join(root, "Movie (2024).mkv")
|
||||
|
||||
Reference in New Issue
Block a user