🏡 index : quine-global/quintodrome.git

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"
)

// fakeLyricsService returns canned lyrics per media-file ID and counts calls.
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}}})
		// Clients send hex-encoded ids (matching real traffic and the other handler tests).
		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())
	})
})