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