From 6730716d263e6c3fd37225554c54514244aaad8d Mon Sep 17 00:00:00 2001 From: Kendall Garner <17521368+kgarner7@users.noreply.github.com> Date: Wed, 9 Jul 2025 03:27:40 +0000 Subject: [PATCH] fix(scanner): lyrics tag parsing to properly handle both ID3 and aliased tags * fix(taglib): parse both id3 and aliased tags, as lyrics appears to be mapped to lyrics-xxx * address feedback, make confusing test more stable --- adapters/taglib/end_to_end_test.go | 122 ++++++++++++++++++++++------- model/metadata/metadata.go | 8 +- resources/mappings.yaml | 3 +- tests/fixtures/mixed-lyrics.flac | Bin 0 -> 32199 bytes 4 files changed, 100 insertions(+), 33 deletions(-) create mode 100644 tests/fixtures/mixed-lyrics.flac diff --git a/adapters/taglib/end_to_end_test.go b/adapters/taglib/end_to_end_test.go index e192bbdd7..e4d94bb24 100644 --- a/adapters/taglib/end_to_end_test.go +++ b/adapters/taglib/end_to_end_test.go @@ -79,22 +79,29 @@ var _ = Describe("Extractor", func() { var e *extractor + parseTestFile := func(path string) *model.MediaFile { + mds, err := e.Parse(path) + Expect(err).ToNot(HaveOccurred()) + + info, ok := mds[path] + Expect(ok).To(BeTrue()) + + fileInfo, err := os.Stat(path) + Expect(err).ToNot(HaveOccurred()) + info.FileInfo = testFileInfo{FileInfo: fileInfo} + + metadata := metadata.New(path, info) + mf := metadata.ToMediaFile(1, "folderID") + return &mf + } + BeforeEach(func() { e = &extractor{} }) Describe("ReplayGain", func() { DescribeTable("test replaygain end-to-end", func(file string, trackGain, trackPeak, albumGain, albumPeak *float64) { - path := "tests/fixtures/" + file - mds, err := e.Parse(path) - Expect(err).ToNot(HaveOccurred()) - - info := mds[path] - fileInfo, _ := os.Stat(path) - info.FileInfo = testFileInfo{FileInfo: fileInfo} - - metadata := metadata.New(path, info) - mf := metadata.ToMediaFile(1, "folderID") + mf := parseTestFile("tests/fixtures/" + file) Expect(mf.RGTrackGain).To(Equal(trackGain)) Expect(mf.RGTrackPeak).To(Equal(trackPeak)) @@ -106,18 +113,82 @@ var _ = Describe("Extractor", func() { ) }) + Describe("lyrics", func() { + makeLyrics := func(code, secondLine string) model.Lyrics { + return model.Lyrics{ + DisplayArtist: "", + DisplayTitle: "", + Lang: code, + Line: []model.Line{ + {Start: gg.P(int64(0)), Value: "This is"}, + {Start: gg.P(int64(2500)), Value: secondLine}, + }, + Offset: nil, + Synced: true, + } + } + + It("should fetch both synced and unsynced lyrics in mixed flac", func() { + mf := parseTestFile("tests/fixtures/mixed-lyrics.flac") + + lyrics, err := mf.StructuredLyrics() + Expect(err).ToNot(HaveOccurred()) + Expect(lyrics).To(HaveLen(2)) + + Expect(lyrics[0].Synced).To(BeTrue()) + Expect(lyrics[1].Synced).To(BeFalse()) + }) + + It("should handle mp3 with uslt and sylt", func() { + mf := parseTestFile("tests/fixtures/test.mp3") + + lyrics, err := mf.StructuredLyrics() + Expect(err).ToNot(HaveOccurred()) + Expect(lyrics).To(HaveLen(4)) + + engSylt := makeLyrics("eng", "English SYLT") + engUslt := makeLyrics("eng", "English") + unsSylt := makeLyrics("xxx", "unspecified SYLT") + unsUslt := makeLyrics("xxx", "unspecified") + + // Why is the order inconsistent between runs? Nobody knows + Expect(lyrics).To(Or( + Equal(model.LyricList{engSylt, engUslt, unsSylt, unsUslt}), + Equal(model.LyricList{unsSylt, unsUslt, engSylt, engUslt}), + )) + }) + + DescribeTable("format-specific lyrics", func(file string, isId3 bool) { + mf := parseTestFile("tests/fixtures/" + file) + + lyrics, err := mf.StructuredLyrics() + Expect(err).To(Not(HaveOccurred())) + Expect(lyrics).To(HaveLen(2)) + + unspec := makeLyrics("xxx", "unspecified") + eng := makeLyrics("xxx", "English") + + if isId3 { + eng.Lang = "eng" + } + + Expect(lyrics).To(Or( + Equal(model.LyricList{unspec, eng}), + Equal(model.LyricList{eng, unspec}))) + }, + Entry("flac", "test.flac", false), + Entry("m4a", "test.m4a", false), + Entry("ogg", "test.ogg", false), + Entry("wma", "test.wma", false), + Entry("wv", "test.wv", false), + Entry("wav", "test.wav", true), + Entry("aiff", "test.aiff", true), + ) + }) + Describe("Participants", func() { DescribeTable("test tags consistent across formats", func(format string) { - path := "tests/fixtures/test." + format - mds, err := e.Parse(path) - Expect(err).ToNot(HaveOccurred()) - - info := mds[path] - fileInfo, _ := os.Stat(path) - info.FileInfo = testFileInfo{FileInfo: fileInfo} - - metadata := metadata.New(path, info) - mf := metadata.ToMediaFile(1, "folderID") + mf := parseTestFile("tests/fixtures/test." + format) for _, data := range roles { role := data.Role @@ -176,16 +247,7 @@ var _ = Describe("Extractor", func() { ) It("should parse wma", func() { - path := "tests/fixtures/test.wma" - mds, err := e.Parse(path) - Expect(err).ToNot(HaveOccurred()) - - info := mds[path] - fileInfo, _ := os.Stat(path) - info.FileInfo = testFileInfo{FileInfo: fileInfo} - - metadata := metadata.New(path, info) - mf := metadata.ToMediaFile(1, "folderID") + mf := parseTestFile("tests/fixtures/test.wma") for _, data := range roles { role := data.Role diff --git a/model/metadata/metadata.go b/model/metadata/metadata.go index aea4238a4..1372d0034 100644 --- a/model/metadata/metadata.go +++ b/model/metadata/metadata.go @@ -245,10 +245,14 @@ func processPairMapping(name model.TagName, mapping model.TagConf, lowered model } } + // always parse id3 pairs. For lyrics, Taglib appears to always provide lyrics:xxx + // Prefer that over format-specific tags + id3Base := parseID3Pairs(name, lowered) + if len(aliasValues) > 0 { - return parseVorbisPairs(aliasValues) + id3Base = append(id3Base, parseVorbisPairs(aliasValues)...) } - return parseID3Pairs(name, lowered) + return id3Base } func parseID3Pairs(name model.TagName, lowered model.Tags) []string { diff --git a/resources/mappings.yaml b/resources/mappings.yaml index f461d889e..d1da5c620 100644 --- a/resources/mappings.yaml +++ b/resources/mappings.yaml @@ -108,7 +108,8 @@ main: bpm: aliases: [ tbpm, bpm, tmpo, wm/beatsperminute ] lyrics: - aliases: [ uslt:description, lyrics, ©lyr, wm/lyrics, unsyncedlyrics ] + # Note, @lyr and wm/lyrics have been removed. Taglib somehow appears to always populate `lyrics:xxx` + aliases: [ uslt:description, lyrics, unsyncedlyrics ] maxLength: 32768 type: pair # ex: lyrics:eng, lyrics:xxx comment: diff --git a/tests/fixtures/mixed-lyrics.flac b/tests/fixtures/mixed-lyrics.flac new file mode 100644 index 0000000000000000000000000000000000000000..d048234f55260d9a0204db7dab1fd1f9ba00b497 GIT binary patch literal 32199 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 QCzn5%*TV#%IG>ya