mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-01 07:21:17 +00:00
* refactor(scanner): make tag value splitting position-based Replaces the ZWSP substitution trick with index-based cutting, in preparation for artist split exceptions, which need match positions. * feat(scanner): protect whitelisted names in tag value splitting Separator matches inside word-bounded exception matches no longer split. Matching is case-insensitive and longest-first; boundaries are rune-aware. * feat(scanner): add Scanner.ArtistSplitExceptions config option * feat(scanner): honor artist split exceptions for participant tags Applies Scanner.ArtistSplitExceptions to artist, albumartist and role tag splitting. Generic tags (genre, mood, ...) are unaffected. * fix(scanner): apply split exceptions when per-tag Split overrides participant tags Per-tag Tags.<name>.Split makes the generic ingestion path split the tag before participant mapping runs, bypassing the whitelist. Attach the exceptions to participant tag mappings (including sort variants) in clean(). * feat(scanner): split performer names and honor split exceptions Performer pair values were never split; multiple names in one PERFORMER value stayed a single artist. Split them with the roles separators, using the same whitelist protection as other participant tags. * test(scanner): lock MBID ordering for split performer values * refactor(scanner): consolidate split-exception wiring and drop hot-path lock ArtistSplitExceptionsRx is called per tag mapping per scanned file across concurrent goroutines; replace the mutex+joined-key cache with an atomic pointer compared via slices.Equal. Route all participant call sites through WithParticipantExceptions and a shared splitParticipantValues helper. * refactor(scanner): unexport artistSplitExceptionsRx All external callers go through WithParticipantExceptions, so the accessor does not need to be part of the model package API.
895 lines
32 KiB
Go
895 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(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"))
|
|
})
|
|
})
|
|
})
|