package subsonic import ( "encoding/json" "path/filepath" "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") syncedList, _ := model.ParseLyrics(GinkgoT().Context(), ".lrc", "eng", []byte(syncedLyrics)) unsyncedList, _ := model.ParseLyrics(GinkgoT().Context(), ".lrc", "xxx", []byte(unsyncedLyrics)) synced, _ := syncedList.Main() unsynced, _ := unsyncedList.Main() 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") syncedList, _ := model.ParseLyrics(GinkgoT().Context(), ".lrc", "eng", []byte(metadata+"\n"+syncedLyrics)) synced, _ := syncedList.Main() 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") fixturesDir, err := filepath.Abs("tests/fixtures") Expect(err).ToNot(HaveOccurred()) mockRepo.SetData(model.MediaFiles{ { ID: "1", LibraryPath: fixturesDir, Path: "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") fixturesDir, err := filepath.Abs("tests/fixtures") Expect(err).ToNot(HaveOccurred()) mockRepo.SetData(model.MediaFiles{ { ID: "1", LibraryPath: fixturesDir, Path: "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", AgentID: "lead", Cue: []responses.LyricCue{ { Start: tokenStartA, End: &tokenEndA, ByteStart: 0, ByteEnd: 4, Value: "Hello", }, }, }, { Index: 0, Start: &lineStart, End: &lineEnd, Value: "echo", AgentID: "__nd_bg__|lead", Cue: []responses.LyricCue{ { Start: tokenStartB, End: &tokenEndB, ByteStart: 0, ByteEnd: 3, Value: "echo", }, }, }, }, }, }, }) }) It("should preserve shared edge text when remapping agent cue lines", func() { lineStart := int64(1000) lineEnd := int64(2000) cueStart := int64(1200) cueEnd := int64(1800) cueLines := buildCueLines(model.Line{ Start: &lineStart, End: &lineEnd, Value: "(Hello)", Cue: []model.Cue{ { Start: &cueStart, End: &cueEnd, Value: "Hello", ByteStart: 1, ByteEnd: 5, AgentID: "lead", }, { Start: &cueStart, End: &cueEnd, Value: "Hello", ByteStart: 1, ByteEnd: 5, AgentID: "__nd_bg__|lead", }, }, }, 0, newLyricAgents([]model.Agent{ {ID: "lead", Role: "main"}, {ID: "__nd_bg__|lead", Role: "bg"}, })) Expect(cueLines).To(Equal([]responses.CueLine{ { Index: 0, Start: &lineStart, End: &lineEnd, Value: "(Hello)", AgentID: "lead", Cue: []responses.LyricCue{ { Start: cueStart, End: &cueEnd, Value: "Hello", ByteStart: 1, ByteEnd: 5, }, }, }, { Index: 0, Start: &lineStart, End: &lineEnd, Value: "(Hello)", AgentID: "__nd_bg__|lead", Cue: []responses.LyricCue{ { Start: cueStart, End: &cueEnd, Value: "Hello", ByteStart: 1, ByteEnd: 5, }, }, }, })) }) It("should remap cue offsets for interleaved agent cue lines", func() { r := newGetRequest("id=1&enhanced=true") lineStart := int64(82889) lineEnd := int64(86859) realStart := int64(85593) realEnd := int64(85934) slowStart := int64(85934) slowEnd := int64(86751) bgStartA := int64(83881) bgEndA := int64(84243) bgStartB := int64(86232) bgEndB := int64(86859) lyricsJSON, err := json.Marshal(model.LyricList{ { Lang: "eng", Agents: []model.Agent{{ID: "v2", Role: "main"}, {ID: "__nd_bg__|v2", Role: "bg"}}, Synced: true, Line: []model.Line{ { Start: &lineStart, End: &lineEnd, Value: "real slow (When you slide)", Cue: []model.Cue{ { Start: &realStart, End: &realEnd, Value: "real", ByteStart: 0, ByteEnd: 3, AgentID: "v2", }, { Start: &slowStart, End: &slowEnd, Value: "slow", ByteStart: 5, ByteEnd: 8, AgentID: "v2", }, { Start: &bgStartA, End: &bgEndA, Value: "(When you", ByteStart: 10, ByteEnd: 18, AgentID: "__nd_bg__|v2", }, { Start: &bgStartB, End: &bgEndB, Value: "slide)", ByteStart: 20, ByteEnd: 25, AgentID: "__nd_bg__|v2", }, }, }, }, }, }) 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: "v2", Role: "main"}, {ID: "__nd_bg__|v2", Role: "bg"}, }, Line: []responses.Line{ { Start: &lineStart, Value: "real slow (When you slide)", }, }, CueLine: []responses.CueLine{ { Index: 0, Start: &lineStart, End: &lineEnd, Value: "real slow", AgentID: "v2", Cue: []responses.LyricCue{ { Start: realStart, End: &realEnd, ByteStart: 0, ByteEnd: 3, Value: "real", }, { Start: slowStart, End: &slowEnd, ByteStart: 5, ByteEnd: 8, Value: "slow", }, }, }, { Index: 0, Start: &lineStart, End: &lineEnd, Value: "(When you slide)", AgentID: "__nd_bg__|v2", Cue: []responses.LyricCue{ { Start: bgStartA, End: &bgEndA, ByteStart: 0, ByteEnd: 8, Value: "(When you", }, { Start: bgStartB, End: &bgEndB, ByteStart: 10, ByteEnd: 15, Value: "slide)", }, }, }, }, }, }, }) }) 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}, }, }, }, }, }, }) }) })