🏡 index : quine-global/quintodrome.git

package jellyfin

import (
	"context"
	"encoding/json"
	"fmt"
	"net/http/httptest"
	"strconv"
	"sync/atomic"
	"time"

	"github.com/navidrome/navidrome/core/external"
	"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/onsi/ginkgo/v2"
	. "github.com/onsi/gomega"
)

var _ = Describe("awaitSimilar", func() {
	var api *Router
	ctxFor := func(userID string) context.Context {
		return request.WithUser(context.Background(), model.User{ID: userID})
	}
	shortenWait := func() {
		old := similarWait
		similarWait = 20 * time.Millisecond
		DeferCleanup(func() { similarWait = old })
	}

	BeforeEach(func() {
		api = &Router{}
	})

	It("returns the fetch result when it completes within the wait", func() {
		res := api.awaitSimilar(ctxFor(testID("u1")), "id1", 20, func(context.Context) dto.QueryResult {
			return result([]dto.BaseItemDto{{Name: "fast"}}, 1, 0)
		})
		Expect(res.Items).To(HaveLen(1))
		Expect(res.Items[0].Name).To(Equal("fast"))
	})

	It("returns an empty result when the fetch exceeds the wait", func() {
		shortenWait()
		release := make(chan struct{})
		DeferCleanup(func() { close(release) })
		res := api.awaitSimilar(ctxFor(testID("u1")), "id2", 20, func(context.Context) dto.QueryResult {
			<-release // hung provider; would finish caching in the background
			return result([]dto.BaseItemDto{{Name: "late"}}, 1, 0)
		})
		Expect(res.Items).To(BeEmpty())
		Expect(res.TotalRecordCount).To(Equal(0))
	})

	It("dedupes requests into the in-flight fetch", func() {
		shortenWait()
		var calls atomic.Int32
		release := make(chan struct{})
		fetch := func(context.Context) dto.QueryResult {
			calls.Add(1)
			<-release
			return result(nil, 0, 0)
		}
		// Both calls time out, but the flight can't complete before release closes, so the
		// second call must join it rather than start a new fetch.
		api.awaitSimilar(ctxFor(testID("u1")), "id3", 20, fetch)
		api.awaitSimilar(ctxFor(testID("u1")), "id3", 20, fetch)
		close(release)
		Eventually(calls.Load).Should(Equal(int32(1)))
		Consistently(calls.Load, "50ms").Should(Equal(int32(1)))
	})

	It("does not share fetches across users (items embed the user's annotations)", func() {
		var calls atomic.Int32
		fetch := func(context.Context) dto.QueryResult {
			calls.Add(1)
			return result(nil, 0, 0)
		}
		api.awaitSimilar(ctxFor(testID("u1")), "id4", 20, fetch)
		api.awaitSimilar(ctxFor(testID("u2")), "id4", 20, fetch)
		Expect(calls.Load()).To(Equal(int32(2)))
	})

	It("hands the fetch a deadline-bounded background context", func() {
		var deadline time.Time
		var hasDeadline bool
		api.awaitSimilar(ctxFor(testID("u1")), "id5", 20, func(ctx context.Context) dto.QueryResult {
			deadline, hasDeadline = ctx.Deadline()
			return result(nil, 0, 0)
		})
		Expect(hasDeadline).To(BeTrue(), "background fetch must not be able to run forever")
		Expect(time.Until(deadline)).To(BeNumerically("<=", similarFetchTimeout))
	})
})

// blockingProvider hangs SimilarSongs until release is closed, simulating a slow/unreachable agent.
type blockingProvider struct {
	external.Provider
	release chan struct{}
}

func (p *blockingProvider) SimilarSongs(context.Context, string, int) (model.MediaFiles, error) {
	<-p.release
	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
		similarWait = 20 * time.Millisecond
		DeferCleanup(func() { similarWait = old })

		ds := &tests.MockDataStore{}
		ds.MediaFile().(*tests.MockMediaFileRepo).SetData(model.MediaFiles{
			{ID: testID("s1"), Title: "Seed Song", LibraryID: 1},
		})
		release := make(chan struct{})
		DeferCleanup(func() { close(release) })
		api := &Router{ds: ds, provider: &blockingProvider{release: release}}

		w := httptest.NewRecorder()
		r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID(testID("s1"))+"/InstantMix", nil).
			WithContext(request.WithUser(context.Background(), model.User{ID: testID("u1"), Libraries: model.Libraries{{ID: 1}}}))
		r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1")))
		api.getInstantMix(w, r)

		var res dto.QueryResult
		Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed())
		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: testID("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: testID(fmt.Sprintf("t%d", i)), Title: fmt.Sprintf("Track %d", i), LibraryID: 1})
		}
		ds := &tests.MockDataStore{}
		ds.MediaFile().(*tests.MockMediaFileRepo).SetData(songs)
		api := &Router{ds: ds, provider: &fakeSimilarProvider{songs: songs[1:]}}

		w := httptest.NewRecorder()
		r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID(testID("s1"))+"/InstantMix?limit="+strconv.Itoa(want), nil).
			WithContext(request.WithUser(context.Background(), model.User{ID: testID("u1"), Libraries: model.Libraries{{ID: 1}}}))
		r = withChiURLParam(r, "itemId", dto.EncodeID(testID("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")
	})

	It("returns a mix for a genre id, which GetEntityByID can't resolve", func() {
		ds := &tests.MockDataStore{}
		songs := model.MediaFiles{
			{ID: testID("m1"), Title: "Track 1", LibraryID: 1},
			{ID: testID("m2"), Title: "Track 2", LibraryID: 1},
		}
		api := &Router{ds: ds, provider: &fakeSimilarProvider{songs: songs}}

		w := httptest.NewRecorder()
		r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID(testID("g1"))+"/InstantMix", nil).
			WithContext(request.WithUser(context.Background(), model.User{ID: testID("u1"), Libraries: model.Libraries{{ID: 1}}}))
		r = withChiURLParam(r, "itemId", dto.EncodeID(testID("g1")))
		api.getInstantMix(w, r)

		Expect(w.Code).To(Equal(200))
		var res dto.QueryResult
		Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed())
		Expect(res.Items).To(HaveLen(2))
	})
})

var _ = Describe("getSimilarAlbums", func() {
	It("does not return the seed album as its own similar album", func() {
		// With no external agent the provider falls back to the album's own tracks, which map
		// straight back to the requested album.
		ds := &tests.MockDataStore{}
		ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{
			{ID: testID("al-1"), Name: "Seed Album", LibraryID: 1},
		})
		api := &Router{ds: ds, provider: &fakeSimilarProvider{
			songs: model.MediaFiles{{ID: testID("m1"), AlbumID: testID("al-1"), LibraryID: 1}},
		}}

		w := httptest.NewRecorder()
		r := httptest.NewRequest("GET", "/Albums/"+dto.EncodeID(testID("al-1"))+"/Similar?limit=10", nil).
			WithContext(request.WithUser(context.Background(), model.User{ID: testID("u1"), Libraries: model.Libraries{{ID: 1}}}))
		r = withChiURLParam(r, "itemId", dto.EncodeID(testID("al-1")))
		api.getSimilarAlbums(w, r)

		Expect(w.Code).To(Equal(200))
		var res dto.QueryResult
		Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed())
		Expect(res.Items).To(BeEmpty())
	})

	It("returns albums derived from the provider's similar songs", func() {
		ds := &tests.MockDataStore{}
		ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{
			{ID: testID("al-2"), Name: "Other", LibraryID: 1},
		})
		api := &Router{ds: ds, provider: &fakeSimilarProvider{
			songs: model.MediaFiles{{ID: testID("m1"), AlbumID: testID("al-2"), LibraryID: 1}},
		}}

		w := httptest.NewRecorder()
		r := httptest.NewRequest("GET", "/Albums/"+dto.EncodeID(testID("al-1"))+"/Similar?limit=10", nil).
			WithContext(request.WithUser(context.Background(), model.User{ID: testID("u1"), Libraries: model.Libraries{{ID: 1}}}))
		r = withChiURLParam(r, "itemId", dto.EncodeID(testID("al-1")))
		api.getSimilarAlbums(w, r)

		Expect(w.Code).To(Equal(200))
		var res dto.QueryResult
		Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed())
		Expect(res.Items).To(HaveLen(1))
		Expect(res.Items[0].Name).To(Equal("Other"))
	})
})