package jellyfin
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/server/jellyfin/dto"
"github.com/navidrome/navidrome/tests"
"github.com/navidrome/navidrome/utils/cache"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
type fakeLyricsService struct {
lyrics map[string]model.LyricList
err error
calls int
hadDeadline bool
}
func (f *fakeLyricsService) GetLyrics(ctx context.Context, mf *model.MediaFile) (model.LyricList, error) {
f.calls++
_, f.hadDeadline = ctx.Deadline()
if err := ctx.Err(); err != nil {
return nil, err
}
if f.err != nil {
return nil, f.err
}
return f.lyrics[mf.ID], nil
}
func (f *fakeLyricsService) GetLyricsByArtistTitle(context.Context, string, string) (model.LyricList, error) {
return nil, nil
}
func p(ms int64) *int64 { return &ms }
func newTestLyricsCache() cache.SimpleCache[string, model.LyricList] {
return cache.NewSimpleCache[string, model.LyricList](cache.Options{SizeLimit: 1000})
}
var _ = Describe("getLyrics", func() {
var api *Router
var ds *tests.MockDataStore
var fake *fakeLyricsService
BeforeEach(func() {
ds = &tests.MockDataStore{}
ds.MediaFile().(*tests.MockMediaFileRepo).SetData(model.MediaFiles{
{ID: testID("s1"), Title: "Song", LibraryID: 1},
{ID: testID("s2"), Title: "Silent Song", LibraryID: 1},
})
fake = &fakeLyricsService{lyrics: map[string]model.LyricList{}}
api = &Router{
ds: ds,
lyrics: fake,
lyricsCache: newTestLyricsCache(),
}
})
doRequest := func(id string) *httptest.ResponseRecorder {
w := httptest.NewRecorder()
ctx := request.WithUser(context.Background(), model.User{ID: testID("u1"), Libraries: model.Libraries{{ID: 1}}})
enc := dto.EncodeID(id)
r := httptest.NewRequest("GET", "/Audio/"+enc+"/Lyrics", nil).WithContext(ctx)
r = withChiURLParam(r, "itemId", enc)
invoke(api.getLyrics, w, r)
return w
}
It("returns 200 with a LyricDto for a track with synced lyrics", func() {
fake.lyrics[testID("s1")] = model.LyricList{
{Kind: "main", Synced: true, Line: []model.Line{{Start: p(1000), Value: "hello"}}},
}
w := doRequest(testID("s1"))
Expect(w.Code).To(Equal(http.StatusOK))
var res dto.LyricDto
Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed())
Expect(res.Lyrics).To(HaveLen(1))
Expect(res.Lyrics[0].Text).To(Equal("hello"))
Expect(res.Lyrics[0].Start).ToNot(BeNil())
Expect(*res.Lyrics[0].Start).To(Equal(int64(10000000)))
})
It("serves the main-kind lyric when a translation is also present", func() {
fake.lyrics[testID("s1")] = model.LyricList{
{Kind: "translation", Synced: true, Line: []model.Line{{Start: p(1000), Value: "bonjour"}}},
{Kind: "main", Synced: true, Line: []model.Line{{Start: p(1000), Value: "hello"}}},
}
w := doRequest(testID("s1"))
Expect(w.Code).To(Equal(http.StatusOK))
var res dto.LyricDto
Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed())
Expect(res.Lyrics).To(HaveLen(1))
Expect(res.Lyrics[0].Text).To(Equal("hello"))
})
It("returns 404 when the service returns no lyrics", func() {
w := doRequest(testID("s2"))
Expect(w.Code).To(Equal(http.StatusNotFound))
})
It("returns 404 when the main lyric has no lines", func() {
fake.lyrics[testID("s1")] = model.LyricList{{Kind: "main", Lang: "eng"}}
w := doRequest(testID("s1"))
Expect(w.Code).To(Equal(http.StatusNotFound))
})
It("returns 404 for an unknown item id", func() {
w := doRequest("unknown")
Expect(w.Code).To(Equal(http.StatusNotFound))
})
It("caches results so a second request doesn't re-invoke the service", func() {
fake.lyrics[testID("s1")] = model.LyricList{
{Kind: "main", Synced: true, Line: []model.Line{{Start: p(1000), Value: "hello"}}},
}
Expect(doRequest(testID("s1")).Code).To(Equal(http.StatusOK))
Expect(doRequest(testID("s1")).Code).To(Equal(http.StatusOK))
Expect(fake.calls).To(Equal(1))
})
It("caches empty results too", func() {
Expect(doRequest(testID("s2")).Code).To(Equal(http.StatusNotFound))
Expect(doRequest(testID("s2")).Code).To(Equal(http.StatusNotFound))
Expect(fake.calls).To(Equal(1))
})
It("completes and caches the fetch even when the request context is cancelled", func() {
fake.lyrics[testID("s1")] = model.LyricList{
{Kind: "main", Synced: true, Line: []model.Line{{Start: p(1000), Value: "hello"}}},
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
list := api.cachedLyrics(ctx, &model.MediaFile{ID: testID("s1")})
Expect(list).ToNot(BeEmpty())
Expect(doRequest(testID("s1")).Code).To(Equal(http.StatusOK))
Expect(fake.calls).To(Equal(1))
})
It("bounds the detached fetch with a timeout", func() {
Expect(doRequest(testID("s2")).Code).To(Equal(http.StatusNotFound))
Expect(fake.hadDeadline).To(BeTrue())
})
})