From ba2bac7f55e092c0647f2fd2899ba97267eb45f7 Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Sun, 5 Jul 2026 19:41:00 -0400 Subject: [PATCH] fix(scanner): reconcile deleted files promptly and honor file_removal_grace (#312) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * 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 * 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 --------- Co-authored-by: Claude Fable 5 --- internal/autoscan/service.go | 22 ++++ internal/autoscan/service_test.go | 89 +++++++++++++ internal/config/config.go | 11 +- internal/config/db_loader.go | 10 ++ internal/config/restart_keys.go | 1 + internal/scanner/audiobook_scan.go | 2 +- internal/scanner/ebook_scan.go | 2 +- internal/scanner/file_repo.go | 25 ++-- internal/scanner/podcast_scan.go | 2 +- internal/scanner/scanner.go | 32 +++-- internal/scantrigger/scantrigger.go | 84 +++++++++++-- internal/scantrigger/scantrigger_test.go | 152 +++++++++++++++++++++++ 12 files changed, 385 insertions(+), 47 deletions(-) diff --git a/internal/autoscan/service.go b/internal/autoscan/service.go index 4209fc65..29bf12f6 100644 --- a/internal/autoscan/service.go +++ b/internal/autoscan/service.go @@ -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}) } diff --git a/internal/autoscan/service_test.go b/internal/autoscan/service_test.go index 3048a179..7a1623fe 100644 --- a/internal/autoscan/service_test.go +++ b/internal/autoscan/service_test.go @@ -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}, diff --git a/internal/config/config.go b/internal/config/config.go index b0471396..946f4bd1 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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"` diff --git a/internal/config/db_loader.go b/internal/config/db_loader.go index 048d7f4d..fcef840e 100644 --- a/internal/config/db_loader.go +++ b/internal/config/db_loader.go @@ -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) diff --git a/internal/config/restart_keys.go b/internal/config/restart_keys.go index ac4b816a..1f388fc1 100644 --- a/internal/config/restart_keys.go +++ b/internal/config/restart_keys.go @@ -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, diff --git a/internal/scanner/audiobook_scan.go b/internal/scanner/audiobook_scan.go index 05b384a5..ed654328 100644 --- a/internal/scanner/audiobook_scan.go +++ b/internal/scanner/audiobook_scan.go @@ -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) } diff --git a/internal/scanner/ebook_scan.go b/internal/scanner/ebook_scan.go index 87c5b648..713f71e9 100644 --- a/internal/scanner/ebook_scan.go +++ b/internal/scanner/ebook_scan.go @@ -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) } diff --git a/internal/scanner/file_repo.go b/internal/scanner/file_repo.go index ad360912..4b7e0477 100644 --- a/internal/scanner/file_repo.go +++ b/internal/scanner/file_repo.go @@ -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) diff --git a/internal/scanner/podcast_scan.go b/internal/scanner/podcast_scan.go index 18d211e4..f5be171e 100644 --- a/internal/scanner/podcast_scan.go +++ b/internal/scanner/podcast_scan.go @@ -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) } diff --git a/internal/scanner/scanner.go b/internal/scanner/scanner.go index 138b2277..998b2b0c 100644 --- a/internal/scanner/scanner.go +++ b/internal/scanner/scanner.go @@ -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) } diff --git a/internal/scantrigger/scantrigger.go b/internal/scantrigger/scantrigger.go index b9ca0b48..4483c56f 100644 --- a/internal/scantrigger/scantrigger.go +++ b/internal/scantrigger/scantrigger.go @@ -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) { diff --git a/internal/scantrigger/scantrigger_test.go b/internal/scantrigger/scantrigger_test.go index a0271447..7b5c2b81 100644 --- a/internal/scantrigger/scantrigger_test.go +++ b/internal/scantrigger/scantrigger_test.go @@ -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")