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:
Quick
2026-07-05 19:41:00 -04:00
committed by GitHub
co-authored by Claude Fable 5
parent 28340ad6f5
commit ba2bac7f55
12 changed files with 385 additions and 47 deletions
+22
View File
@@ -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})
}
+89
View File
@@ -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},
+6 -5
View File
@@ -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"`
+10
View File
@@ -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)
+1
View File
@@ -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,
+1 -1
View File
@@ -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)
}
+1 -1
View File
@@ -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)
}
+8 -17
View File
@@ -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)
+1 -1
View File
@@ -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
View File
@@ -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)
}
+73 -11
View File
@@ -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) {
+152
View File
@@ -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")