diff --git a/server/jellyfin/similar.go b/server/jellyfin/similar.go index 33cdc4c88..085080850 100644 --- a/server/jellyfin/similar.go +++ b/server/jellyfin/similar.go @@ -23,6 +23,9 @@ var similarWait = 10 * time.Second const ( defaultSimilarLimit = 20 maxSimilarLimit = 100 + // A mix is a playback queue, not a "related items" list: Finamp's Radio Mix asks for 250, so the + // Similar ceiling would truncate it. Real Jellyfin builds mixes from a 200-track genre query. + maxInstantMixLimit = 500 ) // similarFetchTimeout bounds the detached background fetch so a hung provider can't hold a goroutine @@ -91,7 +94,7 @@ func (api *Router) getSimilarItems(w http.ResponseWriter, r *http.Request) { func (api *Router) getInstantMix(w http.ResponseWriter, r *http.Request) { ctx := r.Context() id := api.resolveItemID(ctx, dto.DecodeID(chi.URLParam(r, "itemId"))) - limit := clampLimit(req.Params(r).IntOr("limit", 0), defaultSimilarLimit, maxSimilarLimit) + limit := clampLimit(req.Params(r).IntOr("limit", 0), defaultSimilarLimit, maxInstantMixLimit) entity, err := model.GetEntityByID(ctx, api.ds, id) if err != nil { diff --git a/server/jellyfin/similar_test.go b/server/jellyfin/similar_test.go index cbb68e5cf..302566195 100644 --- a/server/jellyfin/similar_test.go +++ b/server/jellyfin/similar_test.go @@ -3,7 +3,9 @@ package jellyfin import ( "context" "encoding/json" + "fmt" "net/http/httptest" + "strconv" "sync/atomic" "time" @@ -103,6 +105,16 @@ func (p *blockingProvider) SimilarSongs(context.Context, string, int) (model.Med return nil, nil } +// fakeSimilarProvider returns up to count of its canned songs, like a real agent honoring the limit. +type fakeSimilarProvider struct { + external.Provider + songs model.MediaFiles +} + +func (p *fakeSimilarProvider) SimilarSongs(_ context.Context, _ string, count int) (model.MediaFiles, error) { + return p.songs[:min(count, len(p.songs))], nil +} + var _ = Describe("getInstantMix", func() { It("returns the seed track even when the provider fetch exceeds the wait", func() { old := similarWait @@ -128,4 +140,28 @@ var _ = Describe("getInstantMix", func() { Expect(res.Items).To(HaveLen(1)) Expect(res.Items[0].Name).To(Equal("Seed Song")) }) + + // Finamp's Radio Mix asks for limit=250. Clamping that to the Similar ceiling (100) truncated the + // queue, so InstantMix gets its own, higher ceiling. + It("honors a mix-sized limit above the Similar ceiling", func() { + const want = 250 + songs := model.MediaFiles{{ID: "s1", Title: "Seed Song", LibraryID: 1}} + for i := range want + 50 { // more than requested, so only the limit bounds the result + songs = append(songs, model.MediaFile{ID: fmt.Sprintf("t%d", i), Title: fmt.Sprintf("Track %d", i), LibraryID: 1}) + } + ds := &tests.MockDataStore{} + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(songs) + api := &Router{ds: ds, provider: &fakeSimilarProvider{songs: songs[1:]}} + + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID("s1")+"/InstantMix?limit="+strconv.Itoa(want), nil). + WithContext(request.WithUser(context.Background(), model.User{ID: "u1", Libraries: model.Libraries{{ID: 1}}})) + r = withChiURLParam(r, "itemId", dto.EncodeID("s1")) + api.getInstantMix(w, r) + + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).To(HaveLen(want), "a Radio Mix-sized request must not be truncated to the Similar ceiling") + Expect(res.Items[0].Name).To(Equal("Seed Song"), "the seed must still lead the mix") + }) })