4481874482
Forward-fix from running the prior two commits in production. Backfill
on a real library exposed two further issues:
(1) gofmt -s flagged the column-aligned stubMeta methods in
mbids_test.go. Switched to the gofmt-canonical single-space form.
(2) Many albums failed to heal with SQLSTATE 23505 (unique constraint
albums_mbid_unique). Cause: the operator's library has duplicate
album rows in the DB — same MusicBrainz release, split into
multiple rows by the pre-MBID scanner because of subtle title or
artist disagreements between files. When backfill now correctly
extracts the MBID, two rows want to claim the same one and the
partial unique index rejects the second.
The conflict is correct DB behavior — we shouldn't have two rows
with the same MBID — but it's not a write failure to alarm the
operator about. Detect 23505 specifically:
- downgrade the log line from Warn to Info
- track these in a new BackfillMBIDsResult.Duplicates counter
(separate from Skipped, which retains its "no MBID in tag"
meaning)
- leave the duplicate row's mbid NULL; merging duplicates is a
separate (future) operator workflow
Same handling threaded through the scanner heal path so a regular
rescan doesn't generate the noise either.
JSON tally + admin UI gain a "Duplicates" line so the operator
can see how many duplicate rows their library carries.
Follow-up scope (separate task): a duplicate-album merge UX that
reparents the duplicate's tracks to the canonical row and deletes
the orphaned album row.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
157 lines
5.0 KiB
Go
157 lines
5.0 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) (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++
|
|
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++
|
|
continue
|
|
}
|
|
logger.Warn("mbid backfill: set album mbid failed",
|
|
"album_id", r.AlbumID, "err", err)
|
|
res.Skipped++
|
|
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)
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|