🏡 index : quine-global/quintodrome.git

package jellyfin

import (
	"context"
	"errors"
	"net/http"
	"net/http/httptest"
	"strings"

	"github.com/navidrome/navidrome/core"
	"github.com/navidrome/navidrome/core/scrobbler"
	"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"
)

// fakePlayTracker is a local double for scrobbler.PlayTracker, mirroring
// server/subsonic's fakePlayTracker.
type fakePlayTracker struct {
	scrobbler.PlayTracker
	reported  []scrobbler.ReportPlaybackParams
	submitted []scrobbler.Submission
}

func (f *fakePlayTracker) ReportPlayback(_ context.Context, p scrobbler.ReportPlaybackParams) error {
	f.reported = append(f.reported, p)
	return nil
}

func (f *fakePlayTracker) Submit(_ context.Context, s []scrobbler.Submission) error {
	f.submitted = append(f.submitted, s...)
	return nil
}

// fakePlayers is a local double for core.Players, used to exercise withPlayer.
type fakePlayers struct {
	core.Players
	err           error
	registerCalls int
	lastClient    string
	trc           *model.Transcoding
}

func (f *fakePlayers) Register(_ context.Context, id, client, _, _ string) (*model.Player, *model.Transcoding, error) {
	f.registerCalls++
	f.lastClient = client
	if f.err != nil {
		return nil, nil, f.err
	}
	return &model.Player{ID: id, Client: client}, f.trc, nil
}

var _ = Describe("Sessions", func() {
	var api *Router
	var pt *fakePlayTracker

	authed := func(r *http.Request) *http.Request {
		ctx := request.WithUser(context.Background(), model.User{ID: testID("u1"), UserName: "alice"})
		ctx = request.WithPlayer(ctx, model.Player{ID: testID("p1"), Client: "Finamp"})
		return r.WithContext(ctx)
	}

	BeforeEach(func() {
		pt = &fakePlayTracker{}
		api = &Router{ds: &tests.MockDataStore{}, scrobbler: pt}
	})

	Describe("reportPlaybackStart", func() {
		It("reports playback start with the item id and position", func() {
			w := httptest.NewRecorder()
			r := authed(httptest.NewRequest("POST", "/Sessions/Playing", strings.NewReader(`{"ItemId":"`+dto.EncodeID(testID("s1"))+`","PositionTicks":10000000}`)))

			invoke(api.reportPlaybackStart, w, r)

			Expect(w.Code).To(Equal(http.StatusNoContent))
			Expect(pt.reported).To(HaveLen(1))
			Expect(pt.reported[0].MediaId).To(Equal(testID("s1")))
			Expect(pt.reported[0].PositionMs).To(Equal(int64(1000)))
			Expect(pt.reported[0].State).To(Equal(scrobbler.StatePlaying))
			Expect(pt.reported[0].ClientId).To(Equal(testID("p1")))
			Expect(pt.reported[0].ClientName).To(Equal("Finamp"))
		})

		It("falls back to the ItemId query param when the body has none", func() {
			w := httptest.NewRecorder()
			r := authed(httptest.NewRequest("POST", "/Sessions/Playing?ItemId="+dto.EncodeID(testID("s2")), nil))

			invoke(api.reportPlaybackStart, w, r)

			Expect(w.Code).To(Equal(http.StatusNoContent))
			Expect(pt.reported).To(HaveLen(1))
			Expect(pt.reported[0].MediaId).To(Equal(testID("s2")))
		})

		It("still reports (with an empty MediaId) for a malformed ItemId, rather than failing the request", func() {
			w := httptest.NewRecorder()
			r := authed(httptest.NewRequest("POST", "/Sessions/Playing", strings.NewReader(`{"ItemId":"not-a-valid-id","PositionTicks":10000000}`)))

			invoke(api.reportPlaybackStart, w, r)

			Expect(w.Code).To(Equal(http.StatusNoContent))
			Expect(pt.reported).To(HaveLen(1))
			Expect(pt.reported[0].MediaId).To(BeEmpty())
		})
	})

	Describe("reportPlaybackProgress", func() {
		It("reports the playing state when not paused", func() {
			w := httptest.NewRecorder()
			r := authed(httptest.NewRequest("POST", "/Sessions/Playing/Progress", strings.NewReader(`{"ItemId":"`+dto.EncodeID(testID("s1"))+`","PositionTicks":20000000,"IsPaused":false}`)))

			invoke(api.reportPlaybackProgress, w, r)

			Expect(w.Code).To(Equal(http.StatusNoContent))
			Expect(pt.reported).To(HaveLen(1))
			Expect(pt.reported[0].State).To(Equal(scrobbler.StatePlaying))
			Expect(pt.reported[0].PositionMs).To(Equal(int64(2000)))
		})

		It("reports the paused state when IsPaused is true", func() {
			w := httptest.NewRecorder()
			r := authed(httptest.NewRequest("POST", "/Sessions/Playing/Progress", strings.NewReader(`{"ItemId":"`+dto.EncodeID(testID("s1"))+`","PositionTicks":20000000,"IsPaused":true}`)))

			invoke(api.reportPlaybackProgress, w, r)

			Expect(w.Code).To(Equal(http.StatusNoContent))
			Expect(pt.reported).To(HaveLen(1))
			Expect(pt.reported[0].State).To(Equal(scrobbler.StatePaused))
		})
	})

	Describe("reportPlaybackStopped", func() {
		It("reports the stopped state and lets the scrobbler apply its play threshold", func() {
			w := httptest.NewRecorder()
			r := authed(httptest.NewRequest("POST", "/Sessions/Playing/Stopped", strings.NewReader(`{"ItemId":"`+dto.EncodeID(testID("s1"))+`","PositionTicks":600000000}`)))

			invoke(api.reportPlaybackStopped, w, r)

			Expect(w.Code).To(Equal(http.StatusNoContent))

			Expect(pt.reported).To(HaveLen(1))
			Expect(pt.reported[0].MediaId).To(Equal(testID("s1")))
			Expect(pt.reported[0].State).To(Equal(scrobbler.StateStopped))
			Expect(pt.reported[0].PositionMs).To(Equal(int64(60000)))
			// IgnoreScrobble stays false so ReportPlayback's own StateStopped threshold decides
			// whether the play counts; we no longer force a Submit that would bypass it.
			Expect(pt.reported[0].IgnoreScrobble).To(BeFalse())
			Expect(pt.submitted).To(BeEmpty())
		})
	})

	Describe("acknowledge", func() {
		It("returns 204 No Content and does not touch the scrobbler", func() {
			w := httptest.NewRecorder()
			r := authed(httptest.NewRequest("POST", "/Sessions/Capabilities", strings.NewReader(`{"SupportsMediaControl":true}`)))

			api.acknowledge(w, r)

			Expect(w.Code).To(Equal(http.StatusNoContent))
			Expect(pt.reported).To(BeEmpty())
			Expect(pt.submitted).To(BeEmpty())
		})
	})
})

var _ = Describe("withPlayer middleware", func() {
	var api *Router
	var fp *fakePlayers

	BeforeEach(func() {
		fp = &fakePlayers{}
		api = &Router{ds: &tests.MockDataStore{}, players: fp}
	})

	It("registers a player from the Emby device info and injects it into the context", func() {
		var gotPlayer model.Player
		var gotOk bool
		next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			gotPlayer, gotOk = request.PlayerFrom(r.Context())
			w.WriteHeader(http.StatusNoContent)
		})

		w := httptest.NewRecorder()
		r := httptest.NewRequest("POST", "/Sessions/Playing", nil)
		r.Header.Set("X-Emby-Authorization", `MediaBrowser Client="Finamp", Device="Pixel", DeviceId="dev1", Version="1.0"`)

		api.withPlayer(next).ServeHTTP(w, r)

		Expect(w.Code).To(Equal(http.StatusNoContent))
		Expect(gotOk).To(BeTrue())
		Expect(gotPlayer.ID).To(Equal("dev1"))
		Expect(gotPlayer.Client).To(Equal("Finamp"))
	})

	It("fails open (no player in context) when registration errors", func() {
		fp.err = errors.New("boom")
		var gotOk bool
		next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			_, gotOk = request.PlayerFrom(r.Context())
			w.WriteHeader(http.StatusNoContent)
		})

		w := httptest.NewRecorder()
		r := httptest.NewRequest("POST", "/Sessions/Playing", nil)

		api.withPlayer(next).ServeHTTP(w, r)

		Expect(w.Code).To(Equal(http.StatusNoContent))
		Expect(gotOk).To(BeFalse())
	})

	// The /socket handshake authenticates via ?api_key= with no X-Emby-Authorization header, so it
	// carries no client/device info; registering it would create a junk player named " []".
	It("skips registration when the request has no client or device info", func() {
		next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) })

		w := httptest.NewRecorder()
		r := httptest.NewRequest("GET", "/socket?api_key=tok", nil)

		api.withPlayer(next).ServeHTTP(w, r)

		Expect(w.Code).To(Equal(http.StatusNoContent))
		Expect(fp.registerCalls).To(Equal(0))
	})
})