update
Some checks failed
Pipeline: Test, Lint, Build / Get version info (push) Has been cancelled
Pipeline: Test, Lint, Build / Lint Go code (push) Has been cancelled
Pipeline: Test, Lint, Build / Test Go code (push) Has been cancelled
Pipeline: Test, Lint, Build / Test JS code (push) Has been cancelled
Pipeline: Test, Lint, Build / Lint i18n files (push) Has been cancelled
Pipeline: Test, Lint, Build / Check Docker configuration (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (darwin/amd64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (darwin/arm64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/386) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/amd64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/arm/v5) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/arm/v6) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/arm/v7) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/arm64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (windows/386) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (windows/amd64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Push to GHCR (push) Has been cancelled
Pipeline: Test, Lint, Build / Push to Docker Hub (push) Has been cancelled
Pipeline: Test, Lint, Build / Cleanup digest artifacts (push) Has been cancelled
Pipeline: Test, Lint, Build / Build Windows installers (push) Has been cancelled
Pipeline: Test, Lint, Build / Package/Release (push) Has been cancelled
Pipeline: Test, Lint, Build / Upload Linux PKG (push) Has been cancelled
Close stale issues and PRs / stale (push) Has been cancelled
POEditor import / update-translations (push) Has been cancelled

This commit is contained in:
2025-12-08 16:16:23 +01:00
commit c251f174ed
1349 changed files with 194301 additions and 0 deletions

View File

@@ -0,0 +1,57 @@
package metadata
import (
"cmp"
"crypto/md5"
"fmt"
"strings"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/model"
)
// These are the legacy ID functions that were used in the original Navidrome ID generation.
// They are kept here for backwards compatibility with existing databases.
func legacyTrackID(mf model.MediaFile, prependLibId bool) string {
id := mf.Path
if prependLibId && mf.LibraryID != model.DefaultLibraryID {
id = fmt.Sprintf("%d\\%s", mf.LibraryID, id)
}
return fmt.Sprintf("%x", md5.Sum([]byte(id)))
}
func legacyAlbumID(mf model.MediaFile, md Metadata, prependLibId bool) string {
_, _, releaseDate := md.mapDates()
albumPath := strings.ToLower(fmt.Sprintf("%s\\%s", legacyMapAlbumArtistName(md), legacyMapAlbumName(md)))
if !conf.Server.Scanner.GroupAlbumReleases {
if len(releaseDate) != 0 {
albumPath = fmt.Sprintf("%s\\%s", albumPath, releaseDate)
}
}
if prependLibId && mf.LibraryID != model.DefaultLibraryID {
albumPath = fmt.Sprintf("%d\\%s", mf.LibraryID, albumPath)
}
return fmt.Sprintf("%x", md5.Sum([]byte(albumPath)))
}
func legacyMapAlbumArtistName(md Metadata) string {
values := []string{
md.String(model.TagAlbumArtist),
"",
md.String(model.TagTrackArtist),
consts.UnknownArtist,
}
if md.Bool(model.TagCompilation) {
values[1] = consts.VariousArtists
}
return cmp.Or(values...)
}
func legacyMapAlbumName(md Metadata) string {
return cmp.Or(
md.String(model.TagAlbum),
consts.UnknownAlbum,
)
}

View File

@@ -0,0 +1,185 @@
package metadata
import (
"cmp"
"encoding/json"
"maps"
"math"
"strconv"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils/str"
)
func (md Metadata) ToMediaFile(libID int, folderID string) model.MediaFile {
mf := model.MediaFile{
LibraryID: libID,
FolderID: folderID,
Tags: maps.Clone(md.tags),
}
// Title and Album
mf.Title = md.mapTrackTitle()
mf.Album = md.mapAlbumName()
mf.SortTitle = md.String(model.TagTitleSort)
mf.SortAlbumName = md.String(model.TagAlbumSort)
mf.OrderTitle = str.SanitizeFieldForSorting(mf.Title)
mf.OrderAlbumName = str.SanitizeFieldForSortingNoArticle(mf.Album)
mf.Compilation = md.Bool(model.TagCompilation)
// Disc and Track info
mf.TrackNumber, _ = md.NumAndTotal(model.TagTrackNumber)
mf.DiscNumber, _ = md.NumAndTotal(model.TagDiscNumber)
mf.DiscSubtitle = md.String(model.TagDiscSubtitle)
mf.CatalogNum = md.String(model.TagCatalogNumber)
mf.Comment = md.String(model.TagComment)
mf.BPM = int(math.Round(md.Float(model.TagBPM)))
mf.Lyrics = md.mapLyrics()
mf.ExplicitStatus = md.mapExplicitStatusTag()
// Dates
date, origDate, relDate := md.mapDates()
mf.OriginalYear, mf.OriginalDate = origDate.Year(), string(origDate)
mf.ReleaseYear, mf.ReleaseDate = relDate.Year(), string(relDate)
mf.Year, mf.Date = date.Year(), string(date)
// MBIDs
mf.MbzRecordingID = md.String(model.TagMusicBrainzRecordingID)
mf.MbzReleaseTrackID = md.String(model.TagMusicBrainzTrackID)
mf.MbzAlbumID = md.String(model.TagMusicBrainzAlbumID)
mf.MbzReleaseGroupID = md.String(model.TagMusicBrainzReleaseGroupID)
mf.MbzAlbumType = md.String(model.TagReleaseType)
// ReplayGain
mf.RGAlbumPeak = md.NullableFloat(model.TagReplayGainAlbumPeak)
mf.RGAlbumGain = md.mapGain(model.TagReplayGainAlbumGain, model.TagR128AlbumGain)
mf.RGTrackPeak = md.NullableFloat(model.TagReplayGainTrackPeak)
mf.RGTrackGain = md.mapGain(model.TagReplayGainTrackGain, model.TagR128TrackGain)
// General properties
mf.HasCoverArt = md.HasPicture()
mf.Duration = md.Length()
mf.BitRate = md.AudioProperties().BitRate
mf.SampleRate = md.AudioProperties().SampleRate
mf.BitDepth = md.AudioProperties().BitDepth
mf.Channels = md.AudioProperties().Channels
mf.Path = md.FilePath()
mf.Suffix = md.Suffix()
mf.Size = md.Size()
mf.BirthTime = md.BirthTime()
mf.UpdatedAt = md.ModTime()
mf.Participants = md.mapParticipants()
mf.Artist = md.mapDisplayArtist()
mf.AlbumArtist = md.mapDisplayAlbumArtist(mf)
// Persistent IDs
mf.PID = md.trackPID(mf)
mf.AlbumID = md.albumID(mf, conf.Server.PID.Album)
// BFR These IDs will go away once the UI handle multiple participants.
// BFR For Legacy Subsonic compatibility, we will set them in the API handlers
mf.ArtistID = mf.Participants.First(model.RoleArtist).ID
mf.AlbumArtistID = mf.Participants.First(model.RoleAlbumArtist).ID
// BFR What to do with sort/order artist names?
mf.OrderArtistName = mf.Participants.First(model.RoleArtist).OrderArtistName
mf.OrderAlbumArtistName = mf.Participants.First(model.RoleAlbumArtist).OrderArtistName
mf.SortArtistName = mf.Participants.First(model.RoleArtist).SortArtistName
mf.SortAlbumArtistName = mf.Participants.First(model.RoleAlbumArtist).SortArtistName
// Don't store tags that are first-class fields (and are not album-level tags) in the
// MediaFile struct. This is to avoid redundancy in the DB
//
// Remove all tags from the main section that are not flagged as album tags
for tag, conf := range model.TagMainMappings() {
if !conf.Album {
delete(mf.Tags, tag)
}
}
return mf
}
func (md Metadata) AlbumID(mf model.MediaFile, pidConf string) string {
return md.albumID(mf, pidConf)
}
func (md Metadata) mapGain(rg, r128 model.TagName) *float64 {
v := md.Gain(rg)
if v != nil {
return v
}
r128value := md.String(r128)
if r128value != "" {
var v, err = strconv.Atoi(r128value)
if err != nil {
return nil
}
// Convert Q7.8 to float
value := float64(v) / 256.0
// Adding 5 dB to normalize with ReplayGain level
value += 5
return &value
}
return nil
}
func (md Metadata) mapLyrics() string {
rawLyrics := md.Pairs(model.TagLyrics)
lyricList := make(model.LyricList, 0, len(rawLyrics))
for _, raw := range rawLyrics {
lang := raw.Key()
text := raw.Value()
lyrics, err := model.ToLyrics(lang, text)
if err != nil {
log.Warn("Unexpected failure occurred when parsing lyrics", "file", md.filePath, err)
continue
}
if !lyrics.IsEmpty() {
lyricList = append(lyricList, *lyrics)
}
}
res, err := json.Marshal(lyricList)
if err != nil {
log.Warn("Unexpected error occurred when serializing lyrics", "file", md.filePath, err)
return ""
}
return string(res)
}
func (md Metadata) mapExplicitStatusTag() string {
switch md.first(model.TagExplicitStatus) {
case "1", "4":
return "e"
case "2":
return "c"
default:
return ""
}
}
func (md Metadata) mapDates() (date Date, originalDate Date, releaseDate Date) {
// Start with defaults
date = md.Date(model.TagRecordingDate)
originalDate = md.Date(model.TagOriginalDate)
releaseDate = md.Date(model.TagReleaseDate)
// For some historic reason, taggers have been writing the Release Date of an album to the Date tag,
// and leave the Release Date tag empty.
legacyMappings := (originalDate != "") &&
(releaseDate == "") &&
(date >= originalDate)
if legacyMappings {
return originalDate, originalDate, date
}
// when there's no Date, first fall back to Original Date, then to Release Date.
date = cmp.Or(date, originalDate, releaseDate)
return date, originalDate, releaseDate
}

View File

@@ -0,0 +1,121 @@
package metadata_test
import (
"encoding/json"
"os"
"sort"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/metadata"
"github.com/navidrome/navidrome/tests"
. "github.com/navidrome/navidrome/utils/gg"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("ToMediaFile", func() {
var (
props metadata.Info
md metadata.Metadata
mf model.MediaFile
)
BeforeEach(func() {
_, filePath, _ := tests.TempFile(GinkgoT(), "test", ".mp3")
fileInfo, _ := os.Stat(filePath)
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("Dates", func() {
It("should parse properly tagged dates ", func() {
mf = toMediaFile(model.RawTags{
"ORIGINALDATE": {"1978-09-10"},
"DATE": {"1977-03-04"},
"RELEASEDATE": {"2002-01-02"},
})
Expect(mf.Year).To(Equal(1977))
Expect(mf.Date).To(Equal("1977-03-04"))
Expect(mf.OriginalYear).To(Equal(1978))
Expect(mf.OriginalDate).To(Equal("1978-09-10"))
Expect(mf.ReleaseYear).To(Equal(2002))
Expect(mf.ReleaseDate).To(Equal("2002-01-02"))
})
It("should parse dates with only year", func() {
mf = toMediaFile(model.RawTags{
"ORIGINALYEAR": {"1978"},
"DATE": {"1977"},
"RELEASEDATE": {"2002"},
})
Expect(mf.Year).To(Equal(1977))
Expect(mf.Date).To(Equal("1977"))
Expect(mf.OriginalYear).To(Equal(1978))
Expect(mf.OriginalDate).To(Equal("1978"))
Expect(mf.ReleaseYear).To(Equal(2002))
Expect(mf.ReleaseDate).To(Equal("2002"))
})
It("should parse dates tagged the legacy way (no release date)", func() {
mf = toMediaFile(model.RawTags{
"DATE": {"2014"},
"ORIGINALDATE": {"1966"},
})
Expect(mf.Year).To(Equal(1966))
Expect(mf.OriginalYear).To(Equal(1966))
Expect(mf.ReleaseYear).To(Equal(2014))
})
DescribeTable("legacyReleaseDate (TaggedLikePicard old behavior)",
func(recordingDate, originalDate, releaseDate, expected string) {
mf := toMediaFile(model.RawTags{
"DATE": {recordingDate},
"ORIGINALDATE": {originalDate},
"RELEASEDATE": {releaseDate},
})
Expect(mf.ReleaseDate).To(Equal(expected))
},
Entry("regular mapping", "2020-05-15", "2019-02-10", "2021-01-01", "2021-01-01"),
Entry("legacy mapping", "2020-05-15", "2019-02-10", "", "2020-05-15"),
Entry("legacy mapping, originalYear < year", "2018-05-15", "2019-02-10", "2021-01-01", "2021-01-01"),
Entry("legacy mapping, originalYear empty", "2020-05-15", "", "2021-01-01", "2021-01-01"),
Entry("legacy mapping, releaseYear", "2020-05-15", "2019-02-10", "2021-01-01", "2021-01-01"),
Entry("legacy mapping, same dates", "2020-05-15", "2020-05-15", "", "2020-05-15"),
)
})
Describe("Lyrics", func() {
It("should parse the lyrics", func() {
mf = toMediaFile(model.RawTags{
"LYRICS:XXX": {"Lyrics"},
"LYRICS:ENG": {
"[00:00.00]This is\n[00:02.50]English SYLT\n",
},
})
var actual model.LyricList
err := json.Unmarshal([]byte(mf.Lyrics), &actual)
Expect(err).ToNot(HaveOccurred())
expected := model.LyricList{
{Lang: "eng", Line: []model.Line{
{Value: "This is", Start: P(int64(0))},
{Value: "English SYLT", Start: P(int64(2500))},
}, Synced: true},
{Lang: "xxx", Line: []model.Line{{Value: "Lyrics"}}, Synced: false},
}
sort.Slice(actual, func(i, j int) bool { return actual[i].Lang < actual[j].Lang })
sort.Slice(expected, func(i, j int) bool { return expected[i].Lang < expected[j].Lang })
Expect(actual).To(Equal(expected))
})
})
})

View File

@@ -0,0 +1,236 @@
package metadata
import (
"cmp"
"strings"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils/str"
"golang.org/x/text/cases"
"golang.org/x/text/language"
)
type roleTags struct {
name model.TagName
sort model.TagName
mbid model.TagName
}
var roleMappings = map[model.Role]roleTags{
model.RoleComposer: {name: model.TagComposer, sort: model.TagComposerSort, mbid: model.TagMusicBrainzComposerID},
model.RoleLyricist: {name: model.TagLyricist, sort: model.TagLyricistSort, mbid: model.TagMusicBrainzLyricistID},
model.RoleConductor: {name: model.TagConductor, mbid: model.TagMusicBrainzConductorID},
model.RoleArranger: {name: model.TagArranger, mbid: model.TagMusicBrainzArrangerID},
model.RoleDirector: {name: model.TagDirector, mbid: model.TagMusicBrainzDirectorID},
model.RoleProducer: {name: model.TagProducer, mbid: model.TagMusicBrainzProducerID},
model.RoleEngineer: {name: model.TagEngineer, mbid: model.TagMusicBrainzEngineerID},
model.RoleMixer: {name: model.TagMixer, mbid: model.TagMusicBrainzMixerID},
model.RoleRemixer: {name: model.TagRemixer, mbid: model.TagMusicBrainzRemixerID},
model.RoleDJMixer: {name: model.TagDJMixer, mbid: model.TagMusicBrainzDJMixerID},
}
func (md Metadata) mapParticipants() model.Participants {
participants := make(model.Participants)
// Parse track artists
artists := md.parseArtists(
model.TagTrackArtist, model.TagTrackArtists,
model.TagTrackArtistSort, model.TagTrackArtistsSort,
model.TagMusicBrainzArtistID,
)
participants.Add(model.RoleArtist, artists...)
// Parse album artists
albumArtists := md.parseArtists(
model.TagAlbumArtist, model.TagAlbumArtists,
model.TagAlbumArtistSort, model.TagAlbumArtistsSort,
model.TagMusicBrainzAlbumArtistID,
)
if len(albumArtists) == 1 && albumArtists[0].Name == consts.UnknownArtist {
if md.Bool(model.TagCompilation) {
albumArtists = md.buildArtists([]string{consts.VariousArtists}, nil, []string{consts.VariousArtistsMbzId})
} else {
albumArtists = artists
}
}
participants.Add(model.RoleAlbumArtist, albumArtists...)
// Parse all other roles
for role, info := range roleMappings {
names := md.getRoleValues(info.name)
if len(names) > 0 {
sorts := md.Strings(info.sort)
mbids := md.Strings(info.mbid)
artists := md.buildArtists(names, sorts, mbids)
participants.Add(role, artists...)
}
}
rolesMbzIdMap := md.buildRoleMbidMaps()
md.processPerformers(participants, rolesMbzIdMap)
md.syncMissingMbzIDs(participants)
return participants
}
// buildRoleMbidMaps creates a map of roles to MBZ IDs
func (md Metadata) buildRoleMbidMaps() map[string][]string {
titleCaser := cases.Title(language.Und)
rolesMbzIdMap := make(map[string][]string)
for _, mbid := range md.Pairs(model.TagMusicBrainzPerformerID) {
role := titleCaser.String(mbid.Key())
rolesMbzIdMap[role] = append(rolesMbzIdMap[role], mbid.Value())
}
return rolesMbzIdMap
}
func (md Metadata) processPerformers(participants model.Participants, rolesMbzIdMap map[string][]string) {
// roleIdx keeps track of the index of the MBZ ID for each role
roleIdx := make(map[string]int)
for role := range rolesMbzIdMap {
roleIdx[role] = 0
}
titleCaser := cases.Title(language.Und)
for _, performer := range md.Pairs(model.TagPerformer) {
name := performer.Value()
subRole := titleCaser.String(performer.Key())
artist := model.Artist{
ID: md.artistID(name),
Name: name,
OrderArtistName: str.SanitizeFieldForSortingNoArticle(name),
MbzArtistID: md.getPerformerMbid(subRole, rolesMbzIdMap, roleIdx),
}
participants.AddWithSubRole(model.RolePerformer, subRole, artist)
}
}
// getPerformerMbid returns the MBZ ID for a performer, based on the subrole
func (md Metadata) getPerformerMbid(subRole string, rolesMbzIdMap map[string][]string, roleIdx map[string]int) string {
if mbids, exists := rolesMbzIdMap[subRole]; exists && roleIdx[subRole] < len(mbids) {
defer func() { roleIdx[subRole]++ }()
return mbids[roleIdx[subRole]]
}
return ""
}
// syncMissingMbzIDs fills in missing MBZ IDs for artists that have been previously parsed
func (md Metadata) syncMissingMbzIDs(participants model.Participants) {
artistMbzIDMap := make(map[string]string)
for _, artist := range append(participants[model.RoleArtist], participants[model.RoleAlbumArtist]...) {
if artist.MbzArtistID != "" {
artistMbzIDMap[artist.Name] = artist.MbzArtistID
}
}
for role, list := range participants {
for i, artist := range list {
if artist.MbzArtistID == "" {
if mbzID, exists := artistMbzIDMap[artist.Name]; exists {
participants[role][i].MbzArtistID = mbzID
}
}
}
}
}
func (md Metadata) parseArtists(
name model.TagName, names model.TagName, sort model.TagName,
sorts model.TagName, mbid model.TagName,
) []model.Artist {
nameValues := md.getArtistValues(name, names)
sortValues := md.getArtistValues(sort, sorts)
mbids := md.Strings(mbid)
if len(nameValues) == 0 {
nameValues = []string{consts.UnknownArtist}
}
return md.buildArtists(nameValues, sortValues, mbids)
}
func (md Metadata) buildArtists(names, sorts, mbids []string) []model.Artist {
var artists []model.Artist
for i, name := range names {
id := md.artistID(name)
artist := model.Artist{
ID: id,
Name: name,
OrderArtistName: str.SanitizeFieldForSortingNoArticle(name),
}
if i < len(sorts) {
artist.SortArtistName = sorts[i]
}
if i < len(mbids) {
artist.MbzArtistID = mbids[i]
}
artists = append(artists, artist)
}
return artists
}
// getRoleValues returns the values of a role tag, splitting them if necessary
func (md Metadata) getRoleValues(role model.TagName) []string {
values := md.Strings(role)
if len(values) == 0 {
return nil
}
conf := model.TagMainMappings()[role]
if conf.Split == nil {
conf = model.TagRolesConf()
}
if len(conf.Split) > 0 {
values = conf.SplitTagValue(values)
return filterDuplicatedOrEmptyValues(values)
}
return values
}
// getArtistValues returns the values of a single or multi artist tag, splitting them if necessary
func (md Metadata) getArtistValues(single, multi model.TagName) []string {
vMulti := md.Strings(multi)
if len(vMulti) > 0 {
return vMulti
}
vSingle := md.Strings(single)
if len(vSingle) != 1 {
return vSingle
}
conf := model.TagMainMappings()[single]
if conf.Split == nil {
conf = model.TagArtistsConf()
}
if len(conf.Split) > 0 {
vSingle = conf.SplitTagValue(vSingle)
return filterDuplicatedOrEmptyValues(vSingle)
}
return vSingle
}
func (md Metadata) mapDisplayName(singularTagName, pluralTagName model.TagName) string {
return cmp.Or(
strings.Join(md.tags[singularTagName], conf.Server.Scanner.ArtistJoiner),
strings.Join(md.tags[pluralTagName], conf.Server.Scanner.ArtistJoiner),
)
}
func (md Metadata) mapDisplayArtist() string {
return cmp.Or(
md.mapDisplayName(model.TagTrackArtist, model.TagTrackArtists),
consts.UnknownArtist,
)
}
func (md Metadata) mapDisplayAlbumArtist(mf model.MediaFile) string {
fallbackName := consts.UnknownArtist
if md.Bool(model.TagCompilation) {
fallbackName = consts.VariousArtists
}
return cmp.Or(
md.mapDisplayName(model.TagAlbumArtist, model.TagAlbumArtists),
mf.Participants.First(model.RoleAlbumArtist).Name,
fallbackName,
)
}

View File

@@ -0,0 +1,785 @@
package metadata_test
import (
"os"
"github.com/google/uuid"
"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"),
))
})
})
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"))
})
})
})
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(""))
}
})
})
})

387
model/metadata/metadata.go Normal file
View File

@@ -0,0 +1,387 @@
package metadata
import (
"cmp"
"io/fs"
"math"
"path"
"regexp"
"strconv"
"strings"
"time"
"github.com/google/uuid"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils/slice"
)
type Info struct {
FileInfo FileInfo
Tags model.RawTags
AudioProperties AudioProperties
HasPicture bool
}
type FileInfo interface {
fs.FileInfo
BirthTime() time.Time
}
type AudioProperties struct {
Duration time.Duration
BitRate int
BitDepth int
SampleRate int
Channels int
}
type Date string
func (d Date) Year() int {
if d == "" {
return 0
}
y, _ := strconv.Atoi(string(d[:4]))
return y
}
type Pair string
func (p Pair) Key() string { return p.parse(0) }
func (p Pair) Value() string { return p.parse(1) }
func (p Pair) parse(i int) string {
parts := strings.SplitN(string(p), consts.Zwsp, 2)
if len(parts) > i {
return parts[i]
}
return ""
}
func (p Pair) String() string {
return string(p)
}
func NewPair(key, value string) string {
return key + consts.Zwsp + value
}
func New(filePath string, info Info) Metadata {
return Metadata{
filePath: filePath,
fileInfo: info.FileInfo,
tags: clean(filePath, info.Tags),
audioProps: info.AudioProperties,
hasPicture: info.HasPicture,
}
}
type Metadata struct {
filePath string
fileInfo FileInfo
tags model.Tags
audioProps AudioProperties
hasPicture bool
}
func (md Metadata) FilePath() string { return md.filePath }
func (md Metadata) ModTime() time.Time { return md.fileInfo.ModTime() }
func (md Metadata) BirthTime() time.Time { return md.fileInfo.BirthTime() }
func (md Metadata) Size() int64 { return md.fileInfo.Size() }
func (md Metadata) Suffix() string {
return strings.ToLower(strings.TrimPrefix(path.Ext(md.filePath), "."))
}
func (md Metadata) AudioProperties() AudioProperties { return md.audioProps }
func (md Metadata) Length() float32 { return float32(md.audioProps.Duration.Milliseconds()) / 1000 }
func (md Metadata) HasPicture() bool { return md.hasPicture }
func (md Metadata) All() model.Tags { return md.tags }
func (md Metadata) Strings(key model.TagName) []string { return md.tags[key] }
func (md Metadata) String(key model.TagName) string { return md.first(key) }
func (md Metadata) Int(key model.TagName) int64 { v, _ := strconv.Atoi(md.first(key)); return int64(v) }
func (md Metadata) Bool(key model.TagName) bool { v, _ := strconv.ParseBool(md.first(key)); return v }
func (md Metadata) Date(key model.TagName) Date { return md.date(key) }
func (md Metadata) NumAndTotal(key model.TagName) (int, int) { return md.tuple(key) }
func (md Metadata) Float(key model.TagName, def ...float64) float64 {
return float(md.first(key), def...)
}
func (md Metadata) NullableFloat(key model.TagName) *float64 { return nullableFloat(md.first(key)) }
func (md Metadata) Gain(key model.TagName) *float64 {
v := strings.TrimSpace(strings.Replace(md.first(key), "dB", "", 1))
return nullableFloat(v)
}
func (md Metadata) Pairs(key model.TagName) []Pair {
values := md.tags[key]
return slice.Map(values, func(v string) Pair { return Pair(v) })
}
func (md Metadata) first(key model.TagName) string {
if v, ok := md.tags[key]; ok && len(v) > 0 {
return v[0]
}
return ""
}
func float(value string, def ...float64) float64 {
v := nullableFloat(value)
if v != nil {
return *v
}
if len(def) > 0 {
return def[0]
}
return 0
}
func nullableFloat(value string) *float64 {
v, err := strconv.ParseFloat(value, 64)
if err != nil || v == math.Inf(-1) || math.IsInf(v, 1) || math.IsNaN(v) {
return nil
}
return &v
}
// Used for tracks and discs
func (md Metadata) tuple(key model.TagName) (int, int) {
tag := md.first(key)
if tag == "" {
return 0, 0
}
tuple := strings.Split(tag, "/")
t1, t2 := 0, 0
t1, _ = strconv.Atoi(tuple[0])
if len(tuple) > 1 {
t2, _ = strconv.Atoi(tuple[1])
} else {
t2tag := md.first(key + "total")
t2, _ = strconv.Atoi(t2tag)
}
return t1, t2
}
var dateRegex = regexp.MustCompile(`([12]\d\d\d)`)
func (md Metadata) date(tagName model.TagName) Date {
return Date(md.first(tagName))
}
// date tries to parse a date from a tag, it tries to get at least the year. See the tests for examples.
func parseDate(filePath string, tagName model.TagName, tagValue string) string {
if len(tagValue) < 4 {
return ""
}
// first get just the year
match := dateRegex.FindStringSubmatch(tagValue)
if len(match) == 0 {
log.Debug("Error parsing date", "file", filePath, "tag", tagName, "date", tagValue)
return ""
}
// if the tag is just the year, return it
if len(tagValue) < 5 {
return match[1]
}
// if the tag is too long, truncate it
tagValue = tagValue[:min(10, len(tagValue))]
// then try to parse the full date
for _, mask := range []string{"2006-01-02", "2006-01"} {
_, err := time.Parse(mask, tagValue)
if err == nil {
return tagValue
}
}
log.Debug("Error parsing month and day from date", "file", filePath, "tag", tagName, "date", tagValue)
return match[1]
}
// clean filters out tags that are not in the mappings or are empty,
// combine equivalent tags and remove duplicated values.
// It keeps the order of the tags names as they are defined in the mappings.
func clean(filePath string, tags model.RawTags) model.Tags {
lowered := lowerTags(tags)
mappings := model.TagMappings()
cleaned := make(model.Tags, len(mappings))
for name, mapping := range mappings {
var values []string
switch mapping.Type {
case model.TagTypePair:
values = processPairMapping(name, mapping, lowered)
default:
values = processRegularMapping(mapping, lowered)
}
cleaned[name] = values
}
cleaned = filterEmptyTags(cleaned)
return sanitizeAll(filePath, cleaned)
}
func processRegularMapping(mapping model.TagConf, lowered model.Tags) []string {
var values []string
for _, alias := range mapping.Aliases {
if vs, ok := lowered[model.TagName(alias)]; ok {
splitValues := mapping.SplitTagValue(vs)
values = append(values, splitValues...)
}
}
return values
}
func lowerTags(tags model.RawTags) model.Tags {
lowered := make(model.Tags, len(tags))
for k, v := range tags {
lowered[model.TagName(strings.ToLower(k))] = v
}
return lowered
}
func processPairMapping(name model.TagName, mapping model.TagConf, lowered model.Tags) []string {
var aliasValues []string
for _, alias := range mapping.Aliases {
if vs, ok := lowered[model.TagName(alias)]; ok {
aliasValues = append(aliasValues, vs...)
}
}
// always parse id3 pairs. For lyrics, Taglib appears to always provide lyrics:xxx
// Prefer that over format-specific tags
id3Base := parseID3Pairs(name, lowered)
if len(aliasValues) > 0 {
id3Base = append(id3Base, parseVorbisPairs(aliasValues)...)
}
return id3Base
}
func parseID3Pairs(name model.TagName, lowered model.Tags) []string {
var pairs []string
prefix := string(name) + ":"
for tagKey, tagValues := range lowered {
keyStr := string(tagKey)
if strings.HasPrefix(keyStr, prefix) {
keyPart := strings.TrimPrefix(keyStr, prefix)
if keyPart == string(name) {
keyPart = ""
}
for _, v := range tagValues {
pairs = append(pairs, NewPair(keyPart, v))
}
}
}
return pairs
}
var vorbisPairRegex = regexp.MustCompile(`\(([^()]+(?:\([^()]*\)[^()]*)*)\)`)
// parseVorbisPairs, from
//
// "Salaam Remi (drums (drum set) and organ)",
//
// to
//
// "drums (drum set) and organ" -> "Salaam Remi",
func parseVorbisPairs(values []string) []string {
pairs := make([]string, 0, len(values))
for _, value := range values {
matches := vorbisPairRegex.FindAllStringSubmatch(value, -1)
if len(matches) == 0 {
pairs = append(pairs, NewPair("", value))
continue
}
key := strings.TrimSpace(matches[0][1])
key = strings.ToLower(key)
valueWithoutKey := strings.TrimSpace(strings.Replace(value, "("+matches[0][1]+")", "", 1))
pairs = append(pairs, NewPair(key, valueWithoutKey))
}
return pairs
}
func filterEmptyTags(tags model.Tags) model.Tags {
for k, v := range tags {
clean := filterDuplicatedOrEmptyValues(v)
if len(clean) == 0 {
delete(tags, k)
} else {
tags[k] = clean
}
}
return tags
}
func filterDuplicatedOrEmptyValues(values []string) []string {
seen := make(map[string]struct{}, len(values))
var result []string
for _, v := range values {
if v == "" {
continue
}
if _, ok := seen[v]; ok {
continue
}
seen[v] = struct{}{}
result = append(result, v)
}
return result
}
func sanitizeAll(filePath string, tags model.Tags) model.Tags {
cleaned := model.Tags{}
for k, v := range tags {
tag, found := model.TagMappings()[k]
if !found {
continue
}
var values []string
for _, value := range v {
cleanedValue := sanitize(filePath, k, tag, value)
if cleanedValue != "" {
values = append(values, cleanedValue)
}
}
if len(values) > 0 {
cleaned[k] = values
}
}
return cleaned
}
const defaultMaxTagLength = 1024
func sanitize(filePath string, tagName model.TagName, tag model.TagConf, value string) string {
// First truncate the value to the maximum length
maxLength := cmp.Or(tag.MaxLength, defaultMaxTagLength)
if len(value) > maxLength {
log.Trace("Truncated tag value", "tag", tagName, "value", value, "length", len(value), "maxLength", maxLength)
value = value[:maxLength]
}
switch tag.Type {
case model.TagTypeDate:
value = parseDate(filePath, tagName, value)
if value == "" {
log.Trace("Invalid date tag value", "tag", tagName, "value", value)
}
case model.TagTypeInteger:
_, err := strconv.Atoi(value)
if err != nil {
log.Trace("Invalid integer tag value", "tag", tagName, "value", value)
return ""
}
case model.TagTypeFloat:
_, err := strconv.ParseFloat(value, 64)
if err != nil {
log.Trace("Invalid float tag value", "tag", tagName, "value", value)
return ""
}
case model.TagTypeUUID:
_, err := uuid.Parse(value)
if err != nil {
log.Trace("Invalid UUID tag value", "tag", tagName, "value", value)
return ""
}
}
return value
}

View File

@@ -0,0 +1,32 @@
package metadata_test
import (
"io/fs"
"testing"
"time"
"github.com/djherbis/times"
_ "github.com/mattn/go-sqlite3"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func TestMetadata(t *testing.T) {
tests.Init(t, true)
log.SetLevel(log.LevelFatal)
RegisterFailHandler(Fail)
RunSpecs(t, "Metadata Suite")
}
type testFileInfo struct {
fs.FileInfo
}
func (t testFileInfo) BirthTime() time.Time {
if ts := times.Get(t.FileInfo); ts.HasBirthTime() {
return ts.BirthTime()
}
return t.FileInfo.ModTime()
}

View File

@@ -0,0 +1,298 @@
package metadata_test
import (
"os"
"strings"
"time"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/metadata"
"github.com/navidrome/navidrome/utils"
"github.com/navidrome/navidrome/utils/gg"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Metadata", func() {
var (
filePath string
fileInfo os.FileInfo
props metadata.Info
md metadata.Metadata
)
BeforeEach(func() {
// It is easier to have a real file to test the mod and birth times
filePath = utils.TempFileName("test", ".mp3")
f, _ := os.Create(filePath)
DeferCleanup(func() {
_ = f.Close()
_ = os.Remove(filePath)
})
fileInfo, _ = os.Stat(filePath)
props = metadata.Info{
AudioProperties: metadata.AudioProperties{
Duration: time.Minute * 3,
BitRate: 320,
},
HasPicture: true,
FileInfo: testFileInfo{fileInfo},
}
})
Describe("Metadata", func() {
Describe("New", func() {
It("should create a new Metadata object with the correct properties", func() {
props.Tags = model.RawTags{
"©ART": {"First Artist", "Second Artist"},
"----:com.apple.iTunes:CATALOGNUMBER": {"1234"},
"tbpm": {"120.6"},
"WM/IsCompilation": {"1"},
}
md = metadata.New(filePath, props)
Expect(md.FilePath()).To(Equal(filePath))
Expect(md.ModTime()).To(Equal(fileInfo.ModTime()))
Expect(md.BirthTime()).To(BeTemporally("~", md.ModTime(), time.Second))
Expect(md.Size()).To(Equal(fileInfo.Size()))
Expect(md.Suffix()).To(Equal("mp3"))
Expect(md.AudioProperties()).To(Equal(props.AudioProperties))
Expect(md.Length()).To(Equal(float32(3 * 60)))
Expect(md.HasPicture()).To(Equal(props.HasPicture))
Expect(md.Strings(model.TagTrackArtist)).To(Equal([]string{"First Artist", "Second Artist"}))
Expect(md.String(model.TagTrackArtist)).To(Equal("First Artist"))
Expect(md.Int(model.TagCatalogNumber)).To(Equal(int64(1234)))
Expect(md.Float(model.TagBPM)).To(Equal(120.6))
Expect(md.Bool(model.TagCompilation)).To(BeTrue())
Expect(md.All()).To(SatisfyAll(
HaveLen(4),
HaveKeyWithValue(model.TagTrackArtist, []string{"First Artist", "Second Artist"}),
HaveKeyWithValue(model.TagBPM, []string{"120.6"}),
HaveKeyWithValue(model.TagCompilation, []string{"1"}),
HaveKeyWithValue(model.TagCatalogNumber, []string{"1234"}),
))
})
It("should clean the tags map correctly", func() {
const unknownTag = "UNKNOWN_TAG"
props.Tags = model.RawTags{
"TPE1": {"Artist Name", "Artist Name", ""},
"©ART": {"Second Artist"},
"CatalogNumber": {""},
"Album": {"Album Name", "", "Album Name"},
"Date": {"2022-10-02 12:15:01"},
"Year": {"2022", "2022", ""},
"Genre": {"Pop", "", "Pop", "Rock"},
"Track": {"1/10", "1/10", ""},
unknownTag: {"value"},
}
md = metadata.New(filePath, props)
Expect(md.All()).To(SatisfyAll(
Not(HaveKey(unknownTag)),
HaveKeyWithValue(model.TagTrackArtist, []string{"Artist Name", "Second Artist"}),
HaveKeyWithValue(model.TagAlbum, []string{"Album Name"}),
HaveKeyWithValue(model.TagRecordingDate, []string{"2022-10-02"}),
HaveKeyWithValue(model.TagReleaseDate, []string{"2022"}),
HaveKeyWithValue(model.TagGenre, []string{"Pop", "Rock"}),
HaveKeyWithValue(model.TagTrackNumber, []string{"1/10"}),
HaveLen(6),
))
})
It("should truncate long strings", func() {
props.Tags = model.RawTags{
"Title": {strings.Repeat("a", 2048)},
"Comment": {strings.Repeat("a", 8192)},
"lyrics:xxx": {strings.Repeat("a", 60000)},
}
md = metadata.New(filePath, props)
Expect(md.String(model.TagTitle)).To(HaveLen(1024))
Expect(md.String(model.TagComment)).To(HaveLen(4096))
pair := md.Pairs(model.TagLyrics)
Expect(pair).To(HaveLen(1))
Expect(pair[0].Key()).To(Equal("xxx"))
// Note: a total of 6 characters are lost from maxLength from
// the key portion and separator
Expect(pair[0].Value()).To(HaveLen(32762))
})
It("should split multiple values", func() {
props.Tags = model.RawTags{
"Genre": {"Rock/Pop;;Punk"},
}
md = metadata.New(filePath, props)
Expect(md.Strings(model.TagGenre)).To(Equal([]string{"Rock", "Pop", "Punk"}))
})
})
DescribeTable("Date",
func(value string, expectedYear int, expectedDate string) {
props.Tags = model.RawTags{
"date": {value},
}
md = metadata.New(filePath, props)
testDate := md.Date(model.TagRecordingDate)
Expect(string(testDate)).To(Equal(expectedDate))
Expect(testDate.Year()).To(Equal(expectedYear))
},
Entry(nil, "1985", 1985, "1985"),
Entry(nil, "2002-01", 2002, "2002-01"),
Entry(nil, "1969.06", 1969, "1969"),
Entry(nil, "1980.07.25", 1980, "1980"),
Entry(nil, "2004-00-00", 2004, "2004"),
Entry(nil, "2016-12-31", 2016, "2016-12-31"),
Entry(nil, "2016-12-31 12:15", 2016, "2016-12-31"),
Entry(nil, "2013-May-12", 2013, "2013"),
Entry(nil, "May 12, 2016", 2016, "2016"),
Entry(nil, "01/10/1990", 1990, "1990"),
Entry(nil, "invalid", 0, ""),
)
DescribeTable("NumAndTotal",
func(num, total string, expectedNum int, expectedTotal int) {
props.Tags = model.RawTags{
"Track": {num},
"TrackTotal": {total},
}
md = metadata.New(filePath, props)
testNum, testTotal := md.NumAndTotal(model.TagTrackNumber)
Expect(testNum).To(Equal(expectedNum))
Expect(testTotal).To(Equal(expectedTotal))
},
Entry(nil, "2", "", 2, 0),
Entry(nil, "2", "10", 2, 10),
Entry(nil, "2/10", "", 2, 10),
Entry(nil, "", "", 0, 0),
Entry(nil, "A", "", 0, 0),
)
Describe("Performers", func() {
Describe("ID3", func() {
BeforeEach(func() {
props.Tags = model.RawTags{
"PERFORMER:GUITAR": {"Guitarist 1", "Guitarist 2"},
"PERFORMER:BACKGROUND VOCALS": {"Backing Singer"},
"PERFORMER:PERFORMER": {"Wonderlove", "Lovewonder"},
}
})
It("should return the performers", func() {
md = metadata.New(filePath, props)
Expect(md.All()).To(HaveKey(model.TagPerformer))
Expect(md.Strings(model.TagPerformer)).To(ConsistOf(
metadata.NewPair("guitar", "Guitarist 1"),
metadata.NewPair("guitar", "Guitarist 2"),
metadata.NewPair("background vocals", "Backing Singer"),
metadata.NewPair("", "Wonderlove"),
metadata.NewPair("", "Lovewonder"),
))
})
})
Describe("Vorbis", func() {
BeforeEach(func() {
props.Tags = model.RawTags{
"PERFORMER": {
"John Adams (Rhodes piano)",
"Vincent Henry (alto saxophone, baritone saxophone and tenor saxophone)",
"Salaam Remi (drums (drum set) and organ)",
"Amy Winehouse (guitar)",
"Amy Winehouse (vocals)",
"Wonderlove",
},
}
})
It("should return the performers", func() {
md = metadata.New(filePath, props)
Expect(md.All()).To(HaveKey(model.TagPerformer))
Expect(md.Strings(model.TagPerformer)).To(ConsistOf(
metadata.NewPair("rhodes piano", "John Adams"),
metadata.NewPair("alto saxophone, baritone saxophone and tenor saxophone", "Vincent Henry"),
metadata.NewPair("drums (drum set) and organ", "Salaam Remi"),
metadata.NewPair("guitar", "Amy Winehouse"),
metadata.NewPair("vocals", "Amy Winehouse"),
metadata.NewPair("", "Wonderlove"),
))
})
})
})
Describe("Lyrics", func() {
BeforeEach(func() {
props.Tags = model.RawTags{
"LYRICS:POR": {"Letras"},
"LYRICS:ENG": {"Lyrics"},
}
})
It("should return the lyrics", func() {
md = metadata.New(filePath, props)
Expect(md.All()).To(HaveKey(model.TagLyrics))
Expect(md.Strings(model.TagLyrics)).To(ContainElements(
metadata.NewPair("por", "Letras"),
metadata.NewPair("eng", "Lyrics"),
))
})
})
Describe("ReplayGain", func() {
createMF := func(tag, tagValue string) model.MediaFile {
props.Tags = model.RawTags{
tag: {tagValue},
}
md = metadata.New(filePath, props)
return md.ToMediaFile(0, "0")
}
DescribeTable("Gain",
func(tagValue string, expected *float64) {
mf := createMF("replaygain_track_gain", tagValue)
Expect(mf.RGTrackGain).To(Equal(expected))
},
Entry("0", "0", gg.P(0.0)),
Entry("1.2dB", "1.2dB", gg.P(1.2)),
Entry("Infinity", "Infinity", nil),
Entry("Invalid value", "INVALID VALUE", nil),
Entry("NaN", "NaN", nil),
)
DescribeTable("Peak",
func(tagValue string, expected *float64) {
mf := createMF("replaygain_track_peak", tagValue)
Expect(mf.RGTrackPeak).To(Equal(expected))
},
Entry("0", "0", gg.P(0.0)),
Entry("1.0", "1.0", gg.P(1.0)),
Entry("0.5", "0.5", gg.P(0.5)),
Entry("Invalid dB suffix", "0.7dB", nil),
Entry("Infinity", "Infinity", nil),
Entry("Invalid value", "INVALID VALUE", nil),
Entry("NaN", "NaN", nil),
)
DescribeTable("getR128GainValue",
func(tagValue string, expected *float64) {
mf := createMF("r128_track_gain", tagValue)
Expect(mf.RGTrackGain).To(Equal(expected))
},
Entry("0", "0", gg.P(5.0)),
Entry("-3776", "-3776", gg.P(-9.75)),
Entry("Infinity", "Infinity", nil),
Entry("Invalid value", "INVALID VALUE", nil),
)
})
})
})

View File

@@ -0,0 +1,106 @@
package metadata
import (
"cmp"
"fmt"
"path/filepath"
"strings"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/id"
"github.com/navidrome/navidrome/utils"
"github.com/navidrome/navidrome/utils/slice"
"github.com/navidrome/navidrome/utils/str"
)
type hashFunc = func(...string) string
// createGetPID returns a function that calculates the persistent ID for a given spec, getting the referenced values from the metadata
// The spec is a pipe-separated list of fields, where each field is a comma-separated list of attributes
// Attributes can be either tags or some processed values like folder, albumid, albumartistid, etc.
// For each field, it gets all its attributes values and concatenates them, then hashes the result.
// If a field is empty, it is skipped and the function looks for the next field.
type getPIDFunc = func(mf model.MediaFile, md Metadata, spec string, prependLibId bool) string
func createGetPID(hash hashFunc) getPIDFunc {
var getPID getPIDFunc
getAttr := func(mf model.MediaFile, md Metadata, attr string, prependLibId bool) string {
attr = strings.TrimSpace(strings.ToLower(attr))
switch attr {
case "albumid":
return getPID(mf, md, conf.Server.PID.Album, prependLibId)
case "folder":
return filepath.Dir(mf.Path)
case "albumartistid":
return hash(str.Clear(strings.ToLower(mf.AlbumArtist)))
case "title":
return mf.Title
case "album":
return str.Clear(strings.ToLower(md.String(model.TagAlbum)))
}
return md.String(model.TagName(attr))
}
getPID = func(mf model.MediaFile, md Metadata, spec string, prependLibId bool) string {
pid := ""
fields := strings.Split(spec, "|")
for _, field := range fields {
attributes := strings.Split(field, ",")
hasValue := false
values := slice.Map(attributes, func(attr string) string {
v := getAttr(mf, md, attr, prependLibId)
if v != "" {
hasValue = true
}
return v
})
if hasValue {
pid += strings.Join(values, "\\")
break
}
}
if prependLibId {
pid = fmt.Sprintf("%d\\%s", mf.LibraryID, pid)
}
return hash(pid)
}
return func(mf model.MediaFile, md Metadata, spec string, prependLibId bool) string {
switch spec {
case "track_legacy":
return legacyTrackID(mf, prependLibId)
case "album_legacy":
return legacyAlbumID(mf, md, prependLibId)
}
return getPID(mf, md, spec, prependLibId)
}
}
func (md Metadata) trackPID(mf model.MediaFile) string {
return createGetPID(id.NewHash)(mf, md, conf.Server.PID.Track, true)
}
func (md Metadata) albumID(mf model.MediaFile, pidConf string) string {
return createGetPID(id.NewHash)(mf, md, pidConf, true)
}
// BFR Must be configurable?
func (md Metadata) artistID(name string) string {
mf := model.MediaFile{AlbumArtist: name}
return createGetPID(id.NewHash)(mf, md, "albumartistid", false)
}
func (md Metadata) mapTrackTitle() string {
if title := md.String(model.TagTitle); title != "" {
return title
}
return utils.BaseName(md.FilePath())
}
func (md Metadata) mapAlbumName() string {
return cmp.Or(
md.String(model.TagAlbum),
consts.UnknownAlbum,
)
}

View File

@@ -0,0 +1,272 @@
package metadata
import (
"strings"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/model"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("getPID", func() {
var (
md Metadata
mf model.MediaFile
sum hashFunc
getPID getPIDFunc
)
BeforeEach(func() {
sum = func(s ...string) string { return "(" + strings.Join(s, ",") + ")" }
getPID = createGetPID(sum)
})
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,version,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() {
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"},
"version": {"version"},
"releasedate": {"2021-01-01"},
}
mf.AlbumArtist = "Album Artist"
Expect(getPID(mf, md, spec, false)).To(Equal("(((album artist)\\album name\\version\\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)"))
})
})
})
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))
})
})
})
})
})