diff --git a/model/metadata/map_mediafile.go b/model/metadata/map_mediafile.go index 2135824d2..c779116a8 100644 --- a/model/metadata/map_mediafile.go +++ b/model/metadata/map_mediafile.go @@ -6,7 +6,9 @@ import ( "encoding/json" "maps" "math" + "slices" "strconv" + "strings" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" @@ -42,6 +44,7 @@ func (md Metadata) ToMediaFile(lib model.Library, folderID string) model.MediaFi } mf.Lyrics = md.mapLyrics() mf.ExplicitStatus = md.mapExplicitStatusTag() + md.mapMovementNumbers(mf.Tags) // Dates date, origDate, relDate := md.mapDates() @@ -135,6 +138,27 @@ func (md Metadata) mapGain(rg, r128 model.TagName) *float64 { return nil } +// mapMovementNumbers splits ID3 MVIN "n/N" values, like tuple() does for track and disc numbers. +// Totals are only filled when every value has one, so the parallel movement tags stay aligned. +func (md Metadata) mapMovementNumbers(tags model.Tags) { + values := md.Strings(model.TagMovementNumber) + if !slices.ContainsFunc(values, func(v string) bool { return strings.Contains(v, "/") }) { + return + } + numbers := make([]string, len(values)) + totals := make([]string, len(values)) + allHaveTotals := true + for i, v := range values { + var found bool + numbers[i], totals[i], found = strings.Cut(v, "/") + allHaveTotals = allHaveTotals && found + } + tags[model.TagMovementNumber] = numbers + if allHaveTotals && len(md.Strings(model.TagMovementTotal)) == 0 { + tags[model.TagMovementTotal] = totals + } +} + func (md Metadata) mapLyrics() string { rawLyrics := md.Pairs(model.TagLyrics) diff --git a/model/metadata/map_mediafile_test.go b/model/metadata/map_mediafile_test.go index c19398841..d5d9ba9f4 100644 --- a/model/metadata/map_mediafile_test.go +++ b/model/metadata/map_mediafile_test.go @@ -156,6 +156,27 @@ var _ = Describe("ToMediaFile", func() { }) }) + Describe("Movements", func() { + It("splits an ID3 MVIN n/N value into movement number and total", func() { + mf = toMediaFile(model.RawTags{"movementname": {"Allegro"}, "movementnumber": {"1/4"}}) + Expect(mf.Tags).To(HaveKeyWithValue(model.TagMovementNumber, []string{"1"})) + Expect(mf.Tags).To(HaveKeyWithValue(model.TagMovementTotal, []string{"4"})) + Expect(mf.Movements()).To(Equal([]model.Movement{{Name: "Allegro", Number: 1, Count: 4}})) + }) + It("maps MP4 movement number and count", func() { + mf = toMediaFile(model.RawTags{"movementname": {"Andante"}, "movementnumber": {"2"}, "movementcount": {"4"}}) + Expect(mf.Movements()).To(Equal([]model.Movement{{Name: "Andante", Number: 2, Count: 4}})) + }) + It("prefers an explicit movement total over the n/N total", func() { + mf = toMediaFile(model.RawTags{"movementname": {"Allegro"}, "movement": {"2/4"}, "movementtotal": {"5"}}) + Expect(mf.Movements()).To(Equal([]model.Movement{{Name: "Allegro", Number: 2, Count: 5}})) + }) + It("does not add a total when only some values have one", func() { + mf = toMediaFile(model.RawTags{"movementname": {"I", "II"}, "movement": {"1/4", "2"}}) + Expect(mf.Movements()).To(Equal([]model.Movement{{Name: "I", Number: 1}, {Name: "II", Number: 2}})) + }) + }) + Describe("BitDepth", func() { It("maps the bit depth when present", func() { props.AudioProperties = metadata.AudioProperties{BitDepth: 24} diff --git a/resources/mappings.yaml b/resources/mappings.yaml index 294654b6a..643b477c5 100644 --- a/resources/mappings.yaml +++ b/resources/mappings.yaml @@ -231,9 +231,9 @@ additional: movementname: aliases: [ mvnm, movementname, ©mvn ] movementtotal: - aliases: [ movementtotal, mvc ] + aliases: [ movementtotal, movementcount, mvc ] movement: - aliases: [ mvin, movement, mvi ] + aliases: [ mvin, movement, movementnumber, mvi ] recordlabel: aliases: [ tpub, label, publisher, ----:com.apple.itunes:label, wm/publisher, organization ] album: true