fix(scanner): map movement number and count from ID3 MVIN and MP4 tags (#6357)

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
This commit is contained in:
Deluan Quintão authored and GitHub committed 2026-10-21 13:26:26 +01:00
1 parent df77c462e8
commit 56cc32aede
3 files changed
+47 -2

No files matched your search

+24
View File
@@ -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)
+21
View File
@@ -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}
+2 -2
View File
@@ -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