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 }