diff --git a/core/lyrics/lyrics_test.go b/core/lyrics/lyrics_test.go index a097b90fc..a16d04712 100644 --- a/core/lyrics/lyrics_test.go +++ b/core/lyrics/lyrics_test.go @@ -17,7 +17,7 @@ import ( . "github.com/onsi/gomega" ) -var _ = Describe("sources", func() { +var _ = Describe("Lyrics", func() { var mf model.MediaFile var ctx context.Context @@ -153,7 +153,7 @@ var _ = Describe("sources", func() { Lyrics: string(lyricsJson), Path: "tests/fixtures/test.mp3", } - ctx = context.Background() + ctx = GinkgoT().Context() }) DescribeTable("Lyrics Priority", func(priority string, expected model.LyricList) { @@ -356,50 +356,48 @@ var _ = Describe("sources", func() { Expect(list).To(Equal(embeddedLyrics)) // falls through to embedded }) }) -}) -var _ = Describe("GetLyricsByArtistTitle", func() { - var svc lyrics.Lyrics - var repo *tests.MockMediaFileRepo - var ds *tests.MockDataStore - var ctx context.Context + var _ = Describe("GetLyricsByArtistTitle", func() { + var svc lyrics.Lyrics + var repo *tests.MockMediaFileRepo + var ds *tests.MockDataStore - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - conf.Server.LyricsPriority = "embedded" - repo = &tests.MockMediaFileRepo{} - ds = &tests.MockDataStore{MockedMediaFile: repo} - svc = lyrics.NewLyrics(ds, nil) - ctx = context.Background() - }) - - It("bounds the query to a duplicate window", func() { - repo.SetData(model.MediaFiles{}) - _, err := svc.GetLyricsByArtistTitle(ctx, "Rick Astley", "Never Gonna Give You Up") - Expect(err).ToNot(HaveOccurred()) - Expect(repo.Options.Max).To(Equal(10)) - }) - - It("returns nil when no media file matches", func() { - repo.SetData(model.MediaFiles{}) - list, err := svc.GetLyricsByArtistTitle(ctx, "Nobody", "No Song") - Expect(err).ToNot(HaveOccurred()) - Expect(list).To(BeNil()) - }) - - It("resolves lyrics from the matched media files", func() { - embedded, err := model.ToLyrics("eng", "Embedded lyrics line") - Expect(err).ToNot(HaveOccurred()) - embeddedJSON, err := json.Marshal(model.LyricList{*embedded}) - Expect(err).ToNot(HaveOccurred()) - repo.SetData(model.MediaFiles{ - {ID: "1", Title: "Never Gonna Give You Up", Lyrics: string(embeddedJSON)}, + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.LyricsPriority = "embedded" + repo = &tests.MockMediaFileRepo{} + ds = &tests.MockDataStore{MockedMediaFile: repo} + svc = lyrics.NewLyrics(ds, nil) }) - list, err := svc.GetLyricsByArtistTitle(ctx, "Rick Astley", "Never Gonna Give You Up") - Expect(err).ToNot(HaveOccurred()) - Expect(list).To(HaveLen(1)) - Expect(list[0].Line[0].Value).To(Equal("Embedded lyrics line")) + It("bounds the query to a duplicate window", func() { + repo.SetData(model.MediaFiles{}) + _, err := svc.GetLyricsByArtistTitle(ctx, "Rick Astley", "Never Gonna Give You Up") + Expect(err).ToNot(HaveOccurred()) + Expect(repo.Options.Max).To(Equal(10)) + }) + + It("returns nil when no media file matches", func() { + repo.SetData(model.MediaFiles{}) + list, err := svc.GetLyricsByArtistTitle(ctx, "Nobody", "No Song") + Expect(err).ToNot(HaveOccurred()) + Expect(list).To(BeNil()) + }) + + It("resolves lyrics from the matched media files", func() { + embedded, err := model.ToLyrics("eng", "Embedded lyrics line") + Expect(err).ToNot(HaveOccurred()) + embeddedJSON, err := json.Marshal(model.LyricList{*embedded}) + Expect(err).ToNot(HaveOccurred()) + repo.SetData(model.MediaFiles{ + {ID: "1", Title: "Never Gonna Give You Up", Lyrics: string(embeddedJSON)}, + }) + + list, err := svc.GetLyricsByArtistTitle(ctx, "Rick Astley", "Never Gonna Give You Up") + Expect(err).ToNot(HaveOccurred()) + Expect(list).To(HaveLen(1)) + Expect(list[0].Line[0].Value).To(Equal("Embedded lyrics line")) + }) }) }) diff --git a/core/lyrics/sources_test.go b/core/lyrics/sources_test.go index e3bf14e46..002931c0c 100644 --- a/core/lyrics/sources_test.go +++ b/core/lyrics/sources_test.go @@ -61,195 +61,26 @@ var _ = Describe("sources", func() { Expect(lyrics).To(HaveLen(0)) }) - It("should return synchronized lyrics from a file", func() { - mf := model.MediaFile{Path: "tests/fixtures/test.mp3"} - lyrics, err := fromExternalFile(ctx, &mf, ".lrc") + // fromExternalFile delegates format parsing to model.ParseLyricsFile; the + // per-format parser output is covered exhaustively in the model package. + // Here we only verify each suffix is read from disk and routed to a parser. + DescribeTable("should read the sidecar file and route its suffix to a parser", + func(path, suffix string, expectSynced bool) { + mf := model.MediaFile{Path: path} + lyrics, err := fromExternalFile(ctx, &mf, suffix) - Expect(err).To(BeNil()) - Expect(lyrics).To(Equal(model.LyricList{ - model.Lyrics{ - DisplayArtist: "Rick Astley", - DisplayTitle: "That one song", - Lang: "eng", - Line: []model.Line{ - { - Start: new(int64(18800)), - Value: "We're no strangers to love", - }, - { - Start: new(int64(22801)), - Value: "You know the rules and so do I", - }, - }, - Offset: new(int64(-100)), - Synced: true, - }, - })) - }) - - It("should return Enhanced LRC lyrics with word-level cues from a file", func() { - mf := model.MediaFile{Path: "tests/fixtures/test-enhanced.mp3"} - lyrics, err := fromExternalFile(ctx, &mf, ".lrc") - - Expect(err).To(BeNil()) - Expect(lyrics).To(HaveLen(1)) - Expect(lyrics[0].DisplayArtist).To(Equal("Test Artist")) - Expect(lyrics[0].DisplayTitle).To(Equal("Enhanced Test")) - Expect(lyrics[0].Lang).To(Equal("eng")) - Expect(lyrics[0].Synced).To(BeTrue()) - Expect(lyrics[0].Line).To(HaveLen(3)) - - // Line 1: has inline markers → Cue array populated - Expect(lyrics[0].Line[0].Start).To(Equal(new(int64(1000)))) - Expect(lyrics[0].Line[0].End).To(Equal(new(int64(3000)))) - Expect(lyrics[0].Line[0].Value).To(Equal("Some lyrics here")) - Expect(lyrics[0].Line[0].Cue).To(HaveLen(3)) - Expect(*lyrics[0].Line[0].Cue[0].Start).To(Equal(int64(1000))) - Expect(lyrics[0].Line[0].Cue[0].Value).To(Equal("Some ")) - Expect(lyrics[0].Line[0].Cue[0].End).To(Equal(new(int64(1500)))) - Expect(lyrics[0].Line[0].Cue[0].ByteStart).To(Equal(0)) - Expect(lyrics[0].Line[0].Cue[0].ByteEnd).To(Equal(4)) - Expect(*lyrics[0].Line[0].Cue[1].Start).To(Equal(int64(1500))) - Expect(lyrics[0].Line[0].Cue[1].Value).To(Equal("lyrics ")) - Expect(lyrics[0].Line[0].Cue[1].End).To(Equal(new(int64(2000)))) - Expect(lyrics[0].Line[0].Cue[1].ByteStart).To(Equal(5)) - Expect(lyrics[0].Line[0].Cue[1].ByteEnd).To(Equal(11)) - Expect(*lyrics[0].Line[0].Cue[2].Start).To(Equal(int64(2000))) - Expect(lyrics[0].Line[0].Cue[2].Value).To(Equal("here")) - Expect(lyrics[0].Line[0].Cue[2].End).To(Equal(new(int64(3000)))) - Expect(lyrics[0].Line[0].Cue[2].ByteStart).To(Equal(12)) - Expect(lyrics[0].Line[0].Cue[2].ByteEnd).To(Equal(15)) - - // Line 2: has inline markers - Expect(lyrics[0].Line[1].Start).To(Equal(new(int64(3000)))) - Expect(lyrics[0].Line[1].End).To(Equal(new(int64(5000)))) - Expect(lyrics[0].Line[1].Value).To(Equal("More words")) - Expect(lyrics[0].Line[1].Cue).To(HaveLen(2)) - Expect(lyrics[0].Line[1].Cue[0].End).To(Equal(new(int64(3500)))) - Expect(lyrics[0].Line[1].Cue[1].End).To(Equal(new(int64(5000)))) - Expect(lyrics[0].Line[1].Cue[0].ByteStart).To(Equal(0)) - Expect(lyrics[0].Line[1].Cue[0].ByteEnd).To(Equal(4)) - Expect(lyrics[0].Line[1].Cue[1].ByteStart).To(Equal(5)) - Expect(lyrics[0].Line[1].Cue[1].ByteEnd).To(Equal(9)) - - // Line 3: plain line, no cues - Expect(lyrics[0].Line[2].Start).To(Equal(new(int64(5000)))) - Expect(lyrics[0].Line[2].Value).To(Equal("Plain line without inline markers")) - Expect(lyrics[0].Line[2].Cue).To(BeNil()) - }) - - It("should return Enhanced LRC lyrics from an ELRC file", func() { - mf := model.MediaFile{Path: "tests/fixtures/test.mp3"} - lyrics, err := fromExternalFile(ctx, &mf, ".elrc") - - Expect(err).To(BeNil()) - Expect(lyrics).To(HaveLen(1)) - Expect(lyrics[0].DisplayArtist).To(Equal("ELRC Artist")) - Expect(lyrics[0].DisplayTitle).To(Equal("ELRC Song")) - Expect(lyrics[0].Lang).To(Equal("eng")) - Expect(lyrics[0].Synced).To(BeTrue()) - Expect(lyrics[0].Line).To(HaveLen(2)) - - Expect(lyrics[0].Line[0].Start).To(Equal(new(int64(1000)))) - Expect(lyrics[0].Line[0].End).To(Equal(new(int64(3000)))) - Expect(lyrics[0].Line[0].Value).To(Equal("Lead words")) - Expect(lyrics[0].Line[0].Cue).To(HaveLen(2)) - Expect(*lyrics[0].Line[0].Cue[0].Start).To(Equal(int64(1000))) - Expect(lyrics[0].Line[0].Cue[0].Value).To(Equal("Lead ")) - Expect(lyrics[0].Line[0].Cue[0].End).To(Equal(new(int64(1500)))) - Expect(lyrics[0].Line[0].Cue[0].ByteStart).To(Equal(0)) - Expect(lyrics[0].Line[0].Cue[0].ByteEnd).To(Equal(4)) - Expect(*lyrics[0].Line[0].Cue[1].Start).To(Equal(int64(1500))) - Expect(lyrics[0].Line[0].Cue[1].Value).To(Equal("words")) - Expect(lyrics[0].Line[0].Cue[1].End).To(Equal(new(int64(3000)))) - Expect(lyrics[0].Line[0].Cue[1].ByteStart).To(Equal(5)) - Expect(lyrics[0].Line[0].Cue[1].ByteEnd).To(Equal(9)) - - Expect(lyrics[0].Line[1].Start).To(Equal(new(int64(3000)))) - Expect(lyrics[0].Line[1].Value).To(Equal("Fallback line")) - Expect(lyrics[0].Line[1].Cue).To(BeNil()) - }) - - It("should return unsynchronized lyrics from a file", func() { - mf := model.MediaFile{Path: "tests/fixtures/test.mp3"} - lyrics, err := fromExternalFile(ctx, &mf, ".txt") - - Expect(err).To(BeNil()) - Expect(lyrics).To(Equal(model.LyricList{ - model.Lyrics{ - Lang: "xxx", - Line: []model.Line{ - { - Value: "We're no strangers to love", - }, - { - Value: "You know the rules and so do I", - }, - }, - Synced: false, - }, - })) - }) - - It("should return synchronized lyrics from an SRT file", func() { - mf := model.MediaFile{Path: "tests/fixtures/test.mp3"} - lyrics, err := fromExternalFile(ctx, &mf, ".srt") - - Expect(err).To(BeNil()) - Expect(lyrics).To(Equal(model.LyricList{ - model.Lyrics{ - Lang: "xxx", - Line: []model.Line{ - { - Start: new(int64(18800)), - End: new(int64(22800)), - Value: "We're from subtitles", - }, - { - Start: new(int64(22801)), - End: new(int64(26000)), - Value: "Another subtitle line", - }, - }, - Synced: true, - }, - })) - }) - - It("should return synchronized multilingual lyrics from a TTML file", func() { - mf := model.MediaFile{Path: "tests/fixtures/test.mp3"} - lyrics, err := fromExternalFile(ctx, &mf, ".ttml") - - Expect(err).To(BeNil()) - Expect(lyrics).To(Equal(model.LyricList{ - { - Kind: "main", - Lang: "eng", - Line: []model.Line{ - { - Start: new(int64(18800)), - Value: "We're no strangers to love", - }, - { - Start: new(int64(22800)), - Value: "You know the rules and so do I", - }, - }, - Synced: true, - }, - { - Kind: "main", - Lang: "por", - Line: []model.Line{ - { - Start: new(int64(18800)), - Value: "Nao somos estranhos ao amor", - }, - }, - Synced: true, - }, - })) - }) + Expect(err).To(BeNil()) + Expect(lyrics).ToNot(BeEmpty()) + Expect(lyrics[0].Line).ToNot(BeEmpty()) + Expect(lyrics[0].Synced).To(Equal(expectSynced)) + }, + Entry(".lrc synced", "tests/fixtures/test.mp3", ".lrc", true), + Entry(".elrc enhanced", "tests/fixtures/test.mp3", ".elrc", true), + Entry(".txt plain", "tests/fixtures/test.mp3", ".txt", false), + Entry(".srt subtitles", "tests/fixtures/test.mp3", ".srt", true), + Entry(".ttml multilingual", "tests/fixtures/test.mp3", ".ttml", true), + Entry(".yaml lyricsfile", "tests/fixtures/test.mp3", ".yaml", true), + ) It("should handle LRC files with UTF-8 BOM marker (issue #4631)", func() { // The function looks for , so we need to pass @@ -313,97 +144,5 @@ var _ = Describe("sources", func() { Expect(lyrics[0].Line[1].Value).To(Equal("UTF16 line two")) }) - It("should return Lyricsfile YAML lines with inferred end timestamps", func() { - mf := model.MediaFile{Path: "tests/fixtures/test.mp3"} - lyrics, err := fromExternalFile(ctx, &mf, ".yaml") - - Expect(err).To(BeNil()) - Expect(lyrics).To(Equal(model.LyricList{ - model.Lyrics{ - DisplayArtist: "Test Artist", - DisplayTitle: "Sample Track", - Kind: "main", - Lang: "eng", - Line: []model.Line{ - {Start: new(int64(18800)), End: new(int64(22801)), Value: "We're no strangers to love"}, - {Start: new(int64(22801)), Value: "You know the rules and so do I"}, - }, - Offset: new(int64(-100)), - Synced: true, - }, - })) - }) - - It("should return Lyricsfile YAML word cues with inclusive byte offsets", func() { - mf := model.MediaFile{Path: "tests/fixtures/test-words.mp3"} - lyrics, err := fromExternalFile(ctx, &mf, ".yaml") - - Expect(err).To(BeNil()) - Expect(lyrics).To(HaveLen(1)) - Expect(lyrics[0].DisplayArtist).To(Equal("Test Artist")) - Expect(lyrics[0].DisplayTitle).To(Equal("Karaoke Test")) - Expect(lyrics[0].Kind).To(Equal("main")) - Expect(lyrics[0].Lang).To(Equal("eng")) - Expect(lyrics[0].Synced).To(BeTrue()) - Expect(lyrics[0].Agents).To(BeNil()) - Expect(lyrics[0].Line).To(HaveLen(1)) - - line := lyrics[0].Line[0] - Expect(line.Start).To(Equal(new(int64(1000)))) - Expect(line.End).To(Equal(new(int64(3000)))) - Expect(line.Value).To(Equal("Hello world")) - Expect(line.Cue).To(HaveLen(2)) - - Expect(line.Cue[0].Start).To(Equal(new(int64(1000)))) - Expect(line.Cue[0].End).To(Equal(new(int64(1500)))) - Expect(line.Cue[0].Value).To(Equal("Hello ")) - Expect(line.Cue[0].ByteStart).To(Equal(0)) - Expect(line.Cue[0].ByteEnd).To(Equal(5)) - Expect(line.Cue[0].AgentID).To(Equal("")) - - Expect(line.Cue[1].Start).To(Equal(new(int64(1500)))) - Expect(line.Cue[1].End).To(Equal(new(int64(3000)))) - Expect(line.Cue[1].Value).To(Equal("world")) - Expect(line.Cue[1].ByteStart).To(Equal(6)) - Expect(line.Cue[1].ByteEnd).To(Equal(10)) - Expect(line.Cue[1].AgentID).To(Equal("")) - }) - - It("should synthesise voice agents for overlapping Lyricsfile YAML lines", func() { - mf := model.MediaFile{Path: "tests/fixtures/test-overlapping.mp3"} - lyrics, err := fromExternalFile(ctx, &mf, ".yaml") - - Expect(err).To(BeNil()) - Expect(lyrics).To(HaveLen(1)) - Expect(lyrics[0].Agents).To(Equal([]model.Agent{ - {ID: "voice-0", Role: "main"}, - {ID: "voice-1", Role: "voice"}, - })) - Expect(lyrics[0].Line).To(HaveLen(2)) - - Expect(lyrics[0].Line[0].Value).To(Equal("Lead vocal")) - Expect(lyrics[0].Line[0].Cue).To(HaveLen(2)) - Expect(lyrics[0].Line[0].Cue[0].AgentID).To(Equal("voice-0")) - Expect(lyrics[0].Line[0].Cue[1].AgentID).To(Equal("voice-0")) - - Expect(lyrics[0].Line[1].Value).To(Equal("echo")) - Expect(lyrics[0].Line[1].Cue).To(HaveLen(1)) - Expect(lyrics[0].Line[1].Cue[0].AgentID).To(Equal("voice-1")) - }) - - It("should emit empty Line[] with Synced=false for instrumental Lyricsfile YAML", func() { - mf := model.MediaFile{Path: "tests/fixtures/test-instrumental.mp3"} - lyrics, err := fromExternalFile(ctx, &mf, ".yaml") - - Expect(err).To(BeNil()) - Expect(lyrics).To(HaveLen(1)) - Expect(lyrics[0].Kind).To(Equal("main")) - Expect(lyrics[0].Lang).To(Equal("eng")) - Expect(lyrics[0].DisplayArtist).To(Equal("Composer")) - Expect(lyrics[0].DisplayTitle).To(Equal("Solo Piano")) - Expect(lyrics[0].Synced).To(BeFalse()) - Expect(lyrics[0].Line).To(BeEmpty()) - Expect(lyrics[0].Agents).To(BeNil()) - }) }) }) diff --git a/model/metadata/map_mediafile_test.go b/model/metadata/map_mediafile_test.go index 8c2f7ea5f..0770a79f2 100644 --- a/model/metadata/map_mediafile_test.go +++ b/model/metadata/map_mediafile_test.go @@ -118,30 +118,6 @@ var _ = Describe("ToMediaFile", func() { Expect(actual).To(Equal(expected)) }) - It("should parse embedded TTML lyrics before sanitizing XML tags", func() { - mf = toMediaFile(model.RawTags{ - "LYRICS:ENG": {` - -
-

Embedded TTML line

-
- -
`}, - }) - var actual model.LyricList - err := json.Unmarshal([]byte(mf.Lyrics), &actual) - Expect(err).ToNot(HaveOccurred()) - - Expect(actual).To(Equal(model.LyricList{ - { - Kind: "main", - Lang: "eng", - Line: []model.Line{{Start: new(int64(1000)), End: new(int64(2500)), Value: "Embedded TTML line"}}, - Synced: true, - }, - })) - }) - It("should parse embedded TTML lyrics longer than the metadata tag max length", func() { padding := strings.Repeat(`padding`, 1400) content := ` @@ -161,6 +137,9 @@ var _ = Describe("ToMediaFile", func() { ` + // Guards that embedded lyrics longer than the old 32KB tag cap are no + // longer truncated before parsing. Parser correctness lives in the + // model lyrics tests; here we only confirm the full tag is mapped. Expect(len(content)).To(BeNumerically(">", 32768)) mf = toMediaFile(model.RawTags{ @@ -171,31 +150,8 @@ var _ = Describe("ToMediaFile", func() { Expect(err).ToNot(HaveOccurred()) Expect(actual).To(HaveLen(1)) - Expect(actual[0].Kind).To(Equal("main")) - Expect(actual[0].Lang).To(Equal("en")) - Expect(actual[0].Line).To(Equal([]model.Line{ - {Start: new(int64(1000)), End: new(int64(2500)), Value: "Long embedded TTML line"}, - })) - }) - - It("should parse embedded SRT lyrics with the tag language", func() { - mf = toMediaFile(model.RawTags{ - "LYRICS:POR": {`1 -00:00:18,800 --> 00:00:22,800 -Estamos nas legendas`}, - }) - var actual model.LyricList - err := json.Unmarshal([]byte(mf.Lyrics), &actual) - Expect(err).ToNot(HaveOccurred()) - - Expect(actual).To(Equal(model.LyricList{ - { - Lang: "por", - Line: []model.Line{ - {Start: new(int64(18800)), End: new(int64(22800)), Value: "Estamos nas legendas"}, - }, - Synced: true, - }, + Expect(actual[0].Line).To(ContainElement(model.Line{ + Start: new(int64(1000)), End: new(int64(2500)), Value: "Long embedded TTML line", })) }) }) diff --git a/server/subsonic/lyrics_test.go b/server/subsonic/lyrics_test.go new file mode 100644 index 000000000..e0f291b70 --- /dev/null +++ b/server/subsonic/lyrics_test.go @@ -0,0 +1,618 @@ +package subsonic + +import ( + "encoding/json" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/core/lyrics" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/server/subsonic/responses" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("GetLyricsBySongId", func() { + var router *Router + var ds model.DataStore + mockRepo := &mockedMediaFile{MockMediaFileRepo: tests.MockMediaFileRepo{}} + + BeforeEach(func() { + ds = &tests.MockDataStore{ + MockedMediaFile: mockRepo, + } + router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, lyrics.NewLyrics(ds, nil), nil, nil) + DeferCleanup(configtest.SetupConfig()) + conf.Server.LyricsPriority = "embedded,.lrc" + }) + + const syncedLyrics = "[00:18.80]We're no strangers to love\n[00:22.801]You know the rules and so do I" + const unsyncedLyrics = "We're no strangers to love\nYou know the rules and so do I" + const metadata = "[ar:Rick Astley]\n[ti:That one song]\n[offset:-100]" + var times = []int64{18800, 22801} + + compareResponses := func(actual *responses.LyricsList, expected responses.LyricsList) { + Expect(actual).ToNot(BeNil()) + Expect(actual.StructuredLyrics).To(HaveLen(len(expected.StructuredLyrics))) + for i, realLyric := range actual.StructuredLyrics { + expectedLyric := expected.StructuredLyrics[i] + + Expect(realLyric.DisplayArtist).To(Equal(expectedLyric.DisplayArtist)) + Expect(realLyric.DisplayTitle).To(Equal(expectedLyric.DisplayTitle)) + Expect(realLyric.Kind).To(Equal(expectedLyric.Kind)) + Expect(realLyric.Lang).To(Equal(expectedLyric.Lang)) + Expect(realLyric.Synced).To(Equal(expectedLyric.Synced)) + Expect(realLyric.Agents).To(Equal(expectedLyric.Agents)) + + if expectedLyric.Offset == nil { + Expect(realLyric.Offset).To(BeNil()) + } else { + Expect(*realLyric.Offset).To(Equal(*expectedLyric.Offset)) + } + + Expect(realLyric.Line).To(HaveLen(len(expectedLyric.Line))) + for j, realLine := range realLyric.Line { + expectedLine := expectedLyric.Line[j] + Expect(realLine.Value).To(Equal(expectedLine.Value)) + + if expectedLine.Start == nil { + Expect(realLine.Start).To(BeNil()) + } else { + Expect(*realLine.Start).To(Equal(*expectedLine.Start)) + } + } + + Expect(realLyric.CueLine).To(HaveLen(len(expectedLyric.CueLine))) + for j, realCueLine := range realLyric.CueLine { + expectedCueLine := expectedLyric.CueLine[j] + Expect(realCueLine.Index).To(Equal(expectedCueLine.Index)) + Expect(realCueLine.Value).To(Equal(expectedCueLine.Value)) + Expect(realCueLine.AgentID).To(Equal(expectedCueLine.AgentID)) + if expectedCueLine.Start == nil { + Expect(realCueLine.Start).To(BeNil()) + } else { + Expect(*realCueLine.Start).To(Equal(*expectedCueLine.Start)) + } + if expectedCueLine.End == nil { + Expect(realCueLine.End).To(BeNil()) + } else { + Expect(*realCueLine.End).To(Equal(*expectedCueLine.End)) + } + + Expect(realCueLine.Cue).To(HaveLen(len(expectedCueLine.Cue))) + for k, realCue := range realCueLine.Cue { + expectedCue := expectedCueLine.Cue[k] + Expect(realCue.Value).To(Equal(expectedCue.Value)) + Expect(realCue.Start).To(Equal(expectedCue.Start)) + Expect(realCue.ByteStart).To(Equal(expectedCue.ByteStart)) + Expect(realCue.ByteEnd).To(Equal(expectedCue.ByteEnd)) + if expectedCue.End == nil { + Expect(realCue.End).To(BeNil()) + } else { + Expect(*realCue.End).To(Equal(*expectedCue.End)) + } + } + } + } + } + + It("should return mixed lyrics", func() { + r := newGetRequest("id=1") + synced, _ := model.ToLyrics("eng", syncedLyrics) + unsynced, _ := model.ToLyrics("xxx", unsyncedLyrics) + lyricsJson, err := json.Marshal(model.LyricList{ + *synced, *unsynced, + }) + Expect(err).ToNot(HaveOccurred()) + + mockRepo.SetData(model.MediaFiles{ + { + ID: "1", + Artist: "Rick Astley", + Title: "Never Gonna Give You Up", + Lyrics: string(lyricsJson), + }, + }) + + response, err := router.GetLyricsBySongId(r) + Expect(err).ToNot(HaveOccurred()) + compareResponses(response.LyricsList, responses.LyricsList{ + StructuredLyrics: responses.StructuredLyrics{ + { + Lang: "eng", + DisplayArtist: "Rick Astley", + DisplayTitle: "Never Gonna Give You Up", + Synced: true, + Line: []responses.Line{ + { + Start: ×[0], + Value: "We're no strangers to love", + }, + { + Start: ×[1], + Value: "You know the rules and so do I", + }, + }, + }, + { + Lang: "xxx", + DisplayArtist: "Rick Astley", + DisplayTitle: "Never Gonna Give You Up", + Synced: false, + Line: []responses.Line{ + { + Value: "We're no strangers to love", + }, + { + Value: "You know the rules and so do I", + }, + }, + }, + }, + }) + }) + + It("should parse lrc metadata", func() { + r := newGetRequest("id=1") + synced, _ := model.ToLyrics("eng", metadata+"\n"+syncedLyrics) + lyricsJson, err := json.Marshal(model.LyricList{ + *synced, + }) + Expect(err).ToNot(HaveOccurred()) + mockRepo.SetData(model.MediaFiles{ + { + ID: "1", + Artist: "Rick Astley", + Title: "Never Gonna Give You Up", + Lyrics: string(lyricsJson), + }, + }) + + response, err := router.GetLyricsBySongId(r) + Expect(err).ToNot(HaveOccurred()) + + compareResponses(response.LyricsList, responses.LyricsList{ + StructuredLyrics: responses.StructuredLyrics{ + { + DisplayArtist: "Rick Astley", + DisplayTitle: "That one song", + Lang: "eng", + Synced: true, + Line: []responses.Line{ + { + Start: ×[0], + Value: "We're no strangers to love", + }, + { + Start: ×[1], + Value: "You know the rules and so do I", + }, + }, + Offset: new(int64(-100)), + }, + }, + }) + }) + + It("should return multilingual TTML sidecar lyrics", func() { + conf.Server.LyricsPriority = ".ttml,embedded" + r := newGetRequest("id=1") + + mockRepo.SetData(model.MediaFiles{ + { + ID: "1", + Path: "tests/fixtures/test.mp3", + Artist: "Rick Astley", + Title: "Never Gonna Give You Up", + Lyrics: "[]", + }, + }) + + response, err := router.GetLyricsBySongId(r) + Expect(err).ToNot(HaveOccurred()) + + porTime := int64(18800) + ttmlTime := int64(22800) + compareResponses(response.LyricsList, responses.LyricsList{ + StructuredLyrics: responses.StructuredLyrics{ + { + DisplayArtist: "Rick Astley", + DisplayTitle: "Never Gonna Give You Up", + Lang: "eng", + Synced: true, + Line: []responses.Line{ + { + Start: ×[0], + Value: "We're no strangers to love", + }, + { + Start: &ttmlTime, + Value: "You know the rules and so do I", + }, + }, + }, + { + DisplayArtist: "Rick Astley", + DisplayTitle: "Never Gonna Give You Up", + Lang: "por", + Synced: true, + Line: []responses.Line{ + { + Start: &porTime, + Value: "Nao somos estranhos ao amor", + }, + }, + }, + }, + }) + }) + + It("should return metadata-linked translation and pronunciation tracks from TTML", func() { + conf.Server.LyricsPriority = ".ttml,embedded" + r := newGetRequest("id=1&enhanced=true") + + mockRepo.SetData(model.MediaFiles{ + { + ID: "1", + Path: "tests/fixtures/test-metadata.mp3", + Artist: "Rick Astley", + Title: "Never Gonna Give You Up", + Lyrics: "[]", + }, + }) + + response, err := router.GetLyricsBySongId(r) + Expect(err).ToNot(HaveOccurred()) + + mainStartA := int64(1000) + mainStartB := int64(2000) + tokenStartA := int64(2000) + tokenEndA := int64(2300) + tokenStartB := int64(2300) + tokenEndB := int64(2600) + compareResponses(response.LyricsList, responses.LyricsList{ + StructuredLyrics: responses.StructuredLyrics{ + { + DisplayArtist: "Rick Astley", + DisplayTitle: "Never Gonna Give You Up", + Kind: "main", + Lang: "ja", + Synced: true, + Line: []responses.Line{ + { + Start: &mainStartA, + Value: "こんにちは", + }, + { + Start: &mainStartB, + Value: "こんばんは", + }, + }, + }, + { + DisplayArtist: "Rick Astley", + DisplayTitle: "Never Gonna Give You Up", + Kind: "translation", + Lang: "es", + Synced: true, + Line: []responses.Line{ + { + Start: &mainStartA, + Value: "Hola", + }, + }, + }, + { + DisplayArtist: "Rick Astley", + DisplayTitle: "Never Gonna Give You Up", + Kind: "pronunciation", + Lang: "ja-latn", + Synced: true, + Line: []responses.Line{ + { + Start: &mainStartB, + Value: "konni", + }, + }, + CueLine: []responses.CueLine{ + { + Index: 0, + Start: &mainStartB, + End: &tokenEndB, + Value: "konni", + Cue: []responses.LyricCue{ + { + Start: tokenStartA, + End: &tokenEndA, + ByteStart: 0, + ByteEnd: 1, + Value: "ko", + }, + { + Start: tokenStartB, + End: &tokenEndB, + ByteStart: 2, + ByteEnd: 4, + Value: "nni", + }, + }, + }, + }, + }, + }, + }) + }) + + It("should return cue lines for songLyrics v2 clients with enhanced=true", func() { + r := newGetRequest("id=1&enhanced=true") + + lineStart := int64(1000) + lineEnd := int64(3000) + tokenStartA := int64(1000) + tokenEndA := int64(1400) + tokenStartB := int64(2000) + tokenEndB := int64(2500) + lyricsJson, err := json.Marshal(model.LyricList{ + { + Lang: "eng", + Agents: []model.Agent{{ID: "lead", Role: "main"}, {ID: "__nd_bg__|lead", Role: "bg"}}, + Synced: true, + Line: []model.Line{ + { + Start: &lineStart, + End: &lineEnd, + Value: "Hello echo", + Cue: []model.Cue{ + { + Start: &tokenStartA, + End: &tokenEndA, + Value: "Hello", + ByteStart: 0, + ByteEnd: 4, + AgentID: "lead", + }, + { + Start: &tokenStartB, + End: &tokenEndB, + Value: "echo", + ByteStart: 6, + ByteEnd: 9, + AgentID: "__nd_bg__|lead", + }, + }, + }, + }, + }, + }) + Expect(err).ToNot(HaveOccurred()) + + mockRepo.SetData(model.MediaFiles{ + { + ID: "1", + Artist: "Rick Astley", + Title: "Never Gonna Give You Up", + Lyrics: string(lyricsJson), + }, + }) + + response, err := router.GetLyricsBySongId(r) + Expect(err).ToNot(HaveOccurred()) + compareResponses(response.LyricsList, responses.LyricsList{ + StructuredLyrics: responses.StructuredLyrics{ + { + DisplayArtist: "Rick Astley", + DisplayTitle: "Never Gonna Give You Up", + Kind: "main", + Lang: "eng", + Synced: true, + Agents: []responses.Agent{ + {ID: "lead", Role: "main"}, + {ID: "__nd_bg__|lead", Role: "bg"}, + }, + Line: []responses.Line{ + { + Start: &lineStart, + Value: "Hello echo", + }, + }, + CueLine: []responses.CueLine{ + { + Index: 0, + Start: &lineStart, + End: &lineEnd, + Value: "Hello echo", + AgentID: "lead", + Cue: []responses.LyricCue{ + { + Start: tokenStartA, + End: &tokenEndA, + ByteStart: 0, + ByteEnd: 4, + Value: "Hello", + }, + }, + }, + { + Index: 0, + Start: &lineStart, + End: &lineEnd, + Value: "Hello echo", + AgentID: "__nd_bg__|lead", + Cue: []responses.LyricCue{ + { + Start: tokenStartB, + End: &tokenEndB, + ByteStart: 6, + ByteEnd: 9, + Value: "echo", + }, + }, + }, + }, + }, + }, + }) + }) + + It("should keep enhanced line-level lyrics when no cue data is available", func() { + r := newGetRequest("id=1&enhanced=true") + + lineStart := int64(1000) + lineEnd := int64(3000) + lyricsJSON, err := json.Marshal(model.LyricList{ + { + Kind: "main", + Lang: "eng", + Synced: true, + Line: []model.Line{ + { + Start: &lineStart, + End: &lineEnd, + Value: "Line without word timing", + }, + }, + }, + }) + Expect(err).ToNot(HaveOccurred()) + + mockRepo.SetData(model.MediaFiles{ + { + ID: "1", + Artist: "Rick Astley", + Title: "Never Gonna Give You Up", + Lyrics: string(lyricsJSON), + }, + }) + + response, err := router.GetLyricsBySongId(r) + Expect(err).ToNot(HaveOccurred()) + compareResponses(response.LyricsList, responses.LyricsList{ + StructuredLyrics: responses.StructuredLyrics{ + { + DisplayArtist: "Rick Astley", + DisplayTitle: "Never Gonna Give You Up", + Kind: "main", + Lang: "eng", + Synced: true, + Line: []responses.Line{ + { + Start: &lineStart, + Value: "Line without word timing", + }, + }, + }, + }, + }) + }) + + It("should return required cue byte offsets for ambiguous and multibyte cue lines", func() { + r := newGetRequest("id=1&enhanced=true") + + asciiLineStart := int64(0) + asciiLineEnd := int64(2400) + asciiCueStartA := int64(0) + asciiCueEndA := int64(300) + asciiCueStartB := int64(900) + asciiCueEndB := int64(1300) + asciiCueStartC := int64(1300) + asciiCueEndC := int64(1600) + asciiCueStartD := int64(1600) + + utfLineStart := int64(2747) + utfLineEnd := int64(6214) + utfCueStartA := int64(2747) + utfCueEndA := int64(3018) + utfCueStartB := int64(3018) + utfCueEndB := int64(3179) + utfCueStartC := int64(3582) + utfCueEndC := int64(4100) + utfCueStartD := int64(4500) + utfCueEndD := int64(6214) + + lyricsJSON, err := json.Marshal(model.LyricList{ + { + Lang: "eng", + Synced: true, + Line: []model.Line{ + { + Start: &asciiLineStart, + End: &asciiLineEnd, + Value: "Oh love love me tonight", + Cue: []model.Cue{ + {Start: &asciiCueStartA, End: &asciiCueEndA, Value: "Oh", ByteStart: 0, ByteEnd: 1}, + {Start: &asciiCueStartB, End: &asciiCueEndB, Value: "love", ByteStart: 8, ByteEnd: 11}, + {Start: &asciiCueStartC, End: &asciiCueEndC, Value: "me", ByteStart: 13, ByteEnd: 14}, + {Start: &asciiCueStartD, Value: "tonight", ByteStart: 16, ByteEnd: 22}, + }, + }, + { + Start: &utfLineStart, + End: &utfLineEnd, + Value: "눈을 뜬 순간", + Cue: []model.Cue{ + {Start: &utfCueStartA, End: &utfCueEndA, Value: "눈", ByteStart: 0, ByteEnd: 2}, + {Start: &utfCueStartB, End: &utfCueEndB, Value: "을", ByteStart: 3, ByteEnd: 5}, + {Start: &utfCueStartC, End: &utfCueEndC, Value: "뜬", ByteStart: 7, ByteEnd: 9}, + {Start: &utfCueStartD, End: &utfCueEndD, Value: "순간", ByteStart: 11, ByteEnd: 16}, + }, + }, + }, + }, + }) + Expect(err).ToNot(HaveOccurred()) + + mockRepo.SetData(model.MediaFiles{ + { + ID: "1", + Artist: "Rick Astley", + Title: "Never Gonna Give You Up", + Lyrics: string(lyricsJSON), + }, + }) + + response, err := router.GetLyricsBySongId(r) + Expect(err).ToNot(HaveOccurred()) + compareResponses(response.LyricsList, responses.LyricsList{ + StructuredLyrics: responses.StructuredLyrics{ + { + DisplayArtist: "Rick Astley", + DisplayTitle: "Never Gonna Give You Up", + Kind: "main", + Lang: "eng", + Synced: true, + Line: []responses.Line{ + {Start: &asciiLineStart, Value: "Oh love love me tonight"}, + {Start: &utfLineStart, Value: "눈을 뜬 순간"}, + }, + CueLine: []responses.CueLine{ + { + Index: 0, + Start: &asciiLineStart, + End: &asciiLineEnd, + Value: "Oh love love me tonight", + Cue: []responses.LyricCue{ + {Start: asciiCueStartA, End: &asciiCueEndA, Value: "Oh", ByteStart: 0, ByteEnd: 1}, + {Start: asciiCueStartB, End: &asciiCueEndB, Value: "love", ByteStart: 8, ByteEnd: 11}, + {Start: asciiCueStartC, End: &asciiCueEndC, Value: "me", ByteStart: 13, ByteEnd: 14}, + {Start: asciiCueStartD, End: &asciiLineEnd, Value: "tonight", ByteStart: 16, ByteEnd: 22}, + }, + }, + { + Index: 1, + Start: &utfLineStart, + End: &utfLineEnd, + Value: "눈을 뜬 순간", + Cue: []responses.LyricCue{ + {Start: utfCueStartA, End: &utfCueEndA, Value: "눈", ByteStart: 0, ByteEnd: 2}, + {Start: utfCueStartB, End: &utfCueEndB, Value: "을", ByteStart: 3, ByteEnd: 5}, + {Start: utfCueStartC, End: &utfCueEndC, Value: "뜬", ByteStart: 7, ByteEnd: 9}, + {Start: utfCueStartD, End: &utfCueEndD, Value: "순간", ByteStart: 11, ByteEnd: 16}, + }, + }, + }, + }, + }, + }) + }) +}) diff --git a/server/subsonic/media_retrieval_test.go b/server/subsonic/media_retrieval_test.go index be7ca4515..60deda208 100644 --- a/server/subsonic/media_retrieval_test.go +++ b/server/subsonic/media_retrieval_test.go @@ -16,7 +16,6 @@ import ( "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/lyrics" "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/server/subsonic/responses" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -119,28 +118,12 @@ var _ = Describe("MediaRetrievalController", func() { }) Expect(err).ToNot(HaveOccurred()) - baseTime := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC) mockRepo.SetData(model.MediaFiles{ { - ID: "2", - Artist: "Rick Astley", - Title: "Never Gonna Give You Up", - Lyrics: "[]", - UpdatedAt: baseTime.Add(2 * time.Hour), // No lyrics, newer - }, - { - ID: "1", - Artist: "Rick Astley", - Title: "Never Gonna Give You Up", - Lyrics: string(lyricsJson), - UpdatedAt: baseTime.Add(1 * time.Hour), // Has lyrics, older - }, - { - ID: "3", - Artist: "Rick Astley", - Title: "Never Gonna Give You Up", - Lyrics: "[]", - UpdatedAt: baseTime.Add(3 * time.Hour), // No lyrics, newest + ID: "1", + Artist: "Rick Astley", + Title: "Never Gonna Give You Up", + Lyrics: string(lyricsJson), }, }) response, err := router.GetLyrics(r) @@ -187,12 +170,6 @@ var _ = Describe("MediaRetrievalController", func() { Artist: "Rick Astley", Title: "Never Gonna Give You Up", }, - { - Path: "tests/fixtures/test.mp3", - ID: "2", - Artist: "Rick Astley", - Title: "Never Gonna Give You Up", - }, }) response, err := router.GetLyrics(r) Expect(err).ToNot(HaveOccurred()) @@ -200,633 +177,6 @@ var _ = Describe("MediaRetrievalController", func() { Expect(response.Lyrics.Title).To(Equal("Never Gonna Give You Up")) Expect(response.Lyrics.Value).To(Equal("We're no strangers to love\nYou know the rules and so do I\n")) }) - - It("should prefer higher-priority sidecar lyrics across duplicate candidates", func() { - conf.Server.LyricsPriority = ".ttml,embedded" - r := newGetRequest("artist=Rick+Astley", "title=Never+Gonna+Give+You+Up") - baseTime := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC) - embedded, err := model.ToLyrics("eng", "Newest duplicate embedded lyrics") - Expect(err).ToNot(HaveOccurred()) - embeddedJSON, err := json.Marshal(model.LyricList{*embedded}) - Expect(err).ToNot(HaveOccurred()) - mockRepo.SetData(model.MediaFiles{ - { - ID: "1", - Path: "tests/fixtures/01 Invisible (RED) Edit Version.mp3", - Artist: "Rick Astley", - Title: "Never Gonna Give You Up", - Lyrics: string(embeddedJSON), - UpdatedAt: baseTime.Add(2 * time.Hour), // Newer duplicate with embedded lyrics only - }, - { - ID: "2", - Path: "tests/fixtures/test.mp3", - Artist: "Rick Astley", - Title: "Never Gonna Give You Up", - Lyrics: "[]", - UpdatedAt: baseTime.Add(1 * time.Hour), // Older, but has TTML sidecar - }, - }) - - response, err := router.GetLyrics(r) - Expect(err).ToNot(HaveOccurred()) - Expect(response.Lyrics.Artist).To(Equal("Rick Astley")) - Expect(response.Lyrics.Title).To(Equal("Never Gonna Give You Up")) - Expect(response.Lyrics.Value).To(Equal("We're no strangers to love\nYou know the rules and so do I\n")) - // The lyrics service bounds the legacy lookup to a duplicate window. - Expect(mockRepo.Options.Max).To(Equal(10)) - }) - }) - - Describe("GetLyricsBySongId", func() { - const syncedLyrics = "[00:18.80]We're no strangers to love\n[00:22.801]You know the rules and so do I" - const unsyncedLyrics = "We're no strangers to love\nYou know the rules and so do I" - const metadata = "[ar:Rick Astley]\n[ti:That one song]\n[offset:-100]" - var times = []int64{18800, 22801} - - compareResponses := func(actual *responses.LyricsList, expected responses.LyricsList) { - Expect(actual).ToNot(BeNil()) - Expect(actual.StructuredLyrics).To(HaveLen(len(expected.StructuredLyrics))) - for i, realLyric := range actual.StructuredLyrics { - expectedLyric := expected.StructuredLyrics[i] - - Expect(realLyric.DisplayArtist).To(Equal(expectedLyric.DisplayArtist)) - Expect(realLyric.DisplayTitle).To(Equal(expectedLyric.DisplayTitle)) - Expect(realLyric.Kind).To(Equal(expectedLyric.Kind)) - Expect(realLyric.Lang).To(Equal(expectedLyric.Lang)) - Expect(realLyric.Synced).To(Equal(expectedLyric.Synced)) - Expect(realLyric.Agents).To(Equal(expectedLyric.Agents)) - - if expectedLyric.Offset == nil { - Expect(realLyric.Offset).To(BeNil()) - } else { - Expect(*realLyric.Offset).To(Equal(*expectedLyric.Offset)) - } - - Expect(realLyric.Line).To(HaveLen(len(expectedLyric.Line))) - for j, realLine := range realLyric.Line { - expectedLine := expectedLyric.Line[j] - Expect(realLine.Value).To(Equal(expectedLine.Value)) - - if expectedLine.Start == nil { - Expect(realLine.Start).To(BeNil()) - } else { - Expect(*realLine.Start).To(Equal(*expectedLine.Start)) - } - } - - Expect(realLyric.CueLine).To(HaveLen(len(expectedLyric.CueLine))) - for j, realCueLine := range realLyric.CueLine { - expectedCueLine := expectedLyric.CueLine[j] - Expect(realCueLine.Index).To(Equal(expectedCueLine.Index)) - Expect(realCueLine.Value).To(Equal(expectedCueLine.Value)) - Expect(realCueLine.AgentID).To(Equal(expectedCueLine.AgentID)) - if expectedCueLine.Start == nil { - Expect(realCueLine.Start).To(BeNil()) - } else { - Expect(*realCueLine.Start).To(Equal(*expectedCueLine.Start)) - } - if expectedCueLine.End == nil { - Expect(realCueLine.End).To(BeNil()) - } else { - Expect(*realCueLine.End).To(Equal(*expectedCueLine.End)) - } - - Expect(realCueLine.Cue).To(HaveLen(len(expectedCueLine.Cue))) - for k, realCue := range realCueLine.Cue { - expectedCue := expectedCueLine.Cue[k] - Expect(realCue.Value).To(Equal(expectedCue.Value)) - Expect(realCue.Start).To(Equal(expectedCue.Start)) - Expect(realCue.ByteStart).To(Equal(expectedCue.ByteStart)) - Expect(realCue.ByteEnd).To(Equal(expectedCue.ByteEnd)) - if expectedCue.End == nil { - Expect(realCue.End).To(BeNil()) - } else { - Expect(*realCue.End).To(Equal(*expectedCue.End)) - } - } - } - } - } - - It("should return mixed lyrics", func() { - r := newGetRequest("id=1") - synced, _ := model.ToLyrics("eng", syncedLyrics) - unsynced, _ := model.ToLyrics("xxx", unsyncedLyrics) - lyricsJson, err := json.Marshal(model.LyricList{ - *synced, *unsynced, - }) - Expect(err).ToNot(HaveOccurred()) - - mockRepo.SetData(model.MediaFiles{ - { - ID: "1", - Artist: "Rick Astley", - Title: "Never Gonna Give You Up", - Lyrics: string(lyricsJson), - }, - }) - - response, err := router.GetLyricsBySongId(r) - Expect(err).ToNot(HaveOccurred()) - compareResponses(response.LyricsList, responses.LyricsList{ - StructuredLyrics: responses.StructuredLyrics{ - { - Lang: "eng", - DisplayArtist: "Rick Astley", - DisplayTitle: "Never Gonna Give You Up", - Synced: true, - Line: []responses.Line{ - { - Start: ×[0], - Value: "We're no strangers to love", - }, - { - Start: ×[1], - Value: "You know the rules and so do I", - }, - }, - }, - { - Lang: "xxx", - DisplayArtist: "Rick Astley", - DisplayTitle: "Never Gonna Give You Up", - Synced: false, - Line: []responses.Line{ - { - Value: "We're no strangers to love", - }, - { - Value: "You know the rules and so do I", - }, - }, - }, - }, - }) - }) - - It("should parse lrc metadata", func() { - r := newGetRequest("id=1") - synced, _ := model.ToLyrics("eng", metadata+"\n"+syncedLyrics) - lyricsJson, err := json.Marshal(model.LyricList{ - *synced, - }) - Expect(err).ToNot(HaveOccurred()) - mockRepo.SetData(model.MediaFiles{ - { - ID: "1", - Artist: "Rick Astley", - Title: "Never Gonna Give You Up", - Lyrics: string(lyricsJson), - }, - }) - - response, err := router.GetLyricsBySongId(r) - Expect(err).ToNot(HaveOccurred()) - - compareResponses(response.LyricsList, responses.LyricsList{ - StructuredLyrics: responses.StructuredLyrics{ - { - DisplayArtist: "Rick Astley", - DisplayTitle: "That one song", - Lang: "eng", - Synced: true, - Line: []responses.Line{ - { - Start: ×[0], - Value: "We're no strangers to love", - }, - { - Start: ×[1], - Value: "You know the rules and so do I", - }, - }, - Offset: new(int64(-100)), - }, - }, - }) - }) - - It("should return multilingual TTML sidecar lyrics", func() { - conf.Server.LyricsPriority = ".ttml,embedded" - r := newGetRequest("id=1") - - mockRepo.SetData(model.MediaFiles{ - { - ID: "1", - Path: "tests/fixtures/test.mp3", - Artist: "Rick Astley", - Title: "Never Gonna Give You Up", - Lyrics: "[]", - }, - }) - - response, err := router.GetLyricsBySongId(r) - Expect(err).ToNot(HaveOccurred()) - - porTime := int64(18800) - ttmlTime := int64(22800) - compareResponses(response.LyricsList, responses.LyricsList{ - StructuredLyrics: responses.StructuredLyrics{ - { - DisplayArtist: "Rick Astley", - DisplayTitle: "Never Gonna Give You Up", - Lang: "eng", - Synced: true, - Line: []responses.Line{ - { - Start: ×[0], - Value: "We're no strangers to love", - }, - { - Start: &ttmlTime, - Value: "You know the rules and so do I", - }, - }, - }, - { - DisplayArtist: "Rick Astley", - DisplayTitle: "Never Gonna Give You Up", - Lang: "por", - Synced: true, - Line: []responses.Line{ - { - Start: &porTime, - Value: "Nao somos estranhos ao amor", - }, - }, - }, - }, - }) - }) - - It("should return metadata-linked translation and pronunciation tracks from TTML", func() { - conf.Server.LyricsPriority = ".ttml,embedded" - r := newGetRequest("id=1&enhanced=true") - - mockRepo.SetData(model.MediaFiles{ - { - ID: "1", - Path: "tests/fixtures/test-metadata.mp3", - Artist: "Rick Astley", - Title: "Never Gonna Give You Up", - Lyrics: "[]", - }, - }) - - response, err := router.GetLyricsBySongId(r) - Expect(err).ToNot(HaveOccurred()) - - mainStartA := int64(1000) - mainStartB := int64(2000) - tokenStartA := int64(2000) - tokenEndA := int64(2300) - tokenStartB := int64(2300) - tokenEndB := int64(2600) - compareResponses(response.LyricsList, responses.LyricsList{ - StructuredLyrics: responses.StructuredLyrics{ - { - DisplayArtist: "Rick Astley", - DisplayTitle: "Never Gonna Give You Up", - Kind: "main", - Lang: "ja", - Synced: true, - Line: []responses.Line{ - { - Start: &mainStartA, - Value: "こんにちは", - }, - { - Start: &mainStartB, - Value: "こんばんは", - }, - }, - }, - { - DisplayArtist: "Rick Astley", - DisplayTitle: "Never Gonna Give You Up", - Kind: "translation", - Lang: "es", - Synced: true, - Line: []responses.Line{ - { - Start: &mainStartA, - Value: "Hola", - }, - }, - }, - { - DisplayArtist: "Rick Astley", - DisplayTitle: "Never Gonna Give You Up", - Kind: "pronunciation", - Lang: "ja-latn", - Synced: true, - Line: []responses.Line{ - { - Start: &mainStartB, - Value: "konni", - }, - }, - CueLine: []responses.CueLine{ - { - Index: 0, - Start: &mainStartB, - End: &tokenEndB, - Value: "konni", - Cue: []responses.LyricCue{ - { - Start: tokenStartA, - End: &tokenEndA, - ByteStart: 0, - ByteEnd: 1, - Value: "ko", - }, - { - Start: tokenStartB, - End: &tokenEndB, - ByteStart: 2, - ByteEnd: 4, - Value: "nni", - }, - }, - }, - }, - }, - }, - }) - }) - - It("should return cue lines for songLyrics v2 clients with enhanced=true", func() { - r := newGetRequest("id=1&enhanced=true") - - lineStart := int64(1000) - lineEnd := int64(3000) - tokenStartA := int64(1000) - tokenEndA := int64(1400) - tokenStartB := int64(2000) - tokenEndB := int64(2500) - lyricsJson, err := json.Marshal(model.LyricList{ - { - Lang: "eng", - Agents: []model.Agent{{ID: "lead", Role: "main"}, {ID: "__nd_bg__|lead", Role: "bg"}}, - Synced: true, - Line: []model.Line{ - { - Start: &lineStart, - End: &lineEnd, - Value: "Hello echo", - Cue: []model.Cue{ - { - Start: &tokenStartA, - End: &tokenEndA, - Value: "Hello", - ByteStart: 0, - ByteEnd: 4, - AgentID: "lead", - }, - { - Start: &tokenStartB, - End: &tokenEndB, - Value: "echo", - ByteStart: 6, - ByteEnd: 9, - AgentID: "__nd_bg__|lead", - }, - }, - }, - }, - }, - }) - Expect(err).ToNot(HaveOccurred()) - - mockRepo.SetData(model.MediaFiles{ - { - ID: "1", - Artist: "Rick Astley", - Title: "Never Gonna Give You Up", - Lyrics: string(lyricsJson), - }, - }) - - response, err := router.GetLyricsBySongId(r) - Expect(err).ToNot(HaveOccurred()) - compareResponses(response.LyricsList, responses.LyricsList{ - StructuredLyrics: responses.StructuredLyrics{ - { - DisplayArtist: "Rick Astley", - DisplayTitle: "Never Gonna Give You Up", - Kind: "main", - Lang: "eng", - Synced: true, - Agents: []responses.Agent{ - {ID: "lead", Role: "main"}, - {ID: "__nd_bg__|lead", Role: "bg"}, - }, - Line: []responses.Line{ - { - Start: &lineStart, - Value: "Hello echo", - }, - }, - CueLine: []responses.CueLine{ - { - Index: 0, - Start: &lineStart, - End: &lineEnd, - Value: "Hello echo", - AgentID: "lead", - Cue: []responses.LyricCue{ - { - Start: tokenStartA, - End: &tokenEndA, - ByteStart: 0, - ByteEnd: 4, - Value: "Hello", - }, - }, - }, - { - Index: 0, - Start: &lineStart, - End: &lineEnd, - Value: "Hello echo", - AgentID: "__nd_bg__|lead", - Cue: []responses.LyricCue{ - { - Start: tokenStartB, - End: &tokenEndB, - ByteStart: 6, - ByteEnd: 9, - Value: "echo", - }, - }, - }, - }, - }, - }, - }) - }) - - It("should keep enhanced line-level lyrics when no cue data is available", func() { - r := newGetRequest("id=1&enhanced=true") - - lineStart := int64(1000) - lineEnd := int64(3000) - lyricsJSON, err := json.Marshal(model.LyricList{ - { - Kind: "main", - Lang: "eng", - Synced: true, - Line: []model.Line{ - { - Start: &lineStart, - End: &lineEnd, - Value: "Line without word timing", - }, - }, - }, - }) - Expect(err).ToNot(HaveOccurred()) - - mockRepo.SetData(model.MediaFiles{ - { - ID: "1", - Artist: "Rick Astley", - Title: "Never Gonna Give You Up", - Lyrics: string(lyricsJSON), - }, - }) - - response, err := router.GetLyricsBySongId(r) - Expect(err).ToNot(HaveOccurred()) - compareResponses(response.LyricsList, responses.LyricsList{ - StructuredLyrics: responses.StructuredLyrics{ - { - DisplayArtist: "Rick Astley", - DisplayTitle: "Never Gonna Give You Up", - Kind: "main", - Lang: "eng", - Synced: true, - Line: []responses.Line{ - { - Start: &lineStart, - Value: "Line without word timing", - }, - }, - }, - }, - }) - }) - - It("should return required cue byte offsets for ambiguous and multibyte cue lines", func() { - r := newGetRequest("id=1&enhanced=true") - - asciiLineStart := int64(0) - asciiLineEnd := int64(2400) - asciiCueStartA := int64(0) - asciiCueEndA := int64(300) - asciiCueStartB := int64(900) - asciiCueEndB := int64(1300) - asciiCueStartC := int64(1300) - asciiCueEndC := int64(1600) - asciiCueStartD := int64(1600) - - utfLineStart := int64(2747) - utfLineEnd := int64(6214) - utfCueStartA := int64(2747) - utfCueEndA := int64(3018) - utfCueStartB := int64(3018) - utfCueEndB := int64(3179) - utfCueStartC := int64(3582) - utfCueEndC := int64(4100) - utfCueStartD := int64(4500) - utfCueEndD := int64(6214) - - lyricsJSON, err := json.Marshal(model.LyricList{ - { - Lang: "eng", - Synced: true, - Line: []model.Line{ - { - Start: &asciiLineStart, - End: &asciiLineEnd, - Value: "Oh love love me tonight", - Cue: []model.Cue{ - {Start: &asciiCueStartA, End: &asciiCueEndA, Value: "Oh", ByteStart: 0, ByteEnd: 1}, - {Start: &asciiCueStartB, End: &asciiCueEndB, Value: "love", ByteStart: 8, ByteEnd: 11}, - {Start: &asciiCueStartC, End: &asciiCueEndC, Value: "me", ByteStart: 13, ByteEnd: 14}, - {Start: &asciiCueStartD, Value: "tonight", ByteStart: 16, ByteEnd: 22}, - }, - }, - { - Start: &utfLineStart, - End: &utfLineEnd, - Value: "눈을 뜬 순간", - Cue: []model.Cue{ - {Start: &utfCueStartA, End: &utfCueEndA, Value: "눈", ByteStart: 0, ByteEnd: 2}, - {Start: &utfCueStartB, End: &utfCueEndB, Value: "을", ByteStart: 3, ByteEnd: 5}, - {Start: &utfCueStartC, End: &utfCueEndC, Value: "뜬", ByteStart: 7, ByteEnd: 9}, - {Start: &utfCueStartD, End: &utfCueEndD, Value: "순간", ByteStart: 11, ByteEnd: 16}, - }, - }, - }, - }, - }) - Expect(err).ToNot(HaveOccurred()) - - mockRepo.SetData(model.MediaFiles{ - { - ID: "1", - Artist: "Rick Astley", - Title: "Never Gonna Give You Up", - Lyrics: string(lyricsJSON), - }, - }) - - response, err := router.GetLyricsBySongId(r) - Expect(err).ToNot(HaveOccurred()) - compareResponses(response.LyricsList, responses.LyricsList{ - StructuredLyrics: responses.StructuredLyrics{ - { - DisplayArtist: "Rick Astley", - DisplayTitle: "Never Gonna Give You Up", - Kind: "main", - Lang: "eng", - Synced: true, - Line: []responses.Line{ - {Start: &asciiLineStart, Value: "Oh love love me tonight"}, - {Start: &utfLineStart, Value: "눈을 뜬 순간"}, - }, - CueLine: []responses.CueLine{ - { - Index: 0, - Start: &asciiLineStart, - End: &asciiLineEnd, - Value: "Oh love love me tonight", - Cue: []responses.LyricCue{ - {Start: asciiCueStartA, End: &asciiCueEndA, Value: "Oh", ByteStart: 0, ByteEnd: 1}, - {Start: asciiCueStartB, End: &asciiCueEndB, Value: "love", ByteStart: 8, ByteEnd: 11}, - {Start: asciiCueStartC, End: &asciiCueEndC, Value: "me", ByteStart: 13, ByteEnd: 14}, - {Start: asciiCueStartD, End: &asciiLineEnd, Value: "tonight", ByteStart: 16, ByteEnd: 22}, - }, - }, - { - Index: 1, - Start: &utfLineStart, - End: &utfLineEnd, - Value: "눈을 뜬 순간", - Cue: []responses.LyricCue{ - {Start: utfCueStartA, End: &utfCueEndA, Value: "눈", ByteStart: 0, ByteEnd: 2}, - {Start: utfCueStartB, End: &utfCueEndB, Value: "을", ByteStart: 3, ByteEnd: 5}, - {Start: utfCueStartC, End: &utfCueEndC, Value: "뜬", ByteStart: 7, ByteEnd: 9}, - {Start: utfCueStartD, End: &utfCueEndD, Value: "순간", ByteStart: 11, ByteEnd: 16}, - }, - }, - }, - }, - }, - }) - }) }) })