feat(library): detect missing files and stop offering them — #2523
Nothing in Minstrel ever noticed a deleted file. The walk only visits paths that exist, so a row whose file was gone was never scanned, never errored, never counted — permanently invisible. classifyEvent ignores fsnotify removals by design, and the safety-net scan is the same walk, so it covers additions only. Rows accumulated forever. Found on the operator's library: a completed scan reported skipped=24185 errored=0 while the MBID backfill (which opens files by DB path rather than walking) logged ~40 "no such file or directory" across three reorganised albums. Those rows also kept their pre-#2499 welded genre, which is how this surfaced — the version-stamped tag re-read can only reach files the walk visits. The harm is not cosmetic. tracks is the candidate universe for recommendation.sql / discover.sql / system_mixes.sql and nothing filtered on file existence, so a mix could spend a slot on a track that cannot stream. Marks rather than deletes. A missing file is a claim about the filesystem and the filesystem lies transiently — an unmounted volume, a network blip, a container that started before its media mount attached. Every sweep in internal/gc resolves a truth INSIDE the database and is safe to run blind; this one is not, so no deletion happens here. Three guards refuse to act on ambiguous evidence: every scan root must resolve to a non-empty directory, the walk must have seen at least one file, and one reconcile may newly mark at most 25% of the library. Clearing a mark is never the dangerous direction, so it runs unconditionally — otherwise a library that tripped the cap could never recover once the mount returned. Only a full Scan reconciles. The walk's set of seen paths is the evidence, and ScanFiles has no basis for concluding anything about files it did not look at. Excludes marked tracks from all 13 track-emitting queries (radio x2, system mixes x5, discover x4, most-played x2), the 6 play-history seed picks, and the genre browse axis. Deliberately NOT filtered: the shared ListPlaylistTracks read path, because it also serves user-curated playlists where hiding a track the user added would be wrong — system playlists shed orphans on their next daily rebuild instead. History and the taste profile also keep them: those record the past, and a track you played 200 times still says something about your taste. Reconcile tallies land in scan_runs so a disappearance is visible rather than discovered when a mix comes up short.
This commit is contained in:
@@ -60,6 +60,11 @@ type Stats struct {
|
||||
Updated int `json:"updated"`
|
||||
Skipped int `json:"skipped"`
|
||||
Errored int `json:"errored"`
|
||||
// Missing / Restored come from the reconcile pass, not the walk (#2523):
|
||||
// rows whose file the walk didn't find, and rows whose file came back.
|
||||
// Only a full Scan sets these — see reconcileMissing.
|
||||
Missing int `json:"missing"`
|
||||
Restored int `json:"restored"`
|
||||
}
|
||||
|
||||
type Scanner struct {
|
||||
@@ -76,6 +81,12 @@ func New(pool *pgxpool.Pool, logger *slog.Logger, paths []string) *Scanner {
|
||||
// newer than the existing row's updated_at. Walk errors and per-file errors
|
||||
// are logged + counted; the scan keeps going.
|
||||
//
|
||||
// It then reconciles: rows whose file the walk never saw get marked missing,
|
||||
// and rows whose file has come back get un-marked (#2523). Only a FULL scan may
|
||||
// do this — the walk's set of seen paths is the evidence, and a partial
|
||||
// (watcher-driven) scan has no basis for concluding anything about files it
|
||||
// didn't look at. That's why ScanFiles does not reconcile.
|
||||
//
|
||||
// progressCb (may be nil) receives the current Stats snapshot after each
|
||||
// processed file. Used by the orchestrator to drive partial-tally writes.
|
||||
func (s *Scanner) Scan(ctx context.Context, progressCb func(Stats)) (Stats, error) {
|
||||
@@ -83,6 +94,12 @@ func (s *Scanner) Scan(ctx context.Context, progressCb func(Stats)) (Stats, erro
|
||||
q := dbq.New(s.pool)
|
||||
start := time.Now()
|
||||
|
||||
// Every audio path the walk visited. Reconcile diffs this against the table,
|
||||
// so it costs no extra filesystem I/O — the walk already established which
|
||||
// files exist. ~100 bytes/path, so a 250k-track library is ~25MB, which is
|
||||
// worth it to avoid a second stat pass over the whole library.
|
||||
seen := make(map[string]struct{}, 8192)
|
||||
|
||||
for _, root := range s.paths {
|
||||
if err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
|
||||
if ctx.Err() != nil {
|
||||
@@ -102,6 +119,11 @@ func (s *Scanner) Scan(ctx context.Context, progressCb func(Stats)) (Stats, erro
|
||||
if !audioExtensions[strings.ToLower(filepath.Ext(path))] {
|
||||
return nil
|
||||
}
|
||||
// Recorded before scanFile so a file that exists but fails to parse
|
||||
// still counts as present. It's a broken file, not a missing one,
|
||||
// and marking it missing would hide it from the operator behind the
|
||||
// wrong explanation.
|
||||
seen[path] = struct{}{}
|
||||
if _, _, err := s.scanFile(ctx, q, path, &stats); err != nil {
|
||||
s.logger.Warn("library scan file error", "path", path, "err", err)
|
||||
stats.Errored++
|
||||
@@ -115,12 +137,26 @@ func (s *Scanner) Scan(ctx context.Context, progressCb func(Stats)) (Stats, erro
|
||||
}
|
||||
}
|
||||
|
||||
// Reconcile only after a COMPLETE walk. A cancelled scan has a partial
|
||||
// `seen` set, which would mark everything it hadn't reached yet.
|
||||
if err := ctx.Err(); err != nil {
|
||||
return stats, err
|
||||
}
|
||||
if err := s.reconcileMissing(ctx, q, seen, &stats); err != nil {
|
||||
// Not fatal: the walk's results are already persisted and useful. The
|
||||
// guards deliberately refuse to act on ambiguous evidence, and that
|
||||
// refusal arrives here as an error.
|
||||
s.logger.Warn("library scan: reconcile skipped", "err", err)
|
||||
}
|
||||
|
||||
s.logger.Info("library scan complete",
|
||||
"scanned", stats.Scanned,
|
||||
"added", stats.Added,
|
||||
"updated", stats.Updated,
|
||||
"skipped", stats.Skipped,
|
||||
"errored", stats.Errored,
|
||||
"missing", stats.Missing,
|
||||
"restored", stats.Restored,
|
||||
"duration_ms", time.Since(start).Milliseconds(),
|
||||
)
|
||||
if err := ctx.Err(); err != nil {
|
||||
|
||||
Reference in New Issue
Block a user