e2432caa65
tracks.mbid was 100% NULL: the scanner only extracted album + artist MBIDs, never the recording MBID. The ListenBrainz similarity worker is gated on tracks.mbid IS NOT NULL, so track_similarity could never populate — starving For-You/radio's strongest candidate source (lb_similar) on every library. - mbids.go: add extractRecordingMBID (mbz.Recording / Picard musicbrainz_recordingid). Separate fn so extractMBIDs' signature + unit tests stay untouched. - scanner.go: persist recording MBID via UpsertTrack (heals on the file_path conflict, so re-scans backfill for free). - BackfillTrackMBIDs: one-shot pass mirroring BackfillMBIDs, wired as scan Stage 2b (idempotent via SetTrackMbidIfNull, gated by BackfillCap, log-only progress). - migration 0029: tracks_mbid_unique (0002, written when the column was always NULL) wrongly assumes one MBID == one track. A recording appears on multiple releases, so rows legitimately share a recording MBID. Replace with a non-unique partial index. Zero-risk: column is 100% NULL at migration time. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
263 lines
8.1 KiB
Go
263 lines
8.1 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)
|
|
}
|
|
|
|
// BackfillTrackMBIDsResult tallies the track recording-MBID backfill.
|
|
type BackfillTrackMBIDsResult struct {
|
|
Processed int // Tracks considered (mbid IS NULL).
|
|
Healed int // Tracks whose recording MBID we set.
|
|
Skipped int // Tracks whose file was missing / untagged / write failed.
|
|
}
|
|
|
|
// BackfillTrackMBIDs walks tracks with NULL mbid, re-reads each file's
|
|
// tags, and persists the MusicBrainz recording MBID. This is the
|
|
// counterpart to BackfillMBIDs (which heals album/artist MBIDs); it
|
|
// unblocks the ListenBrainz similarity pipeline, which is gated on
|
|
// tracks.mbid IS NOT NULL.
|
|
//
|
|
// Idempotent via SetTrackMbidIfNull: re-running costs the same N file
|
|
// reads but is a no-op for already-healed rows. limit caps one call;
|
|
// pass -1 for unbounded (caller normally batches per scan).
|
|
func BackfillTrackMBIDs(ctx context.Context, pool *pgxpool.Pool, logger *slog.Logger,
|
|
limit int, progressCb func(BackfillTrackMBIDsResult)) (BackfillTrackMBIDsResult, error) {
|
|
q := dbq.New(pool)
|
|
queryLimit := int32(limit)
|
|
if limit < 0 {
|
|
queryLimit = 1<<31 - 1 // effectively unbounded
|
|
}
|
|
|
|
rows, err := q.ListTracksMissingMbidWithPath(ctx, queryLimit)
|
|
if err != nil {
|
|
return BackfillTrackMBIDsResult{}, fmt.Errorf("list tracks missing mbid: %w", err)
|
|
}
|
|
|
|
var res BackfillTrackMBIDsResult
|
|
for i, r := range rows {
|
|
if ctx.Err() != nil {
|
|
return res, ctx.Err()
|
|
}
|
|
res.Processed++
|
|
|
|
rec := readRecordingMBIDForFile(r.FilePath, logger)
|
|
if rec == "" {
|
|
res.Skipped++
|
|
if progressCb != nil {
|
|
progressCb(res)
|
|
}
|
|
continue
|
|
}
|
|
|
|
m := rec
|
|
if uerr := q.SetTrackMbidIfNull(ctx, dbq.SetTrackMbidIfNullParams{
|
|
ID: r.ID, Mbid: &m,
|
|
}); uerr != nil {
|
|
logger.Warn("track mbid backfill: set failed",
|
|
"track_id", r.ID, "err", uerr)
|
|
res.Skipped++
|
|
if progressCb != nil {
|
|
progressCb(res)
|
|
}
|
|
continue
|
|
}
|
|
|
|
res.Healed++
|
|
if (i+1)%500 == 0 {
|
|
logger.Info("track mbid backfill progress",
|
|
"processed", res.Processed, "healed", res.Healed, "skipped", res.Skipped)
|
|
}
|
|
if progressCb != nil {
|
|
progressCb(res)
|
|
}
|
|
}
|
|
|
|
logger.Info("track mbid backfill complete",
|
|
"processed", res.Processed, "healed", res.Healed, "skipped", res.Skipped)
|
|
return res, nil
|
|
}
|
|
|
|
// readRecordingMBIDForFile opens one audio file and returns its
|
|
// MusicBrainz recording MBID, or "" when the file can't be opened, the
|
|
// tag read fails, or the tag is absent. Errors are logged at Warn, not
|
|
// propagated — one broken file must not halt the backfill.
|
|
func readRecordingMBIDForFile(path string, logger *slog.Logger) string {
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
logger.Warn("track mbid backfill: open failed", "path", path, "err", err)
|
|
return ""
|
|
}
|
|
defer func() { _ = f.Close() }()
|
|
|
|
meta, err := tag.ReadFrom(f)
|
|
if err != nil {
|
|
logger.Warn("track mbid backfill: tag read failed", "path", path, "err", err)
|
|
return ""
|
|
}
|
|
return extractRecordingMBID(meta)
|
|
}
|