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) }