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 2026-10-21 13:26:26 +01:00 • committed by GitHub
commit 56cc32aede
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 47 additions and 2 deletions

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)

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}

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