navidrome/server/jellyfin/api.go
Deluan ca38e50c4f 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.
2026-07-05 17:05:43 -04:00

108 lines
3.8 KiB
Go

package jellyfin
import (
"encoding/json"
"net/http"
"sync"
"github.com/go-chi/chi/v5"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/core/artwork"
"github.com/navidrome/navidrome/core/playlists"
"github.com/navidrome/navidrome/core/scrobbler"
"github.com/navidrome/navidrome/core/stream"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
)
type Router struct {
http.Handler
ds model.DataStore
artwork artwork.Artwork
streamer stream.MediaStreamer
transcodeDecider stream.TranscodeDecider
players core.Players
scrobbler scrobbler.PlayTracker
playlists playlists.Playlists
serverIDOnce sync.Once
serverIDVal string
}
func New(ds model.DataStore, artwork artwork.Artwork, streamer stream.MediaStreamer,
transcodeDecider stream.TranscodeDecider, players core.Players,
scrobbler scrobbler.PlayTracker, playlists playlists.Playlists) *Router {
r := &Router{
ds: ds, artwork: artwork, streamer: streamer, transcodeDecider: transcodeDecider,
players: players, scrobbler: scrobbler, playlists: playlists,
}
r.Handler = r.routes()
return r
}
func (api *Router) routes() http.Handler {
r := chi.NewRouter()
// Public (no auth): handshake + login.
r.Get("/System/Info/Public", api.getPublicSystemInfo)
r.Get("/System/Ping", api.ping)
r.Post("/System/Ping", api.ping)
r.Get("/QuickConnect/Enabled", api.quickConnectEnabled)
r.Post("/Users/AuthenticateByName", api.authenticateByName)
r.Get("/Users/Public", api.getPublicUsers)
// Images are intentionally public and not library-scoped: artwork isn't sensitive media
// content, and clients (e.g. Finamp) load it via <img> tags with only ?api_key= in the URL.
r.Get("/Items/{itemId}/Images/{type}", api.getItemImage)
r.Get("/Items/{itemId}/Images/{type}/{index}", api.getItemImage)
r.Group(func(r chi.Router) {
r.Use(api.authenticate)
r.Get("/UserViews", api.getUserViews)
r.Get("/Users/{userId}/Views", api.getUserViews)
r.Get("/Users/Me", api.getCurrentUser)
r.Get("/Users/{userId}", api.getCurrentUser)
r.Get("/Items", api.getItems)
r.Get("/Users/{userId}/Items", api.getItems)
r.Get("/Items/{itemId}", api.getItem)
r.Get("/Users/{userId}/Items/{itemId}", api.getItem)
r.Get("/Users/{userId}/Items/Latest", api.getLatest)
r.Post("/Users/{userId}/FavoriteItems/{itemId}", api.markFavorite)
r.Delete("/Users/{userId}/FavoriteItems/{itemId}", api.unmarkFavorite)
r.Post("/Users/{userId}/Items/{itemId}/Rating", api.setRating)
r.Delete("/Users/{userId}/Items/{itemId}/Rating", api.removeRating)
r.Get("/Artists", api.getArtists)
r.Get("/Artists/AlbumArtists", api.getArtists)
r.Get("/Genres", api.getGenres)
r.Get("/MusicGenres", api.getGenres)
r.Get("/Audio/{itemId}/stream", api.streamAudio)
r.Get("/Audio/{itemId}/stream.{container}", api.streamAudio)
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
}
// ok writes payload as JSON.
func (api *Router) ok(w http.ResponseWriter, r *http.Request, payload any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
if err := json.NewEncoder(w).Encode(payload); err != nil {
log.Error(r.Context(), "Jellyfin API: error encoding response", err)
}
}