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.
This commit is contained in:
Deluan 2026-07-05 17:05:43 -04:00
commit ca38e50c4f
4 changed files with 358 additions and 0 deletions

View file

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

View file

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

121
server/jellyfin/sessions.go Normal file
View file

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

View file

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