navidrome/model/metadata/map_participants_test.go
Deluan Quintão 758e64c999
feat(scanner): per-library PID configuration (#6252)
* feat(model): add per-library PID config columns

* refactor(metadata): pass PID config to ToMediaFile and add spec validation

* feat(scanner): rescan only libraries whose PID config changed

* feat(server): validate library PID config and rescan on change

* feat(ui): edit per-library PID config

* fix(ui): label the PID mode selects

* fix: tighten per-library PID rescan edge cases

An interrupted PID rescan no longer upgrades every library to a full scan, a save that loses the race for the scanner logs at debug, the confirm dialog only shows when the effective PID spec changes, and it now gets translation keys.

* refactor(metadata): pass the library to ToMediaFile

ToMediaFile and core.Inspect took the library ID and its PID config as
separate arguments, so a caller could mix values from two libraries. They
now take the model.Library and resolve the effective PID config from it.

* chore: tidy per-library PID comments, PropTypes and migration

Trim comments that restated the code, add PropTypes to the new UI
components, and recreate the migration with make migration-sql.

* fix(ui): show the PID spec help under its input

* feat(cmd): make inspect use the file's library PID config

inspect always used the global PID config, so it showed different IDs than
the scanner for files in a library with an override. It now finds the
file's library in the DB and uses its effective config, falling back to
the global config when there is no DB or the file is outside every
library. It never creates a DB. The library path matcher moves from
core/playlists to model so both can use it.

* refactor: simplify per-library PID code

Share the DB-file check between CLI commands, move ErrAlreadyScanning to
model so core no longer imports scanner, read the libraries once for
insights, and let ValidatePIDSpec accept an empty spec and look tags up
directly. In the scanner, use FullScanInProgress instead of a second
flag, and skip recomputing album IDs when the album spec did not change.
In the UI, share the PID inputs between Create and Edit, and use docsUrl.

* feat(ui): add section titles to Library Create and pre-fill Custom PID specs

Custom now starts from the global spec, so admins edit a working spec
instead of typing one from scratch.

* fix(inspect): map files with the library-relative path the scanner uses

Inspect gave metadata the file's directory as typed, so folder-based PIDs
never matched the DB. It now uses the path relative to the library root,
through the scanner's helper, which moves to model.

* fix(scanner): say when a PID rescan only covers target folders

* fix: reject tag aliases in album PID specs and match root libraries

Tags are stored under canonical names, so an alias in a spec always reads
as empty. In an album spec that gives every album the same ID, so album
specs now require the tag name. Track specs keep accepting aliases, since
the default one uses them. LibraryMatcher now matches paths under a
library at the filesystem root.

* refactor(model): move the tag alias lookup to tag_mappings.go

* test: run the library matcher and inspect tests on Windows

Build test paths with filepath instead of Unix literals, so they use the
OS separator like filepath.Abs output, and drop the Windows skips.

* feat(ui): add pt-BR translations for per-library PID settings
2026-10-02 05:05:32 -04:00

894 lines
32 KiB
Go

package metadata_test
import (
"os"
"github.com/google/uuid"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/metadata"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
. "github.com/onsi/gomega/gstruct"
"github.com/onsi/gomega/types"
)
var _ = Describe("Participants", func() {
var (
props metadata.Info
md metadata.Metadata
mf model.MediaFile
mbid1, mbid2, mbid3 string
)
BeforeEach(func() {
_, filePath, _ := tests.TempFile(GinkgoT(), "test", ".mp3")
fileInfo, _ := os.Stat(filePath)
mbid1 = uuid.NewString()
mbid2 = uuid.NewString()
mbid3 = uuid.NewString()
props = metadata.Info{
FileInfo: testFileInfo{fileInfo},
}
})
var toMediaFile = func(tags model.RawTags) model.MediaFile {
props.Tags = tags
md = metadata.New("filepath", props)
return md.ToMediaFile(model.Library{ID: 1}, "folderID")
}
Describe("ARTIST(S) tags", func() {
Context("No ARTIST/ARTISTS tags", func() {
BeforeEach(func() {
mf = toMediaFile(model.RawTags{})
})
It("should set the display name to Unknown Artist", func() {
Expect(mf.Artist).To(Equal("[Unknown Artist]"))
})
It("should set artist to Unknown Artist", func() {
Expect(mf.Artist).To(Equal("[Unknown Artist]"))
})
It("should add an Unknown Artist to participants", func() {
participants := mf.Participants
Expect(participants).To(HaveLen(2)) // ARTIST and ALBUMARTIST
artist := participants[model.RoleArtist][0]
Expect(artist.ID).ToNot(BeEmpty())
Expect(artist.Name).To(Equal("[Unknown Artist]"))
Expect(artist.OrderArtistName).To(Equal("[unknown artist]"))
Expect(artist.SortArtistName).To(BeEmpty())
Expect(artist.MbzArtistID).To(BeEmpty())
})
})
Context("Single-valued ARTIST tags, no ARTISTS tags", func() {
BeforeEach(func() {
mf = toMediaFile(model.RawTags{
"ARTIST": {"Artist Name"},
"ARTISTSORT": {"Name, Artist"},
"MUSICBRAINZ_ARTISTID": {mbid1},
})
})
It("should use the artist tag as display name", func() {
Expect(mf.Artist).To(Equal("Artist Name"))
})
It("should populate the participants", func() {
participants := mf.Participants
Expect(participants).To(HaveLen(2)) // ARTIST and ALBUMARTIST
Expect(participants).To(SatisfyAll(
HaveKeyWithValue(model.RoleArtist, HaveLen(1)),
))
Expect(mf.Artist).To(Equal("Artist Name"))
artist := participants[model.RoleArtist][0]
Expect(artist.ID).ToNot(BeEmpty())
Expect(artist.Name).To(Equal("Artist Name"))
Expect(artist.OrderArtistName).To(Equal("artist name"))
Expect(artist.SortArtistName).To(Equal("Name, Artist"))
Expect(artist.MbzArtistID).To(Equal(mbid1))
})
})
Context("Multiple values in a Single-valued ARTIST tags, no ARTISTS tags", func() {
BeforeEach(func() {
mf = toMediaFile(model.RawTags{
"ARTIST": {"Artist Name feat. Someone Else"},
"ARTISTSORT": {"Name, Artist feat. Else, Someone"},
"MUSICBRAINZ_ARTISTID": {mbid1},
})
})
It("should use the full string as display name", func() {
Expect(mf.Artist).To(Equal("Artist Name feat. Someone Else"))
Expect(mf.SortArtistName).To(Equal("Name, Artist"))
Expect(mf.OrderArtistName).To(Equal("artist name"))
})
It("should split the tag", func() {
participants := mf.Participants
Expect(participants).To(SatisfyAll(
HaveKeyWithValue(model.RoleArtist, HaveLen(2)),
))
By("adding the first artist to the participants")
artist0 := participants[model.RoleArtist][0]
Expect(artist0.ID).ToNot(BeEmpty())
Expect(artist0.Name).To(Equal("Artist Name"))
Expect(artist0.OrderArtistName).To(Equal("artist name"))
Expect(artist0.SortArtistName).To(Equal("Name, Artist"))
By("assuming the MBID is for the first artist")
Expect(artist0.MbzArtistID).To(Equal(mbid1))
By("adding the second artist to the participants")
artist1 := participants[model.RoleArtist][1]
Expect(artist1.ID).ToNot(BeEmpty())
Expect(artist1.Name).To(Equal("Someone Else"))
Expect(artist1.OrderArtistName).To(Equal("someone else"))
Expect(artist1.SortArtistName).To(Equal("Else, Someone"))
Expect(artist1.MbzArtistID).To(BeEmpty())
})
It("should split the tag using case-insensitive separators", func() {
mf = toMediaFile(model.RawTags{
"ARTIST": {"A1 FEAT. A2"},
})
participants := mf.Participants
Expect(participants).To(SatisfyAll(
HaveKeyWithValue(model.RoleArtist, HaveLen(2)),
))
artist1 := participants[model.RoleArtist][0]
Expect(artist1.Name).To(Equal("A1"))
artist2 := participants[model.RoleArtist][1]
Expect(artist2.Name).To(Equal("A2"))
})
It("should not add an empty artist after split", func() {
mf = toMediaFile(model.RawTags{
"ARTIST": {"John Doe / / Jane Doe"},
})
participants := mf.Participants
Expect(participants).To(HaveKeyWithValue(model.RoleArtist, HaveLen(2)))
artists := participants[model.RoleArtist]
Expect(artists[0].Name).To(Equal("John Doe"))
Expect(artists[1].Name).To(Equal("Jane Doe"))
})
})
Context("Multi-valued ARTIST tags, no ARTISTS tags", func() {
BeforeEach(func() {
mf = toMediaFile(model.RawTags{
"ARTIST": {"First Artist", "Second Artist"},
"ARTISTSORT": {"Name, First Artist", "Name, Second Artist"},
"MUSICBRAINZ_ARTISTID": {mbid1, mbid2},
})
})
It("should concatenate all ARTIST values as display name", func() {
Expect(mf.Artist).To(Equal("First Artist • Second Artist"))
})
It("should populate the participants with all artists", func() {
participants := mf.Participants
Expect(participants).To(HaveLen(2)) // ARTIST and ALBUMARTIST
Expect(participants).To(SatisfyAll(
HaveKeyWithValue(model.RoleArtist, HaveLen(2)),
))
artist0 := participants[model.RoleArtist][0]
Expect(artist0.ID).ToNot(BeEmpty())
Expect(artist0.Name).To(Equal("First Artist"))
Expect(artist0.OrderArtistName).To(Equal("first artist"))
Expect(artist0.SortArtistName).To(Equal("Name, First Artist"))
Expect(artist0.MbzArtistID).To(Equal(mbid1))
artist1 := participants[model.RoleArtist][1]
Expect(artist1.ID).ToNot(BeEmpty())
Expect(artist1.Name).To(Equal("Second Artist"))
Expect(artist1.OrderArtistName).To(Equal("second artist"))
Expect(artist1.SortArtistName).To(Equal("Name, Second Artist"))
Expect(artist1.MbzArtistID).To(Equal(mbid2))
})
})
Context("Single-valued ARTIST tag, single-valued ARTISTS tag, same values", func() {
BeforeEach(func() {
mf = toMediaFile(model.RawTags{
"ARTIST": {"Artist Name"},
"ARTISTS": {"Artist Name"},
"ARTISTSORT": {"Name, Artist"},
"MUSICBRAINZ_ARTISTID": {mbid1},
})
})
It("should use the ARTIST tag as display name", func() {
Expect(mf.Artist).To(Equal("Artist Name"))
})
It("should populate the participants with the ARTIST", func() {
participants := mf.Participants
Expect(participants).To(HaveLen(2)) // ARTIST and ALBUMARTIST
Expect(participants).To(SatisfyAll(
HaveKeyWithValue(model.RoleArtist, HaveLen(1)),
))
artist := participants[model.RoleArtist][0]
Expect(artist.ID).ToNot(BeEmpty())
Expect(artist.Name).To(Equal("Artist Name"))
Expect(artist.OrderArtistName).To(Equal("artist name"))
Expect(artist.SortArtistName).To(Equal("Name, Artist"))
Expect(artist.MbzArtistID).To(Equal(mbid1))
})
})
Context("Single-valued ARTIST tag, single-valued ARTISTS tag, different values", func() {
BeforeEach(func() {
mf = toMediaFile(model.RawTags{
"ARTIST": {"Artist Name"},
"ARTISTS": {"Artist Name 2"},
"ARTISTSORT": {"Name, Artist"},
"MUSICBRAINZ_ARTISTID": {mbid1},
})
})
It("should use the ARTIST tag as display name", func() {
Expect(mf.Artist).To(Equal("Artist Name"))
})
It("should use only artists from ARTISTS", func() {
participants := mf.Participants
Expect(participants).To(HaveLen(2)) // ARTIST and ALBUMARTIST
Expect(participants).To(SatisfyAll(
HaveKeyWithValue(model.RoleArtist, HaveLen(1)),
))
artist := participants[model.RoleArtist][0]
Expect(artist.ID).ToNot(BeEmpty())
Expect(artist.Name).To(Equal("Artist Name 2"))
Expect(artist.OrderArtistName).To(Equal("artist name 2"))
Expect(artist.SortArtistName).To(Equal("Name, Artist"))
Expect(artist.MbzArtistID).To(Equal(mbid1))
})
})
Context("No ARTIST tag, multi-valued ARTISTS tag", func() {
BeforeEach(func() {
mf = toMediaFile(model.RawTags{
"ARTISTS": {"First Artist", "Second Artist"},
"ARTISTSSORT": {"Name, First Artist", "Name, Second Artist"},
})
})
It("should concatenate ARTISTS as display name", func() {
Expect(mf.Artist).To(Equal("First Artist • Second Artist"))
})
It("should populate the participants with all artists", func() {
participants := mf.Participants
Expect(participants).To(HaveLen(2)) // ARTIST and ALBUMARTIST
Expect(participants).To(SatisfyAll(
HaveKeyWithValue(model.RoleArtist, HaveLen(2)),
))
artist0 := participants[model.RoleArtist][0]
Expect(artist0.ID).ToNot(BeEmpty())
Expect(artist0.Name).To(Equal("First Artist"))
Expect(artist0.OrderArtistName).To(Equal("first artist"))
Expect(artist0.SortArtistName).To(Equal("Name, First Artist"))
Expect(artist0.MbzArtistID).To(BeEmpty())
artist1 := participants[model.RoleArtist][1]
Expect(artist1.ID).ToNot(BeEmpty())
Expect(artist1.Name).To(Equal("Second Artist"))
Expect(artist1.OrderArtistName).To(Equal("second artist"))
Expect(artist1.SortArtistName).To(Equal("Name, Second Artist"))
Expect(artist1.MbzArtistID).To(BeEmpty())
})
})
Context("Single-valued ARTIST tags, multi-valued ARTISTS tags", func() {
BeforeEach(func() {
mf = toMediaFile(model.RawTags{
"ARTIST": {"First Artist & Second Artist"},
"ARTISTSORT": {"Name, First Artist & Name, Second Artist"},
"MUSICBRAINZ_ARTISTID": {mbid1, mbid2},
"ARTISTS": {"First Artist", "Second Artist"},
"ARTISTSSORT": {"Name, First Artist", "Name, Second Artist"},
})
})
It("should use the single-valued tag as display name", func() {
Expect(mf.Artist).To(Equal("First Artist & Second Artist"))
})
It("should prioritize multi-valued tags over single-valued tags", func() {
participants := mf.Participants
Expect(participants).To(HaveLen(2)) // ARTIST and ALBUMARTIST
Expect(participants).To(SatisfyAll(
HaveKeyWithValue(model.RoleArtist, HaveLen(2)),
))
artist0 := participants[model.RoleArtist][0]
Expect(artist0.ID).ToNot(BeEmpty())
Expect(artist0.Name).To(Equal("First Artist"))
Expect(artist0.OrderArtistName).To(Equal("first artist"))
Expect(artist0.SortArtistName).To(Equal("Name, First Artist"))
Expect(artist0.MbzArtistID).To(Equal(mbid1))
artist1 := participants[model.RoleArtist][1]
Expect(artist1.ID).ToNot(BeEmpty())
Expect(artist1.Name).To(Equal("Second Artist"))
Expect(artist1.OrderArtistName).To(Equal("second artist"))
Expect(artist1.SortArtistName).To(Equal("Name, Second Artist"))
Expect(artist1.MbzArtistID).To(Equal(mbid2))
})
})
// Not a good tagging strategy, but supported anyway.
Context("Multi-valued ARTIST tags, multi-valued ARTISTS tags", func() {
BeforeEach(func() {
mf = toMediaFile(model.RawTags{
"ARTIST": {"First Artist", "Second Artist"},
"ARTISTSORT": {"Name, First Artist", "Name, Second Artist"},
"MUSICBRAINZ_ARTISTID": {mbid1, mbid2},
"ARTISTS": {"First Artist 2", "Second Artist 2"},
"ARTISTSSORT": {"2, First Artist Name", "2, Second Artist Name"},
})
})
It("should use ARTIST values concatenated as a display name ", func() {
Expect(mf.Artist).To(Equal("First Artist • Second Artist"))
})
It("should prioritize ARTISTS tags", func() {
participants := mf.Participants
Expect(participants).To(HaveLen(2)) // ARTIST and ALBUMARTIST
Expect(participants).To(SatisfyAll(
HaveKeyWithValue(model.RoleArtist, HaveLen(2)),
))
artist0 := participants[model.RoleArtist][0]
Expect(artist0.ID).ToNot(BeEmpty())
Expect(artist0.Name).To(Equal("First Artist 2"))
Expect(artist0.OrderArtistName).To(Equal("first artist 2"))
Expect(artist0.SortArtistName).To(Equal("2, First Artist Name"))
Expect(artist0.MbzArtistID).To(Equal(mbid1))
artist1 := participants[model.RoleArtist][1]
Expect(artist1.ID).ToNot(BeEmpty())
Expect(artist1.Name).To(Equal("Second Artist 2"))
Expect(artist1.OrderArtistName).To(Equal("second artist 2"))
Expect(artist1.SortArtistName).To(Equal("2, Second Artist Name"))
Expect(artist1.MbzArtistID).To(Equal(mbid2))
})
})
})
Describe("ALBUMARTIST(S) tags", func() {
// Only test specific scenarios for ALBUMARTIST(S) tags, as the logic is the same as for ARTIST(S) tags.
Context("No ALBUMARTIST/ALBUMARTISTS tags", func() {
When("the COMPILATION tag is not set", func() {
BeforeEach(func() {
mf = toMediaFile(model.RawTags{
"ARTIST": {"Artist Name"},
"ARTISTSORT": {"Name, Artist"},
"MUSICBRAINZ_ARTISTID": {mbid1},
})
})
It("should use the ARTIST as ALBUMARTIST", func() {
Expect(mf.AlbumArtist).To(Equal("Artist Name"))
})
It("should add the ARTIST to participants as ALBUMARTIST", func() {
participants := mf.Participants
Expect(participants).To(HaveLen(2))
Expect(participants).To(SatisfyAll(
HaveKeyWithValue(model.RoleAlbumArtist, HaveLen(1)),
))
albumArtist := participants[model.RoleAlbumArtist][0]
Expect(albumArtist.ID).ToNot(BeEmpty())
Expect(albumArtist.Name).To(Equal("Artist Name"))
Expect(albumArtist.OrderArtistName).To(Equal("artist name"))
Expect(albumArtist.SortArtistName).To(Equal("Name, Artist"))
Expect(albumArtist.MbzArtistID).To(Equal(mbid1))
})
})
When("the COMPILATION tag is not set and there is no ALBUMARTIST tag", func() {
BeforeEach(func() {
mf = toMediaFile(model.RawTags{
"ARTIST": {"Artist Name", "Another Artist"},
"ARTISTSORT": {"Name, Artist", "Artist, Another"},
})
})
It("should use the first ARTIST as ALBUMARTIST", func() {
Expect(mf.AlbumArtist).To(Equal("Artist Name"))
})
It("should add the ARTIST to participants as ALBUMARTIST", func() {
participants := mf.Participants
Expect(participants).To(HaveLen(2))
Expect(participants).To(SatisfyAll(
HaveKeyWithValue(model.RoleAlbumArtist, HaveLen(2)),
))
albumArtist := participants[model.RoleAlbumArtist][0]
Expect(albumArtist.Name).To(Equal("Artist Name"))
Expect(albumArtist.SortArtistName).To(Equal("Name, Artist"))
albumArtist = participants[model.RoleAlbumArtist][1]
Expect(albumArtist.Name).To(Equal("Another Artist"))
Expect(albumArtist.SortArtistName).To(Equal("Artist, Another"))
})
})
When("the COMPILATION tag is true", func() {
BeforeEach(func() {
mf = toMediaFile(model.RawTags{
"COMPILATION": {"1"},
})
})
It("should use the Various Artists as display name", func() {
Expect(mf.AlbumArtist).To(Equal("Various Artists"))
})
It("should add the Various Artists to participants as ALBUMARTIST", func() {
participants := mf.Participants
Expect(participants).To(HaveLen(2))
Expect(participants).To(SatisfyAll(
HaveKeyWithValue(model.RoleAlbumArtist, HaveLen(1)),
))
albumArtist := participants[model.RoleAlbumArtist][0]
Expect(albumArtist.ID).ToNot(BeEmpty())
Expect(albumArtist.Name).To(Equal("Various Artists"))
Expect(albumArtist.OrderArtistName).To(Equal("various artists"))
Expect(albumArtist.SortArtistName).To(BeEmpty())
Expect(albumArtist.MbzArtistID).To(Equal(consts.VariousArtistsMbzId))
})
})
When("the COMPILATION tag is true and there are ALBUMARTIST tags", func() {
BeforeEach(func() {
mf = toMediaFile(model.RawTags{
"COMPILATION": {"1"},
"ALBUMARTIST": {"Album Artist Name 1", "Album Artist Name 2"},
})
})
It("should use the ALBUMARTIST names as display name", func() {
Expect(mf.AlbumArtist).To(Equal("Album Artist Name 1 • Album Artist Name 2"))
})
})
})
Context("ALBUMARTIST tag is set", func() {
BeforeEach(func() {
mf = toMediaFile(model.RawTags{
"ARTIST": {"Track Artist Name"},
"ARTISTSORT": {"Name, Track Artist"},
"MUSICBRAINZ_ARTISTID": {mbid1},
"ALBUMARTIST": {"Album Artist Name"},
"ALBUMARTISTSORT": {"Album Artist Sort Name"},
"MUSICBRAINZ_ALBUMARTISTID": {mbid2},
})
})
It("should use the ALBUMARTIST as display name", func() {
Expect(mf.AlbumArtist).To(Equal("Album Artist Name"))
})
It("should populate the participants with the ALBUMARTIST", func() {
participants := mf.Participants
Expect(participants).To(HaveLen(2))
Expect(participants).To(SatisfyAll(
HaveKeyWithValue(model.RoleAlbumArtist, HaveLen(1)),
))
albumArtist := participants[model.RoleAlbumArtist][0]
Expect(albumArtist.ID).ToNot(BeEmpty())
Expect(albumArtist.Name).To(Equal("Album Artist Name"))
Expect(albumArtist.OrderArtistName).To(Equal("album artist name"))
Expect(albumArtist.SortArtistName).To(Equal("Album Artist Sort Name"))
Expect(albumArtist.MbzArtistID).To(Equal(mbid2))
})
})
})
Describe("COMPOSER and LYRICIST tags (with sort names)", func() {
DescribeTable("should return the correct participation",
func(role model.Role, nameTag, sortTag string) {
mf = toMediaFile(model.RawTags{
nameTag: {"First Name", "Second Name"},
sortTag: {"Name, First", "Name, Second"},
})
participants := mf.Participants
Expect(participants).To(HaveKeyWithValue(role, HaveLen(2)))
p := participants[role]
Expect(p[0].ID).ToNot(BeEmpty())
Expect(p[0].Name).To(Equal("First Name"))
Expect(p[0].SortArtistName).To(Equal("Name, First"))
Expect(p[0].OrderArtistName).To(Equal("first name"))
Expect(p[1].ID).ToNot(BeEmpty())
Expect(p[1].Name).To(Equal("Second Name"))
Expect(p[1].SortArtistName).To(Equal("Name, Second"))
Expect(p[1].OrderArtistName).To(Equal("second name"))
},
Entry("COMPOSER", model.RoleComposer, "COMPOSER", "COMPOSERSORT"),
Entry("LYRICIST", model.RoleLyricist, "LYRICIST", "LYRICISTSORT"),
)
})
Describe("PERFORMER tags", func() {
When("PERFORMER tag is set", func() {
matchPerformer := func(name, orderName, subRole string) types.GomegaMatcher {
return MatchFields(IgnoreExtras, Fields{
"Artist": MatchFields(IgnoreExtras, Fields{
"Name": Equal(name),
"OrderArtistName": Equal(orderName),
}),
"SubRole": Equal(subRole),
})
}
It("should return the correct participation", func() {
mf = toMediaFile(model.RawTags{
"PERFORMER:GUITAR": {"Eric Clapton", "B.B. King"},
"PERFORMER:BASS": {"Nathan East"},
"PERFORMER:HAMMOND ORGAN": {"Tim Carmon"},
})
participants := mf.Participants
Expect(participants).To(HaveKeyWithValue(model.RolePerformer, HaveLen(4)))
p := participants[model.RolePerformer]
Expect(p).To(ContainElements(
matchPerformer("Eric Clapton", "eric clapton", "Guitar"),
matchPerformer("B.B. King", "b.b. king", "Guitar"),
matchPerformer("Nathan East", "nathan east", "Bass"),
matchPerformer("Tim Carmon", "tim carmon", "Hammond Organ"),
))
})
It("should split multiple names in a single value", func() {
mf = toMediaFile(model.RawTags{
"PERFORMER:GUITAR": {"Eric Clapton/B.B. King"},
"PERFORMER:BASS": {"Nathan East"},
})
participants := mf.Participants
Expect(participants).To(HaveKeyWithValue(model.RolePerformer, HaveLen(3)))
p := participants[model.RolePerformer]
Expect(p).To(ContainElements(
matchPerformer("Eric Clapton", "eric clapton", "Guitar"),
matchPerformer("B.B. King", "b.b. king", "Guitar"),
matchPerformer("Nathan East", "nathan east", "Bass"),
))
})
It("should assign MBIDs in order to names split from a single value", func() {
mf = toMediaFile(model.RawTags{
"PERFORMER:GUITAR": {"Eric Clapton/B.B. King"},
"MUSICBRAINZ_PERFORMERID:GUITAR": {mbid1, mbid2},
})
p := mf.Participants[model.RolePerformer]
Expect(p).To(HaveLen(2))
Expect(p[0].Name).To(Equal("Eric Clapton"))
Expect(p[0].MbzArtistID).To(Equal(mbid1))
Expect(p[1].Name).To(Equal("B.B. King"))
Expect(p[1].MbzArtistID).To(Equal(mbid2))
})
})
When("MUSICBRAINZ_PERFORMERID tag is set", func() {
matchPerformer := func(name, orderName, subRole, mbid string) types.GomegaMatcher {
return MatchFields(IgnoreExtras, Fields{
"Artist": MatchFields(IgnoreExtras, Fields{
"Name": Equal(name),
"OrderArtistName": Equal(orderName),
"MbzArtistID": Equal(mbid),
}),
"SubRole": Equal(subRole),
})
}
It("should map MBIDs to the correct performer", func() {
mf = toMediaFile(model.RawTags{
"PERFORMER:GUITAR": {"Eric Clapton", "B.B. King"},
"PERFORMER:BASS": {"Nathan East"},
"MUSICBRAINZ_PERFORMERID:GUITAR": {"mbid1", "mbid2"},
"MUSICBRAINZ_PERFORMERID:BASS": {"mbid3"},
})
participants := mf.Participants
Expect(participants).To(HaveKeyWithValue(model.RolePerformer, HaveLen(3)))
p := participants[model.RolePerformer]
Expect(p).To(ContainElements(
matchPerformer("Eric Clapton", "eric clapton", "Guitar", "mbid1"),
matchPerformer("B.B. King", "b.b. king", "Guitar", "mbid2"),
matchPerformer("Nathan East", "nathan east", "Bass", "mbid3"),
))
})
It("should handle mismatched performer names and MBIDs for sub-roles", func() {
mf = toMediaFile(model.RawTags{
"PERFORMER:VOCALS": {"Singer A", "Singer B", "Singer C"},
"MUSICBRAINZ_PERFORMERID:VOCALS": {"mbid_vocals_a", "mbid_vocals_b"}, // Fewer MBIDs
"PERFORMER:DRUMS": {"Drummer X"},
"MUSICBRAINZ_PERFORMERID:DRUMS": {"mbid_drums_x", "mbid_drums_y"}, // More MBIDs
})
participants := mf.Participants
Expect(participants).To(HaveKeyWithValue(model.RolePerformer, HaveLen(4))) // 3 vocalists + 1 drummer
p := participants[model.RolePerformer]
Expect(p).To(ContainElements(
matchPerformer("Singer A", "singer a", "Vocals", "mbid_vocals_a"),
matchPerformer("Singer B", "singer b", "Vocals", "mbid_vocals_b"),
matchPerformer("Singer C", "singer c", "Vocals", ""),
matchPerformer("Drummer X", "drummer x", "Drums", "mbid_drums_x"),
))
})
})
})
Describe("Other tags", func() {
DescribeTable("should return the correct participation",
func(role model.Role, tag string) {
mf = toMediaFile(model.RawTags{
tag: {"John Doe", "Jane Doe"},
})
participants := mf.Participants
Expect(participants).To(HaveKeyWithValue(role, HaveLen(2)))
p := participants[role]
Expect(p[0].ID).ToNot(BeEmpty())
Expect(p[0].Name).To(Equal("John Doe"))
Expect(p[0].OrderArtistName).To(Equal("john doe"))
Expect(p[1].ID).ToNot(BeEmpty())
Expect(p[1].Name).To(Equal("Jane Doe"))
Expect(p[1].OrderArtistName).To(Equal("jane doe"))
},
Entry("CONDUCTOR", model.RoleConductor, "CONDUCTOR"),
Entry("ARRANGER", model.RoleArranger, "ARRANGER"),
Entry("PRODUCER", model.RoleProducer, "PRODUCER"),
Entry("ENGINEER", model.RoleEngineer, "ENGINEER"),
Entry("MIXER", model.RoleMixer, "MIXER"),
Entry("REMIXER", model.RoleRemixer, "REMIXER"),
Entry("DJMIXER", model.RoleDJMixer, "DJMIXER"),
Entry("DIRECTOR", model.RoleDirector, "DIRECTOR"),
)
})
Describe("Role value splitting", func() {
When("the tag is single valued", func() {
It("should split the values by the configured separator", func() {
mf = toMediaFile(model.RawTags{
"COMPOSER": {"John Doe/Someone Else/The Album Artist"},
})
participants := mf.Participants
Expect(participants).To(HaveKeyWithValue(model.RoleComposer, HaveLen(3)))
composers := participants[model.RoleComposer]
Expect(composers[0].Name).To(Equal("John Doe"))
Expect(composers[1].Name).To(Equal("Someone Else"))
Expect(composers[2].Name).To(Equal("The Album Artist"))
})
It("should not add an empty participant after split", func() {
mf = toMediaFile(model.RawTags{
"COMPOSER": {"John Doe/"},
})
participants := mf.Participants
Expect(participants).To(HaveKeyWithValue(model.RoleComposer, HaveLen(1)))
composers := participants[model.RoleComposer]
Expect(composers[0].Name).To(Equal("John Doe"))
})
It("should trim the values", func() {
mf = toMediaFile(model.RawTags{
"COMPOSER": {"John Doe / Someone Else / The Album Artist"},
})
participants := mf.Participants
Expect(participants).To(HaveKeyWithValue(model.RoleComposer, HaveLen(3)))
composers := participants[model.RoleComposer]
Expect(composers[0].Name).To(Equal("John Doe"))
Expect(composers[1].Name).To(Equal("Someone Else"))
Expect(composers[2].Name).To(Equal("The Album Artist"))
})
})
// Sibling fix to https://github.com/navidrome/navidrome/issues/5065: when
// multiple frames map to the same role tag (e.g. TIPL producer entries),
// the configured split separator must still apply to each value.
When("the tag has multiple values", func() {
It("should split each value individually", func() {
mf = toMediaFile(model.RawTags{
"COMPOSER": {"John Doe/Jane Doe", "Someone Else"},
})
participants := mf.Participants
Expect(participants).To(HaveKeyWithValue(model.RoleComposer, HaveLen(3)))
composers := participants[model.RoleComposer]
Expect(composers).To(ConsistOf(
HaveField("Name", "John Doe"),
HaveField("Name", "Jane Doe"),
HaveField("Name", "Someone Else"),
))
})
})
})
Describe("MBID tags", func() {
It("should set the MBID for the artist based on the track/album artist", func() {
mf = toMediaFile(model.RawTags{
"ARTIST": {"John Doe", "Jane Doe"},
"MUSICBRAINZ_ARTISTID": {mbid1, mbid2},
"ALBUMARTIST": {"The Album Artist"},
"MUSICBRAINZ_ALBUMARTISTID": {mbid3},
"COMPOSER": {"John Doe", "Someone Else", "The Album Artist"},
"PRODUCER": {"Jane Doe", "John Doe"},
})
participants := mf.Participants
Expect(participants).To(HaveKeyWithValue(model.RoleComposer, HaveLen(3)))
composers := participants[model.RoleComposer]
Expect(composers[0].MbzArtistID).To(Equal(mbid1))
Expect(composers[1].MbzArtistID).To(BeEmpty())
Expect(composers[2].MbzArtistID).To(Equal(mbid3))
Expect(participants).To(HaveKeyWithValue(model.RoleProducer, HaveLen(2)))
producers := participants[model.RoleProducer]
Expect(producers[0].MbzArtistID).To(Equal(mbid2))
Expect(producers[1].MbzArtistID).To(Equal(mbid1))
})
})
Describe("Non-standard MBID tags", func() {
var allMappings = map[model.Role]model.TagName{
model.RoleComposer: model.TagMusicBrainzComposerID,
model.RoleLyricist: model.TagMusicBrainzLyricistID,
model.RoleConductor: model.TagMusicBrainzConductorID,
model.RoleArranger: model.TagMusicBrainzArrangerID,
model.RoleDirector: model.TagMusicBrainzDirectorID,
model.RoleProducer: model.TagMusicBrainzProducerID,
model.RoleEngineer: model.TagMusicBrainzEngineerID,
model.RoleMixer: model.TagMusicBrainzMixerID,
model.RoleRemixer: model.TagMusicBrainzRemixerID,
model.RoleDJMixer: model.TagMusicBrainzDJMixerID,
}
It("should handle more artists than mbids", func() {
for key := range allMappings {
mf = toMediaFile(map[string][]string{
key.String(): {"a", "b", "c"},
allMappings[key].String(): {"f634bf6d-d66a-425d-888a-28ad39392759", "3dfa3c70-d7d3-4b97-b953-c298dd305e12"},
})
participants := mf.Participants
Expect(participants).To(HaveKeyWithValue(key, HaveLen(3)))
roles := participants[key]
Expect(roles[0].Name).To(Equal("a"))
Expect(roles[1].Name).To(Equal("b"))
Expect(roles[2].Name).To(Equal("c"))
Expect(roles[0].MbzArtistID).To(Equal("f634bf6d-d66a-425d-888a-28ad39392759"))
Expect(roles[1].MbzArtistID).To(Equal("3dfa3c70-d7d3-4b97-b953-c298dd305e12"))
Expect(roles[2].MbzArtistID).To(Equal(""))
}
})
It("should handle more mbids than artists", func() {
for key := range allMappings {
mf = toMediaFile(map[string][]string{
key.String(): {"a", "b"},
allMappings[key].String(): {"f634bf6d-d66a-425d-888a-28ad39392759", "3dfa3c70-d7d3-4b97-b953-c298dd305e12"},
})
participants := mf.Participants
Expect(participants).To(HaveKeyWithValue(key, HaveLen(2)))
roles := participants[key]
Expect(roles[0].Name).To(Equal("a"))
Expect(roles[1].Name).To(Equal("b"))
Expect(roles[0].MbzArtistID).To(Equal("f634bf6d-d66a-425d-888a-28ad39392759"))
Expect(roles[1].MbzArtistID).To(Equal("3dfa3c70-d7d3-4b97-b953-c298dd305e12"))
}
})
It("should refuse duplicate names if no mbid specified", func() {
for key := range allMappings {
mf = toMediaFile(map[string][]string{
key.String(): {"a", "b", "a", "a"},
})
participants := mf.Participants
Expect(participants).To(HaveKeyWithValue(key, HaveLen(2)))
roles := participants[key]
Expect(roles[0].Name).To(Equal("a"))
Expect(roles[0].MbzArtistID).To(Equal(""))
Expect(roles[1].Name).To(Equal("b"))
Expect(roles[1].MbzArtistID).To(Equal(""))
}
})
})
Describe("Artist split exceptions", func() {
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
})
It("does not split a whitelisted artist name on the default separators", func() {
// " feat. " is a default artists separator (mappings.yaml)
conf.Server.Scanner.ArtistSplitExceptions = []string{"Someone feat. Else"}
mf = toMediaFile(model.RawTags{
"ARTIST": {"Artist Name feat. Someone feat. Else"},
})
artists := mf.Participants[model.RoleArtist]
Expect(artists).To(HaveLen(2))
Expect(artists[0].Name).To(Equal("Artist Name"))
Expect(artists[1].Name).To(Equal("Someone feat. Else"))
})
It("does not split a whitelisted name in role tags", func() {
// "/" is a default roles separator (mappings.yaml)
conf.Server.Scanner.ArtistSplitExceptions = []string{"AC/DC"}
mf = toMediaFile(model.RawTags{
"COMPOSER": {"AC/DC/John Doe"},
})
composers := mf.Participants[model.RoleComposer]
Expect(composers).To(HaveLen(2))
Expect(composers[0].Name).To(Equal("AC/DC"))
Expect(composers[1].Name).To(Equal("John Doe"))
})
It("splits normally when the exception does not match", func() {
conf.Server.Scanner.ArtistSplitExceptions = []string{"Iron and Wine"}
mf = toMediaFile(model.RawTags{
"ARTIST": {"Artist Name feat. Someone Else"},
})
artists := mf.Participants[model.RoleArtist]
Expect(artists).To(HaveLen(2))
Expect(artists[0].Name).To(Equal("Artist Name"))
Expect(artists[1].Name).To(Equal("Someone Else"))
})
It("does not split a whitelisted name in performer tags", func() {
conf.Server.Scanner.ArtistSplitExceptions = []string{"AC/DC"}
mf = toMediaFile(model.RawTags{
"PERFORMER:GUITAR": {"AC/DC/Brian Johnson"},
})
performers := mf.Participants[model.RolePerformer]
Expect(performers).To(HaveLen(2))
Expect(performers[0].Name).To(Equal("AC/DC"))
Expect(performers[1].Name).To(Equal("Brian Johnson"))
})
})
})