diff --git a/server/e2e/e2e_suite_test.go b/server/e2e/e2e_suite_test.go index 1c1c7bc63..ac4aaa5f2 100644 --- a/server/e2e/e2e_suite_test.go +++ b/server/e2e/e2e_suite_test.go @@ -70,17 +70,19 @@ const ( mbidSomethingRec = "44444444-4444-4444-a444-444444444444" // mbz_recording_id ) -// Lyric fixture payloads used in buildTestFS. -const embeddedTTML = `

embedded ttml line

` +// lyricFixture reads a public-domain lyric fixture (the same files the parser +// benchmarks use) so the e2e fixtures stay in sync with real-world content, +// including the word-level timing carried by the .elrc and .yaml variants. +func lyricFixture(name string) string { + // tests.Init chdirs to the project root, so reference fixtures from there. + data, err := os.ReadFile(filepath.Join("tests", "fixtures", "lyrics", name)) + Expect(err).ToNot(HaveOccurred(), "reading lyric fixture %q", name) + return string(data) +} -const sidecarLyricsfileYAML = `version: "1.0" -metadata: - title: Sidecar YAML - language: eng -lines: - - text: sidecar yaml line - start_ms: 6000 -` +// firstFixtureLine is the opening lyric line shared by every auld-lang-syne +// fixture; tests assert against it regardless of source format. +const firstFixtureLine = "Should auld acquaintance be forgot," // Shared test state var ( @@ -192,24 +194,30 @@ func buildTestFS() storagetest.FakeFS { "bitrate": 4500, "samplerate": 48000, "bitdepth": 24, "channels": 6, "duration": int64(180), }), - // Lyrics fixtures (isolated under Lyrics/ to keep other suite counts stable) - // Embedded — lyrics delivered via the "lyrics" tag, parsed at scan time - "Lyrics/Embedded/01 - Embedded Synced LRC.mp3": lyricsAlbum(track(1, "Embedded Synced LRC", - _t{"lyrics": "[00:01.00]embedded lrc line one\n[00:02.00]embedded lrc line two"})), + // Lyrics fixtures (isolated under Lyrics/ to keep other suite counts stable). + // Content comes from tests/fixtures/lyrics (the same public-domain files the + // parser benchmarks use); the .elrc and .yaml variants carry word-level + // timing, which drives the v1 (line-level) vs v2 (enhanced/word-level) tests. + // + // Embedded — lyrics delivered via the "lyrics" tag, parsed at scan time. + // "Enhanced LRC" embeds ELRC (word-level) content; the title is kept generic + // since ELRC is still valid LRC. + "Lyrics/Embedded/01 - Embedded Enhanced LRC.mp3": lyricsAlbum(track(1, "Embedded Enhanced LRC", + _t{"lyrics": lyricFixture("auld-lang-syne.elrc")})), "Lyrics/Embedded/02 - Embedded Plain.mp3": lyricsAlbum(track(2, "Embedded Plain", - _t{"lyrics": "plain embedded line one\nplain embedded line two"})), + _t{"lyrics": lyricFixture("auld-lang-syne.txt")})), "Lyrics/Embedded/03 - Embedded TTML.mp3": lyricsAlbum(track(3, "Embedded TTML", - _t{"lyrics": embeddedTTML})), + _t{"lyrics": lyricFixture("auld-lang-syne.ttml")})), // Sidecar — raw lyric text files read from the library FS at request time via fromExternalFile. // The scanner skips non-audio extensions (.lrc, .srt, .yaml), so placing them as raw MapFile // entries is safe: they are visible to the fake FS but invisible to the scanner. "Lyrics/Sidecar/01 - Sidecar LRC.mp3": lyricsAlbum(track(1, "Sidecar LRC")), - "Lyrics/Sidecar/01 - Sidecar LRC.lrc": &fstest.MapFile{Data: []byte("[00:03.00]sidecar lrc line"), ModTime: time.Now()}, + "Lyrics/Sidecar/01 - Sidecar LRC.lrc": &fstest.MapFile{Data: []byte(lyricFixture("auld-lang-syne.lrc")), ModTime: time.Now()}, "Lyrics/Sidecar/02 - Sidecar SRT.mp3": lyricsAlbum(track(2, "Sidecar SRT")), - "Lyrics/Sidecar/02 - Sidecar SRT.srt": &fstest.MapFile{Data: []byte("1\n00:00:04,000 --> 00:00:05,000\nsidecar srt line\n"), ModTime: time.Now()}, + "Lyrics/Sidecar/02 - Sidecar SRT.srt": &fstest.MapFile{Data: []byte(lyricFixture("auld-lang-syne.srt")), ModTime: time.Now()}, "Lyrics/Sidecar/03 - Sidecar YAML.mp3": lyricsAlbum(track(3, "Sidecar YAML")), - "Lyrics/Sidecar/03 - Sidecar YAML.yaml": &fstest.MapFile{Data: []byte(sidecarLyricsfileYAML), ModTime: time.Now()}, + "Lyrics/Sidecar/03 - Sidecar YAML.yaml": &fstest.MapFile{Data: []byte(lyricFixture("auld-lang-syne.yaml")), ModTime: time.Now()}, // _empty folder (directory with no audio) "_empty/.keep": &fstest.MapFile{Data: []byte{}, ModTime: time.Now()}, diff --git a/server/e2e/subsonic_lyrics_test.go b/server/e2e/subsonic_lyrics_test.go index ca08ae441..5f5c0bd0a 100644 --- a/server/e2e/subsonic_lyrics_test.go +++ b/server/e2e/subsonic_lyrics_test.go @@ -27,56 +27,106 @@ var _ = Describe("Lyrics endpoints", func() { return list.StructuredLyrics[0] } - Describe("getLyricsBySongId (v2 structured)", func() { - DescribeTable("returns structured lyrics", - func(title string, wantSynced bool, wantFirstLine, wantLang string) { + // songLyrics extension v1: getLyricsBySongId without the enhanced parameter + // returns line-level structured lyrics (line[], lang, synced) and must NOT + // emit any v2/enhanced fields — no cueLine, kind, or agents — even for + // formats that carry word-level timing (ELRC, Lyricsfile YAML). + Describe("getLyricsBySongId v1 (line-level, not enhanced)", func() { + DescribeTable("returns line-level lyrics without enhanced fields", + func(title string, wantSynced bool, wantLang string) { resp := doReq("getLyricsBySongId", "id", songID(title)) Expect(resp.Status).To(Equal("ok")) got := firstLyric(resp.LyricsList) + Expect(got.Synced).To(Equal(wantSynced)) Expect(got.Lang).To(Equal(wantLang)) Expect(got.Line).ToNot(BeEmpty()) - Expect(got.Line[0].Value).To(Equal(wantFirstLine)) + Expect(got.Line[0].Value).To(Equal(firstFixtureLine)) + + // v1 must not expose any enhanced (v2) data. + Expect(got.CueLine).To(BeEmpty()) + Expect(got.Kind).To(BeEmpty()) + Expect(got.Agents).To(BeEmpty()) }, - // "xxx" is the ISO 639-2 code for "no language specified" - Entry("synced LRC embedded", "Embedded Synced LRC", true, "embedded lrc line one", "xxx"), - Entry("plain text embedded", "Embedded Plain", false, "plain embedded line one", "xxx"), - Entry("TTML embedded", "Embedded TTML", true, "embedded ttml line", "xxx"), - Entry("LRC sidecar", "Sidecar LRC", true, "sidecar lrc line", "xxx"), - Entry("SRT sidecar", "Sidecar SRT", true, "sidecar srt line", "xxx"), - // YAML sidecar fixture sets "language: eng"; verify Navidrome passes it through unchanged. - Entry("YAML sidecar", "Sidecar YAML", true, "sidecar yaml line", "eng"), + // "xxx" is the ISO 639-2 code for "no language specified"; the .lrc/.elrc + // fixtures declare [lang:eng], the .ttml declares xml:lang, the .yaml sets + // language: eng, while .srt carries no language and the embedded plain + // text has none — so each format exercises a different language path. + Entry("embedded enhanced LRC (word-level)", "Embedded Enhanced LRC", true, "eng"), + Entry("embedded plain text", "Embedded Plain", false, "xxx"), + Entry("embedded TTML", "Embedded TTML", true, "eng"), + Entry("LRC sidecar", "Sidecar LRC", true, "eng"), + Entry("SRT sidecar", "Sidecar SRT", true, "xxx"), + Entry("YAML sidecar (word-level)", "Sidecar YAML", true, "eng"), ) }) + // songLyrics extension v2: getLyricsBySongId?enhanced=true opts in to + // word/syllable-level timing (cueLine) and the kind classification. Formats + // that carry word-level timing (ELRC, Lyricsfile YAML) surface cueLine; all + // formats gain kind="main" for a single untyped lyric layer. + Describe("getLyricsBySongId v2 (enhanced)", func() { + It("exposes word-level cueLine for an enhanced-LRC source", func() { + resp := doReq("getLyricsBySongId", "id", songID("Embedded Enhanced LRC"), "enhanced", "true") + Expect(resp.Status).To(Equal("ok")) + got := firstLyric(resp.LyricsList) + + Expect(got.Kind).To(Equal("main")) + Expect(got.CueLine).ToNot(BeEmpty()) + // The first line "Should auld acquaintance be forgot," has one cue per word. + Expect(got.CueLine[0].Cue).To(HaveLen(5)) + Expect(got.CueLine[0].Cue[0].Value).To(Equal("Should ")) + }) + + It("exposes word-level cueLine for a Lyricsfile YAML sidecar", func() { + resp := doReq("getLyricsBySongId", "id", songID("Sidecar YAML"), "enhanced", "true") + Expect(resp.Status).To(Equal("ok")) + got := firstLyric(resp.LyricsList) + + Expect(got.Kind).To(Equal("main")) + Expect(got.CueLine).ToNot(BeEmpty()) + Expect(got.CueLine[0].Cue).ToNot(BeEmpty()) + Expect(got.CueLine[0].Cue[0].Value).To(Equal("Should ")) + }) + + It("emits kind but no cueLine for a line-level (SRT) source", func() { + resp := doReq("getLyricsBySongId", "id", songID("Sidecar SRT"), "enhanced", "true") + Expect(resp.Status).To(Equal("ok")) + got := firstLyric(resp.LyricsList) + + // SRT has no word-level timing, so even enhanced yields no cueLine. + Expect(got.Kind).To(Equal("main")) + Expect(got.CueLine).To(BeEmpty()) + Expect(got.Line).ToNot(BeEmpty()) + }) + }) + + // getLyrics is the original Subsonic (pre-OpenSubsonic) endpoint. It looks up + // by artist/title and returns the main lyric flattened to plain text — every + // line's Value joined by newlines, with all timing/markup dropped. Synced and + // word-level formats (ELRC/TTML/SRT/YAML) all degrade to plain text here. Describe("getLyrics (legacy artist/title)", func() { - // The legacy endpoint flattens the main lyric to plain text: it emits each - // line's Value joined by newlines, dropping all timing/markup. This is the - // v1 contract — synced structured formats (LRC/TTML/SRT/YAML) all "fall - // back" to LRC-style plain text here. DescribeTable("returns the main lyric as plain text across formats and sources", - func(title string, wantLines []string) { + func(title string) { resp := doReq("getLyrics", "artist", "Lyric Tester", "title", title) Expect(resp.Status).To(Equal("ok")) Expect(resp.Lyrics).ToNot(BeNil()) Expect(resp.Lyrics.Artist).To(Equal("Lyric Tester")) Expect(resp.Lyrics.Title).To(Equal(title)) - for _, line := range wantLines { - Expect(resp.Lyrics.Value).To(ContainSubstring(line)) - } - // v1 fallback: no timing markup leaks into the plain-text value, - // regardless of the source format (LRC brackets, SRT arrows, XML - // tags). The structured content is reduced to LRC-style plain text. + Expect(resp.Lyrics.Value).To(ContainSubstring(firstFixtureLine)) + + // No timing markup leaks into the plain-text value, regardless of the + // source format: no LRC brackets/word markers, SRT arrows, or XML tags. Expect(resp.Lyrics.Value).ToNot(ContainSubstring("[")) Expect(resp.Lyrics.Value).ToNot(ContainSubstring("-->")) Expect(resp.Lyrics.Value).ToNot(ContainSubstring("<")) }, - Entry("embedded synced LRC", "Embedded Synced LRC", []string{"embedded lrc line one", "embedded lrc line two"}), - Entry("embedded plain text", "Embedded Plain", []string{"plain embedded line one", "plain embedded line two"}), - Entry("embedded TTML", "Embedded TTML", []string{"embedded ttml line"}), - Entry("LRC sidecar", "Sidecar LRC", []string{"sidecar lrc line"}), - Entry("SRT sidecar", "Sidecar SRT", []string{"sidecar srt line"}), - Entry("YAML sidecar", "Sidecar YAML", []string{"sidecar yaml line"}), + Entry("embedded enhanced LRC", "Embedded Enhanced LRC"), + Entry("embedded plain text", "Embedded Plain"), + Entry("embedded TTML", "Embedded TTML"), + Entry("LRC sidecar", "Sidecar LRC"), + Entry("SRT sidecar", "Sidecar SRT"), + Entry("YAML sidecar", "Sidecar YAML"), ) }) })