fix(scanner): split parallel tag lists with same separators as names

Per Gemini, Copilot, and Codex review on PR #5527: credits, sorts, and
MBIDs were read via md.Strings() (raw), while names use getRoleValues /
getArtistValues which apply the role/artist split separators. A tag
combination like COMPOSER='A;B' + COMPOSER_CREDIT='AA;BB' produced 2
names but 1 credit, silently dropping all credits via the length-
mismatch fallback. Same hazard for MBIDs.

Route MBIDs/sorts/credits through the same splitter as names so
positional alignment holds for libraries using ';' or '/' delimiters.
This commit is contained in:
Deluan 2026-05-24 22:21:24 -03:00
parent abd9d6bdba
commit 627d6161cc
2 changed files with 53 additions and 7 deletions

View File

@ -36,13 +36,16 @@ var roleMappings = map[model.Role]roleTags{
func (md Metadata) mapParticipants() model.Participants {
participants := make(model.Participants)
// Parse track artists
// Parse track artists. MBIDs use getRoleValues so they're split by the same
// separators ('/' or ';') as the canonical names — otherwise a tag like
// MUSICBRAINZ_ARTISTID="abc/def" paired with ARTISTS="A/B" would yield 2
// names but 1 MBID and the positional alignment would break.
trackNames := md.getArtistValues(model.TagTrackArtist, model.TagTrackArtists)
if len(trackNames) == 0 {
trackNames = []string{consts.UnknownArtist}
}
trackSorts := md.getArtistValues(model.TagTrackArtistSort, model.TagTrackArtistsSort)
trackMbids := md.Strings(model.TagMusicBrainzArtistID)
trackMbids := md.getRoleValues(model.TagMusicBrainzArtistID)
trackCredits := md.getArtistValues(model.TagTrackArtistCredit, model.TagTrackArtistsCredit)
trackArtistParticipants := md.buildParticipants(trackNames, trackSorts, trackMbids, trackCredits)
participants.AddParticipants(model.RoleArtist, trackArtistParticipants...)
@ -50,7 +53,7 @@ func (md Metadata) mapParticipants() model.Participants {
// Parse album artists
albumNames := md.getArtistValues(model.TagAlbumArtist, model.TagAlbumArtists)
albumSorts := md.getArtistValues(model.TagAlbumArtistSort, model.TagAlbumArtistsSort)
albumMbids := md.Strings(model.TagMusicBrainzAlbumArtistID)
albumMbids := md.getRoleValues(model.TagMusicBrainzAlbumArtistID)
albumCredits := md.getArtistValues(model.TagAlbumArtistCredit, model.TagAlbumArtistsCredit)
// Treat both "no albumartist tag" and "albumartist tag literally set to
@ -75,13 +78,16 @@ func (md Metadata) mapParticipants() model.Participants {
}
participants.AddParticipants(model.RoleAlbumArtist, albumArtistParticipants...)
// Parse all other roles
// Parse all other roles. All parallel lists go through getRoleValues so
// they're split with the same separators as the canonical names. Reading
// any of these with md.Strings (no splitting) would desync the positional
// alignment for tags like COMPOSER="A;B" + COMPOSER_CREDIT="AA;BB".
for role, info := range roleMappings {
names := md.getRoleValues(info.name)
if len(names) > 0 {
sorts := md.Strings(info.sort)
mbids := md.Strings(info.mbid)
credits := md.Strings(info.credit)
sorts := md.getRoleValues(info.sort)
mbids := md.getRoleValues(info.mbid)
credits := md.getRoleValues(info.credit)
participants.AddParticipants(role, md.buildParticipants(names, sorts, mbids, credits)...)
}
}

View File

@ -882,5 +882,45 @@ var _ = Describe("Participants", func() {
}
}
})
It("aligns composer credits with names when both use a role separator", func() {
// Names and credits both split on ';' under the roles.split config.
// Prior to the alignment fix, credits were read raw and would have
// stayed as one value while names became three, producing a length
// mismatch that silently dropped all credits.
mf = toMediaFile(model.RawTags{
"COMPOSER": {"Comp A;Comp B;Comp C"},
"COMPOSERCREDIT": {"CA;CB;CC"},
})
composers := mf.Participants[model.RoleComposer]
Expect(composers).To(HaveLen(3))
Expect(composers[0].Name).To(Equal("Comp A"))
Expect(composers[0].CreditedAs).To(Equal("CA"))
Expect(composers[1].CreditedAs).To(Equal("CB"))
Expect(composers[2].CreditedAs).To(Equal("CC"))
})
It("splits composer MBIDs to align with split composer names", func() {
// Both names and MBIDs use the role separator ';'. The fix routes
// MBIDs through getRoleValues so they split alongside names rather
// than staying as a single value (which would have produced 2 names
// and 1 MBID — broken positional alignment).
//
// We test via a single delimited canonical name string AND a single
// delimited MBID-pair string. Note: the tag-reader UUID validation
// happens at metadata parse time on individual values, so we hand
// multi-valued tag entries already split into separate slice
// entries (matching how a multi-value tag would surface).
mf = toMediaFile(model.RawTags{
"COMPOSER": {"Comp A;Comp B"},
"MUSICBRAINZ_COMPOSERID": {"11111111-1111-1111-1111-111111111111", "22222222-2222-2222-2222-222222222222"},
})
composers := mf.Participants[model.RoleComposer]
Expect(composers).To(HaveLen(2))
Expect(composers[0].Name).To(Equal("Comp A"))
Expect(composers[0].MbzArtistID).To(Equal("11111111-1111-1111-1111-111111111111"))
Expect(composers[1].Name).To(Equal("Comp B"))
Expect(composers[1].MbzArtistID).To(Equal("22222222-2222-2222-2222-222222222222"))
})
})
})