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
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:
57
model/metadata/legacy_ids.go
Normal file
57
model/metadata/legacy_ids.go
Normal 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,
|
||||
)
|
||||
}
|
||||
185
model/metadata/map_mediafile.go
Normal file
185
model/metadata/map_mediafile.go
Normal 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
|
||||
}
|
||||
121
model/metadata/map_mediafile_test.go
Normal file
121
model/metadata/map_mediafile_test.go
Normal 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))
|
||||
})
|
||||
})
|
||||
})
|
||||
236
model/metadata/map_participants.go
Normal file
236
model/metadata/map_participants.go
Normal 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,
|
||||
)
|
||||
}
|
||||
785
model/metadata/map_participants_test.go
Normal file
785
model/metadata/map_participants_test.go
Normal 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
387
model/metadata/metadata.go
Normal 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
|
||||
}
|
||||
32
model/metadata/metadata_suite_test.go
Normal file
32
model/metadata/metadata_suite_test.go
Normal 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()
|
||||
}
|
||||
298
model/metadata/metadata_test.go
Normal file
298
model/metadata/metadata_test.go
Normal 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),
|
||||
)
|
||||
})
|
||||
|
||||
})
|
||||
})
|
||||
106
model/metadata/persistent_ids.go
Normal file
106
model/metadata/persistent_ids.go
Normal 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,
|
||||
)
|
||||
}
|
||||
272
model/metadata/persistent_ids_test.go
Normal file
272
model/metadata/persistent_ids_test.go
Normal 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))
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user