feat(scanner): detect deleted media files without a full scan

- Add a periodic verify_file_presence task that stats live files per library and marks vanished ones missing, then reconciles memberships
- Skip files under unreachable/suspect-empty roots and abort on bulk disappearance, deferring to a full scan
- Add FileRepository.ListPresentByFolder and MarkMissingByIDs
- Hide files missing from disk in catalog detail playback responses, bounded by a time and count budget
This commit is contained in:
Quick
2026-07-25 06:29:55 +00:00
parent a102630365
commit 67a8e7aa22
7 changed files with 770 additions and 0 deletions
+9
View File
@@ -1967,6 +1967,9 @@ func main() {
if deps.FolderRepo != nil && deps.LibraryScanQueue != nil {
taskMgr.Register(tasks.NewScanLibrariesTask(deps.FolderRepo, deps.LibraryScanQueue, deps.EventBus))
}
if deps.FolderRepo != nil && deps.Scanner != nil {
taskMgr.Register(tasks.NewVerifyFilePresenceTask(deps.FolderRepo, deps.Scanner, 0))
}
taskMgr.Register(tasks.NewCleanupOrphanedMediaItemsTask(catalog.NewOrphanedProvisionalCleaner(deps.DB)))
taskMgr.Register(tasks.NewBackfillMediaItemAliasesTask(catalog.NewItemAliasRepository(deps.DB)))
if deps.S3Public != nil {
@@ -2207,6 +2210,9 @@ func main() {
if deps.ImageResolver != nil {
absDetailSvc.SetImageResolver(deps.ImageResolver)
}
if deps.FileRepo != nil {
absDetailSvc.SetMissingFileMarker(deps.FileRepo)
}
var absScopeResolver scopeResolver
if policySystem != nil {
absScopeResolver = policy.NewViewerResolver(absUserRepo, userStoreProvider, nil, policySystem.PDP(), accessGroupStore)
@@ -2499,6 +2505,9 @@ func main() {
detailSvc.SetGroupClaimRepository(catalog.NewGroupClaimRepository(deps.DB))
detailSvc.SetProbeEnsurer(deps.ProbeEnsurer)
detailSvc.SetChapterThumbnailQueuer(deps.ChapterThumbnailQueuer)
if deps.FileRepo != nil {
detailSvc.SetMissingFileMarker(deps.FileRepo)
}
if deps.ImageResolver != nil {
detailSvc.SetImageResolver(deps.ImageResolver)
}
+80
View File
@@ -6,6 +6,7 @@ import (
"errors"
"fmt"
"log/slog"
"os"
"path/filepath"
"sort"
"strconv"
@@ -614,6 +615,13 @@ type DetailService struct {
originalLangFn func(context.Context, string) string
probeEnsurer PlaybackProbeEnsurer
chapterThumbs ChapterThumbnailQueuer
missingMarker MissingFileMarker
}
// MissingFileMarker records that a file the catalog believed was present is
// not on disk. Mirrors the playback preflight's marker.
type MissingFileMarker interface {
MarkMissing(ctx context.Context, id int, since time.Time) error
}
// NewDetailService creates a new DetailService.
@@ -663,6 +671,13 @@ func (s *DetailService) SetChapterThumbnailQueuer(queuer ChapterThumbnailQueuer)
s.chapterThumbs = queuer
}
// SetMissingFileMarker enables the presence check in preparePlaybackFiles. When
// unset the check still filters the response, it just cannot persist what it
// found, so the next request repeats the work.
func (s *DetailService) SetMissingFileMarker(marker MissingFileMarker) {
s.missingMarker = marker
}
func (s *DetailService) SetFolderRepository(repo interface {
GetByID(ctx context.Context, id int) (*models.MediaFolder, error)
}) {
@@ -3320,6 +3335,8 @@ func (s *DetailService) preparePlaybackFiles(ctx context.Context, files []*model
return files
}
files = s.dropVanishedFiles(ctx, files)
prepared := make([]*models.MediaFile, 0, len(files))
for _, file := range files {
if file == nil {
@@ -3337,6 +3354,69 @@ func (s *DetailService) preparePlaybackFiles(ctx context.Context, files []*model
return prepared
}
// vanishedCheckBudget caps how long the presence check may spend on one
// request. Detail loads are on the interactive path, and a library root that
// has become slow rather than absent must not turn every detail view into a
// stall — past the budget the remaining files are returned unchecked and the
// background sweep picks them up.
const vanishedCheckBudget = 250 * time.Millisecond
// vanishedCheckLimit caps how many files are checked per request. A movie has
// a handful of versions, but an audiobook item can carry hundreds of files,
// and the marginal value of checking the 300th one inline is nil.
const vanishedCheckLimit = 32
// dropVanishedFiles removes files that are no longer on disk and records the
// removal so every other read path sees it too.
//
// The scanner and the background presence sweep are what keep the catalog
// honest in bulk; this is the last check before a file is offered to a user as
// something they can play. Without it a file deleted since the last sweep is
// still listed as a version, and the user finds out by pressing play and
// getting an error — which is the failure this is here to prevent.
//
// Only os.ErrNotExist drops a file. Any other stat error means the file's
// state is unknown, and an unreadable-but-present file is still worth
// offering: the playback preflight will produce a real error if it is not.
func (s *DetailService) dropVanishedFiles(ctx context.Context, files []*models.MediaFile) []*models.MediaFile {
deadline := time.Now().Add(vanishedCheckBudget)
kept := make([]*models.MediaFile, 0, len(files))
var vanished []*models.MediaFile
for i, file := range files {
if file == nil {
continue
}
if i >= vanishedCheckLimit || time.Now().After(deadline) {
kept = append(kept, file)
continue
}
if _, err := os.Stat(file.FilePath); err != nil && errors.Is(err, os.ErrNotExist) {
vanished = append(vanished, file)
continue
}
kept = append(kept, file)
}
if len(vanished) == 0 {
return files
}
if s.missingMarker != nil {
since := time.Now().UTC()
for _, file := range vanished {
if err := s.missingMarker.MarkMissing(ctx, file.ID, since); err != nil {
slog.WarnContext(ctx, "failed to mark vanished file missing", "component", "catalog",
"file_id", file.ID, "path", file.FilePath, "error", err)
}
}
}
slog.InfoContext(ctx, "catalog: hid files missing from disk", "component", "catalog",
"count", len(vanished), "remaining", len(kept))
return kept
}
func (s *DetailService) queueWatchPlaybackFiles(
ctx context.Context,
contentID string,
+162
View File
@@ -0,0 +1,162 @@
package catalog
import (
"context"
"errors"
"os"
"path/filepath"
"testing"
"time"
"github.com/Silo-Server/silo-server/internal/models"
)
type recordingMissingMarker struct {
marked []int
err error
}
func (m *recordingMissingMarker) MarkMissing(_ context.Context, id int, _ time.Time) error {
m.marked = append(m.marked, id)
return m.err
}
func fileAt(id int, path string) *models.MediaFile {
return &models.MediaFile{ID: id, FilePath: path}
}
func TestDropVanishedFilesHidesRemovedVersions(t *testing.T) {
t.Parallel()
dir := t.TempDir()
live := filepath.Join(dir, "movie.2160p.mkv")
if err := os.WriteFile(live, []byte("x"), 0o644); err != nil {
t.Fatalf("writing live file: %v", err)
}
marker := &recordingMissingMarker{}
svc := &DetailService{missingMarker: marker}
// The upgrade case: an older release was replaced on disk but its row is
// still live because no scan has run since.
got := svc.dropVanishedFiles(context.Background(), []*models.MediaFile{
fileAt(1, filepath.Join(dir, "movie.1080p.mkv")),
fileAt(2, live),
})
if len(got) != 1 || got[0].ID != 2 {
t.Fatalf("kept %d files (first id %v), want only the file that exists on disk", len(got), fileIDsOf(got))
}
if len(marker.marked) != 1 || marker.marked[0] != 1 {
t.Fatalf("marked = %v, want [1]", marker.marked)
}
}
func TestDropVanishedFilesKeepsEverythingWhenNothingVanished(t *testing.T) {
t.Parallel()
dir := t.TempDir()
paths := []string{filepath.Join(dir, "a.mkv"), filepath.Join(dir, "b.mkv")}
for _, p := range paths {
if err := os.WriteFile(p, []byte("x"), 0o644); err != nil {
t.Fatalf("writing %s: %v", p, err)
}
}
marker := &recordingMissingMarker{}
svc := &DetailService{missingMarker: marker}
files := []*models.MediaFile{fileAt(1, paths[0]), fileAt(2, paths[1])}
got := svc.dropVanishedFiles(context.Background(), files)
if len(got) != 2 {
t.Fatalf("kept %d files, want 2", len(got))
}
if len(marker.marked) != 0 {
t.Fatalf("marked = %v, want none", marker.marked)
}
}
// A failing MarkMissing must not resurrect the file in the response: the user
// still cannot play it, so listing it would reintroduce the exact dead-click
// this check exists to prevent.
func TestDropVanishedFilesHidesEvenWhenMarkingFails(t *testing.T) {
t.Parallel()
dir := t.TempDir()
marker := &recordingMissingMarker{err: errors.New("database down")}
svc := &DetailService{missingMarker: marker}
got := svc.dropVanishedFiles(context.Background(), []*models.MediaFile{
fileAt(1, filepath.Join(dir, "gone.mkv")),
})
if len(got) != 0 {
t.Fatalf("kept %d files, want 0", len(got))
}
}
// The marker is optional wiring; the filter must still work without it.
func TestDropVanishedFilesWorksWithoutMarker(t *testing.T) {
t.Parallel()
dir := t.TempDir()
svc := &DetailService{}
got := svc.dropVanishedFiles(context.Background(), []*models.MediaFile{
fileAt(1, filepath.Join(dir, "gone.mkv")),
})
if len(got) != 0 {
t.Fatalf("kept %d files, want 0", len(got))
}
}
// Past the per-request limit files pass through unchecked rather than making
// an item with hundreds of files pay for hundreds of stats on every load.
func TestDropVanishedFilesRespectsCheckLimit(t *testing.T) {
t.Parallel()
dir := t.TempDir()
files := make([]*models.MediaFile, 0, vanishedCheckLimit+5)
for i := range vanishedCheckLimit + 5 {
files = append(files, fileAt(i+1, filepath.Join(dir, "gone.mkv")))
}
marker := &recordingMissingMarker{}
svc := &DetailService{missingMarker: marker}
got := svc.dropVanishedFiles(context.Background(), files)
if len(got) != 5 {
t.Fatalf("kept %d files, want 5 (the ones past the check limit)", len(got))
}
if len(marker.marked) != vanishedCheckLimit {
t.Fatalf("marked %d files, want %d", len(marker.marked), vanishedCheckLimit)
}
}
func TestPreparePlaybackFilesDropsVanished(t *testing.T) {
t.Parallel()
dir := t.TempDir()
live := filepath.Join(dir, "live.mkv")
if err := os.WriteFile(live, []byte("x"), 0o644); err != nil {
t.Fatalf("writing live file: %v", err)
}
svc := &DetailService{missingMarker: &recordingMissingMarker{}}
got := svc.preparePlaybackFiles(context.Background(), []*models.MediaFile{
fileAt(1, filepath.Join(dir, "gone.mkv")),
fileAt(2, live),
})
if len(got) != 1 || got[0].ID != 2 {
t.Fatalf("preparePlaybackFiles returned %v, want only file 2", fileIDsOf(got))
}
}
func fileIDsOf(files []*models.MediaFile) []int {
ids := make([]int, 0, len(files))
for _, f := range files {
if f != nil {
ids = append(ids, f.ID)
}
}
return ids
}
+53
View File
@@ -2640,6 +2640,59 @@ func claimRepresentativeWindow(limit int) int {
return window
}
// PresentFileRef is the minimal projection the presence sweep needs: enough to
// stat a file and mark it missing. Deliberately not the full media_files row —
// the sweep walks every live file in a library, so the per-row cost matters.
type PresentFileRef struct {
ID int
Path string
}
// ListPresentByFolder returns id/path for every file in the folder that the
// catalog currently believes is on disk (missing_since IS NULL). Ordered by id
// so a sweep interrupted partway through covers a stable prefix.
func (r *FileRepository) ListPresentByFolder(ctx context.Context, folderID int) ([]PresentFileRef, error) {
rows, err := r.pool.Query(ctx,
`SELECT id, file_path FROM media_files
WHERE media_folder_id = $1 AND missing_since IS NULL
ORDER BY id ASC`, folderID)
if err != nil {
return nil, fmt.Errorf("listing present files for folder %d: %w", folderID, err)
}
defer rows.Close()
refs := make([]PresentFileRef, 0)
for rows.Next() {
var ref PresentFileRef
if err := rows.Scan(&ref.ID, &ref.Path); err != nil {
return nil, fmt.Errorf("scanning present file for folder %d: %w", folderID, err)
}
refs = append(refs, ref)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterating present files for folder %d: %w", folderID, err)
}
return refs, nil
}
// MarkMissingByIDs sets missing_since on every given file that is not already
// marked, in one statement. Returns the number of rows newly marked; ids that
// were already missing (a concurrent scan beat us to them) are not counted and
// keep their original timestamp, so the removal grace is measured from first
// detection rather than being extended by every later sweep.
func (r *FileRepository) MarkMissingByIDs(ctx context.Context, ids []int, since time.Time) (int, error) {
if len(ids) == 0 {
return 0, nil
}
tag, err := r.pool.Exec(ctx,
`UPDATE media_files SET missing_since = $1, updated_at = NOW()
WHERE id = ANY($2) AND missing_since IS NULL`, since, ids)
if err != nil {
return 0, fmt.Errorf("marking files missing: %w", err)
}
return int(tag.RowsAffected()), nil
}
// MarkMissing sets the missing_since timestamp for the given media file.
func (r *FileRepository) MarkMissing(ctx context.Context, id int, since time.Time) error {
tag, err := r.pool.Exec(ctx,
+211
View File
@@ -0,0 +1,211 @@
package scanner
import (
"context"
"errors"
"fmt"
"log/slog"
"os"
"sync"
"time"
"github.com/Silo-Server/silo-server/internal/models"
)
// presenceSweepWorkers bounds the concurrent stat calls. A sweep is pure
// syscall work with no ffprobe or hashing, but library roots are frequently
// network mounts where a wide fan-out buys latency at the cost of hammering
// the server, so keep the fan-out modest.
const presenceSweepWorkers = 16
// presenceSweepAbortFraction is the share of a library's live files that may
// vanish in a single sweep before the sweep refuses to act on the result.
//
// The sweep runs unattended every few minutes and only ever observes the
// filesystem through stat(2), so it cannot distinguish "the operator deleted
// half the library" from "a mount dropped out but left a populated-looking
// directory behind". The root probes upstream catch the common shapes of that
// fault; this is the backstop for the ones they miss. Bulk removals are real,
// but they are also the case where being wrong is most expensive, so the sweep
// defers them to a full scan rather than guessing.
const presenceSweepAbortFraction = 0.5
// presenceSweepAbortFloor is the number of vanished files below which the
// fraction check does not apply. Without it a library with two files would
// abort on a single legitimate deletion.
const presenceSweepAbortFloor = 20
// PresenceSweepResult reports what one folder's sweep observed.
type PresenceSweepResult struct {
Checked int
Vanished int
Marked int
// Skipped counts live files under a protected (unreachable or
// suspect-empty) root, which the sweep does not stat at all.
Skipped int
// Aborted is set when the vanished count tripped the bulk-removal
// backstop and nothing was marked for this folder.
Aborted bool
// MembershipsRemoved and ItemsDeleted come from the reconcile pass that
// follows a sweep that marked anything.
MembershipsRemoved int
ItemsDeleted int
}
// VerifyPresence stats every file the catalog believes is on disk for one
// folder and marks the vanished ones missing, then reconciles library
// membership so titles left with no playable file drop out of the catalog.
//
// This exists because a deleted file is otherwise invisible to the server
// until the next full library scan: catalog reads all filter on
// missing_since, so the row keeps advertising a playable version, and the
// first thing to notice is a user pressing play. A sweep is cheap enough
// (one stat per live file, no probing) to run on a short interval, which
// closes that window from a scan interval down to the sweep interval.
//
// It deliberately does not empty trash. Deciding that a row is gone for good
// is the full scan's job, which has walked the tree and knows what is
// actually there; the sweep only knows that specific paths did not resolve.
func (s *Scanner) VerifyPresence(ctx context.Context, folder *models.MediaFolder) (PresenceSweepResult, error) {
var result PresenceSweepResult
if s == nil || s.fileRepo == nil || folder == nil {
return result, fmt.Errorf("verify presence: scanner not configured")
}
// Protect files under roots that are unreachable or suspect-empty: those
// files have not been removed, their storage is just not answering, and
// stat would report every one of them as gone.
configuredPaths, err := s.configuredFolderPaths(ctx, folder)
if err != nil {
return result, err
}
configuredRoots := cleanScanRoots(configuredPaths)
protectedRoots := probeUnreachableRoots(ctx, folder.ID, configuredRoots)
suspectRoots, err := s.suspectEmptyRoots(ctx, folder.ID, configuredRoots, protectedRoots)
if err != nil {
return result, err
}
protectedRoots = append(protectedRoots, suspectRoots...)
refs, err := s.fileRepo.ListPresentByFolder(ctx, folder.ID)
if err != nil {
return result, err
}
if len(refs) == 0 {
return result, nil
}
candidates := make([]PresentFileRef, 0, len(refs))
for _, ref := range refs {
if len(protectedRoots) > 0 && pathWithinAnyRoot(ref.Path, protectedRoots) {
result.Skipped++
continue
}
candidates = append(candidates, ref)
}
result.Checked = len(candidates)
if len(candidates) == 0 {
return result, nil
}
vanished, err := statVanished(ctx, candidates)
if err != nil {
return result, err
}
result.Vanished = len(vanished)
if len(vanished) == 0 {
return result, nil
}
if len(vanished) >= presenceSweepAbortFloor &&
float64(len(vanished)) >= float64(result.Checked)*presenceSweepAbortFraction {
result.Aborted = true
slog.WarnContext(ctx, "scanner: presence sweep saw a bulk disappearance; deferring to a full scan",
"component", "scanner",
"folder_id", folder.ID,
"checked", result.Checked,
"vanished", result.Vanished,
)
return result, nil
}
marked, err := s.fileRepo.MarkMissingByIDs(ctx, vanished, time.Now().UTC())
if err != nil {
return result, err
}
result.Marked = marked
if marked == 0 {
return result, nil
}
removedMemberships, deletedItems, orphanedImageDirs, err := s.reconcileLibraryMemberships(ctx, folder.ID, protectedRoots)
if err != nil {
return result, fmt.Errorf("reconciling library membership for folder %d: %w", folder.ID, err)
}
result.MembershipsRemoved = removedMemberships
result.ItemsDeleted = deletedItems
if s.s3Client != nil && len(orphanedImageDirs) > 0 {
bucket := s.s3Client.Bucket()
for _, dir := range orphanedImageDirs {
_, _ = s.s3Client.DeletePrefix(ctx, bucket, dir)
}
}
slog.InfoContext(ctx, "scanner: presence sweep marked files missing", "component", "scanner",
"folder_id", folder.ID,
"checked", result.Checked,
"marked", result.Marked,
"memberships_removed", result.MembershipsRemoved,
"items_deleted", result.ItemsDeleted,
)
return result, nil
}
// statVanished returns the ids of refs whose path no longer exists. Only
// os.ErrNotExist counts: a permission error, a timing-out mount, or any other
// fault means the file's state is unknown, and treating unknown as gone is
// what the sweep must never do.
func statVanished(ctx context.Context, refs []PresentFileRef) ([]int, error) {
workers := presenceSweepWorkers
if len(refs) < workers {
workers = len(refs)
}
var (
mu sync.Mutex
vanished []int
)
jobs := make(chan PresentFileRef)
var wg sync.WaitGroup
for range workers {
wg.Add(1)
go func() {
defer wg.Done()
for ref := range jobs {
if _, err := os.Stat(ref.Path); err == nil || !errors.Is(err, os.ErrNotExist) {
continue
}
mu.Lock()
vanished = append(vanished, ref.ID)
mu.Unlock()
}
}()
}
var walkErr error
for _, ref := range refs {
if err := ctx.Err(); err != nil {
walkErr = err
break
}
jobs <- ref
}
close(jobs)
wg.Wait()
if walkErr != nil {
return nil, walkErr
}
return vanished, nil
}
+136
View File
@@ -0,0 +1,136 @@
package scanner
import (
"context"
"os"
"path/filepath"
"slices"
"testing"
)
func TestStatVanishedReportsOnlyAbsentFiles(t *testing.T) {
t.Parallel()
dir := t.TempDir()
present := filepath.Join(dir, "present.mkv")
if err := os.WriteFile(present, []byte("x"), 0o644); err != nil {
t.Fatalf("writing present file: %v", err)
}
refs := []PresentFileRef{
{ID: 1, Path: present},
{ID: 2, Path: filepath.Join(dir, "gone.mkv")},
{ID: 3, Path: filepath.Join(dir, "also-gone.mkv")},
}
vanished, err := statVanished(context.Background(), refs)
if err != nil {
t.Fatalf("statVanished: %v", err)
}
slices.Sort(vanished)
if !slices.Equal(vanished, []int{2, 3}) {
t.Fatalf("vanished = %v, want [2 3]", vanished)
}
}
// A path whose parent directory is unreadable stats with EACCES, not ENOENT.
// The sweep must leave those files alone: unknown state is not removal.
func TestStatVanishedIgnoresNonNotExistErrors(t *testing.T) {
t.Parallel()
if os.Geteuid() == 0 {
t.Skip("running as root: permission bits do not deny access")
}
dir := t.TempDir()
locked := filepath.Join(dir, "locked")
if err := os.Mkdir(locked, 0o755); err != nil {
t.Fatalf("creating locked dir: %v", err)
}
target := filepath.Join(locked, "movie.mkv")
if err := os.WriteFile(target, []byte("x"), 0o644); err != nil {
t.Fatalf("writing target: %v", err)
}
if err := os.Chmod(locked, 0o000); err != nil {
t.Fatalf("chmod: %v", err)
}
t.Cleanup(func() { _ = os.Chmod(locked, 0o755) })
vanished, err := statVanished(context.Background(), []PresentFileRef{{ID: 1, Path: target}})
if err != nil {
t.Fatalf("statVanished: %v", err)
}
if len(vanished) != 0 {
t.Fatalf("vanished = %v, want empty: a permission error must not count as removal", vanished)
}
}
func TestStatVanishedHandlesEmptyInput(t *testing.T) {
t.Parallel()
vanished, err := statVanished(context.Background(), nil)
if err != nil {
t.Fatalf("statVanished: %v", err)
}
if len(vanished) != 0 {
t.Fatalf("vanished = %v, want empty", vanished)
}
}
func TestStatVanishedStopsOnCancelledContext(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithCancel(context.Background())
cancel()
refs := make([]PresentFileRef, 0, 64)
for i := range 64 {
refs = append(refs, PresentFileRef{ID: i + 1, Path: filepath.Join(t.TempDir(), "gone.mkv")})
}
if _, err := statVanished(ctx, refs); err == nil {
t.Fatal("statVanished returned nil error for a cancelled context")
}
}
// The bulk-removal backstop is the difference between "a few files were
// deleted" and "the storage went away"; assert the exact boundary so a change
// to either constant is deliberate.
func TestPresenceSweepAbortThreshold(t *testing.T) {
t.Parallel()
aborts := func(checked, vanished int) bool {
return vanished >= presenceSweepAbortFloor &&
float64(vanished) >= float64(checked)*presenceSweepAbortFraction
}
cases := []struct {
name string
checked, vanished int
want bool
}{
{"single deletion in a large library", 5000, 1, false},
{"routine upgrade churn", 5000, 40, false},
{"small library emptied stays under the floor", 10, 10, false},
{"half a large library vanishing", 5000, 2500, true},
{"just under the floor", 40, 19, false},
{"at the floor and at the fraction", 40, 20, true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := aborts(tc.checked, tc.vanished); got != tc.want {
t.Fatalf("aborts(checked=%d, vanished=%d) = %v, want %v",
tc.checked, tc.vanished, got, tc.want)
}
})
}
}
func TestVerifyPresenceRequiresConfiguredScanner(t *testing.T) {
t.Parallel()
var s *Scanner
if _, err := s.VerifyPresence(context.Background(), nil); err == nil {
t.Fatal("VerifyPresence on a nil scanner returned nil error")
}
}
@@ -0,0 +1,119 @@
package tasks
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"github.com/Silo-Server/silo-server/internal/models"
"github.com/Silo-Server/silo-server/internal/scanner"
"github.com/Silo-Server/silo-server/internal/taskmanager"
)
// defaultVerifyFilePresenceIntervalMs is how often the sweep runs. Fifteen
// minutes is the ceiling on how long a deleted file can keep advertising
// itself as playable when no autoscan source covers the library.
const defaultVerifyFilePresenceIntervalMs int64 = 15 * 60 * 1000
// PresenceVerifier stats a library's cataloged files and marks the vanished
// ones missing. Implemented by *scanner.Scanner.
type PresenceVerifier interface {
VerifyPresence(ctx context.Context, folder *models.MediaFolder) (scanner.PresenceSweepResult, error)
}
// VerifyFilePresenceTask closes the window between a file being deleted on
// disk and the catalog noticing. Catalog reads hide files with missing_since
// set, so marking is all it takes to stop offering a dead file for playback;
// without this the first thing to notice a deletion is a failed play.
type VerifyFilePresenceTask struct {
folderRepo ScanFolderRepository
verifier PresenceVerifier
intervalMs int64
}
type verifyFilePresenceResult struct {
Checked int `json:"checked"`
Marked int `json:"marked"`
MembershipsRemoved int `json:"memberships_removed"`
ItemsDeleted int `json:"items_deleted"`
FoldersAborted int `json:"folders_aborted"`
Errors int `json:"errors"`
}
// NewVerifyFilePresenceTask builds the sweep task. intervalMs is in
// MILLISECONDS; a non-positive value falls back to the default cadence.
func NewVerifyFilePresenceTask(folderRepo ScanFolderRepository, verifier PresenceVerifier, intervalMs int64) *VerifyFilePresenceTask {
if intervalMs <= 0 {
intervalMs = defaultVerifyFilePresenceIntervalMs
}
return &VerifyFilePresenceTask{folderRepo: folderRepo, verifier: verifier, intervalMs: intervalMs}
}
func (t *VerifyFilePresenceTask) Key() string { return "verify_file_presence" }
func (t *VerifyFilePresenceTask) Name() string { return "Verify Media File Presence" }
func (t *VerifyFilePresenceTask) Description() string {
return "Checks that cataloged media files still exist on disk and hides the ones that were removed, without waiting for a full library scan"
}
func (t *VerifyFilePresenceTask) Category() taskmanager.TaskCategory {
return taskmanager.TaskCategoryLibrary
}
func (t *VerifyFilePresenceTask) IsHidden() bool { return false }
func (t *VerifyFilePresenceTask) DefaultTriggers() []taskmanager.TriggerConfig {
return []taskmanager.TriggerConfig{
{Type: taskmanager.TriggerTypeInterval, IntervalMs: t.intervalMs},
}
}
func (t *VerifyFilePresenceTask) Execute(ctx context.Context, progress taskmanager.ProgressReporter) error {
if t == nil || t.verifier == nil || t.folderRepo == nil {
progress.Report(100, "File presence verification is not configured")
return nil
}
folders, err := t.folderRepo.GetEnabled(ctx)
if err != nil {
return fmt.Errorf("listing enabled folders: %w", err)
}
if len(folders) == 0 {
progress.Report(100, "No libraries to verify")
return nil
}
var summary verifyFilePresenceResult
// Sequential across folders on purpose: VerifyPresence already fans out
// internally, and libraries commonly share one underlying mount.
for i, folder := range folders {
if folder == nil {
continue
}
if err := ctx.Err(); err != nil {
return err
}
res, verifyErr := t.verifier.VerifyPresence(ctx, folder)
if verifyErr != nil {
slog.ErrorContext(ctx, "presence sweep: failed to verify library", "component", "taskmanager",
"folder_id", folder.ID, "name", folder.Name, "error", verifyErr)
summary.Errors++
} else {
summary.Checked += res.Checked
summary.Marked += res.Marked
summary.MembershipsRemoved += res.MembershipsRemoved
summary.ItemsDeleted += res.ItemsDeleted
if res.Aborted {
summary.FoldersAborted++
}
}
progress.Report(float64(i+1)/float64(len(folders))*100,
fmt.Sprintf("Verified %s (%d/%d libraries)", folder.Name, i+1, len(folders)))
}
if data, err := json.Marshal(summary); err == nil {
progress.SetResultData(data)
}
progress.Report(100, fmt.Sprintf("Checked %d files, hid %d removed", summary.Checked, summary.Marked))
return nil
}