Files
minstrel/internal/library/mbidbackfill.go
T
bvandeusen 3e9e46f190 feat(server/m7-scan-progress): wire stagePublisher into 4 scan stages
Each of the 4 stage workers (library walk, MBID backfill, cover
enrich, artist art enrich) gains a progressCb parameter that
receives a snapshot of the current running state per item processed.
The orchestrator stores the snapshot in a local var that the
publisher's marshal closure reads at flush time.

scanrun.go's 4 stage blocks are rewritten to construct one publisher
per stage, pass a Tick-firing closure to the worker as progressCb,
and call Flush() after the worker returns (routing any persist error
to captureErr — preserves today's diagnostic behavior).

The previous post-stage UpdateScanRun* calls are removed; the
publisher's Flush handles the final write. Cadence: every 500 items
OR every 1s, whichever fires first.

Outside-orchestrator callers (scanner_test.go, enricher_test.go)
pass nil for progressCb — no-op behavior preserved for tests that
don't need progress observability.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 21:14:50 -04:00

170 lines
5.2 KiB
Go

package library
import (
"context"
"errors"
"fmt"
"log/slog"
"os"
"github.com/dhowden/tag"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
// pgUniqueViolation is the SQLSTATE for a unique-constraint conflict.
const pgUniqueViolation = "23505"
// isUniqueViolation reports whether err is a Postgres unique-constraint
// conflict. Used to distinguish "another row already owns this MBID"
// (a duplicate album/artist row in our DB, the operator should merge
// it) from genuine write failures.
func isUniqueViolation(err error) bool {
var pgErr *pgconn.PgError
return errors.As(err, &pgErr) && pgErr.Code == pgUniqueViolation
}
// BackfillMBIDsResult tallies what the one-shot worker did.
type BackfillMBIDsResult struct {
Processed int // Albums considered (mbid IS NULL).
Healed int // Albums whose mbid we set.
Skipped int // Albums where the file was missing or had no MBID tag.
Duplicates int // Albums whose mbid is already held by another row (operator-resolvable).
}
// BackfillMBIDs walks albums with NULL mbid, opens one track per album,
// extracts MusicBrainz IDs from the tags, and persists them. Also heals
// the artist's mbid when the same tag-read yields one. When a row is
// healed, any 'none' cover_art_source on the album is cleared back to
// NULL so the enricher batch retries.
//
// Intended use: one-shot at server boot. Re-running it after the first
// pass costs the same N file reads but is a no-op for healed rows.
//
// limit caps how many albums are processed in one call. Pass -1 for no
// cap. The caller normally batches (e.g. limit=500 per boot) to avoid
// stalling startup on huge libraries.
func BackfillMBIDs(ctx context.Context, pool *pgxpool.Pool, logger *slog.Logger,
limit int, progressCb func(BackfillMBIDsResult)) (BackfillMBIDsResult, error) {
q := dbq.New(pool)
queryLimit := int32(limit)
if limit < 0 {
queryLimit = 1<<31 - 1 // effectively unbounded
}
rows, err := q.ListAlbumsMissingMbidWithTrack(ctx, queryLimit)
if err != nil {
return BackfillMBIDsResult{}, fmt.Errorf("list missing mbid: %w", err)
}
var res BackfillMBIDsResult
for i, r := range rows {
if ctx.Err() != nil {
return res, ctx.Err()
}
res.Processed++
albumMBID, artistMBID := readMBIDsForFile(r.TrackFilePath, logger)
if albumMBID == "" {
res.Skipped++
if progressCb != nil {
progressCb(res)
}
continue
}
// Heal album.
m := albumMBID
if err := q.SetAlbumMbidIfNull(ctx, dbq.SetAlbumMbidIfNullParams{
ID: r.AlbumID, Mbid: &m,
}); err != nil {
if isUniqueViolation(err) {
// Another album row already owns this MBID — i.e. our DB
// has two rows for the same MusicBrainz release (split by
// title/artist disagreements during the pre-MBID scan).
// Operator must merge them; for now, leave this row's
// mbid NULL and skip the artist + cover-clear steps.
logger.Info("mbid backfill: duplicate album mbid (canonical row already owns it)",
"album_id", r.AlbumID, "mbid", albumMBID)
res.Duplicates++
if progressCb != nil {
progressCb(res)
}
continue
}
logger.Warn("mbid backfill: set album mbid failed",
"album_id", r.AlbumID, "err", err)
res.Skipped++
if progressCb != nil {
progressCb(res)
}
continue
}
// Heal artist (if we got one).
if artistMBID != "" {
am := artistMBID
if err := q.SetArtistMbidIfNull(ctx, dbq.SetArtistMbidIfNullParams{
ID: r.ArtistID, Mbid: &am,
}); err != nil {
if isUniqueViolation(err) {
logger.Info("mbid backfill: duplicate artist mbid (canonical row already owns it)",
"artist_id", r.ArtistID, "mbid", artistMBID)
} else {
logger.Warn("mbid backfill: set artist mbid failed",
"artist_id", r.ArtistID, "err", err)
}
}
}
// Clear cover_art_source = 'none' so the cover enricher's next
// batch retries the MBCAA fetch for this album.
if err := q.ClearAlbumCoverNone(ctx, r.AlbumID); err != nil {
logger.Warn("mbid backfill: clear cover none failed",
"album_id", r.AlbumID, "err", err)
}
res.Healed++
// Progress log every 100 healed.
if (i+1)%100 == 0 {
logger.Info("mbid backfill progress",
"processed", res.Processed, "healed", res.Healed,
"skipped", res.Skipped, "duplicates", res.Duplicates)
}
if progressCb != nil {
progressCb(res)
}
}
logger.Info("mbid backfill complete",
"processed", res.Processed,
"healed", res.Healed,
"skipped", res.Skipped,
"duplicates", res.Duplicates)
return res, nil
}
// readMBIDsForFile opens a single audio file, reads tags via dhowden/tag,
// and returns extracted album + artist MBIDs. Returns ("", "") when the
// file can't be opened or the tag read fails — those errors are logged
// at Warn level but not propagated, so a single broken file doesn't
// halt the whole backfill.
func readMBIDsForFile(path string, logger *slog.Logger) (albumMBID, artistMBID string) {
f, err := os.Open(path)
if err != nil {
logger.Warn("mbid backfill: open failed", "path", path, "err", err)
return "", ""
}
defer func() { _ = f.Close() }()
meta, err := tag.ReadFrom(f)
if err != nil {
logger.Warn("mbid backfill: tag read failed", "path", path, "err", err)
return "", ""
}
return extractMBIDs(meta)
}