navidrome/model/metadata/map_mediafile_test.go
Deluan Quintão 56cc32aede
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
2026-10-21 13:26:26 +01:00

192 lines
6.9 KiB
Go

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/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(model.Library{ID: 1}, "folderID")
}
Describe("Persistent IDs", func() {
It("uses the library PID config for the album ID and for albumid in the track spec", func() {
props.Tags = model.RawTags{"ALBUM": {"Kind of Blue"}, "TITLE": {"So What"}}
md = metadata.New("Jazz/Loose/01.mp3", props)
byTags := md.ToMediaFile(model.Library{ID: 1, PIDAlbum: "album", PIDTrack: "albumid,title"}, "folderID")
byFolder := md.ToMediaFile(model.Library{ID: 1, PIDAlbum: "folder", PIDTrack: "albumid,title"}, "folderID")
Expect(byFolder.AlbumID).ToNot(Equal(byTags.AlbumID))
Expect(byFolder.AlbumID).To(Equal(md.AlbumID(byFolder, "folder")))
Expect(byFolder.PID).ToNot(Equal(byTags.PID))
})
})
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: new(int64(0))},
{Value: "English SYLT", Start: new(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))
})
})
Describe("BPM", func() {
It("maps the BPM tag rounded to the nearest integer", func() {
mf = toMediaFile(model.RawTags{"BPM": {"120.6"}})
Expect(mf.BPM).To(Equal(new(121)))
})
It("leaves BPM nil when the tag is absent", func() {
mf = toMediaFile(model.RawTags{})
Expect(mf.BPM).To(BeNil())
})
It("leaves BPM nil when the tag is zero or unparseable", func() {
Expect(toMediaFile(model.RawTags{"BPM": {"0"}}).BPM).To(BeNil())
Expect(toMediaFile(model.RawTags{"BPM": {"fast"}}).BPM).To(BeNil())
})
It("leaves BPM nil when the tag does not fit in 32 bits", func() {
Expect(toMediaFile(model.RawTags{"BPM": {"4294967295"}}).BPM).To(BeNil())
})
It("leaves BPM nil when the tag is negative", func() {
Expect(toMediaFile(model.RawTags{"BPM": {"-120"}}).BPM).To(BeNil())
})
It("keeps the largest 32-bit BPM value", func() {
Expect(toMediaFile(model.RawTags{"BPM": {"2147483647"}}).BPM).To(Equal(new(2147483647)))
})
})
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}
mf = toMediaFile(model.RawTags{})
Expect(mf.BitDepth).To(Equal(new(24)))
})
It("leaves BitDepth nil when zero (lossy codecs have no bit depth)", func() {
props.AudioProperties = metadata.AudioProperties{BitDepth: 0}
mf = toMediaFile(model.RawTags{})
Expect(mf.BitDepth).To(BeNil())
})
})
})