Files
minstrel/internal/library/scanrun.go
T
bvandeusen f6d1cf24f0
test-go / test (push) Successful in 1m0s
test-go / integration (push) Successful in 5m10s
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.
2026-08-06 14:34:53 -04:00

253 lines
7.9 KiB
Go

package library
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"time"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/coverart"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
// LibraryStageTallies wires the scanner.Stats shape into the scan_runs jsonb
// column. Field names match the DB-side jsonb schema referenced by the
// admin SPA. Defined here (not on Stats directly) so the wire format
// stays stable even if the scanner adds internal counters.
type LibraryStageTallies struct {
Scanned int `json:"scanned"`
Added int `json:"added"`
Updated int `json:"updated"`
Skipped int `json:"skipped"`
Errored int `json:"errored"`
// Reconcile results (#2523). Surfaced in the scan record because a track
// disappearing from the library is something the operator should be able to
// see happened, rather than discovering it when a mix comes up short.
Missing int `json:"missing"`
Restored int `json:"restored"`
}
// MBIDBackfillStageTallies wires BackfillMBIDsResult into the scan_runs jsonb column.
type MBIDBackfillStageTallies struct {
Processed int `json:"processed"`
Healed int `json:"healed"`
Skipped int `json:"skipped"`
Duplicates int `json:"duplicates"`
}
// CoverEnrichStageTallies wires EnrichBatch tallies into the scan_runs jsonb column.
type CoverEnrichStageTallies struct {
Processed int `json:"processed"`
Succeeded int `json:"succeeded"`
Failed int `json:"failed"`
}
// ArtistArtEnrichStageTallies wires EnrichArtistBatch tallies into
// the scan_runs jsonb column.
type ArtistArtEnrichStageTallies struct {
Processed int `json:"processed"`
Succeeded int `json:"succeeded"`
Failed int `json:"failed"`
}
// RunScanConfig assembles the knobs for the orchestrator. BackfillCap is
// the album/artist-MBID-backfill batch cap (5000); EnrichCap /
// ArtistEnrichCap gate cover/artist-art enrichment (0 = off, <0 =
// unbounded — #388 removed the global cover-art cap; remote providers
// self-throttle per provider).
type RunScanConfig struct {
BackfillCap int
EnrichCap int
ArtistEnrichCap int // M7 cover-sources: cap for the artist-art enrich stage
DataDir string // M7 cover-sources: where to write artist-art files
}
// RunScan is the unified scan-chain orchestrator. Creates a scan_runs row,
// runs file walk → MBID backfill → cover enrich in sequence, persists per-
// stage tallies as each completes, sets finished_at on the row.
//
// Errors at any stage are logged + recorded in error_message but do NOT
// halt subsequent stages — a scanner failure shouldn't prevent the cover
// enricher from picking up rows that earlier scans imported.
//
// Returns the scan_runs row id so the manual-trigger endpoint can return it.
func RunScan(
ctx context.Context,
pool *pgxpool.Pool,
scanner *Scanner,
enricher *coverart.Enricher,
logger *slog.Logger,
cfg RunScanConfig,
) (pgtype.UUID, error) {
q := dbq.New(pool)
row, err := q.InsertScanRun(ctx)
if err != nil {
return pgtype.UUID{}, fmt.Errorf("insert scan_run: %w", err)
}
logger.Info("scan run started", "id", row.ID)
publishScanEvent("scan.run_started", row.ID, nil)
var firstErr error
captureErr := func(stage string, stageErr error) {
logger.Warn("scan run stage failed", "stage", stage, "id", row.ID, "err", stageErr)
if firstErr == nil {
firstErr = fmt.Errorf("%s: %w", stage, stageErr)
}
}
// Stage 1: library file-walk scan.
if scanner != nil {
var lastStats Stats
libPub := newStagePublisher(ctx, 500, time.Second,
func() []byte {
blob, _ := json.Marshal(LibraryStageTallies(lastStats))
return blob
},
func(blob []byte) error {
return q.UpdateScanRunLibrary(ctx, dbq.UpdateScanRunLibraryParams{
ID: row.ID, Library: blob,
})
},
)
stats, serr := scanner.Scan(ctx, func(s Stats) {
lastStats = s
libPub.Tick()
})
lastStats = stats // ensure final flush sees post-loop value
if err := libPub.Flush(); err != nil {
captureErr("library_persist", err)
}
if serr != nil {
captureErr("library", serr)
}
}
// Stage 2: MBID backfill.
if cfg.BackfillCap != 0 {
var lastRes BackfillMBIDsResult
bfPub := newStagePublisher(ctx, 500, time.Second,
func() []byte {
blob, _ := json.Marshal(MBIDBackfillStageTallies(lastRes))
return blob
},
func(blob []byte) error {
return q.UpdateScanRunMbidBackfill(ctx, dbq.UpdateScanRunMbidBackfillParams{
ID: row.ID, MbidBackfill: blob,
})
},
)
bfRes, bferr := BackfillMBIDs(ctx, pool, logger, cfg.BackfillCap, func(r BackfillMBIDsResult) {
lastRes = r
bfPub.Tick()
})
lastRes = bfRes
if err := bfPub.Flush(); err != nil {
captureErr("mbid_backfill_persist", err)
}
if bferr != nil {
captureErr("mbid_backfill", bferr)
}
}
// Stage 2b: track recording-MBID backfill. Unblocks the ListenBrainz
// similarity pipeline (gated on tracks.mbid IS NOT NULL). Log-only
// progress — no scan_runs jsonb column to avoid a schema addition.
//
// Unbounded (-1), unlike the album backfill's 5000 staged cap: this
// is a one-time whole-library heal with no progress UI, and a cap
// just means tracks.mbid stays partially NULL until N future scans
// catch up (re-reading untagged files wastefully each pass). One
// uncapped pass converges; subsequent scans only re-read the
// remaining NULL (genuinely untagged) rows, which is cheap.
if cfg.BackfillCap != 0 {
if _, tberr := BackfillTrackMBIDs(ctx, pool, logger, -1, nil); tberr != nil {
captureErr("track_mbid_backfill", tberr)
}
}
// Stage 3: cover enrichment.
if enricher != nil && cfg.EnrichCap != 0 {
var lastP, lastS, lastF int
coverPub := newStagePublisher(ctx, 500, time.Second,
func() []byte {
blob, _ := json.Marshal(CoverEnrichStageTallies{
Processed: lastP, Succeeded: lastS, Failed: lastF,
})
return blob
},
func(blob []byte) error {
return q.UpdateScanRunCoverEnrich(ctx, dbq.UpdateScanRunCoverEnrichParams{
ID: row.ID, CoverEnrich: blob,
})
},
)
processed, succeeded, failed, ceerr := enricher.EnrichBatch(ctx, cfg.EnrichCap, func(p, s, f int) {
lastP, lastS, lastF = p, s, f
coverPub.Tick()
})
lastP, lastS, lastF = processed, succeeded, failed
if err := coverPub.Flush(); err != nil {
captureErr("cover_enrich_persist", err)
}
if ceerr != nil {
captureErr("cover_enrich", ceerr)
}
}
// Stage 4: artist art enrichment.
if enricher != nil && cfg.ArtistEnrichCap != 0 && cfg.DataDir != "" {
var lastP, lastS, lastF int
aaPub := newStagePublisher(ctx, 500, time.Second,
func() []byte {
blob, _ := json.Marshal(ArtistArtEnrichStageTallies{
Processed: lastP, Succeeded: lastS, Failed: lastF,
})
return blob
},
func(blob []byte) error {
return q.UpdateScanRunArtistArtEnrich(ctx, dbq.UpdateScanRunArtistArtEnrichParams{
ID: row.ID, ArtistArtEnrich: blob,
})
},
)
processed, succeeded, failed, ceerr := enricher.EnrichArtistBatch(ctx, cfg.ArtistEnrichCap, cfg.DataDir, func(p, s, f int) {
lastP, lastS, lastF = p, s, f
aaPub.Tick()
})
lastP, lastS, lastF = processed, succeeded, failed
if err := aaPub.Flush(); err != nil {
captureErr("artist_art_enrich_persist", err)
}
if ceerr != nil {
captureErr("artist_art_enrich", ceerr)
}
}
// Finalize: set finished_at + error message (or empty string if all stages succeeded).
errMsg := ""
if firstErr != nil {
errMsg = firstErr.Error()
}
if ferr := q.FinishScanRun(ctx, dbq.FinishScanRunParams{
ID: row.ID, Column2: errMsg,
}); ferr != nil {
// Even the finalize failed; log loudly, return the original error if any.
logger.Error("scan run finalize failed", "id", row.ID, "err", ferr)
if firstErr != nil {
return row.ID, firstErr
}
return row.ID, ferr
}
logger.Info("scan run complete", "id", row.ID, "error", errMsg)
publishScanEvent("scan.run_finished", row.ID, map[string]any{
"error_message": errMsg,
})
return row.ID, firstErr
}