navidrome/model/metadata/persistent_ids_test.go

400 lines
14 KiB
Go

package metadata
import (
"strings"
"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/id"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("getPID", func() {
var (
md Metadata
mf model.MediaFile
sum hashFunc
)
getPID := func(mf model.MediaFile, md Metadata, spec string, prependLibId bool) string {
return computePID(mf, md, spec, prependLibId, sum)
}
BeforeEach(func() {
sum = func(s ...string) string { return "(" + strings.Join(s, ",") + ")" }
})
Context("attributes are tags", func() {
spec := "musicbrainz_trackid|album,discnumber,tracknumber"
When("no attributes were present", func() {
It("should return empty pid", func() {
md.tags = map[model.TagName][]string{}
pid := getPID(mf, md, spec, false)
Expect(pid).To(Equal("()"))
})
})
When("all fields are present", func() {
It("should return the pid", func() {
md.tags = map[model.TagName][]string{
"musicbrainz_trackid": {"mbtrackid"},
"album": {"album name"},
"discnumber": {"1"},
"tracknumber": {"1"},
}
Expect(getPID(mf, md, spec, false)).To(Equal("(mbtrackid)"))
})
})
When("only first field is present", func() {
It("should return the pid", func() {
md.tags = map[model.TagName][]string{
"musicbrainz_trackid": {"mbtrackid"},
}
Expect(getPID(mf, md, spec, false)).To(Equal("(mbtrackid)"))
})
})
When("first is empty, but second field is present", func() {
It("should return the pid", func() {
md.tags = map[model.TagName][]string{
"album": {"album name"},
"discnumber": {"1"},
}
Expect(getPID(mf, md, spec, false)).To(Equal("(album name\\1\\)"))
})
})
})
Context("calculated attributes", func() {
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.PID.Album = "musicbrainz_albumid|albumartistid,album,albumversion,releasedate"
})
When("field is title", func() {
It("should return the pid", func() {
spec := "title|folder"
md.tags = map[model.TagName][]string{"title": {"title"}}
md.filePath = "/path/to/file.mp3"
mf.Title = "Title"
Expect(getPID(mf, md, spec, false)).To(Equal("(Title)"))
})
})
When("field is folder", func() {
It("should return the pid", func() {
tests.SkipOnWindows("path separator bug (#TBD-path-sep-metadata)")
spec := "folder|title"
md.tags = map[model.TagName][]string{"title": {"title"}}
mf.Path = "/path/to/file.mp3"
Expect(getPID(mf, md, spec, false)).To(Equal("(/path/to)"))
})
})
When("field is albumid", func() {
It("should return the pid", func() {
spec := "albumid|title"
md.tags = map[model.TagName][]string{
"title": {"title"},
"album": {"album name"},
"albumversion": {"deluxe edition"},
"releasedate": {"2021-01-01"},
}
mf.AlbumArtist = "Album Artist"
Expect(getPID(mf, md, spec, false)).To(Equal("(((album artist)\\album name\\deluxe edition\\2021-01-01))"))
})
})
When("field is albumartistid", func() {
It("should return the pid", func() {
spec := "musicbrainz_albumartistid|albumartistid"
md.tags = map[model.TagName][]string{
"albumartist": {"Album Artist"},
}
mf.AlbumArtist = "Album Artist"
Expect(getPID(mf, md, spec, false)).To(Equal("((album artist))"))
})
})
When("field is album", func() {
It("should return the pid", func() {
spec := "album|title"
md.tags = map[model.TagName][]string{"album": {"Album Name"}}
Expect(getPID(mf, md, spec, false)).To(Equal("(album name)"))
})
})
When("albumid configuration refers to albumid recursively", func() {
It("should avoid infinite recursion", func() {
// Reproduce the issue from #4920
conf.Server.PID.Album = "albumid,album,albumversion,releasedate"
spec := conf.Server.PID.Album
md.tags = map[model.TagName][]string{
"album": {"Album Name"},
"albumversion": {"Version"},
"releasedate": {"2022"},
}
// Should not panic and return a valid PID ignoring the recursive "albumid"
Expect(func() {
pid := getPID(mf, md, spec, false)
Expect(pid).To(Equal("(\\album name\\Version\\2022)"))
}).To(Not(Panic()))
})
})
})
Context("edge cases", func() {
When("the spec has spaces between groups", func() {
It("should return the pid", func() {
spec := "albumartist| Album"
md.tags = map[model.TagName][]string{
"album": {"album name"},
}
Expect(getPID(mf, md, spec, false)).To(Equal("(album name)"))
})
})
When("the spec has spaces", func() {
It("should return the pid", func() {
spec := "albumartist, album"
md.tags = map[model.TagName][]string{
"albumartist": {"Album Artist"},
"album": {"album name"},
}
Expect(getPID(mf, md, spec, false)).To(Equal("(Album Artist\\album name)"))
})
})
When("the spec has mixed case fields", func() {
It("should return the pid", func() {
spec := "albumartist,Album"
md.tags = map[model.TagName][]string{
"albumartist": {"Album Artist"},
"album": {"album name"},
}
Expect(getPID(mf, md, spec, false)).To(Equal("(Album Artist\\album name)"))
})
})
})
Context("prependLibId functionality", func() {
BeforeEach(func() {
mf.LibraryID = 42
})
When("prependLibId is true", func() {
It("should prepend library ID to the hash input", func() {
spec := "album"
md.tags = map[model.TagName][]string{"album": {"Test Album"}}
pid := getPID(mf, md, spec, true)
// The hash function should receive "42\test album" as input
Expect(pid).To(Equal("(42\\test album)"))
})
})
When("prependLibId is false", func() {
It("should not prepend library ID to the hash input", func() {
spec := "album"
md.tags = map[model.TagName][]string{"album": {"Test Album"}}
pid := getPID(mf, md, spec, false)
// The hash function should receive "test album" as input
Expect(pid).To(Equal("(test album)"))
})
})
When("prependLibId is true with complex spec", func() {
It("should prepend library ID to the final hash input", func() {
spec := "musicbrainz_trackid|album,tracknumber"
md.tags = map[model.TagName][]string{
"album": {"Test Album"},
"tracknumber": {"1"},
}
pid := getPID(mf, md, spec, true)
// Should use the fallback field and prepend library ID
Expect(pid).To(Equal("(42\\test album\\1)"))
})
})
When("prependLibId is true with nested albumid", func() {
It("should handle nested albumid calls correctly", func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.PID.Album = "album"
spec := "albumid"
md.tags = map[model.TagName][]string{"album": {"Test Album"}}
mf.AlbumArtist = "Test Artist"
pid := getPID(mf, md, spec, true)
// The albumid call should also use prependLibId=true
Expect(pid).To(Equal("(42\\(42\\test album))"))
})
})
})
Context("legacy specs", func() {
Context("track_legacy", func() {
When("library ID is default (1)", func() {
It("should not prepend library ID even when prependLibId is true", func() {
mf.Path = "/path/to/track.mp3"
mf.LibraryID = 1 // Default library ID
// With default library, both should be the same
pidTrue := getPID(mf, md, "track_legacy", true)
pidFalse := getPID(mf, md, "track_legacy", false)
Expect(pidTrue).To(Equal(pidFalse))
Expect(pidTrue).NotTo(BeEmpty())
})
})
When("library ID is non-default", func() {
It("should prepend library ID when prependLibId is true", func() {
mf.Path = "/path/to/track.mp3"
mf.LibraryID = 2 // Non-default library ID
pidTrue := getPID(mf, md, "track_legacy", true)
pidFalse := getPID(mf, md, "track_legacy", false)
Expect(pidTrue).NotTo(Equal(pidFalse))
Expect(pidTrue).NotTo(BeEmpty())
Expect(pidFalse).NotTo(BeEmpty())
})
})
When("library ID is non-default but prependLibId is false", func() {
It("should not prepend library ID", func() {
mf.Path = "/path/to/track.mp3"
mf.LibraryID = 3
mf2 := mf
mf2.LibraryID = 1 // Default library
pidNonDefault := getPID(mf, md, "track_legacy", false)
pidDefault := getPID(mf2, md, "track_legacy", false)
// Should be the same since prependLibId=false
Expect(pidNonDefault).To(Equal(pidDefault))
})
})
})
Context("album_legacy", func() {
When("library ID is default (1)", func() {
It("should not prepend library ID even when prependLibId is true", func() {
md.tags = map[model.TagName][]string{"album": {"Test Album"}}
mf.LibraryID = 1 // Default library ID
pidTrue := getPID(mf, md, "album_legacy", true)
pidFalse := getPID(mf, md, "album_legacy", false)
Expect(pidTrue).To(Equal(pidFalse))
Expect(pidTrue).NotTo(BeEmpty())
})
})
When("library ID is non-default", func() {
It("should prepend library ID when prependLibId is true", func() {
md.tags = map[model.TagName][]string{"album": {"Test Album"}}
mf.LibraryID = 2 // Non-default library ID
pidTrue := getPID(mf, md, "album_legacy", true)
pidFalse := getPID(mf, md, "album_legacy", false)
Expect(pidTrue).NotTo(Equal(pidFalse))
Expect(pidTrue).NotTo(BeEmpty())
Expect(pidFalse).NotTo(BeEmpty())
})
})
When("library ID is non-default but prependLibId is false", func() {
It("should not prepend library ID", func() {
md.tags = map[model.TagName][]string{"album": {"Test Album"}}
mf.LibraryID = 3
mf2 := mf
mf2.LibraryID = 1 // Default library
pidNonDefault := getPID(mf, md, "album_legacy", false)
pidDefault := getPID(mf2, md, "album_legacy", false)
// Should be the same since prependLibId=false
Expect(pidNonDefault).To(Equal(pidDefault))
})
})
})
})
})
var _ = Describe("computeArtistPID", func() {
var p model.Participant
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
p = model.Participant{Artist: model.Artist{Name: "The Beatles"}}
})
Context("default spec 'name'", func() {
BeforeEach(func() { conf.Server.PID.Artist = consts.DefaultArtistPID })
It("produces the same ID as the legacy artistID(name) hardcoded function", func() {
// Legacy formula: hash(hash(clear(lower(name))))
// It came from artistID(name) routing through computePID(spec="albumartistid"),
// which inside getPIDAttr returns hash(clear(lower(mf.AlbumArtist))), then
// computePID hashes that again.
mfWithAlbumArtist := model.MediaFile{AlbumArtist: p.Name}
legacyMD := Metadata{}
legacyID := computePID(mfWithAlbumArtist, legacyMD, "albumartistid", false, id.NewHash)
newID := computeArtistPID(p, conf.Server.PID.Artist, id.NewHash)
Expect(newID).To(Equal(legacyID))
})
It("normalizes name (clear(lower(...))) before hashing", func() {
// Different casing / punctuation must collapse to the same ID.
p2 := model.Participant{Artist: model.Artist{Name: "the beatles"}}
Expect(computeArtistPID(p, conf.Server.PID.Artist, id.NewHash)).
To(Equal(computeArtistPID(p2, conf.Server.PID.Artist, id.NewHash)))
})
})
Context("spec 'musicbrainz_artistid|name'", func() {
BeforeEach(func() { conf.Server.PID.Artist = "musicbrainz_artistid|name" })
It("uses MBID when present", func() {
p.MbzArtistID = "mbid-1"
withMBID := computeArtistPID(p, conf.Server.PID.Artist, id.NewHash)
p2 := model.Participant{Artist: model.Artist{Name: "Different", MbzArtistID: "mbid-1"}}
withMBIDOtherName := computeArtistPID(p2, conf.Server.PID.Artist, id.NewHash)
Expect(withMBID).To(Equal(withMBIDOtherName))
})
It("falls back to normalized name when MBID is missing", func() {
withoutMBID := computeArtistPID(p, conf.Server.PID.Artist, id.NewHash)
byName := computeArtistPID(p, "name", id.NewHash)
Expect(withoutMBID).To(Equal(byName))
})
})
Context("composite spec 'musicbrainz_artistid|sort_name,name'", func() {
BeforeEach(func() { conf.Server.PID.Artist = "musicbrainz_artistid|sort_name,name" })
It("hashes sort+name composite when MBID is absent", func() {
p.SortArtistName = "Beatles, The"
withSort := computeArtistPID(p, conf.Server.PID.Artist, id.NewHash)
p2 := p
p2.SortArtistName = "Different Sort"
withDifferentSort := computeArtistPID(p2, conf.Server.PID.Artist, id.NewHash)
Expect(withSort).NotTo(Equal(withDifferentSort))
})
})
Context("spec 'sort_name|name' with empty sort_name", func() {
BeforeEach(func() { conf.Server.PID.Artist = "sort_name|name" })
It("falls through to name when SortArtistName is empty", func() {
// sort_name is empty → first pipe alternative produces no value →
// engine falls through to name.
fallback := computeArtistPID(p, conf.Server.PID.Artist, id.NewHash)
byName := computeArtistPID(p, "name", id.NewHash)
Expect(fallback).To(Equal(byName))
})
})
Context("empty inputs", func() {
BeforeEach(func() { conf.Server.PID.Artist = "musicbrainz_artistid|name" })
It("does not panic on empty participant", func() {
empty := model.Participant{Artist: model.Artist{Name: ""}}
Expect(func() { computeArtistPID(empty, conf.Server.PID.Artist, id.NewHash) }).
NotTo(Panic())
})
})
It("buildArtists produces the same Artist.ID as the legacy artistID() under default PID.Artist", func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.PID.Artist = consts.DefaultArtistPID
name := "Some Artist"
md := Metadata{}
artists := md.buildArtists([]string{name}, nil, nil)
// Legacy result computed independently via the old "albumartistid" path:
legacyMF := model.MediaFile{AlbumArtist: name}
expected := computePID(legacyMF, Metadata{}, "albumartistid", false, id.NewHash)
Expect(artists).To(HaveLen(1))
Expect(artists[0].ID).To(Equal(expected))
})
})