From ca38e50c4fc0f8080f8d7471e5207c3fa814b664 Mon Sep 17 00:00:00 2001 From: Deluan Date: Sun, 5 Jul 2026 17:05:43 -0400 Subject: [PATCH] feat(jellyfin): playback reporting and scrobbling Add /Sessions/Playing[/Progress|/Stopped] and /Sessions/Capabilities[/Full] handlers, backed by core/scrobbler.PlayTracker. A new withPlayer middleware resolves/registers a model.Player from the Emby DeviceId header (used directly as the stable player id, unlike Subsonic's cookie fallback) and injects it into the request context for scrobbling. The Stopped report calls ReportPlayback with IgnoreScrobble to end the now-playing session without double-counting, then calls Submit as the single source of the play-count increment and external scrobble. --- server/jellyfin/api.go | 11 ++ server/jellyfin/middlewares.go | 24 ++++ server/jellyfin/sessions.go | 121 ++++++++++++++++++ server/jellyfin/sessions_test.go | 202 +++++++++++++++++++++++++++++++ 4 files changed, 358 insertions(+) create mode 100644 server/jellyfin/sessions.go create mode 100644 server/jellyfin/sessions_test.go diff --git a/server/jellyfin/api.go b/server/jellyfin/api.go index d1eb8f856..ed92a61e6 100644 --- a/server/jellyfin/api.go +++ b/server/jellyfin/api.go @@ -83,6 +83,17 @@ func (api *Router) routes() http.Handler { r.Get("/Audio/{itemId}/universal", api.streamAudio) r.Get("/Items/{itemId}/PlaybackInfo", api.getPlaybackInfo) r.Post("/Items/{itemId}/PlaybackInfo", api.getPlaybackInfo) + + // Playback reports carry only the caller's own play data (see reportPlaybackStart + // doc comment), so no library-access gate is needed here. + r.Group(func(r chi.Router) { + r.Use(api.withPlayer) + r.Post("/Sessions/Playing", api.reportPlaybackStart) + r.Post("/Sessions/Playing/Progress", api.reportPlaybackProgress) + r.Post("/Sessions/Playing/Stopped", api.reportPlaybackStopped) + }) + r.Post("/Sessions/Capabilities", api.postCapabilities) + r.Post("/Sessions/Capabilities/Full", api.postCapabilities) }) return r diff --git a/server/jellyfin/middlewares.go b/server/jellyfin/middlewares.go index 086eb0983..b2961626e 100644 --- a/server/jellyfin/middlewares.go +++ b/server/jellyfin/middlewares.go @@ -1,6 +1,7 @@ package jellyfin import ( + "net" "net/http" "regexp" @@ -76,3 +77,26 @@ func (api *Router) authenticate(next http.Handler) http.Handler { next.ServeHTTP(w, r.WithContext(ctx)) }) } + +// withPlayer resolves/registers a model.Player for the calling device and injects +// it into the context, mirroring the Subsonic API's getPlayer middleware. Unlike +// Subsonic (which has no stable client-supplied device id and falls back to a +// cookie), Jellyfin clients always send a DeviceId in the auth header, so it's +// used directly as the player id: playback reports from the same install +// consistently resolve to the same player/scrobbling session. +func (api *Router) withPlayer(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + a := parseEmbyAuth(r) + ip, _, _ := net.SplitHostPort(r.RemoteAddr) + player, _, err := api.players.Register(ctx, a.DeviceId, a.Client, a.Device, ip) + if err != nil { + // Fail open: log and proceed without a player in context, same as Subsonic's + // getPlayer. Playback reporting handlers degrade gracefully when no player is set. + log.Warn(ctx, "Jellyfin API: could not register player", "client", a.Client, "device", a.Device, err) + next.ServeHTTP(w, r) + return + } + next.ServeHTTP(w, r.WithContext(request.WithPlayer(ctx, *player))) + }) +} diff --git a/server/jellyfin/sessions.go b/server/jellyfin/sessions.go new file mode 100644 index 000000000..6ae63b6c9 --- /dev/null +++ b/server/jellyfin/sessions.go @@ -0,0 +1,121 @@ +package jellyfin + +import ( + "context" + "encoding/json" + "net/http" + "time" + + "github.com/navidrome/navidrome/core/scrobbler" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model/request" +) + +// playbackReport is the subset of Jellyfin's PlaybackStartInfo/PlaybackProgressInfo +// fields Navidrome needs to keep its playback/scrobbling state in sync. +type playbackReport struct { + ItemId string `json:"ItemId"` + PositionTicks int64 `json:"PositionTicks"` + IsPaused bool `json:"IsPaused"` +} + +// decodeReport reads the playback report body. ItemId falls back to a query +// param, as some clients send it there instead of (or in addition to) the JSON body. +func decodeReport(r *http.Request) playbackReport { + var body playbackReport + _ = json.NewDecoder(r.Body).Decode(&body) + if body.ItemId == "" { + body.ItemId = r.URL.Query().Get("ItemId") + } + return body +} + +// clientIdentity returns the scrobbler cache key/display name for the caller's +// player. Both are zero values if withPlayer could not resolve a player. +func clientIdentity(ctx context.Context) (id, name string) { + player, _ := request.PlayerFrom(ctx) + return player.ID, player.Client +} + +// reportPlaybackStart handles POST /Sessions/Playing, sent once when a client +// starts playing an item. +// +// Access control: these Sessions endpoints report the caller's own playback +// history and are never used to look up or expose content, so unlike browse/stream +// endpoints they are intentionally not library-access-gated. +func (api *Router) reportPlaybackStart(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + body := decodeReport(r) + clientId, clientName := clientIdentity(ctx) + err := api.scrobbler.ReportPlayback(ctx, scrobbler.ReportPlaybackParams{ + MediaId: body.ItemId, + PositionMs: body.PositionTicks / 10_000, + State: scrobbler.StatePlaying, + PlaybackRate: 1.0, + ClientId: clientId, + ClientName: clientName, + }) + if err != nil { + log.Warn(ctx, "Jellyfin API: report playback start failed", "id", body.ItemId, err) + } + w.WriteHeader(http.StatusNoContent) +} + +// reportPlaybackProgress handles POST /Sessions/Playing/Progress, sent periodically +// (and on pause/resume/seek) while a client keeps playing an item. +func (api *Router) reportPlaybackProgress(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + body := decodeReport(r) + state := scrobbler.StatePlaying + if body.IsPaused { + state = scrobbler.StatePaused + } + clientId, clientName := clientIdentity(ctx) + err := api.scrobbler.ReportPlayback(ctx, scrobbler.ReportPlaybackParams{ + MediaId: body.ItemId, + PositionMs: body.PositionTicks / 10_000, + State: state, + PlaybackRate: 1.0, + ClientId: clientId, + ClientName: clientName, + }) + if err != nil { + log.Warn(ctx, "Jellyfin API: report playback progress failed", "id", body.ItemId, err) + } + w.WriteHeader(http.StatusNoContent) +} + +// reportPlaybackStopped handles POST /Sessions/Playing/Stopped, sent once when +// playback of an item ends. +func (api *Router) reportPlaybackStopped(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + body := decodeReport(r) + clientId, clientName := clientIdentity(ctx) + + // IgnoreScrobble: Submit below is the sole source of the play-count increment + // and external scrobble for this stop event. Without it, ReportPlayback's own + // threshold-based scrobble logic would double-count the same play. + err := api.scrobbler.ReportPlayback(ctx, scrobbler.ReportPlaybackParams{ + MediaId: body.ItemId, + PositionMs: body.PositionTicks / 10_000, + State: scrobbler.StateStopped, + IgnoreScrobble: true, + ClientId: clientId, + ClientName: clientName, + }) + if err != nil { + log.Warn(ctx, "Jellyfin API: report playback stopped failed", "id", body.ItemId, err) + } + + err = api.scrobbler.Submit(ctx, []scrobbler.Submission{{TrackID: body.ItemId, Timestamp: time.Now()}}) + if err != nil { + log.Warn(ctx, "Jellyfin API: scrobble failed", "id", body.ItemId, err) + } + w.WriteHeader(http.StatusNoContent) +} + +// postCapabilities acknowledges Jellyfin session-capability negotiation. +// Navidrome doesn't track per-session client capabilities, so this is a no-op. +func (api *Router) postCapabilities(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNoContent) +} diff --git a/server/jellyfin/sessions_test.go b/server/jellyfin/sessions_test.go new file mode 100644 index 000000000..b21b59750 --- /dev/null +++ b/server/jellyfin/sessions_test.go @@ -0,0 +1,202 @@ +package jellyfin + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "strings" + "time" + + "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/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 +} + +func (f *fakePlayers) Register(_ context.Context, id, client, _, _ string) (*model.Player, *model.Transcoding, error) { + if f.err != nil { + return nil, nil, f.err + } + return &model.Player{ID: id, Client: client}, nil, 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: "u1", UserName: "alice"}) + ctx = request.WithPlayer(ctx, model.Player{ID: "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":"s1","PositionTicks":10000000}`))) + + api.reportPlaybackStart(w, r) + + Expect(w.Code).To(Equal(http.StatusNoContent)) + Expect(pt.reported).To(HaveLen(1)) + Expect(pt.reported[0].MediaId).To(Equal("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("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=s2", nil)) + + api.reportPlaybackStart(w, r) + + Expect(w.Code).To(Equal(http.StatusNoContent)) + Expect(pt.reported).To(HaveLen(1)) + Expect(pt.reported[0].MediaId).To(Equal("s2")) + }) + }) + + 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":"s1","PositionTicks":20000000,"IsPaused":false}`))) + + 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":"s1","PositionTicks":20000000,"IsPaused":true}`))) + + 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 submits a scrobble", func() { + before := time.Now().Add(-time.Second) + w := httptest.NewRecorder() + r := authed(httptest.NewRequest("POST", "/Sessions/Playing/Stopped", strings.NewReader(`{"ItemId":"s1","PositionTicks":600000000}`))) + + api.reportPlaybackStopped(w, r) + + Expect(w.Code).To(Equal(http.StatusNoContent)) + + Expect(pt.reported).To(HaveLen(1)) + Expect(pt.reported[0].MediaId).To(Equal("s1")) + Expect(pt.reported[0].State).To(Equal(scrobbler.StateStopped)) + Expect(pt.reported[0].PositionMs).To(Equal(int64(60000))) + // IgnoreScrobble must be set: Submit below is the sole play-count/scrobble trigger, + // otherwise ReportPlayback's own threshold logic would double-count the play. + Expect(pt.reported[0].IgnoreScrobble).To(BeTrue()) + + Expect(pt.submitted).To(HaveLen(1)) + Expect(pt.submitted[0].TrackID).To(Equal("s1")) + Expect(pt.submitted[0].Timestamp).To(BeTemporally(">", before)) + }) + }) + + Describe("postCapabilities", 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.postCapabilities(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()) + }) +})