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.
156 lines
5.8 KiB
Go
156 lines
5.8 KiB
Go
package library
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
|
)
|
|
|
|
// trackReconciler is the slice of dbq.Queries reconcileMissing needs. Narrowed
|
|
// to an interface so the guard logic — which is the part that can do damage —
|
|
// is unit-testable against a fake without a database.
|
|
type trackReconciler interface {
|
|
ListTrackPathsForReconcile(ctx context.Context) ([]dbq.ListTrackPathsForReconcileRow, error)
|
|
MarkTracksMissing(ctx context.Context, ids []pgtype.UUID) (int64, error)
|
|
ClearTracksMissing(ctx context.Context, ids []pgtype.UUID) (int64, error)
|
|
}
|
|
|
|
// Reconcile marks tracks whose files have disappeared (#2523).
|
|
//
|
|
// Why this exists: nothing in Minstrel used to notice a deleted file. The walk
|
|
// only visits paths that exist, so a row whose file is gone was never scanned,
|
|
// never errored, never counted — permanently invisible. The watcher ignores
|
|
// removals by design (see classifyEvent), and the safety-net scan is the same
|
|
// walk, so it covers additions only. Rows accumulated forever, kept being
|
|
// offered to recommendations, and failed at playback.
|
|
//
|
|
// Why it MARKS rather than deletes: a missing file is a claim about the
|
|
// filesystem, and the filesystem lies transiently — an unmounted volume, a
|
|
// network-storage blip, a container that started before its media mount
|
|
// attached. Every other sweep in this codebase (internal/gc) resolves a truth
|
|
// *inside* the database and is safe to run blind. This one isn't, so the
|
|
// destructive step is deliberately not here. Marking is reversible: the next
|
|
// good scan clears it.
|
|
|
|
// missingMarkMaxFraction caps how much of the library one reconcile may newly
|
|
// mark missing. A partially-attached mount is the failure this defends against:
|
|
// the roots resolve, the walk succeeds, and it legitimately sees only part of
|
|
// the library — evidence indistinguishable from a mass deletion.
|
|
//
|
|
// A quarter is deliberately conservative. A genuine bulk deletion trips it and
|
|
// gets logged rather than applied, which needs a second scan (or operator
|
|
// action) to take effect. That's the right trade: the cost of over-refusing is
|
|
// a stale row and a log line, and the cost of over-marking is a chunk of the
|
|
// library silently vanishing from every mix.
|
|
const missingMarkMaxFraction = 0.25
|
|
|
|
// reconcileMissing diffs the paths the walk saw against every row in the table.
|
|
// Rows not seen get marked; rows seen that carry a mark get cleared.
|
|
//
|
|
// seen must come from a COMPLETE walk of every configured root. Callers with a
|
|
// partial view must not call this.
|
|
func (s *Scanner) reconcileMissing(
|
|
ctx context.Context, q trackReconciler, seen map[string]struct{}, stats *Stats,
|
|
) error {
|
|
if err := s.verifyRootsPresent(); err != nil {
|
|
return err
|
|
}
|
|
// Roots resolved but the walk found nothing. Either the library is genuinely
|
|
// empty — in which case there is nothing to reconcile — or the mount is
|
|
// hollow. Both mean: don't act.
|
|
if len(seen) == 0 {
|
|
return errors.New("walk saw no audio files; refusing to reconcile")
|
|
}
|
|
|
|
rows, err := q.ListTrackPathsForReconcile(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("list track paths: %w", err)
|
|
}
|
|
if len(rows) == 0 {
|
|
return nil
|
|
}
|
|
|
|
var toMark, toClear []pgtype.UUID
|
|
for _, row := range rows {
|
|
_, present := seen[row.FilePath]
|
|
switch {
|
|
case !present && !row.MissingSince.Valid:
|
|
toMark = append(toMark, row.ID)
|
|
case present && row.MissingSince.Valid:
|
|
toClear = append(toClear, row.ID)
|
|
}
|
|
}
|
|
|
|
// Clear before marking, and unconditionally. Restoring a file is never the
|
|
// dangerous direction, so it must not be blocked by the guard below —
|
|
// otherwise a library that tripped the cap once could never recover its
|
|
// marks even after the mount came back.
|
|
if len(toClear) > 0 {
|
|
n, err := q.ClearTracksMissing(ctx, toClear)
|
|
if err != nil {
|
|
return fmt.Errorf("clear missing marks: %w", err)
|
|
}
|
|
stats.Restored = int(n)
|
|
s.logger.Info("library scan: files returned", "count", n)
|
|
}
|
|
|
|
if len(toMark) == 0 {
|
|
return nil
|
|
}
|
|
if fraction := float64(len(toMark)) / float64(len(rows)); fraction > missingMarkMaxFraction {
|
|
return fmt.Errorf(
|
|
"refusing to mark %d of %d tracks missing (%.0f%% > %.0f%% cap): "+
|
|
"this looks like an unavailable mount rather than a deletion",
|
|
len(toMark), len(rows), fraction*100, missingMarkMaxFraction*100,
|
|
)
|
|
}
|
|
|
|
n, err := q.MarkTracksMissing(ctx, toMark)
|
|
if err != nil {
|
|
return fmt.Errorf("mark tracks missing: %w", err)
|
|
}
|
|
stats.Missing = int(n)
|
|
// Warn, not Info: every one of these is a library entry the operator
|
|
// probably didn't intend to lose, and the only place it surfaces today is
|
|
// this line.
|
|
s.logger.Warn("library scan: tracks marked missing (files not found)",
|
|
"count", n, "library_total", len(rows))
|
|
return nil
|
|
}
|
|
|
|
// verifyRootsPresent is the first and most important guard. If a configured root
|
|
// doesn't resolve to a readable directory, the walk beneath it found nothing and
|
|
// every row under it would look deleted. An unmounted media volume is the
|
|
// obvious case, and it is common enough — a container restart racing its volume
|
|
// mount does exactly this.
|
|
func (s *Scanner) verifyRootsPresent() error {
|
|
if len(s.paths) == 0 {
|
|
return errors.New("no scan roots configured")
|
|
}
|
|
for _, root := range s.paths {
|
|
info, err := os.Stat(root)
|
|
if err != nil {
|
|
return fmt.Errorf("scan root %q unavailable: %w", root, err)
|
|
}
|
|
if !info.IsDir() {
|
|
return fmt.Errorf("scan root %q is not a directory", root)
|
|
}
|
|
entries, err := os.ReadDir(root)
|
|
if err != nil {
|
|
return fmt.Errorf("scan root %q unreadable: %w", root, err)
|
|
}
|
|
// An empty root is the signature of a mount point with nothing mounted
|
|
// on it. `os.Stat` succeeds on the bare directory, so this is the only
|
|
// cheap way to tell the two apart.
|
|
if len(entries) == 0 {
|
|
return fmt.Errorf("scan root %q is empty; refusing to reconcile", root)
|
|
}
|
|
}
|
|
return nil
|
|
}
|