From 56cc32aedea8dcb28919adbdcfc1f51294df2e5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Wed, 21 Oct 2026 13:26:26 +0100 Subject: [PATCH] fix(scanner): map movement number and count from ID3 MVIN and MP4 tags (#6357) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TagLib reports ID3 MVIN and MP4 ©mvi as movementnumber, and MP4 ©mvc as movementcount. Neither name was aliased in mappings.yaml, so getSong returned movements with the name only. Add both aliases. ID3 MVIN stores the value as n/N (e.g. 1/4), which parsed as 0. Split it at scan time, like track and disc numbers, so the stored movement tag holds the number and movementtotal gets the count when the file has no explicit total. Storing the clean value also lets smart playlist rules on the movement tag match. Existing files need a full scan to pick this up. Fixes #6356 --- model/metadata/map_mediafile.go | 24 ++++++++++++++++++++++++ model/metadata/map_mediafile_test.go | 21 +++++++++++++++++++++ resources/mappings.yaml | 4 ++-- 3 files changed, 47 insertions(+), 2 deletions(-) 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