fix: FLAC, Ogg and Opus tracks carry their MusicBrainz recording id from the tag: one reader in library gives the scanner, the backfills and the importer the same ids, and the next scan re-reads the library (#5637)
release / govulncheck (push) Successful in 20s
release / web (push) Successful in 1m30s
release / go (push) Successful in 1m50s
release / integration (push) Successful in 5m19s
release / android (push) Successful in 5m50s
release / Build signed APK (releases and dev) (push) Successful in 6m12s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 16s
release / Verify release artifacts (tag releases only) (push) Skipped

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-11 15:55:25 -04:00
co-authored by Claude Opus 5.5
parent c1305a547d
commit 9e7d50b38e
5 changed files with 109 additions and 76 deletions
+2 -20
View File
@@ -10,7 +10,6 @@ import (
"strings"
"github.com/dhowden/tag"
"github.com/dhowden/tag/mbz"
"git.fabledsword.com/bvandeusen/minstrel/internal/acoustid"
"git.fabledsword.com/bvandeusen/minstrel/internal/library"
@@ -136,15 +135,8 @@ func readFile(ctx context.Context, root, path string) File {
f.Title, f.Artist, f.AlbumArtist, f.Album = m.Title(), m.Artist(), m.AlbumArtist(), m.Album()
f.Track, _ = m.Track()
f.Disc, _ = m.Disc()
info := mbz.Extract(m)
f.GroupMBID = clean(info.Get(mbz.ReleaseGroup))
f.ReleaseMBID = clean(info.Get(mbz.Album))
f.RecordingMBID = clean(info.Get(mbz.Recording))
if f.RecordingMBID == "" && m.Format() == tag.VORBIS {
// Picard's Vorbis key for the recording is MUSICBRAINZ_TRACKID, which
// mbz files under its release-track name.
f.RecordingMBID = clean(info.Get(mbz.Track))
}
ids := library.ReadTagMBIDs(m)
f.GroupMBID, f.ReleaseMBID, f.RecordingMBID = ids.ReleaseGroup, ids.Release, ids.Recording
return f
}
@@ -155,16 +147,6 @@ func rel(root, path string) string {
return filepath.Base(path)
}
// clean takes the first of NUL-separated values: ID3v2.4 multi-values.
func clean(s string) string {
for _, part := range strings.Split(s, "\x00") {
if t := strings.TrimSpace(part); t != "" {
return t
}
}
return ""
}
// inspect reads every audio file under root and asks the printer about each.
// fingerprinted reports whether the printer answered at all.
func inspect(ctx context.Context, root string, p Printer) (files []File, fingerprinted bool, err error) {
+3 -2
View File
@@ -165,7 +165,8 @@ func readMBIDsForFile(path string, logger *slog.Logger) (albumMBID, artistMBID s
logger.Warn("mbid backfill: tag read failed", "path", path, "err", err)
return "", ""
}
return extractMBIDs(meta)
ids := ReadTagMBIDs(meta)
return ids.Release, ids.Artist
}
// BackfillTrackMBIDsResult tallies the track recording-MBID backfill.
@@ -258,5 +259,5 @@ func readRecordingMBIDForFile(path string, logger *slog.Logger) string {
logger.Warn("track mbid backfill: tag read failed", "path", path, "err", err)
return ""
}
return extractRecordingMBID(meta)
return ReadTagMBIDs(meta).Recording
}
+43 -40
View File
@@ -7,54 +7,57 @@ import (
"github.com/dhowden/tag/mbz"
)
// extractMBIDs reads MusicBrainz album and artist IDs from a tag.Metadata.
// TagMBIDs is what a file's MusicBrainz Picard tags name. Empty means the
// tag is absent.
type TagMBIDs struct {
// Release is musicbrainz_albumid: one edition, what albums.mbid stores.
Release string
Artist string
// ReleaseGroup is musicbrainz_releasegroupid. Wants name albums by
// release group, so re-acquisition and request completion key on it
// (#5241).
ReleaseGroup string
// Recording is what tracks.mbid stores and the ListenBrainz Labs
// similar-recordings API keys on.
Recording string
}
// ReadTagMBIDs reads a file's MusicBrainz IDs, for the scanner, the MBID
// backfills and the importer alike.
//
// Delegates to dhowden/tag/mbz, which knows the per-format Raw() conventions:
// - Vorbis (FLAC/OGG) keys are lowercased by the parser.
// - Vorbis (FLAC/OGG/Opus) keys are lowercased by the parser.
// - ID3v2 (MP3) TXXX frames are stored under "TXXX"/"TXXX_N" with
// a *tag.Comm value whose Description holds the Picard tag name.
// - MP4 (M4A) freeform iTunes atoms are stored under their bare sub-name.
//
// Then sanitizes: dhowden's readTextWithDescrFrame (used for ID3v2 TXXX)
// does NOT strip the trailing/embedded NUL bytes that ID3v2.4 uses as
// frame terminator and multi-value separator — so the Text often comes
// out as "uuid\x00" or "uuid1\x00uuid2". Postgres rejects NULs in text
// columns (SQLSTATE 22021). We split on NUL and take the first non-empty
// segment — Minstrel uses the primary release artist for MBCAA lookup,
// so first-wins matches the original semantics.
func extractMBIDs(m tag.Metadata) (albumMBID, artistMBID string) {
// mbz maps keys by name alone, and Picard names the recording differently
// in Vorbis: MUSICBRAINZ_TRACKID there is the recording (the release track
// is MUSICBRAINZ_RELEASETRACKID), so mbz files it under mbz.Track, its name
// for the release track. Read mbz.Recording alone and every FLAC has no
// recording id (#5637).
func ReadTagMBIDs(m tag.Metadata) TagMBIDs {
info := mbz.Extract(m)
return cleanMBID(info.Get(mbz.Album)), cleanMBID(info.Get(mbz.Artist))
}
// extractRecordingMBID reads the MusicBrainz *recording* ID — Picard's
// musicbrainz_recordingid, surfaced by dhowden/tag as mbz.Recording
// (tag display name "MusicBrainz Track Id"). This is the id the
// ListenBrainz Labs similar-recordings API keys on and what tracks.mbid
// stores.
//
// Deliberately NOT mbz.Track ("MusicBrainz Release Track Id"): that is
// per-release-track, whereas similarity is per-recording. Kept separate
// from extractMBIDs so its existing (album, artist) signature and unit
// tests stay untouched.
func extractRecordingMBID(m tag.Metadata) string {
return cleanMBID(mbz.Extract(m).Get(mbz.Recording))
}
// extractReleaseGroupMBID reads the MusicBrainz *release-group* ID —
// Picard's musicbrainz_releasegroupid, surfaced by dhowden/tag as
// mbz.ReleaseGroup. mbz.Album (what albums.mbid stores) is the release: one
// edition. Wants name albums by release group, so re-acquisition and
// request completion key on this one (#5241). Separate from extractMBIDs for
// the same reason extractRecordingMBID is.
func extractReleaseGroupMBID(m tag.Metadata) string {
return cleanMBID(mbz.Extract(m).Get(mbz.ReleaseGroup))
}
func cleanMBID(s string) string {
if s == "" {
return ""
ids := TagMBIDs{
Release: cleanMBID(info.Get(mbz.Album)),
Artist: cleanMBID(info.Get(mbz.Artist)),
ReleaseGroup: cleanMBID(info.Get(mbz.ReleaseGroup)),
Recording: cleanMBID(info.Get(mbz.Recording)),
}
if ids.Recording == "" && m.Format() == tag.VORBIS {
ids.Recording = cleanMBID(info.Get(mbz.Track))
}
return ids
}
// cleanMBID takes the first non-empty NUL-separated value. dhowden's
// readTextWithDescrFrame (used for ID3v2 TXXX) does NOT strip the
// trailing/embedded NUL bytes that ID3v2.4 uses as frame terminator and
// multi-value separator — so the Text often comes out as "uuid\x00" or
// "uuid1\x00uuid2". Postgres rejects NULs in text columns (SQLSTATE 22021).
// Minstrel uses the primary release artist for MBCAA lookup, so first-wins
// matches the original semantics.
func cleanMBID(s string) string {
for _, part := range strings.Split(s, "\x00") {
if t := strings.TrimSpace(part); t != "" {
return t
+52 -10
View File
@@ -40,7 +40,8 @@ func TestExtractMBIDs_Vorbis(t *testing.T) {
"musicbrainz_artistid": "def",
},
}
a, ar := extractMBIDs(m)
ids := ReadTagMBIDs(m)
a, ar := ids.Release, ids.Artist
if a != "abc" || ar != "def" {
t.Errorf("got (%q, %q), want (\"abc\", \"def\")", a, ar)
}
@@ -62,7 +63,8 @@ func TestExtractMBIDs_ID3v2(t *testing.T) {
},
},
}
a, ar := extractMBIDs(m)
ids := ReadTagMBIDs(m)
a, ar := ids.Release, ids.Artist
if a != "abc-123" || ar != "def-456" {
t.Errorf("got (%q, %q), want (\"abc-123\", \"def-456\")", a, ar)
}
@@ -78,7 +80,8 @@ func TestExtractMBIDs_MP4(t *testing.T) {
"MusicBrainz Artist Id": "def",
},
}
a, ar := extractMBIDs(m)
ids := ReadTagMBIDs(m)
a, ar := ids.Release, ids.Artist
if a != "abc" || ar != "def" {
t.Errorf("got (%q, %q), want (\"abc\", \"def\")", a, ar)
}
@@ -86,7 +89,8 @@ func TestExtractMBIDs_MP4(t *testing.T) {
func TestExtractMBIDs_MissingTagsReturnEmpty(t *testing.T) {
m := stubMeta{format: tag.VORBIS, raw: map[string]interface{}{}}
a, ar := extractMBIDs(m)
ids := ReadTagMBIDs(m)
a, ar := ids.Release, ids.Artist
if a != "" || ar != "" {
t.Errorf("got (%q, %q), want empty", a, ar)
}
@@ -104,7 +108,8 @@ func TestExtractMBIDs_VorbisUppercaseKeysMiss(t *testing.T) {
"MUSICBRAINZ_ARTISTID": "wrong-case",
},
}
a, ar := extractMBIDs(m)
ids := ReadTagMBIDs(m)
a, ar := ids.Release, ids.Artist
if a != "" || ar != "" {
t.Errorf("uppercase Vorbis keys must not match dhowden output; got (%q, %q)", a, ar)
}
@@ -127,7 +132,8 @@ func TestExtractMBIDs_ID3v2StripsTrailingNUL(t *testing.T) {
},
},
}
a, ar := extractMBIDs(m)
ids := ReadTagMBIDs(m)
a, ar := ids.Release, ids.Artist
if a != "abc-123" || ar != "def-456" {
t.Errorf("got (%q, %q), want trailing NUL stripped", a, ar)
}
@@ -146,7 +152,7 @@ func TestExtractMBIDs_ID3v2MultiValueTakesFirst(t *testing.T) {
},
},
}
_, ar := extractMBIDs(m)
ar := ReadTagMBIDs(m).Artist
if ar != "primary-mbid" {
t.Errorf("got %q, want %q", ar, "primary-mbid")
}
@@ -186,17 +192,53 @@ func TestExtractReleaseGroupMBID(t *testing.T) {
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := extractReleaseGroupMBID(c.meta); got != "group-1" {
if got := ReadTagMBIDs(c.meta).ReleaseGroup; got != "group-1" {
t.Errorf("release group = %q, want group-1", got)
}
if album, _ := extractMBIDs(c.meta); album != "release-1" {
if album := ReadTagMBIDs(c.meta).Release; album != "release-1" {
t.Errorf("album (release) = %q, want release-1", album)
}
})
}
none := stubMeta{format: tag.VORBIS, raw: map[string]interface{}{"musicbrainz_albumid": "release-1"}}
if got := extractReleaseGroupMBID(none); got != "" {
if got := ReadTagMBIDs(none).ReleaseGroup; got != "" {
t.Errorf("untagged release group = %q, want empty", got)
}
}
// #5637: each format's recording id lands in Recording, and the release-track
// id never does. Picard calls the recording MUSICBRAINZ_TRACKID in Vorbis
// comments, the name mbz gives the release track.
func TestReadTagMBIDs_Recording(t *testing.T) {
cases := []struct {
name string
meta stubMeta
}{
{"vorbis", stubMeta{format: tag.VORBIS, raw: map[string]interface{}{
"musicbrainz_trackid": "rec-1",
"musicbrainz_releasetrackid": "reltrack-1",
}}},
{"id3v2 ufid", stubMeta{format: tag.ID3v2_4, raw: map[string]interface{}{
"UFID": &tag.UFID{Provider: "http://musicbrainz.org", Identifier: []byte("rec-1")},
"TXXX_0": &tag.Comm{Description: "MusicBrainz Release Track Id", Text: "reltrack-1\x00"},
}}},
{"mp4", stubMeta{format: tag.MP4, raw: map[string]interface{}{
"MusicBrainz Track Id": "rec-1",
"MusicBrainz Release Track Id": "reltrack-1",
}}},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := ReadTagMBIDs(c.meta).Recording; got != "rec-1" {
t.Errorf("recording = %q, want rec-1", got)
}
})
}
// A Vorbis file carrying only the release-track id has no recording id.
only := stubMeta{format: tag.VORBIS, raw: map[string]interface{}{"musicbrainz_releasetrackid": "reltrack-1"}}
if got := ReadTagMBIDs(only).Recording; got != "" {
t.Errorf("release track only: recording = %q, want empty", got)
}
}
+9 -4
View File
@@ -64,7 +64,12 @@ var audioExtensions = map[string]bool{
// and MP4 (#2500). dhowden/tag kept only the last, and 3,658 of the
// operator's 4,092 FLACs declare more than one, so most of their genres
// were never stored.
const tagReadVersion int16 = 4
// 5: the recording id is read from FLAC, Ogg Vorbis and Opus (#5637). Picard
// calls it MUSICBRAINZ_TRACKID there and dhowden/tag files that under the
// release-track name, so every Vorbis-tagged track came in without its
// tag id and waited on an AcoustID lookup. A re-read stores the tag id,
// over a looked-up one (D4).
const tagReadVersion int16 = 5
type Stats struct {
Scanned int `json:"scanned"`
@@ -272,9 +277,9 @@ func (s *Scanner) scanFile(
if err != nil {
return pgtype.UUID{}, false, fmt.Errorf("tag read: %w", err)
}
albumMBID, artistMBID := extractMBIDs(meta)
recordingMBID := extractRecordingMBID(meta)
releaseGroupMBID := extractReleaseGroupMBID(meta)
ids := ReadTagMBIDs(meta)
albumMBID, artistMBID := ids.Release, ids.Artist
recordingMBID, releaseGroupMBID := ids.Recording, ids.ReleaseGroup
artistName := meta.Artist()
if artistName == "" {