navidrome/model/metadata/metadata_test.go
Yuuta 3a14faa033
feat(subsonic): add structured sidecar lyrics support with OpenSubsonic v2 karaoke cues and agent layers (#5076)
Expand backend lyrics support with richer sidecar formats and upgrade the
OpenSubsonic songLyrics implementation to the version 2 structured karaoke
contract, while preserving version 1 behavior by default.

Sidecar formats and parsing:
- Add a TTML parser (core/lyrics/ttml.go): clock time, offset time, bare
  decimal seconds, nested timing contexts, and token-level <span> timing for
  word/syllable karaoke. Parses Apple Music-style metadata tracks (translation
  and pronunciation/transliteration) and agent metadata into per-track agents[]
  plus per-cue-line agentId. Hydrates missing line timing from cue timing.
- Add an SRT parser (core/lyrics/srt.go).
- Add a LRCLIB Lyricsfile (.yaml/.yml) parser (model/lyricsfile.go): maps
  per-word lines[].words[] to cues with inclusive UTF-8 byte offsets and
  attributes overlapping lines to synthetic voice agents so parallel vocals
  split correctly in the enhanced response.
- Extend LRC parsing for Enhanced LRC inline <mm:ss.xx> word-timing markers.
- Add UTF-8 BOM and UTF-16 LE support for TTML/LRC sidecars.
- Parse the above formats from embedded tags as well as sidecar files.

Source resolution:
- Default lyricspriority is now
  ".ttml,.yaml,.yml,.elrc,.lrc,.srt,.txt,embedded" so the new formats are
  discoverable without manual configuration.
- Preserve configured source priority across duplicate media-file candidates
  instead of only checking the first DB match, so higher-priority sidecar
  lyrics on older duplicates can still win.
- Raise the embedded-lyrics tag maxLength to 1 MB to fit word-timed
  TTML/Enhanced-LRC karaoke for a full song.

OpenSubsonic songLyrics v2:
- Advertise songLyrics versions [1, 2].
- With enhanced=true, getLyricsBySongId may return structuredLyrics.kind
  (main/translation/pronunciation), cueLine[] line-level karaoke groupings,
  cueLine.cue[] timed words/syllables with required UTF-8 byteStart/byteEnd,
  reusable structuredLyrics.agents[], and cueLine.agentId references.
- Without enhanced=true, the response stays v1-compatible: no kind, no cueLine,
  no agents, no non-main tracks; the existing line[] payload is always
  populated so legacy clients keep working.

Contract details:
- cueLine is emitted only for synced lyrics with cue data.
- Within a cueLine, cue.end is normalized all-or-none and overlaps are removed;
  overlaps across separate cueLines remain valid for parallel vocal layers.
- Missing cue end-times are filled from the next cue or the parent line.
- When cueLines share an index, the one whose agent has role "main" is first.
- LyricCue.Value is serialized as XML chardata; cues with nil start are skipped
  rather than serialized as 0.

Refactoring:
- Move pure format parsers into model/ (lyrics.go, lyrics_ttml.go,
  lyrics_srt.go, lyrics_embedded.go, lyricsfile.go) and extract Subsonic
  response building into server/subsonic/lyrics.go.
- Centralize lyric-kind constants and add Lyrics.EffectiveKind/IsMainKind.
- Add gg.Clone helper.

Spec references:
  https://github.com/opensubsonic/open-subsonic-api/discussions/213
  https://github.com/opensubsonic/open-subsonic-api/pull/218 (songLyrics v2)
  https://github.com/opensubsonic/open-subsonic-api/pull/228 (cue byte offsets)
2026-06-19 12:00:58 -04:00

326 lines
10 KiB
Go

package metadata_test
import (
"os"
"strings"
"time"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/metadata"
"github.com/navidrome/navidrome/utils"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Metadata", func() {
var (
filePath string
fileInfo os.FileInfo
props metadata.Info
md metadata.Metadata
)
BeforeEach(func() {
// It is easier to have a real file to test the mod and birth times
filePath = utils.TempFileName("test", ".mp3")
f, _ := os.Create(filePath)
DeferCleanup(func() {
_ = f.Close()
_ = os.Remove(filePath)
})
fileInfo, _ = os.Stat(filePath)
props = metadata.Info{
AudioProperties: metadata.AudioProperties{
Duration: time.Minute * 3,
BitRate: 320,
},
HasPicture: true,
FileInfo: testFileInfo{fileInfo},
}
})
Describe("Metadata", func() {
Describe("New", func() {
It("should create a new Metadata object with the correct properties", func() {
props.Tags = model.RawTags{
"©ART": {"First Artist", "Second Artist"},
"----:com.apple.iTunes:CATALOGNUMBER": {"1234"},
"tbpm": {"120.6"},
"WM/IsCompilation": {"1"},
}
md = metadata.New(filePath, props)
Expect(md.FilePath()).To(Equal(filePath))
Expect(md.ModTime()).To(Equal(fileInfo.ModTime()))
Expect(md.BirthTime()).To(BeTemporally("~", md.ModTime(), time.Second))
Expect(md.Size()).To(Equal(fileInfo.Size()))
Expect(md.Suffix()).To(Equal("mp3"))
Expect(md.AudioProperties()).To(Equal(props.AudioProperties))
Expect(md.Length()).To(Equal(float32(3 * 60)))
Expect(md.HasPicture()).To(Equal(props.HasPicture))
Expect(md.Strings(model.TagTrackArtist)).To(Equal([]string{"First Artist", "Second Artist"}))
Expect(md.String(model.TagTrackArtist)).To(Equal("First Artist"))
Expect(md.Int(model.TagCatalogNumber)).To(Equal(int64(1234)))
Expect(md.Float(model.TagBPM)).To(Equal(120.6))
Expect(md.Bool(model.TagCompilation)).To(BeTrue())
Expect(md.All()).To(SatisfyAll(
HaveLen(4),
HaveKeyWithValue(model.TagTrackArtist, []string{"First Artist", "Second Artist"}),
HaveKeyWithValue(model.TagBPM, []string{"120.6"}),
HaveKeyWithValue(model.TagCompilation, []string{"1"}),
HaveKeyWithValue(model.TagCatalogNumber, []string{"1234"}),
))
})
It("should clean the tags map correctly", func() {
const unknownTag = "UNKNOWN_TAG"
props.Tags = model.RawTags{
"TPE1": {"Artist Name", "Artist Name", ""},
"©ART": {"Second Artist"},
"CatalogNumber": {""},
"Album": {"Album Name", "", "Album Name"},
"Date": {"2022-10-02 12:15:01"},
"Year": {"2022", "2022", ""},
"Genre": {"Pop", "", "Pop", "Rock"},
"Track": {"1/10", "1/10", ""},
unknownTag: {"value"},
}
md = metadata.New(filePath, props)
Expect(md.All()).To(SatisfyAll(
Not(HaveKey(unknownTag)),
HaveKeyWithValue(model.TagTrackArtist, []string{"Artist Name", "Second Artist"}),
HaveKeyWithValue(model.TagAlbum, []string{"Album Name"}),
HaveKeyWithValue(model.TagRecordingDate, []string{"2022-10-02"}),
HaveKeyWithValue(model.TagReleaseDate, []string{"2022"}),
HaveKeyWithValue(model.TagGenre, []string{"Pop", "Rock"}),
HaveKeyWithValue(model.TagTrackNumber, []string{"1/10"}),
HaveLen(6),
))
})
It("should truncate long strings", func() {
props.Tags = model.RawTags{
"Title": {strings.Repeat("a", 2048)},
"Comment": {strings.Repeat("a", 8192)},
"lyrics:xxx": {strings.Repeat("a", 2_000_000)},
}
md = metadata.New(filePath, props)
Expect(md.String(model.TagTitle)).To(HaveLen(1024))
Expect(md.String(model.TagComment)).To(HaveLen(4096))
pair := md.Pairs(model.TagLyrics)
Expect(pair).To(HaveLen(1))
Expect(pair[0].Key()).To(Equal("xxx"))
// Lyrics keep a much larger cap so word-timed karaoke survives.
// Note: a total of 6 characters are lost from maxLength from
// the key portion and separator.
Expect(pair[0].Value()).To(HaveLen(1048570))
})
It("should split multiple values", func() {
props.Tags = model.RawTags{
"Genre": {"Rock/Pop;;Punk"},
}
md = metadata.New(filePath, props)
Expect(md.Strings(model.TagGenre)).To(Equal([]string{"Rock", "Pop", "Punk"}))
})
// Regression test for https://github.com/navidrome/navidrome/issues/5065
//
// MP3s with both an ID3v2 TMOO frame and a TXXX:MOOD frame are surfaced by
// TagLib's PropertyMap as a single "mood" key with multiple values. The split
// configuration must still apply to each value individually.
It("should split values from multiple frames mapping to the same tag", func() {
props.Tags = model.RawTags{
// Same shape as the bug report: two frames, comma-separated content.
"mood": {"Love, Emotional, Ballad", "Love; Emotional; Ballad"},
}
md = metadata.New(filePath, props)
Expect(md.Strings(model.TagMood)).To(ConsistOf("Love", "Emotional", "Ballad"))
})
})
DescribeTable("Date",
func(value string, expectedYear int, expectedDate string) {
props.Tags = model.RawTags{
"date": {value},
}
md = metadata.New(filePath, props)
testDate := md.Date(model.TagRecordingDate)
Expect(string(testDate)).To(Equal(expectedDate))
Expect(testDate.Year()).To(Equal(expectedYear))
},
Entry(nil, "1985", 1985, "1985"),
Entry(nil, "2002-01", 2002, "2002-01"),
Entry(nil, "1969.06", 1969, "1969"),
Entry(nil, "1980.07.25", 1980, "1980"),
Entry(nil, "2004-00-00", 2004, "2004"),
Entry(nil, "2016-12-31", 2016, "2016-12-31"),
Entry(nil, "2016-12-31 12:15", 2016, "2016-12-31"),
Entry(nil, "2013-May-12", 2013, "2013"),
Entry(nil, "May 12, 2016", 2016, "2016"),
Entry(nil, "01/10/1990", 1990, "1990"),
Entry(nil, "invalid", 0, ""),
)
DescribeTable("NumAndTotal",
func(num, total string, expectedNum int, expectedTotal int) {
props.Tags = model.RawTags{
"Track": {num},
"TrackTotal": {total},
}
md = metadata.New(filePath, props)
testNum, testTotal := md.NumAndTotal(model.TagTrackNumber)
Expect(testNum).To(Equal(expectedNum))
Expect(testTotal).To(Equal(expectedTotal))
},
Entry(nil, "2", "", 2, 0),
Entry(nil, "2", "10", 2, 10),
Entry(nil, "2/10", "", 2, 10),
Entry(nil, "", "", 0, 0),
Entry(nil, "A", "", 0, 0),
)
Describe("Performers", func() {
Describe("ID3", func() {
BeforeEach(func() {
props.Tags = model.RawTags{
"PERFORMER:GUITAR": {"Guitarist 1", "Guitarist 2"},
"PERFORMER:BACKGROUND VOCALS": {"Backing Singer"},
"PERFORMER:PERFORMER": {"Wonderlove", "Lovewonder"},
}
})
It("should return the performers", func() {
md = metadata.New(filePath, props)
Expect(md.All()).To(HaveKey(model.TagPerformer))
Expect(md.Strings(model.TagPerformer)).To(ConsistOf(
metadata.NewPair("guitar", "Guitarist 1"),
metadata.NewPair("guitar", "Guitarist 2"),
metadata.NewPair("background vocals", "Backing Singer"),
metadata.NewPair("", "Wonderlove"),
metadata.NewPair("", "Lovewonder"),
))
})
})
Describe("Vorbis", func() {
BeforeEach(func() {
props.Tags = model.RawTags{
"PERFORMER": {
"John Adams (Rhodes piano)",
"Vincent Henry (alto saxophone, baritone saxophone and tenor saxophone)",
"Salaam Remi (drums (drum set) and organ)",
"Amy Winehouse (guitar)",
"Amy Winehouse (vocals)",
"Wonderlove",
},
}
})
It("should return the performers", func() {
md = metadata.New(filePath, props)
Expect(md.All()).To(HaveKey(model.TagPerformer))
Expect(md.Strings(model.TagPerformer)).To(ConsistOf(
metadata.NewPair("rhodes piano", "John Adams"),
metadata.NewPair("alto saxophone, baritone saxophone and tenor saxophone", "Vincent Henry"),
metadata.NewPair("drums (drum set) and organ", "Salaam Remi"),
metadata.NewPair("guitar", "Amy Winehouse"),
metadata.NewPair("vocals", "Amy Winehouse"),
metadata.NewPair("", "Wonderlove"),
))
})
})
})
Describe("Lyrics", func() {
BeforeEach(func() {
props.Tags = model.RawTags{
"LYRICS:POR": {"Letras"},
"LYRICS:ENG": {"Lyrics"},
}
})
It("should return the lyrics", func() {
md = metadata.New(filePath, props)
Expect(md.All()).To(HaveKey(model.TagLyrics))
Expect(md.Strings(model.TagLyrics)).To(ContainElements(
metadata.NewPair("por", "Letras"),
metadata.NewPair("eng", "Lyrics"),
))
})
It("should preserve lyrics starting with parentheses from alias tags", func() {
props.Tags = model.RawTags{
"LYRICS": {"(line one)\nline two\nline three"},
}
md = metadata.New(filePath, props)
Expect(md.All()).To(HaveKey(model.TagLyrics))
Expect(md.Strings(model.TagLyrics)).To(ContainElements(
metadata.NewPair("xxx", "(line one)\nline two\nline three"),
))
})
})
Describe("ReplayGain", func() {
createMF := func(tag, tagValue string) model.MediaFile {
props.Tags = model.RawTags{
tag: {tagValue},
}
md = metadata.New(filePath, props)
return md.ToMediaFile(0, "0")
}
DescribeTable("Gain",
func(tagValue string, expected *float64) {
mf := createMF("replaygain_track_gain", tagValue)
Expect(mf.RGTrackGain).To(Equal(expected))
},
Entry("0", "0", new(0.0)),
Entry("1.2dB", "1.2dB", new(1.2)),
Entry("Infinity", "Infinity", nil),
Entry("Invalid value", "INVALID VALUE", nil),
Entry("NaN", "NaN", nil),
)
DescribeTable("Peak",
func(tagValue string, expected *float64) {
mf := createMF("replaygain_track_peak", tagValue)
Expect(mf.RGTrackPeak).To(Equal(expected))
},
Entry("0", "0", new(0.0)),
Entry("1.0", "1.0", new(1.0)),
Entry("0.5", "0.5", new(0.5)),
Entry("Invalid dB suffix", "0.7dB", nil),
Entry("Infinity", "Infinity", nil),
Entry("Invalid value", "INVALID VALUE", nil),
Entry("NaN", "NaN", nil),
)
DescribeTable("getR128GainValue",
func(tagValue string, expected *float64) {
mf := createMF("r128_track_gain", tagValue)
Expect(mf.RGTrackGain).To(Equal(expected))
},
Entry("0", "0", new(5.0)),
Entry("-3776", "-3776", new(-9.75)),
Entry("Infinity", "Infinity", nil),
Entry("Invalid value", "INVALID VALUE", nil),
)
})
})
})