fix(jellyfin): don't truncate InstantMix to the Similar ceiling

Finamp's Radio Mix requests /Items/{id}/InstantMix?limit=250, but getInstantMix
clamped the limit to maxSimilarLimit (100), so the queue was cut to 100 tracks.

A mix is a playback queue, not a "related items" list, so it gets its own
ceiling instead of sharing the Similar one. The Similar handlers keep 100.

Verified against a live library: the sonic provider supplies all 250 tracks for
a seed that previously returned 100.
This commit is contained in:
Deluan 2026-07-15 21:21:26 -04:00
parent 09022b4bd2
commit 1d5efdd5a0
2 changed files with 40 additions and 1 deletions

View File

@ -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 {

View File

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