🏡 index : quine-global/quintodrome.git

package jellyfin

import (
	"context"
	"encoding/json"
	"net/http"

	"github.com/navidrome/navidrome/core/scrobbler"
	"github.com/navidrome/navidrome/log"
	"github.com/navidrome/navidrome/model/request"
	"github.com/navidrome/navidrome/server/jellyfin/dto"
)

// 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 (some clients send
// it there) and is decoded here since it flows straight into scrobbler lookups by media file id.
// These endpoints always answer 204 (see reportPlaybackStart), so a malformed id isn't a request
// failure; it just can't identify a track, same as an absent one.
func (api *Router) decodeReport(r *http.Request) playbackReport {
	var body playbackReport
	_ = json.NewDecoder(r.Body).Decode(&body)
	if body.ItemId == "" {
		body.ItemId = r.URL.Query().Get("itemid")
	}
	if id, ok := dto.DecodeID(body.ItemId); ok {
		body.ItemId = id
	} else {
		body.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 an item.
//
// These Sessions endpoints report only the caller's own playback and never expose content, so unlike
// browse/stream they are intentionally not library-access-gated.
func (api *Router) reportPlaybackStart(w http.ResponseWriter, r *http.Request) {
	ctx := r.Context()
	body := api.decodeReport(r)
	clientId, clientName := clientIdentity(ctx)
	err := api.scrobbler.ReportPlayback(ctx, scrobbler.ReportPlaybackParams{
		MediaId:      body.ItemId,
		PositionMs:   dto.MillisFromTicks(body.PositionTicks),
		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 := api.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:   dto.MillisFromTicks(body.PositionTicks),
		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 ends.
//
// Jellyfin clients (Finamp) send a Stopped report on *every* stop, even an immediate track switch,
// so the play threshold is applied server-side: ReportPlayback's StateStopped logic counts the play
// only past 50% (capped at 4 minutes). Force-submitting here would mark a one-second skip as played.
func (api *Router) reportPlaybackStopped(w http.ResponseWriter, r *http.Request) {
	ctx := r.Context()
	body := api.decodeReport(r)
	clientId, clientName := clientIdentity(ctx)

	err := api.scrobbler.ReportPlayback(ctx, scrobbler.ReportPlaybackParams{
		MediaId:    body.ItemId,
		PositionMs: dto.MillisFromTicks(body.PositionTicks),
		State:      scrobbler.StateStopped,
		ClientId:   clientId,
		ClientName: clientName,
	})
	if err != nil {
		log.Warn(ctx, "Jellyfin API: report playback stopped failed", "id", body.ItemId, err)
	}
	w.WriteHeader(http.StatusNoContent)
}

// acknowledge answers requests Navidrome keeps no state for: session capabilities (not tracked per
// session) and playback pings (transcodes live only as long as their stream request).
func (api *Router) acknowledge(w http.ResponseWriter, _ *http.Request) {
	w.WriteHeader(http.StatusNoContent)
}