Add reliable Jellycompat watch scrobbling

- Forward start, pause, resume, and stop events with stable media identities
- Persist and retry terminal scrobbles across teardown and restart paths
- Reject ambiguous playback-report route matches
This commit is contained in:
Quick104
2026-07-22 21:41:05 -04:00
parent a0507c78eb
commit 166c5ef32f
17 changed files with 3776 additions and 152 deletions
+4
View File
@@ -2451,6 +2451,7 @@ func main() {
compatDeps.SeasonRepo = seasonRepo
compatDeps.EpisodeRepo = episodeRepo
compatDeps.ProviderIDRepo = providerIDRepo
compatDeps.StableIdentityResolver = watchstate.NewStableIdentityResolver(itemRepo, episodeRepo, providerIDRepo)
compatDeps.DetailSvc = detailSvc
compatDeps.FolderRepo = folderRepo
compatDeps.SessionMgr = sessionMgr
@@ -2458,6 +2459,9 @@ func main() {
compatDeps.WatchCompletionObserver = deps.WatchCompletionObserver
compatDeps.SettingsRepo = settingsRepo
compatDeps.PersonRepo = personRepo
if watchProviderService != nil {
compatDeps.WatchScrobbler = watchProviderService
}
compatSearchService := catalog.NewCatalogSearchService(
appCtx,
settingsRepo,
+1 -29
View File
@@ -1348,28 +1348,7 @@ func (h *PlaybackHandler) scrobbleEventForSession(ctx context.Context, session *
DurationSeconds: duration,
OccurredAt: time.Now().UTC(),
}
if h.StableIdentityResolver == nil {
event.Kind = "movie"
return event
}
identity := h.StableIdentityResolver.ResolveHistoryIdentity(ctx, mediaItemID)
event.Kind = identity.StableType
if event.Kind == "" {
event.Kind = "movie"
}
event.SeasonNumber = intPtrValue(identity.Season)
event.EpisodeNumber = intPtrValue(identity.Episode)
if identity.ProviderIDs != nil {
event.IMDbID = identity.ProviderIDs["imdb"]
event.TMDBID = identity.ProviderIDs["tmdb"]
event.TVDBID = identity.ProviderIDs["tvdb"]
}
if identity.SeriesProviderIDs != nil {
event.SeriesIMDbID = identity.SeriesProviderIDs["imdb"]
event.SeriesTMDBID = identity.SeriesProviderIDs["tmdb"]
event.SeriesTVDBID = identity.SeriesProviderIDs["tvdb"]
}
return event
return watchsync.ResolveScrobbleIdentity(ctx, h.StableIdentityResolver, event)
}
func (h *PlaybackHandler) scrobbleEventForStoppedSession(
@@ -1406,13 +1385,6 @@ func (h *PlaybackHandler) scrobbleEventForStoppedSession(
return event, true
}
func intPtrValue(value *int) int {
if value == nil {
return 0
}
return *value
}
func (h *PlaybackHandler) buildAdminHistoryEntry(
ctx context.Context,
session *playback.Session,
+5 -1
View File
@@ -66,9 +66,12 @@ func (m *testCompatSessionManager) UpdateProgress(sessionID string, position flo
isPaused: isPaused,
})
if m.sessions != nil {
if _, ok := m.sessions[sessionID]; !ok {
session, ok := m.sessions[sessionID]
if !ok {
return playback.ErrSessionNotFound
}
session.Position = position
session.IsPaused = isPaused
}
return nil
}
@@ -111,6 +114,7 @@ func (m *testCompatSessionManager) UpdateAudioTrack(sessionID string, audioTrack
func (m *testCompatSessionManager) StopSession(sessionID string) error {
m.stopCalls = append(m.stopCalls, sessionID)
delete(m.sessions, sessionID)
return nil
}
+25 -6
View File
@@ -29,6 +29,7 @@ import (
"github.com/Silo-Server/silo-server/internal/subtitles"
"github.com/Silo-Server/silo-server/internal/transcodenode"
"github.com/Silo-Server/silo-server/internal/userstore"
"github.com/Silo-Server/silo-server/internal/watchsync"
)
type playbackInfoRequest struct {
@@ -151,6 +152,14 @@ type PlaybackSessionSyncer interface {
SyncNow(ctx context.Context) error
}
// PlaybackWatchScrobbler forwards a playback lifecycle to connected watch
// providers. The watchsync service implements this interface.
type PlaybackWatchScrobbler interface {
ScrobbleStart(ctx context.Context, event watchsync.ScrobbleEvent) error
ScrobblePause(ctx context.Context, event watchsync.ScrobbleEvent) error
ScrobbleStop(ctx context.Context, event watchsync.ScrobbleEvent) error
}
// PlaybackHandler serves Jellyfin playback negotiation endpoints.
type PlaybackHandler struct {
cfg *config.Config
@@ -173,12 +182,15 @@ type PlaybackHandler struct {
// and node-affinity rule for free. The reconstruction recipe is carried in the
// compat playback store (PlaybackSession.Recipe), since Jellyfin clients cannot
// round-trip a native stream token.
tm *playback.TranscodeManager
SubtitleRepo subtitles.Repository // optional; enables downloaded subtitles
S3Client subtitles.S3Client // optional; for serving S3 subtitles
S3Bucket string // bucket for subtitle storage
SettingsRepo SettingsReader // optional; reads watched threshold setting
SessionSyncer PlaybackSessionSyncer // optional; enables immediate session sync to shared admin view
tm *playback.TranscodeManager
SubtitleRepo subtitles.Repository // optional; enables downloaded subtitles
S3Client subtitles.S3Client // optional; for serving S3 subtitles
S3Bucket string // bucket for subtitle storage
SettingsRepo SettingsReader // optional; reads watched threshold setting
SessionSyncer PlaybackSessionSyncer // optional; enables immediate session sync to shared admin view
WatchScrobbler PlaybackWatchScrobbler
StableIdentityResolver watchsync.ScrobbleIdentityResolver
terminalFallbackDelay time.Duration
// RecipeNodeStore hands a remote transcode's reconstruction recipe to the
// control-plane recipe store (Redis) so a dedicated transcode node that
// restarts can rebuild ffmpeg from it. The node-hop token is server-minted and
@@ -292,8 +304,10 @@ func NewPlaybackHandler(
// ffmpeg crash: drop the dead transcode and stop the upstream native
// session. The recipe stays in the compat store so a resume reconstructs.
nodeURL := ""
var upstreamSession *playback.Session
if h.sessionMgr != nil {
if up, err := h.sessionMgr.GetSession(sessionID); err == nil && up != nil {
upstreamSession = up
nodeURL = up.TranscodeNodeURL
}
}
@@ -305,6 +319,11 @@ func NewPlaybackHandler(
// the dead transcode. The recipe stays in the compat store either way so a
// resume reconstructs.
if h.sessionMgr != nil && h.tm.CloseTranscodeSessionIf(sessionID, dead, nodeURL) {
if h.playbackStore != nil {
if playSession, ok := h.playbackStore.FindByUpstreamSessionID(sessionID); ok {
h.dispatchCompatScrobble(ctx, compatScrobblePause, playSession, upstreamSession, nil)
}
}
_ = h.sessionMgr.StopSession(sessionID)
}
}
@@ -38,14 +38,15 @@ func newReportLivenessHandler(upstreamID string, registerUpstream bool) (*Playba
playbackStore := NewPlaybackSessionStore(time.Hour, nil)
playbackStore.Put(PlaybackSession{
ID: "play-1",
CompatToken: "token-1",
ItemID: "movie-1",
RouteItemID: encodedItemID,
UserID: "user-1",
UpstreamSessionID: upstreamID,
UpstreamPlayMethod: "direct",
MediaSources: []PlaybackMediaSource{source},
ID: "play-1",
CompatToken: "token-1",
ItemID: "movie-1",
RouteItemID: encodedItemID,
UserID: "user-1",
UpstreamSessionID: upstreamID,
UpstreamPlayMethod: "direct",
ProgressPersistenceKnown: true,
MediaSources: []PlaybackMediaSource{source},
})
sessions := map[string]*playback.Session{}
@@ -336,11 +337,10 @@ func TestHandlePlaybackReport_StopViaAliasTearsDown(t *testing.T) {
}
}
// TestHandlePlaybackReport_StopViaRouteMatchDoesNotTearDown proves a Stopped
// report that only matched by item/source route (an ambiguous match when the
// same item plays twice under one token) must not tear down the session it
// happened to hit; stale cleanup owns that session's end of life.
func TestHandlePlaybackReport_StopViaRouteMatchDoesNotTearDown(t *testing.T) {
// TestHandlePlaybackReport_StopViaUniqueRouteTearsDown proves a client that
// replaces its PlaySessionId can still finalize the single caller-owned play
// identified by its item/source route.
func TestHandlePlaybackReport_StopViaUniqueRouteTearsDown(t *testing.T) {
handler, mgr, encodedItemID, sourceID := newReportLivenessHandler("upstream-1", true)
req := httptest.NewRequest(http.MethodPost, "/Sessions/Playing/Stopped", strings.NewReader(
@@ -353,18 +353,89 @@ func TestHandlePlaybackReport_StopViaRouteMatchDoesNotTearDown(t *testing.T) {
if rec.Code != http.StatusNoContent {
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
}
if len(mgr.stopCalls) != 0 {
t.Fatalf("StopSession calls = %v, want none for an ambiguous route-only match", mgr.stopCalls)
if len(mgr.stopCalls) != 1 || mgr.stopCalls[0] != "upstream-1" {
t.Fatalf("StopSession calls = %v, want upstream-1", mgr.stopCalls)
}
if _, ok := handler.playbackStore.Get("play-1"); !ok {
t.Fatal("expected route-only Stopped report to leave the play session in place")
if _, ok := handler.playbackStore.Get("play-1"); ok {
t.Fatal("unique route-only Stopped report left the play session in place")
}
// The final position still lands on the upstream session.
if len(mgr.progressUpdates) != 1 || mgr.progressUpdates[0].sessionID != "upstream-1" {
t.Fatalf("progress updates = %+v, want one update on upstream-1", mgr.progressUpdates)
}
}
// TestHandlePlaybackReport_StopViaAmbiguousRouteDoesNotTearDown proves route
// fallback refuses to choose when the same item/source is playing twice under
// one token.
func TestHandlePlaybackReport_StopViaAmbiguousRouteDoesNotTearDown(t *testing.T) {
handler, mgr, encodedItemID, sourceID := newReportLivenessHandler("upstream-1", true)
original, ok := handler.playbackStore.Get("play-1")
if !ok {
t.Fatal("original play session missing")
}
sibling := *original
sibling.ID = "play-2"
sibling.UpstreamSessionID = "upstream-2"
handler.playbackStore.Put(sibling)
mgr.sessions["upstream-2"] = &playback.Session{ID: "upstream-2", PlayMethod: playback.PlayDirect}
req := httptest.NewRequest(http.MethodPost, "/Sessions/Playing/Stopped", strings.NewReader(
`{"PlaySessionId":"never-seen-psid","ItemId":"`+encodedItemID+`","MediaSourceId":"`+sourceID+`","PositionTicks":9000000000}`))
req = req.WithContext(context.WithValue(req.Context(), compatSessionKey,
&Session{Token: "token-1", StreamAppUserID: 1, ProfileID: "profile-1"}))
rec := httptest.NewRecorder()
handler.HandleSessionPlayingStopped(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
}
if len(mgr.stopCalls) != 0 || len(mgr.progressUpdates) != 0 {
t.Fatalf("ambiguous route mutated playback: stops=%v progress=%+v", mgr.stopCalls, mgr.progressUpdates)
}
if _, ok := handler.playbackStore.Get("play-1"); !ok {
t.Fatal("ambiguous route removed play-1")
}
if _, ok := handler.playbackStore.Get("play-2"); !ok {
t.Fatal("ambiguous route removed play-2")
}
}
func TestHandlePlaybackReport_StopRejectsMixedRouteIdentifiers(t *testing.T) {
handler, mgr, encodedItemID, _ := newReportLivenessHandler("upstream-1", true)
original, ok := handler.playbackStore.Get("play-1")
if !ok {
t.Fatal("original play session missing")
}
sibling := *original
sibling.ID = "play-2"
sibling.ItemID = "movie-2"
sibling.RouteItemID = handler.codec.EncodeStringID(EncodedIDItem, "movie-2")
sibling.UpstreamSessionID = "upstream-2"
sibling.MediaSources = []PlaybackMediaSource{{ID: "source-2", FileID: 43}}
handler.playbackStore.Put(sibling)
mgr.sessions["upstream-2"] = &playback.Session{ID: "upstream-2", PlayMethod: playback.PlayDirect}
req := httptest.NewRequest(http.MethodPost, "/Sessions/Playing/Stopped", strings.NewReader(
`{"PlaySessionId":"stale-play-id","ItemId":"`+encodedItemID+`","MediaSourceId":"source-2","PositionTicks":9000000000}`))
req = req.WithContext(context.WithValue(req.Context(), compatSessionKey,
&Session{Token: "token-1", StreamAppUserID: 1, ProfileID: "profile-1"}))
rec := httptest.NewRecorder()
handler.HandleSessionPlayingStopped(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
}
if len(mgr.stopCalls) != 0 || len(mgr.progressUpdates) != 0 {
t.Fatalf("mixed route identifiers mutated playback: stops=%v progress=%+v", mgr.stopCalls, mgr.progressUpdates)
}
if _, ok := handler.playbackStore.Get("play-1"); !ok {
t.Fatal("mixed route identifiers removed play-1")
}
if _, ok := handler.playbackStore.Get("play-2"); !ok {
t.Fatal("mixed route identifiers removed play-2")
}
}
// TestEnsureUpstreamPlayback_ReviveClosesStaleTranscode proves that when a
// reaped upstream session is recreated under the same play method, any
// transcode still keyed to the stale upstream id is closed first — otherwise
+171
View File
@@ -0,0 +1,171 @@
package jellycompat
import (
"context"
"log/slog"
"time"
"github.com/Silo-Server/silo-server/internal/playback"
"github.com/Silo-Server/silo-server/internal/userstore"
"github.com/Silo-Server/silo-server/internal/watchsync"
)
type compatScrobbleAction string
const (
compatScrobbleStart compatScrobbleAction = "start"
compatScrobblePause compatScrobbleAction = "pause"
compatScrobbleStop compatScrobbleAction = "stop"
)
func (h *PlaybackHandler) dispatchCompatScrobble(
ctx context.Context,
action compatScrobbleAction,
playSession *PlaybackSession,
upstreamSession *playback.Session,
preferredSource *PlaybackMediaSource,
) error {
return h.dispatchCompatScrobbleAt(ctx, action, playSession, upstreamSession, preferredSource, nil)
}
func (h *PlaybackHandler) dispatchCompatScrobbleAt(
ctx context.Context,
action compatScrobbleAction,
playSession *PlaybackSession,
upstreamSession *playback.Session,
preferredSource *PlaybackMediaSource,
positionOverride *float64,
) error {
event, ok := h.compatScrobbleEvent(
ctx, action, playSession, upstreamSession, preferredSource, positionOverride,
)
if !ok {
return nil
}
return h.dispatchCompatScrobbleEvent(ctx, action, event)
}
func (h *PlaybackHandler) compatScrobbleEvent(
ctx context.Context,
action compatScrobbleAction,
playSession *PlaybackSession,
upstreamSession *playback.Session,
preferredSource *PlaybackMediaSource,
positionOverride *float64,
) (watchsync.ScrobbleEvent, bool) {
if h == nil || h.WatchScrobbler == nil || playSession == nil || upstreamSession == nil ||
upstreamSession.DisableProgressPersistence || playSession.ItemID == "" {
return watchsync.ScrobbleEvent{}, false
}
scrobbleCtx, cancel := compatDetachedContext(ctx)
defer cancel()
source := compatScrobbleSource(playSession, upstreamSession, preferredSource)
duration := 0.0
if source != nil {
duration = float64(source.Version.Duration)
}
position := upstreamSession.Position
if positionOverride != nil {
position = *positionOverride
} else if position <= 0 && playSession.InitialSeekSeconds > 0 {
position = playSession.InitialSeekSeconds
}
completed := false
if action == compatScrobbleStop && duration > 0 {
_, completed, _ = userstore.ResolveProgressState(position, duration, h.playbackThresholds(scrobbleCtx))
}
event := watchsync.ResolveScrobbleIdentity(scrobbleCtx, h.StableIdentityResolver, watchsync.ScrobbleEvent{
PlaybackSessionID: upstreamSession.ID,
UserID: upstreamSession.UserID,
ProfileID: upstreamSession.ProfileID,
MediaItemID: playSession.ItemID,
PositionSeconds: position,
DurationSeconds: duration,
Completed: completed,
OccurredAt: time.Now().UTC(),
})
return event, true
}
func (h *PlaybackHandler) dispatchCompatScrobbleEvent(
ctx context.Context,
action compatScrobbleAction,
event watchsync.ScrobbleEvent,
) error {
if h == nil || h.WatchScrobbler == nil {
return nil
}
scrobbleCtx, cancel := compatDetachedContext(ctx)
defer cancel()
var err error
switch action {
case compatScrobblePause:
err = h.WatchScrobbler.ScrobblePause(scrobbleCtx, event)
case compatScrobbleStop:
err = h.WatchScrobbler.ScrobbleStop(scrobbleCtx, event)
default:
err = h.WatchScrobbler.ScrobbleStart(scrobbleCtx, event)
}
if err != nil {
slog.WarnContext(scrobbleCtx, "failed to queue jellycompat watch provider scrobble",
"component", "jellycompat",
"action", action,
"playback_session_id", event.PlaybackSessionID,
"error", err,
)
}
return err
}
func compatScrobbleSource(
playSession *PlaybackSession,
upstreamSession *playback.Session,
preferredSource *PlaybackMediaSource,
) *PlaybackMediaSource {
if preferredSource != nil {
return preferredSource
}
if playSession == nil {
return nil
}
if upstreamSession != nil {
for _, source := range playSession.MediaSources {
if source.FileID == upstreamSession.MediaFileID {
copy := source
return &copy
}
}
}
return firstMediaSource(playSession)
}
// compatScrobbleFallbackSession preserves enough authenticated report state to
// emit a terminal event after the in-memory upstream session has already been
// reaped. The compat play session remains the source of media identity.
func compatScrobbleFallbackSession(
compatSession *Session,
playSession *PlaybackSession,
preferredSource *PlaybackMediaSource,
position float64,
positionKnown bool,
isPaused bool,
) *playback.Session {
if compatSession == nil || playSession == nil || playSession.UpstreamSessionID == "" || !positionKnown {
return nil
}
source := compatScrobbleSource(playSession, nil, preferredSource)
fileID := 0
if source != nil {
fileID = source.FileID
}
return &playback.Session{
ID: playSession.UpstreamSessionID,
UserID: compatSession.StreamAppUserID,
ProfileID: compatSession.ProfileID,
MediaFileID: fileID,
Position: position,
IsPaused: isPaused,
DisableProgressPersistence: !playSession.ProgressPersistenceKnown || playSession.DisableProgressPersistence,
}
}
@@ -0,0 +1,793 @@
package jellycompat
import (
"context"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/Silo-Server/silo-server/internal/playback"
"github.com/Silo-Server/silo-server/internal/watchsync"
)
type compatScrobbleCall struct {
action string
event watchsync.ScrobbleEvent
}
type recordingCompatWatchScrobbler struct {
calls []compatScrobbleCall
}
type channelCompatWatchScrobbler struct {
stopEvents chan watchsync.ScrobbleEvent
failStops int
}
type failingCompatWatchScrobbler struct {
stopCalls atomic.Int32
}
type poisonBatchCompatWatchScrobbler struct {
deliverableSessionID string
stopCalls atomic.Int32
}
func (*failingCompatWatchScrobbler) ScrobbleStart(context.Context, watchsync.ScrobbleEvent) error {
return nil
}
func (*failingCompatWatchScrobbler) ScrobblePause(context.Context, watchsync.ScrobbleEvent) error {
return nil
}
func (s *failingCompatWatchScrobbler) ScrobbleStop(context.Context, watchsync.ScrobbleEvent) error {
s.stopCalls.Add(1)
return errors.New("queue unavailable")
}
func (*poisonBatchCompatWatchScrobbler) ScrobbleStart(context.Context, watchsync.ScrobbleEvent) error {
return nil
}
func (*poisonBatchCompatWatchScrobbler) ScrobblePause(context.Context, watchsync.ScrobbleEvent) error {
return nil
}
func (s *poisonBatchCompatWatchScrobbler) ScrobbleStop(_ context.Context, event watchsync.ScrobbleEvent) error {
s.stopCalls.Add(1)
if event.PlaybackSessionID != s.deliverableSessionID {
return errors.New("poison terminal event")
}
return nil
}
type flakyTerminalPlaybackStore struct {
*PlaybackSessionStore
mu sync.Mutex
failStages int
stageCalls int
}
func (s *flakyTerminalPlaybackStore) StageTerminal(
id string,
compatToken string,
event watchsync.ScrobbleEvent,
authoritative bool,
) (*PlaybackSession, error) {
s.mu.Lock()
defer s.mu.Unlock()
s.stageCalls++
if s.failStages > 0 {
s.failStages--
return nil, errors.New("terminal store unavailable")
}
return s.PlaybackSessionStore.StageTerminal(id, compatToken, event, authoritative)
}
func (s *flakyTerminalPlaybackStore) calls() int {
s.mu.Lock()
defer s.mu.Unlock()
return s.stageCalls
}
func (s *channelCompatWatchScrobbler) ScrobbleStart(context.Context, watchsync.ScrobbleEvent) error {
return nil
}
func (s *channelCompatWatchScrobbler) ScrobblePause(context.Context, watchsync.ScrobbleEvent) error {
return nil
}
func (s *channelCompatWatchScrobbler) ScrobbleStop(_ context.Context, event watchsync.ScrobbleEvent) error {
if s.failStops > 0 {
s.failStops--
return errors.New("queue unavailable")
}
s.stopEvents <- event
return nil
}
func (s *recordingCompatWatchScrobbler) ScrobbleStart(_ context.Context, event watchsync.ScrobbleEvent) error {
s.calls = append(s.calls, compatScrobbleCall{action: "start", event: event})
return nil
}
func (s *recordingCompatWatchScrobbler) ScrobblePause(_ context.Context, event watchsync.ScrobbleEvent) error {
s.calls = append(s.calls, compatScrobbleCall{action: "pause", event: event})
return nil
}
func (s *recordingCompatWatchScrobbler) ScrobbleStop(_ context.Context, event watchsync.ScrobbleEvent) error {
s.calls = append(s.calls, compatScrobbleCall{action: "stop", event: event})
return nil
}
func TestEnsureUpstreamPlaybackStartsWatchProviderScrobble(t *testing.T) {
mgr := &testCompatSessionManager{}
h, store := newActiveEncodingsHandler(mgr)
scrobbler := &recordingCompatWatchScrobbler{}
h.WatchScrobbler = scrobbler
source := PlaybackMediaSource{ID: "source-1", FileID: 42, Version: testCompatVersion()}
store.Put(PlaybackSession{
ID: "play-1",
CompatToken: "token-1",
ItemID: "movie-1",
InitialSeekSeconds: 125,
MediaSources: []PlaybackMediaSource{source},
})
compatSession := &Session{Token: "token-1", StreamAppUserID: 7, ProfileID: "profile-1"}
if _, err := h.ensureUpstreamPlayback(context.Background(), compatSession, "play-1", source, "direct"); err != nil {
t.Fatalf("ensureUpstreamPlayback: %v", err)
}
if len(scrobbler.calls) != 1 {
t.Fatalf("scrobble calls = %d, want 1", len(scrobbler.calls))
}
call := scrobbler.calls[0]
if call.action != "start" || call.event.PlaybackSessionID != "upstream-started" {
t.Fatalf("start call = %+v", call)
}
if call.event.UserID != 7 || call.event.ProfileID != "profile-1" || call.event.MediaItemID != "movie-1" {
t.Fatalf("start scope = %+v", call.event)
}
if call.event.PositionSeconds != 125 || call.event.DurationSeconds != 3600 {
t.Fatalf("start progress = %v/%v, want 125/3600", call.event.PositionSeconds, call.event.DurationSeconds)
}
if _, err := h.ensureUpstreamPlayback(context.Background(), compatSession, "play-1", source, "direct"); err != nil {
t.Fatalf("ensureUpstreamPlayback reuse: %v", err)
}
if len(scrobbler.calls) != 1 {
t.Fatalf("reuse emitted %d scrobbles, want the original start only", len(scrobbler.calls))
}
}
func TestHandlePlaybackReportScrobblesPauseAndResumeTransitions(t *testing.T) {
handler, mgr, _, sourceID := newReportLivenessHandler("upstream-1", true)
scrobbler := &recordingCompatWatchScrobbler{}
handler.WatchScrobbler = scrobbler
mgr.sessions["upstream-1"].UserID = 7
mgr.sessions["upstream-1"].ProfileID = "profile-1"
mgr.sessions["upstream-1"].MediaFileID = 42
post := func(paused bool, ticks int64) {
body := strings.NewReader(`{"PlaySessionId":"play-1","MediaSourceId":"` + sourceID +
`","PositionTicks":` + strconv.FormatInt(ticks, 10) + `,"IsPaused":` + strconv.FormatBool(paused) + `}`)
req := httptest.NewRequest(http.MethodPost, "/Sessions/Playing/Progress", body)
req = req.WithContext(context.WithValue(req.Context(), compatSessionKey,
&Session{Token: "token-1", StreamAppUserID: 7, ProfileID: "profile-1"}))
rec := httptest.NewRecorder()
handler.HandleSessionPlayingProgress(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
}
}
post(true, 600_000_000)
post(true, 700_000_000)
post(false, 800_000_000)
if len(scrobbler.calls) != 2 {
t.Fatalf("scrobble calls = %+v, want pause and resume only", scrobbler.calls)
}
if scrobbler.calls[0].action != "pause" || scrobbler.calls[0].event.PositionSeconds != 60 {
t.Fatalf("pause call = %+v", scrobbler.calls[0])
}
if scrobbler.calls[1].action != "start" || scrobbler.calls[1].event.PositionSeconds != 80 {
t.Fatalf("resume call = %+v", scrobbler.calls[1])
}
}
func TestHandlePlaybackReportPreservesExplicitZeroOnPause(t *testing.T) {
handler, mgr, _, sourceID := newReportLivenessHandler("upstream-1", true)
scrobbler := &recordingCompatWatchScrobbler{}
handler.WatchScrobbler = scrobbler
mgr.sessions["upstream-1"].UserID = 7
mgr.sessions["upstream-1"].ProfileID = "profile-1"
mgr.sessions["upstream-1"].MediaFileID = 42
if err := handler.playbackStore.Update("play-1", func(session *PlaybackSession) error {
session.InitialSeekSeconds = 125
return nil
}); err != nil {
t.Fatalf("set initial seek: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/Sessions/Playing/Progress", strings.NewReader(
`{"PlaySessionId":"play-1","MediaSourceId":"`+sourceID+`","PositionTicks":0,"IsPaused":true}`))
req = req.WithContext(context.WithValue(req.Context(), compatSessionKey,
&Session{Token: "token-1", StreamAppUserID: 7, ProfileID: "profile-1"}))
rec := httptest.NewRecorder()
handler.HandleSessionPlayingProgress(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
}
if len(scrobbler.calls) != 1 || scrobbler.calls[0].action != "pause" ||
scrobbler.calls[0].event.PositionSeconds != 0 {
t.Fatalf("explicit-zero pause scrobble = %+v", scrobbler.calls)
}
}
func TestCompatTeardownScrobblesAuthoritativeStopExactlyOnce(t *testing.T) {
tests := []struct {
name string
stoppedFirst bool
wantPositions []float64
}{
{name: "stopped report first", stoppedFirst: true, wantPositions: []float64{90}},
{name: "active encodings first", stoppedFirst: false, wantPositions: []float64{90}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mgr := &testCompatSessionManager{sessions: map[string]*playback.Session{
"upstream-1": {
ID: "upstream-1",
UserID: 7,
ProfileID: "profile-1",
MediaFileID: 42,
Position: 45,
},
}}
h, store := newActiveEncodingsHandler(mgr)
scrobbler := &recordingCompatWatchScrobbler{}
h.WatchScrobbler = scrobbler
source := PlaybackMediaSource{ID: "source-1", FileID: 42, Version: testCompatVersion()}
store.Put(PlaybackSession{
ID: "play-1",
CompatToken: "token-1",
ItemID: "movie-1",
UpstreamSessionID: "upstream-1",
UpstreamPlayMethod: "direct",
ProgressPersistenceKnown: true,
MediaSources: []PlaybackMediaSource{source},
})
stopped := func() {
req := httptest.NewRequest(http.MethodPost, "/Sessions/Playing/Stopped",
strings.NewReader(`{"PlaySessionId":"play-1","MediaSourceId":"source-1","PositionTicks":900000000}`))
req = req.WithContext(context.WithValue(req.Context(), compatSessionKey,
&Session{Token: "token-1", StreamAppUserID: 7, ProfileID: "profile-1"}))
rec := httptest.NewRecorder()
h.HandleSessionPlayingStopped(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("stopped status = %d", rec.Code)
}
}
activeEncodings := func() {
req := withCompatSession(httptest.NewRequest(http.MethodDelete,
"/Videos/ActiveEncodings?PlaySessionId=play-1", nil), "token-1")
rec := httptest.NewRecorder()
h.HandleDeleteActiveEncodings(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("active encodings status = %d", rec.Code)
}
}
if tt.stoppedFirst {
stopped()
activeEncodings()
} else {
activeEncodings()
stopped()
}
if len(scrobbler.calls) != len(tt.wantPositions) {
t.Fatalf("scrobble calls = %+v, want positions %v", scrobbler.calls, tt.wantPositions)
}
for i, wantPosition := range tt.wantPositions {
if scrobbler.calls[i].action != "stop" || scrobbler.calls[i].event.PositionSeconds != wantPosition {
t.Fatalf("stop call %d = %+v, want position %v", i, scrobbler.calls[i], wantPosition)
}
}
})
}
}
func TestActiveEncodingsOnNonOwnerDefersScrobbleToStoppedReport(t *testing.T) {
mgr := &testCompatSessionManager{sessions: map[string]*playback.Session{}}
h, store := newActiveEncodingsHandler(mgr)
scrobbler := &recordingCompatWatchScrobbler{}
h.WatchScrobbler = scrobbler
h.terminalFallbackDelay = 10 * time.Millisecond
source := PlaybackMediaSource{ID: "source-1", FileID: 42, Version: testCompatVersion()}
store.Put(PlaybackSession{
ID: "play-1",
CompatToken: "token-1",
ItemID: "movie-1",
UpstreamSessionID: "upstream-1",
ProgressPersistenceKnown: true,
MediaSources: []PlaybackMediaSource{source},
})
activeReq := withCompatSession(httptest.NewRequest(http.MethodDelete,
"/Videos/ActiveEncodings?PlaySessionId=play-1", nil), "token-1")
activeRec := httptest.NewRecorder()
h.HandleDeleteActiveEncodings(activeRec, activeReq)
if activeRec.Code != http.StatusNoContent {
t.Fatalf("active encodings status = %d", activeRec.Code)
}
time.Sleep(3 * h.terminalFallbackDelay)
if len(scrobbler.calls) != 0 {
t.Fatalf("non-owner cleanup emitted stale scrobble: %+v", scrobbler.calls)
}
if _, ok := store.Get("play-1"); ok {
t.Fatal("terminal session remained routable after encoder cleanup")
}
if _, ok := store.GetFinalizable("play-1", "token-1"); !ok {
t.Fatal("terminal session was not retained for the final report")
}
stoppedReq := httptest.NewRequest(http.MethodPost, "/Sessions/Playing/Stopped", strings.NewReader(
`{"PlaySessionId":"client-replaced-play-id","MediaSourceId":"source-1","PositionTicks":900000000}`))
stoppedReq = stoppedReq.WithContext(context.WithValue(stoppedReq.Context(), compatSessionKey,
&Session{Token: "token-1", StreamAppUserID: 7, ProfileID: "profile-1"}))
stoppedRec := httptest.NewRecorder()
h.HandleSessionPlayingStopped(stoppedRec, stoppedReq)
if stoppedRec.Code != http.StatusNoContent {
t.Fatalf("stopped status = %d, body = %s", stoppedRec.Code, stoppedRec.Body.String())
}
if len(scrobbler.calls) != 1 || scrobbler.calls[0].action != "stop" ||
scrobbler.calls[0].event.PositionSeconds != 90 {
t.Fatalf("authoritative stopped scrobble = %+v, want one stop at 90s", scrobbler.calls)
}
}
func TestActiveEncodingsFallbackAllowsLaterAuthoritativeStop(t *testing.T) {
mgr := &testCompatSessionManager{sessions: map[string]*playback.Session{
"upstream-1": {
ID: "upstream-1",
UserID: 7,
ProfileID: "profile-1",
MediaFileID: 42,
Position: 45,
},
}}
h, store := newActiveEncodingsHandler(mgr)
scrobbler := &channelCompatWatchScrobbler{stopEvents: make(chan watchsync.ScrobbleEvent, 2)}
h.WatchScrobbler = scrobbler
h.terminalFallbackDelay = 10 * time.Millisecond
store.Put(PlaybackSession{
ID: "play-1",
CompatToken: "token-1",
ItemID: "movie-1",
UpstreamSessionID: "upstream-1",
ProgressPersistenceKnown: true,
MediaSources: []PlaybackMediaSource{{
ID: "source-1", FileID: 42, Version: testCompatVersion(),
}},
})
req := withCompatSession(httptest.NewRequest(http.MethodDelete,
"/Videos/ActiveEncodings?PlaySessionId=play-1", nil), "token-1")
rec := httptest.NewRecorder()
h.HandleDeleteActiveEncodings(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("status = %d", rec.Code)
}
select {
case event := <-scrobbler.stopEvents:
if event.PositionSeconds != 45 || event.PlaybackSessionID != "upstream-1" {
t.Fatalf("fallback stop = %+v, want upstream-1 at 45s", event)
}
case <-time.After(time.Second):
t.Fatal("timed out waiting for ActiveEncodings terminal fallback")
}
terminal, ok := store.GetFinalizable("play-1", "token-1")
if !ok || !terminal.TerminalFallbackSent || terminal.TerminalAuthoritative {
t.Fatalf("fallback terminal state = ok=%v session=%+v", ok, terminal)
}
stoppedReq := httptest.NewRequest(http.MethodPost, "/Sessions/Playing/Stopped", strings.NewReader(
`{"PlaySessionId":"play-1","MediaSourceId":"source-1","PositionTicks":900000000}`))
stoppedReq = stoppedReq.WithContext(context.WithValue(stoppedReq.Context(), compatSessionKey,
&Session{Token: "token-1", StreamAppUserID: 7, ProfileID: "profile-1"}))
stoppedRec := httptest.NewRecorder()
h.HandleSessionPlayingStopped(stoppedRec, stoppedReq)
if stoppedRec.Code != http.StatusNoContent {
t.Fatalf("stopped status = %d", stoppedRec.Code)
}
select {
case event := <-scrobbler.stopEvents:
if event.PositionSeconds != 90 {
t.Fatalf("authoritative stop = %+v, want 90s", event)
}
case <-time.After(time.Second):
t.Fatal("timed out waiting for authoritative stop after fallback")
}
if _, ok := store.GetFinalizable("play-1", "token-1"); ok {
t.Fatal("authoritative terminal event remained after delivery")
}
}
func TestPositionlessLateStopPreservesAndDeliversPendingFallback(t *testing.T) {
mgr := &testCompatSessionManager{sessions: map[string]*playback.Session{
"upstream-1": {
ID: "upstream-1",
UserID: 7,
ProfileID: "profile-1",
MediaFileID: 42,
Position: 45,
},
}}
h, store := newActiveEncodingsHandler(mgr)
scrobbler := &channelCompatWatchScrobbler{stopEvents: make(chan watchsync.ScrobbleEvent, 1)}
h.WatchScrobbler = scrobbler
h.terminalFallbackDelay = time.Hour
store.Put(PlaybackSession{
ID: "play-1",
CompatToken: "token-1",
ItemID: "movie-1",
UpstreamSessionID: "upstream-1",
ProgressPersistenceKnown: true,
MediaSources: []PlaybackMediaSource{{
ID: "source-1", FileID: 42, Version: testCompatVersion(),
}},
})
activeReq := withCompatSession(httptest.NewRequest(
http.MethodDelete, "/Videos/ActiveEncodings?PlaySessionId=play-1", nil,
), "token-1")
h.HandleDeleteActiveEncodings(httptest.NewRecorder(), activeReq)
terminal, ok := store.GetFinalizable("play-1", "token-1")
if !ok || terminal.TerminalScrobbleEvent == nil || terminal.TerminalFallbackSent {
t.Fatalf("pending fallback = ok=%v session=%+v", ok, terminal)
}
stoppedReq := httptest.NewRequest(
http.MethodPost,
"/Sessions/Playing/Stopped",
strings.NewReader(`{"PlaySessionId":"play-1","MediaSourceId":"source-1"}`),
)
stoppedReq = stoppedReq.WithContext(context.WithValue(
stoppedReq.Context(),
compatSessionKey,
&Session{Token: "token-1", StreamAppUserID: 7, ProfileID: "profile-1"},
))
h.HandleSessionPlayingStopped(httptest.NewRecorder(), stoppedReq)
select {
case event := <-scrobbler.stopEvents:
if event.PositionSeconds != 45 {
t.Fatalf("preserved fallback = %+v, want 45s", event)
}
case <-time.After(time.Second):
t.Fatal("timed out waiting for preserved terminal fallback")
}
terminal, ok = store.GetFinalizable("play-1", "token-1")
if !ok || !terminal.TerminalFallbackSent || terminal.TerminalAuthoritative {
t.Fatalf("delivered fallback state = ok=%v session=%+v", ok, terminal)
}
}
func TestStoppedScrobbleQueueFailureRetainsAndRetriesTerminalEvent(t *testing.T) {
handler, mgr, _, sourceID := newReportLivenessHandler("upstream-1", true)
scrobbler := &channelCompatWatchScrobbler{
stopEvents: make(chan watchsync.ScrobbleEvent, 1),
failStops: 1,
}
handler.WatchScrobbler = scrobbler
mgr.sessions["upstream-1"].UserID = 7
mgr.sessions["upstream-1"].ProfileID = "profile-1"
mgr.sessions["upstream-1"].MediaFileID = 42
req := httptest.NewRequest(http.MethodPost, "/Sessions/Playing/Stopped", strings.NewReader(
`{"PlaySessionId":"play-1","MediaSourceId":"`+sourceID+`","PositionTicks":900000000}`))
req = req.WithContext(context.WithValue(req.Context(), compatSessionKey,
&Session{Token: "token-1", StreamAppUserID: 7, ProfileID: "profile-1"}))
rec := httptest.NewRecorder()
handler.HandleSessionPlayingStopped(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("status = %d", rec.Code)
}
if _, ok := handler.playbackStore.GetFinalizable("play-1", "token-1"); !ok {
t.Fatal("terminal event was deleted after queue failure")
}
select {
case event := <-scrobbler.stopEvents:
if event.PositionSeconds != 90 {
t.Fatalf("retried stop = %+v, want 90s", event)
}
case <-time.After(2 * time.Second):
t.Fatal("timed out waiting for terminal queue retry")
}
if _, ok := handler.playbackStore.GetFinalizable("play-1", "token-1"); ok {
t.Fatal("authoritative terminal event remained after successful retry")
}
}
func TestStoppedScrobbleRestagesAfterTerminalPersistenceFailure(t *testing.T) {
handler, mgr, _, sourceID := newReportLivenessHandler("upstream-1", true)
baseStore := handler.playbackStore.(*PlaybackSessionStore)
flakyStore := &flakyTerminalPlaybackStore{PlaybackSessionStore: baseStore, failStages: 1}
handler.playbackStore = flakyStore
scrobbler := &channelCompatWatchScrobbler{stopEvents: make(chan watchsync.ScrobbleEvent, 1)}
handler.WatchScrobbler = scrobbler
mgr.sessions["upstream-1"].UserID = 7
mgr.sessions["upstream-1"].ProfileID = "profile-1"
mgr.sessions["upstream-1"].MediaFileID = 42
req := httptest.NewRequest(http.MethodPost, "/Sessions/Playing/Stopped", strings.NewReader(
`{"PlaySessionId":"play-1","MediaSourceId":"`+sourceID+`","PositionTicks":900000000}`))
req = req.WithContext(context.WithValue(req.Context(), compatSessionKey,
&Session{Token: "token-1", StreamAppUserID: 7, ProfileID: "profile-1"}))
rec := httptest.NewRecorder()
handler.HandleSessionPlayingStopped(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("status = %d", rec.Code)
}
if calls := flakyStore.calls(); calls != 1 {
t.Fatalf("synchronous stage calls = %d, want 1", calls)
}
if _, ok := flakyStore.Get("play-1"); ok {
t.Fatal("failed durable terminal stage left the stopped session routable")
}
if len(mgr.stopCalls) != 1 || mgr.stopCalls[0] != "upstream-1" {
t.Fatalf("cleanup after failed stage = %v, want upstream-1 stopped immediately", mgr.stopCalls)
}
select {
case event := <-scrobbler.stopEvents:
if event.PositionSeconds != 90 {
t.Fatalf("restaged stop = %+v, want 90s", event)
}
case <-time.After(2 * time.Second):
t.Fatal("timed out waiting for terminal restage retry")
}
if calls := flakyStore.calls(); calls < 2 {
t.Fatalf("stage calls = %d, want persistence retry", calls)
}
if _, ok := flakyStore.GetFinalizable("play-1", "token-1"); ok {
t.Fatal("restaged authoritative event remained after delivery")
}
}
func TestStoppedScrobblePreservesExplicitZeroPosition(t *testing.T) {
handler, mgr, _, sourceID := newReportLivenessHandler("upstream-1", true)
scrobbler := &channelCompatWatchScrobbler{stopEvents: make(chan watchsync.ScrobbleEvent, 1)}
handler.WatchScrobbler = scrobbler
mgr.sessions["upstream-1"].Position = 45
mgr.sessions["upstream-1"].UserID = 7
mgr.sessions["upstream-1"].ProfileID = "profile-1"
mgr.sessions["upstream-1"].MediaFileID = 42
req := httptest.NewRequest(http.MethodPost, "/Sessions/Playing/Stopped", strings.NewReader(
`{"PlaySessionId":"play-1","MediaSourceId":"`+sourceID+`","PositionTicks":0}`))
req = req.WithContext(context.WithValue(req.Context(), compatSessionKey,
&Session{Token: "token-1", StreamAppUserID: 7, ProfileID: "profile-1"}))
rec := httptest.NewRecorder()
handler.HandleSessionPlayingStopped(rec, req)
select {
case event := <-scrobbler.stopEvents:
if event.PositionSeconds != 0 {
t.Fatalf("stop position = %v, want explicit zero", event.PositionSeconds)
}
case <-time.After(time.Second):
t.Fatal("timed out waiting for zero-position stop")
}
}
func TestTerminalScrobbleRecoveryDeliversPersistedEventAfterRestart(t *testing.T) {
store := NewPlaybackSessionStore(time.Hour, nil)
store.Put(PlaybackSession{ID: "play-1", CompatToken: "token-1"})
event := watchsync.ScrobbleEvent{
PlaybackSessionID: "upstream-1",
UserID: 7,
ProfileID: "profile-1",
MediaItemID: "movie-1",
PositionSeconds: 90,
}
if _, err := store.StageTerminal("play-1", "token-1", event, true); err != nil {
t.Fatalf("stage terminal event: %v", err)
}
scrobbler := &channelCompatWatchScrobbler{stopEvents: make(chan watchsync.ScrobbleEvent, 1)}
handler := &PlaybackHandler{playbackStore: store, WatchScrobbler: scrobbler}
if err := recoverPendingTerminalScrobbles(context.Background(), handler); err != nil {
t.Fatalf("recover terminal events: %v", err)
}
select {
case got := <-scrobbler.stopEvents:
if got.PlaybackSessionID != "upstream-1" || got.PositionSeconds != 90 {
t.Fatalf("recovered event = %+v", got)
}
case <-time.After(time.Second):
t.Fatal("timed out waiting for recovered terminal event")
}
if _, ok := store.GetFinalizable("play-1", "token-1"); ok {
t.Fatal("recovered authoritative event remained pending")
}
}
func TestTerminalScrobbleRecoveryLeavesRetryToNextScan(t *testing.T) {
store := NewPlaybackSessionStore(time.Hour, nil)
store.Put(PlaybackSession{ID: "play-1", CompatToken: "token-1"})
event := watchsync.ScrobbleEvent{PlaybackSessionID: "upstream-1"}
if _, err := store.StageTerminal("play-1", "token-1", event, true); err != nil {
t.Fatalf("stage terminal event: %v", err)
}
scrobbler := &failingCompatWatchScrobbler{}
handler := &PlaybackHandler{playbackStore: store, WatchScrobbler: scrobbler}
if err := recoverPendingTerminalScrobbles(context.Background(), handler); err != nil {
t.Fatalf("recover terminal events: %v", err)
}
time.Sleep(compatTerminalInitialRetryDelay + 100*time.Millisecond)
if calls := scrobbler.stopCalls.Load(); calls != 1 {
t.Fatalf("recovery stop attempts = %d, want one attempt per scan", calls)
}
if _, ok := store.GetFinalizable("play-1", "token-1"); !ok {
t.Fatal("failed recovery did not retain the terminal event for the next scan")
}
}
func TestTerminalScrobbleRecoveryRotatesPastPoisonBatch(t *testing.T) {
store := NewPlaybackSessionStore(time.Hour, nil)
for i := 0; i <= compatTerminalRecoveryBatchSize; i++ {
id := fmt.Sprintf("play-%03d", i)
store.Put(PlaybackSession{ID: id, CompatToken: "token-1"})
if _, err := store.StageTerminal(
id,
"token-1",
watchsync.ScrobbleEvent{PlaybackSessionID: fmt.Sprintf("upstream-%03d", i)},
true,
); err != nil {
t.Fatalf("stage terminal event %s: %v", id, err)
}
}
scrobbler := &poisonBatchCompatWatchScrobbler{deliverableSessionID: "upstream-100"}
handler := &PlaybackHandler{playbackStore: store, WatchScrobbler: scrobbler}
if err := recoverPendingTerminalScrobbles(context.Background(), handler); err != nil {
t.Fatalf("recover poison batch: %v", err)
}
if _, ok := store.GetFinalizable("play-100", "token-1"); !ok {
t.Fatal("first bounded scan unexpectedly reached the event after the poison batch")
}
if err := recoverPendingTerminalScrobbles(context.Background(), handler); err != nil {
t.Fatalf("recover after poison batch: %v", err)
}
if _, ok := store.GetFinalizable("play-100", "token-1"); ok {
t.Fatal("event after poison batch remained starved on the next scan")
}
if calls := scrobbler.stopCalls.Load(); calls != compatTerminalRecoveryBatchSize+1 {
t.Fatalf("recovery attempts = %d, want %d", calls, compatTerminalRecoveryBatchSize+1)
}
}
func TestReapedSessionFallbackHonorsProgressPersistencePolicy(t *testing.T) {
tests := []struct {
name string
known bool
disabled bool
}{
{name: "disabled", known: true, disabled: true},
{name: "legacy row with unknown policy", known: false, disabled: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
handler, _, _, sourceID := newReportLivenessHandler("upstream-reaped", false)
scrobbler := &recordingCompatWatchScrobbler{}
handler.WatchScrobbler = scrobbler
if err := handler.playbackStore.Update("play-1", func(session *PlaybackSession) error {
session.ProgressPersistenceKnown = tt.known
session.DisableProgressPersistence = tt.disabled
return nil
}); err != nil {
t.Fatalf("set progress policy: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/Sessions/Playing/Stopped", strings.NewReader(
`{"PlaySessionId":"play-1","MediaSourceId":"`+sourceID+`","PositionTicks":900000000}`))
req = req.WithContext(context.WithValue(req.Context(), compatSessionKey,
&Session{Token: "token-1", StreamAppUserID: 7, ProfileID: "profile-1"}))
rec := httptest.NewRecorder()
handler.HandleSessionPlayingStopped(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
}
if len(scrobbler.calls) != 0 {
t.Fatalf("privacy-suppressed fallback emitted scrobble: %+v", scrobbler.calls)
}
})
}
}
func TestHandleSessionStoppedScrobblesAfterUpstreamSessionWasReaped(t *testing.T) {
handler, _, _, sourceID := newReportLivenessHandler("upstream-reaped", false)
scrobbler := &recordingCompatWatchScrobbler{}
handler.WatchScrobbler = scrobbler
req := httptest.NewRequest(http.MethodPost, "/Sessions/Playing/Stopped", strings.NewReader(
`{"PlaySessionId":"play-1","MediaSourceId":"`+sourceID+`","PositionTicks":900000000}`))
req = req.WithContext(context.WithValue(req.Context(), compatSessionKey,
&Session{Token: "token-1", StreamAppUserID: 7, ProfileID: "profile-1"}))
rec := httptest.NewRecorder()
handler.HandleSessionPlayingStopped(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
}
if len(scrobbler.calls) != 1 || scrobbler.calls[0].action != "stop" {
t.Fatalf("scrobble calls = %+v, want one stop", scrobbler.calls)
}
event := scrobbler.calls[0].event
if event.PlaybackSessionID != "upstream-reaped" || event.UserID != 7 || event.ProfileID != "profile-1" {
t.Fatalf("stop scope = %+v", event)
}
if event.MediaItemID != "movie-1" || event.PositionSeconds != 90 || event.DurationSeconds != 3600 {
t.Fatalf("stop progress = %+v", event)
}
if _, ok := handler.playbackStore.GetFinalizable("play-1", "token-1"); ok {
t.Fatal("stopped compat session should be consumed")
}
}
func TestTeardownStillCleansLocalPlaybackAfterAnotherCallerClaimsStop(t *testing.T) {
mgr := &testCompatSessionManager{sessions: map[string]*playback.Session{
"upstream-1": {ID: "upstream-1", UserID: 7, ProfileID: "profile-1", MediaFileID: 42},
}}
h, store := newActiveEncodingsHandler(mgr)
scrobbler := &recordingCompatWatchScrobbler{}
h.WatchScrobbler = scrobbler
store.Put(PlaybackSession{
ID: "play-1",
CompatToken: "token-1",
ItemID: "movie-1",
UpstreamSessionID: "upstream-1",
MediaSources: []PlaybackMediaSource{{ID: "source-1", FileID: 42, Version: testCompatVersion()}},
})
candidate, ok := store.Get("play-1")
if !ok {
t.Fatal("playback session missing")
}
event := watchsync.ScrobbleEvent{PlaybackSessionID: "upstream-1", UserID: 7, ProfileID: "profile-1"}
if _, err := store.StageTerminal("play-1", "token-1", event, true); err != nil {
t.Fatal("failed to stage competing terminal event")
}
if _, err := store.ClaimTerminal("play-1", "token-1", time.Now().Add(compatTerminalClaimLease)); err != nil {
t.Fatal("failed to simulate a competing terminal delivery claim")
}
h.teardownPlaySession(context.Background(), candidate, nil, nil)
if _, err := mgr.GetSession("upstream-1"); err == nil {
t.Fatal("local upstream session was not cleaned up after losing the terminal claim")
}
if len(scrobbler.calls) != 0 {
t.Fatalf("losing teardown emitted provider event: %+v", scrobbler.calls)
}
}
+394 -15
View File
@@ -1,13 +1,21 @@
package jellycompat
import (
"context"
"errors"
"sort"
"sync"
"time"
"github.com/Silo-Server/silo-server/internal/catalog"
"github.com/Silo-Server/silo-server/internal/playback"
"github.com/Silo-Server/silo-server/internal/watchsync"
)
// ErrTerminalClaimUnavailable means a staged terminal event still exists but
// another process owns its delivery lease (or already sent its fallback).
var ErrTerminalClaimUnavailable = errors.New("compat terminal event claim unavailable")
// PlaybackSession stores compat-owned playback negotiation state before the
// native Silo playback session starts.
type PlaybackSession struct {
@@ -19,13 +27,25 @@ type PlaybackSession struct {
// when it differs from ours (Static=true direct play skips PlaybackInfo,
// so the client never learns the server id). Playback reports carrying
// that id resolve to this session directly instead of by ambiguous route.
ClientPlaySessionID string
UserID string
InitialSeekSeconds float64
MediaSources []PlaybackMediaSource
UpstreamSessionID string
UpstreamPlayMethod string
TranscodeStarted bool
ClientPlaySessionID string
UserID string
InitialSeekSeconds float64
MediaSources []PlaybackMediaSource
UpstreamSessionID string
UpstreamPlayMethod string
TranscodeStarted bool
ProgressPersistenceKnown bool
DisableProgressPersistence bool
// Terminal hides a play session from stream and progress routing after
// ActiveEncodings cleanup while retaining the authenticated mapping long
// enough for a later Stopped report to publish its authoritative position.
Terminal bool
TerminalAuthoritative bool
TerminalFallbackSent bool
TerminalClaimUntil time.Time
TerminalEventVersion int64
TerminalClaimVersion int64
TerminalScrobbleEvent *watchsync.ScrobbleEvent
// Recipe is the transcode reconstruction descriptor for this session. Jellyfin
// clients cannot round-trip a native stream token, so jellycompat carries the
// recipe in its own durable compat store (this struct, persisted as JSONB)
@@ -65,6 +85,27 @@ type CompatPlaybackStore interface {
Get(id string) (*PlaybackSession, bool)
// Delete removes a session.
Delete(id string)
// HideFromRouting immediately makes a caller-owned session unavailable to
// stream/progress routing before slower durable terminal staging begins.
HideFromRouting(id, compatToken string) error
// StageTerminal hides a session from playback routing and durably records
// the provider event. An authoritative Stopped event replaces a fallback;
// a later fallback can never replace an authoritative event.
StageTerminal(id, compatToken string, event watchsync.ScrobbleEvent, authoritative bool) (*PlaybackSession, error)
// ClaimTerminal leases one staged event for delivery across server processes.
ClaimTerminal(id, compatToken string, claimUntil time.Time) (*PlaybackSession, error)
// ReleaseTerminalClaim releases an exact lease after delivery failure, or
// records a delivered fallback while retaining the row for a later Stopped.
ReleaseTerminalClaim(id, compatToken string, claimUntil time.Time, claimVersion int64, fallbackSent bool)
// CompleteTerminal deletes an authoritatively delivered terminal row only
// when the caller still owns the exact lease.
CompleteTerminal(id, compatToken string, claimUntil time.Time, claimVersion int64)
// ListPendingTerminals returns retryable authoritative events and unsent
// fallbacks for startup/periodic delivery recovery.
ListPendingTerminals(ctx context.Context, limit int) ([]PlaybackSession, error)
// GetFinalizable reads an active or terminal caller-owned session for report
// validation before an atomic Take.
GetFinalizable(id, compatToken string) (*PlaybackSession, bool)
// Update modifies a session in place under the store's lock.
Update(id string, fn func(*PlaybackSession) error) error
// FindByRoute resolves a route item / media-source id to a session.
@@ -73,6 +114,18 @@ type CompatPlaybackStore interface {
// alias recorded for plays that skipped PlaybackInfo. The alias must
// identify exactly one live session; ambiguity returns not-found.
FindByClientPlaySessionID(compatToken, clientPlaySessionID string) (*PlaybackSession, bool)
// FindFinalizableByClientPlaySessionID is the stop-report variant of alias
// lookup and includes terminal sessions retained by Deactivate. The report
// identifiers disambiguate clients that reuse an alias across plays.
FindFinalizableByClientPlaySessionID(
compatToken, clientPlaySessionID, routeItemID, mediaSourceID string,
) (*PlaybackSession, bool)
// FindFinalizableByRoute resolves exactly one active or terminal session for
// a caller-owned route. Ambiguous matches return not-found.
FindFinalizableByRoute(compatToken, routeID string) (*PlaybackSession, *PlaybackMediaSource, bool)
// FindByUpstreamSessionID resolves the local upstream session that owns a
// compat play. It is used for process-local failure lifecycle handling.
FindByUpstreamSessionID(upstreamSessionID string) (*PlaybackSession, bool)
}
// PlaybackSessionStore keeps compat playback sessions in memory. It is the
@@ -82,6 +135,9 @@ type PlaybackSessionStore struct {
sessions map[string]PlaybackSession
ttl time.Duration
now func() time.Time
// pendingCursor rotates bounded recovery scans through the full queue so a
// permanently failing first batch cannot starve later terminal events.
pendingCursor string
}
// NewPlaybackSessionStore creates a new playback session store.
@@ -140,6 +196,9 @@ func (s *PlaybackSessionStore) Get(id string) (*PlaybackSession, bool) {
s.Delete(id)
return nil, false
}
if session.Terminal {
return nil, false
}
cp := session
return &cp, true
}
@@ -151,13 +210,254 @@ func (s *PlaybackSessionStore) Delete(id string) {
delete(s.sessions, id)
}
func (s *PlaybackSessionStore) compatTokenForID(id string) string {
s.mu.RLock()
defer s.mu.RUnlock()
return s.sessions[id].CompatToken
}
// HideFromRouting marks a session terminal without requiring its provider event
// to have been staged yet. Final-report lookups remain available for retries.
func (s *PlaybackSessionStore) HideFromRouting(id, compatToken string) error {
s.mu.Lock()
defer s.mu.Unlock()
session, ok := s.sessions[id]
if !ok || session.CompatToken != compatToken || !session.ExpiresAt.After(s.now()) {
return ErrSessionNotFound
}
session.Terminal = true
session.UpdatedAt = s.now()
s.sessions[id] = session
return nil
}
func (s *PlaybackSessionStore) terminalIDsByCompatToken(compatToken string) map[string]struct{} {
s.mu.Lock()
defer s.mu.Unlock()
result := make(map[string]struct{})
now := s.now()
for id, session := range s.sessions {
if !session.ExpiresAt.After(now) {
delete(s.sessions, id)
continue
}
if session.CompatToken == compatToken && session.Terminal {
result[id] = struct{}{}
}
}
return result
}
func (s *PlaybackSessionStore) replaceByCompatToken(
compatToken string,
replacements []PlaybackSession,
preserveIDs map[string]struct{},
) []string {
s.mu.Lock()
defer s.mu.Unlock()
affected := make(map[string]struct{}, len(replacements))
for id, session := range s.sessions {
_, preserve := preserveIDs[id]
if session.CompatToken == compatToken && !preserve {
delete(s.sessions, id)
affected[id] = struct{}{}
}
}
for _, session := range replacements {
if _, preserve := preserveIDs[session.ID]; preserve {
continue
}
if session.CreatedAt.IsZero() {
session.CreatedAt = s.now()
}
if session.ExpiresAt.IsZero() {
session.ExpiresAt = session.CreatedAt.Add(s.ttl)
}
s.sessions[session.ID] = session
affected[session.ID] = struct{}{}
}
result := make([]string, 0, len(affected))
for id := range affected {
result = append(result, id)
}
return result
}
// StageTerminal hides a playback session and records the event that must reach
// watch providers. Authoritative final reports replace provisional cleanup
// events; provisional events never overwrite an authoritative report.
func (s *PlaybackSessionStore) StageTerminal(
id string,
compatToken string,
event watchsync.ScrobbleEvent,
authoritative bool,
) (*PlaybackSession, error) {
s.mu.Lock()
defer s.mu.Unlock()
session, ok := s.sessions[id]
if !ok || session.CompatToken != compatToken {
return nil, ErrSessionNotFound
}
if !session.ExpiresAt.After(s.now()) {
delete(s.sessions, id)
return nil, ErrSessionNotFound
}
if !session.TerminalAuthoritative || authoritative {
eventCopy := event
session.TerminalScrobbleEvent = &eventCopy
session.TerminalAuthoritative = authoritative
session.TerminalEventVersion++
}
session.Terminal = true
session.UpdatedAt = s.now()
s.sessions[id] = session
return &session, nil
}
// ClaimTerminal leases one staged event. Expired leases may be reclaimed after
// a process dies; a delivered fallback is skipped unless Stopped subsequently
// staged an authoritative replacement.
func (s *PlaybackSessionStore) ClaimTerminal(id, compatToken string, claimUntil time.Time) (*PlaybackSession, error) {
s.mu.Lock()
defer s.mu.Unlock()
session, ok := s.sessions[id]
if !ok || session.CompatToken != compatToken || !session.Terminal || session.TerminalScrobbleEvent == nil {
return nil, ErrSessionNotFound
}
if !session.ExpiresAt.After(s.now()) {
delete(s.sessions, id)
return nil, ErrSessionNotFound
}
if session.TerminalClaimUntil.After(s.now()) || (session.TerminalFallbackSent && !session.TerminalAuthoritative) {
return nil, ErrTerminalClaimUnavailable
}
session.TerminalClaimUntil = claimUntil
session.TerminalClaimVersion = session.TerminalEventVersion
session.UpdatedAt = s.now()
s.sessions[id] = session
return &session, nil
}
// ReleaseTerminalClaim releases an exact delivery lease. A stale caller cannot
// clear a successor's lease after its own lease expires.
func (s *PlaybackSessionStore) ReleaseTerminalClaim(
id string,
compatToken string,
claimUntil time.Time,
claimVersion int64,
fallbackSent bool,
) {
s.mu.Lock()
defer s.mu.Unlock()
session, ok := s.sessions[id]
if !ok || session.CompatToken != compatToken || !session.TerminalClaimUntil.Equal(claimUntil) ||
session.TerminalClaimVersion != claimVersion {
return
}
session.TerminalClaimUntil = time.Time{}
session.TerminalClaimVersion = 0
if fallbackSent {
session.TerminalFallbackSent = true
}
session.UpdatedAt = s.now()
s.sessions[id] = session
}
// CompleteTerminal removes an authoritatively delivered event while protecting
// a newer lease from a stale completion.
func (s *PlaybackSessionStore) CompleteTerminal(id, compatToken string, claimUntil time.Time, claimVersion int64) {
s.mu.Lock()
defer s.mu.Unlock()
session, ok := s.sessions[id]
if !ok || session.CompatToken != compatToken || !session.TerminalAuthoritative ||
!session.TerminalClaimUntil.Equal(claimUntil) || session.TerminalClaimVersion != claimVersion ||
session.TerminalEventVersion != claimVersion {
return
}
delete(s.sessions, id)
}
// ListPendingTerminals returns staged events that still need delivery. A
// successfully sent fallback remains retained for a possible authoritative
// replacement but is not itself pending.
func (s *PlaybackSessionStore) ListPendingTerminals(_ context.Context, limit int) ([]PlaybackSession, error) {
s.mu.Lock()
defer s.mu.Unlock()
if limit <= 0 {
limit = 100
}
now := s.now()
eligible := make([]PlaybackSession, 0, len(s.sessions))
for id, session := range s.sessions {
if !session.ExpiresAt.After(now) {
delete(s.sessions, id)
continue
}
if !session.Terminal || session.TerminalScrobbleEvent == nil ||
(session.TerminalFallbackSent && !session.TerminalAuthoritative) {
continue
}
eligible = append(eligible, session)
}
sort.Slice(eligible, func(i, j int) bool { return eligible[i].ID < eligible[j].ID })
start := sort.Search(len(eligible), func(i int) bool { return eligible[i].ID > s.pendingCursor })
if start == len(eligible) && s.pendingCursor != "" {
start = 0
s.pendingCursor = ""
}
end := min(start+limit, len(eligible))
result := append([]PlaybackSession(nil), eligible[start:end]...)
if len(result) > 0 {
s.pendingCursor = result[len(result)-1].ID
} else {
s.pendingCursor = ""
}
return result, nil
}
func (s *PlaybackSessionStore) deleteExpired() map[string]string {
s.mu.Lock()
defer s.mu.Unlock()
now := s.now()
removed := make(map[string]string)
for id, session := range s.sessions {
if !session.ExpiresAt.After(now) {
removed[id] = session.CompatToken
delete(s.sessions, id)
}
}
return removed
}
// GetFinalizable returns an active or terminal caller-owned session so a stop
// report can validate its media fields before atomically consuming it.
func (s *PlaybackSessionStore) GetFinalizable(id, compatToken string) (*PlaybackSession, bool) {
s.mu.Lock()
defer s.mu.Unlock()
session, ok := s.sessions[id]
if !ok || session.CompatToken != compatToken {
return nil, false
}
if !session.ExpiresAt.After(s.now()) {
delete(s.sessions, id)
return nil, false
}
copy := session
return &copy, true
}
// Update modifies a playback session in place.
func (s *PlaybackSessionStore) Update(id string, fn func(*PlaybackSession) error) error {
s.mu.Lock()
defer s.mu.Unlock()
session, ok := s.sessions[id]
if !ok {
if !ok || session.Terminal {
return ErrSessionNotFound
}
if !session.ExpiresAt.After(s.now()) {
@@ -178,6 +478,27 @@ func (s *PlaybackSessionStore) Update(id string, fn func(*PlaybackSession) error
// PlaySessionId across plays makes the alias ambiguous, and the caller should
// fall back to route matching instead of binding an arbitrary session.
func (s *PlaybackSessionStore) FindByClientPlaySessionID(compatToken, clientPlaySessionID string) (*PlaybackSession, bool) {
return s.findByClientPlaySessionID(compatToken, clientPlaySessionID, "", "", false)
}
// FindFinalizableByClientPlaySessionID includes terminal sessions retained for
// an authoritative final report. Route identifiers narrow reused client aliases
// to the play described by that report before the uniqueness check runs.
func (s *PlaybackSessionStore) FindFinalizableByClientPlaySessionID(
compatToken, clientPlaySessionID, routeItemID, mediaSourceID string,
) (*PlaybackSession, bool) {
return s.findByClientPlaySessionID(
compatToken, clientPlaySessionID, routeItemID, mediaSourceID, true,
)
}
func (s *PlaybackSessionStore) findByClientPlaySessionID(
compatToken string,
clientPlaySessionID string,
routeItemID string,
mediaSourceID string,
includeTerminal bool,
) (*PlaybackSession, bool) {
if clientPlaySessionID == "" {
return nil, false
}
@@ -187,12 +508,18 @@ func (s *PlaybackSessionStore) FindByClientPlaySessionID(compatToken, clientPlay
now := s.now()
var match *PlaybackSession
for _, session := range s.sessions {
if !session.ExpiresAt.After(now) {
if !session.ExpiresAt.After(now) || (!includeTerminal && session.Terminal) {
continue
}
if session.CompatToken != compatToken {
continue
}
if routeItemID != "" && !mediaSourceIDsEqual(session.RouteItemID, routeItemID) {
continue
}
if mediaSourceID != "" && findMediaSource(&session, mediaSourceID) == nil {
continue
}
if session.ClientPlaySessionID == clientPlaySessionID {
if match != nil {
return nil, false
@@ -204,14 +531,50 @@ func (s *PlaybackSessionStore) FindByClientPlaySessionID(compatToken, clientPlay
return match, match != nil
}
// FindByRoute resolves a route item/media-source identifier to a compat playback session.
func (s *PlaybackSessionStore) FindByRoute(compatToken, routeID string) (*PlaybackSession, *PlaybackMediaSource, bool) {
// FindByUpstreamSessionID resolves the unique compat play attached to a local
// upstream session.
func (s *PlaybackSessionStore) FindByUpstreamSessionID(upstreamSessionID string) (*PlaybackSession, bool) {
if upstreamSessionID == "" {
return nil, false
}
s.mu.RLock()
defer s.mu.RUnlock()
now := s.now()
for _, session := range s.sessions {
if !session.ExpiresAt.After(now) {
if session.ExpiresAt.After(now) && !session.Terminal && session.UpstreamSessionID == upstreamSessionID {
copy := session
return &copy, true
}
}
return nil, false
}
// FindByRoute resolves a route item/media-source identifier to a compat playback session.
func (s *PlaybackSessionStore) FindByRoute(compatToken, routeID string) (*PlaybackSession, *PlaybackMediaSource, bool) {
return s.findByRoute(compatToken, routeID, false, false)
}
// FindFinalizableByRoute includes terminal sessions retained for a stopped
// report, but only returns a unique token-scoped match.
func (s *PlaybackSessionStore) FindFinalizableByRoute(
compatToken, routeID string,
) (*PlaybackSession, *PlaybackMediaSource, bool) {
return s.findByRoute(compatToken, routeID, true, true)
}
func (s *PlaybackSessionStore) findByRoute(
compatToken, routeID string,
includeTerminal, requireUnique bool,
) (*PlaybackSession, *PlaybackMediaSource, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
now := s.now()
var matchedSession *PlaybackSession
var matchedSource *PlaybackMediaSource
for _, session := range s.sessions {
if !session.ExpiresAt.After(now) || (!includeTerminal && session.Terminal) {
continue
}
if compatToken != "" && session.CompatToken != compatToken {
@@ -221,17 +584,33 @@ func (s *PlaybackSessionStore) FindByRoute(compatToken, routeID string) (*Playba
// whatever casing/dash format the client model uses, which may differ
// from the raw route param captured at stream time.
if mediaSourceIDsEqual(session.RouteItemID, routeID) {
if requireUnique && matchedSession != nil {
return nil, nil, false
}
cp := session
return &cp, nil, true
matchedSession = &cp
matchedSource = nil
if !requireUnique {
return matchedSession, nil, true
}
continue
}
for _, source := range session.MediaSources {
if mediaSourceIDsEqual(source.ID, routeID) {
if requireUnique && matchedSession != nil {
return nil, nil, false
}
cp := session
sourceCopy := source
return &cp, &sourceCopy, true
matchedSession = &cp
matchedSource = &sourceCopy
if !requireUnique {
return matchedSession, matchedSource, true
}
break
}
}
}
return nil, nil, false
return matchedSession, matchedSource, matchedSession != nil
}
File diff suppressed because it is too large Load Diff
@@ -7,6 +7,7 @@ import (
"testing"
"time"
"github.com/Silo-Server/silo-server/internal/watchsync"
"github.com/jackc/pgx/v5/pgxpool"
)
@@ -216,3 +217,668 @@ func TestDurableCompatPlaybackStore_NilPoolInMemory(t *testing.T) {
t.Fatal("nil-pool Delete failed")
}
}
func TestPlaybackSessionStoreTerminalClaimIsAtomicAndOwnerScoped(t *testing.T) {
store := NewPlaybackSessionStore(time.Hour, nil)
store.Put(PlaybackSession{ID: "play-1", CompatToken: "owner", UpstreamSessionID: "upstream-1"})
event := watchsync.ScrobbleEvent{PlaybackSessionID: "upstream-1"}
if _, err := store.StageTerminal("play-1", "other", event, true); err == nil {
t.Fatal("foreign token staged terminal event")
}
if _, err := store.StageTerminal("play-1", "owner", event, true); err != nil {
t.Fatal("owner failed to stage terminal event")
}
claimUntil := time.Now().Add(time.Minute)
if _, err := store.ClaimTerminal("play-1", "other", claimUntil); err == nil {
t.Fatal("foreign token claimed terminal event")
}
got, err := store.ClaimTerminal("play-1", "owner", claimUntil)
if err != nil || got.UpstreamSessionID != "upstream-1" {
t.Fatalf("owner claim failed: err=%v session=%+v", err, got)
}
if _, err := store.ClaimTerminal("play-1", "owner", claimUntil.Add(time.Minute)); err == nil {
t.Fatal("terminal event was claimed twice")
}
}
func TestPlaybackSessionStoreDeactivateRetainsOnlyFinalReportLookup(t *testing.T) {
store := NewPlaybackSessionStore(time.Hour, nil)
store.Put(PlaybackSession{
ID: "play-1",
CompatToken: "owner",
ClientPlaySessionID: "client-play-1",
UpstreamSessionID: "upstream-1",
})
event := watchsync.ScrobbleEvent{PlaybackSessionID: "upstream-1"}
terminal, err := store.StageTerminal("play-1", "owner", event, true)
if err != nil || !terminal.Terminal {
t.Fatalf("terminal stage failed: err=%v session=%+v", err, terminal)
}
if _, ok := store.Get("play-1"); ok {
t.Fatal("terminal session remained available to ordinary lookup")
}
if _, ok := store.FindByClientPlaySessionID("owner", "client-play-1"); ok {
t.Fatal("terminal session remained available to ordinary alias lookup")
}
if _, ok := store.GetFinalizable("play-1", "owner"); !ok {
t.Fatal("terminal session was unavailable to final report lookup")
}
if _, ok := store.FindFinalizableByClientPlaySessionID("owner", "client-play-1", "", ""); !ok {
t.Fatal("terminal session was unavailable to final alias lookup")
}
claimUntil := time.Now().Add(time.Minute)
claimed, err := store.ClaimTerminal("play-1", "owner", claimUntil)
if err != nil || !claimed.Terminal {
t.Fatalf("final report could not claim terminal session: err=%v session=%+v", err, claimed)
}
store.CompleteTerminal("play-1", "owner", claimUntil, claimed.TerminalClaimVersion)
if _, ok := store.GetFinalizable("play-1", "owner"); ok {
t.Fatal("authoritatively completed terminal session was retained")
}
}
func TestPlaybackSessionStoreFinalAliasLookupDisambiguatesReusedAlias(t *testing.T) {
store := NewPlaybackSessionStore(time.Hour, nil)
store.Put(PlaybackSession{
ID: "old-play",
CompatToken: "owner",
ClientPlaySessionID: "reused-client-play",
RouteItemID: "old-item",
MediaSources: []PlaybackMediaSource{{ID: "old-source"}},
UpstreamSessionID: "old-upstream",
})
if _, err := store.StageTerminal(
"old-play",
"owner",
watchsync.ScrobbleEvent{PlaybackSessionID: "old-upstream"},
false,
); err != nil {
t.Fatalf("stage old terminal play: %v", err)
}
store.Put(PlaybackSession{
ID: "current-play",
CompatToken: "owner",
ClientPlaySessionID: "reused-client-play",
RouteItemID: "current-item",
MediaSources: []PlaybackMediaSource{{ID: "current-source"}},
UpstreamSessionID: "current-upstream",
})
if _, ok := store.FindFinalizableByClientPlaySessionID(
"owner", "reused-client-play", "", "",
); ok {
t.Fatal("unscoped reused alias unexpectedly selected an arbitrary play")
}
current, ok := store.FindFinalizableByClientPlaySessionID(
"owner", "reused-client-play", "current-item", "current-source",
)
if !ok || current.ID != "current-play" {
t.Fatalf("current report resolved to ok=%v session=%+v", ok, current)
}
old, ok := store.FindFinalizableByClientPlaySessionID(
"owner", "reused-client-play", "old-item", "old-source",
)
if !ok || old.ID != "old-play" {
t.Fatalf("late old report resolved to ok=%v session=%+v", ok, old)
}
oldByRoute, _, ok := store.FindFinalizableByRoute("owner", "old-source")
if !ok || oldByRoute.ID != "old-play" {
t.Fatalf("terminal route resolved to ok=%v session=%+v", ok, oldByRoute)
}
}
func TestPlaybackSessionStoreExpiredTerminalLeaseCanBeReclaimed(t *testing.T) {
now := time.Date(2026, 7, 22, 12, 0, 0, 0, time.UTC)
store := NewPlaybackSessionStore(time.Hour, func() time.Time { return now })
store.Put(PlaybackSession{ID: "play-1", CompatToken: "owner"})
event := watchsync.ScrobbleEvent{PlaybackSessionID: "upstream-1"}
if _, err := store.StageTerminal("play-1", "owner", event, true); err != nil {
t.Fatalf("stage terminal: %v", err)
}
firstLease := now.Add(10 * time.Second)
firstClaim, err := store.ClaimTerminal("play-1", "owner", firstLease)
if err != nil {
t.Fatalf("first claim: %v", err)
}
now = firstLease.Add(time.Microsecond)
secondLease := now.Add(10 * time.Second)
secondClaim, err := store.ClaimTerminal("play-1", "owner", secondLease)
if err != nil {
t.Fatalf("reclaim expired lease: %v", err)
}
store.CompleteTerminal("play-1", "owner", firstLease, firstClaim.TerminalClaimVersion)
if _, ok := store.GetFinalizable("play-1", "owner"); !ok {
t.Fatal("stale first lease completed the successor's terminal row")
}
store.CompleteTerminal("play-1", "owner", secondLease, secondClaim.TerminalClaimVersion)
if _, ok := store.GetFinalizable("play-1", "owner"); ok {
t.Fatal("successor lease did not complete terminal row")
}
}
func TestPlaybackSessionStorePendingScanEvictsExpiredTerminal(t *testing.T) {
now := time.Date(2026, 7, 22, 12, 0, 0, 0, time.UTC)
store := NewPlaybackSessionStore(time.Minute, func() time.Time { return now })
store.Put(PlaybackSession{ID: "play-1", CompatToken: "owner"})
if _, err := store.StageTerminal(
"play-1", "owner", watchsync.ScrobbleEvent{PlaybackSessionID: "upstream-1"}, false,
); err != nil {
t.Fatalf("stage terminal: %v", err)
}
now = now.Add(2 * time.Minute)
pending, err := store.ListPendingTerminals(context.Background(), 100)
if err != nil {
t.Fatalf("list pending terminals: %v", err)
}
if len(pending) != 0 {
t.Fatalf("expired pending terminals = %d, want 0", len(pending))
}
store.mu.RLock()
remaining := len(store.sessions)
store.mu.RUnlock()
if remaining != 0 {
t.Fatalf("expired terminal entries retained in memory = %d", remaining)
}
}
func TestDurableCompatPlaybackStoreExpiryClearsFailureBookkeeping(t *testing.T) {
now := time.Date(2026, 7, 22, 12, 0, 0, 0, time.UTC)
store := NewDurableCompatPlaybackStore(nil, time.Minute, func() time.Time { return now })
store.mem.Put(PlaybackSession{ID: "play-1", CompatToken: "owner"})
store.markUnpersisted("play-1")
store.appendPendingUpdate("play-1", "owner", func(*PlaybackSession) error { return nil })
now = now.Add(2 * time.Minute)
if _, err := store.DeleteExpired(context.Background()); err != nil {
t.Fatalf("delete expired: %v", err)
}
if store.isUnpersisted("play-1") {
t.Fatal("expired session retained its unpersisted marker")
}
if store.hasPendingUpdates("play-1") {
t.Fatal("expired session retained pending update closures")
}
}
func TestDurableCompatPlaybackStoreBoundsGenerationTombstones(t *testing.T) {
store := NewDurableCompatPlaybackStore(nil, time.Hour, nil)
before := store.idGenerationSnapshot("old-session")
for i := 0; i <= compatValidationCacheLimit; i++ {
store.bumpCacheGenerations(fmt.Sprintf("missing-%d", i), "")
}
store.generationMu.Lock()
count := len(store.idGenerations)
epoch := store.generationEpoch
store.generationMu.Unlock()
if count > compatValidationCacheLimit {
t.Fatalf("generation tombstones = %d, limit = %d", count, compatValidationCacheLimit)
}
if epoch == 0 || before == store.idGenerationSnapshot("old-session") {
t.Fatal("generation eviction did not invalidate an older captured stamp")
}
}
func TestDurableCompatPlaybackStoreIDWriteDoesNotRefreshTokenSnapshot(t *testing.T) {
now := time.Date(2026, 7, 22, 12, 0, 0, 0, time.UTC)
store := NewDurableCompatPlaybackStore(nil, time.Hour, func() time.Time { return now })
store.markTokenValidated("owner")
now = now.Add(4 * time.Second)
store.markIDValidated("play-2")
now = now.Add(2 * time.Second)
if !store.shouldRevalidateToken("owner") {
t.Fatal("single-row validation incorrectly extended the token-wide cache window")
}
}
func TestPlaybackSessionStoreNewAuthoritativeEventSurvivesStaleCompletion(t *testing.T) {
store := NewPlaybackSessionStore(time.Hour, nil)
store.Put(PlaybackSession{ID: "play-1", CompatToken: "owner"})
firstEvent := watchsync.ScrobbleEvent{PlaybackSessionID: "upstream-1", PositionSeconds: 45}
if _, err := store.StageTerminal("play-1", "owner", firstEvent, true); err != nil {
t.Fatalf("stage first event: %v", err)
}
firstLease := time.Now().Add(time.Minute)
firstClaim, err := store.ClaimTerminal("play-1", "owner", firstLease)
if err != nil {
t.Fatalf("claim first event: %v", err)
}
secondEvent := watchsync.ScrobbleEvent{PlaybackSessionID: "upstream-1", PositionSeconds: 90}
if _, err := store.StageTerminal("play-1", "owner", secondEvent, true); err != nil {
t.Fatalf("stage replacement event: %v", err)
}
store.CompleteTerminal("play-1", "owner", firstLease, firstClaim.TerminalClaimVersion)
store.ReleaseTerminalClaim("play-1", "owner", firstLease, firstClaim.TerminalClaimVersion, false)
pending, ok := store.GetFinalizable("play-1", "owner")
if !ok || pending.TerminalScrobbleEvent == nil || pending.TerminalScrobbleEvent.PositionSeconds != 90 {
t.Fatalf("replacement event was lost: ok=%v session=%+v", ok, pending)
}
secondLease := firstLease.Add(time.Minute)
secondClaim, err := store.ClaimTerminal("play-1", "owner", secondLease)
if err != nil {
t.Fatalf("claim replacement event: %v", err)
}
store.CompleteTerminal("play-1", "owner", secondLease, secondClaim.TerminalClaimVersion)
if _, ok := store.GetFinalizable("play-1", "owner"); ok {
t.Fatal("replacement event was not completed")
}
}
func TestDurableCompatPlaybackStoreTerminalClaimAcrossInstances(t *testing.T) {
pool := newCompatTestPool(t)
ctx := context.Background()
id := fmt.Sprintf("compat-take-%d", time.Now().UnixNano())
t.Cleanup(func() { _, _ = pool.Exec(ctx, `DELETE FROM jellycompat_playback_sessions WHERE id = $1`, id) })
seed := NewDurableCompatPlaybackStore(pool, time.Hour, nil)
seed.Put(PlaybackSession{ID: id, CompatToken: "owner", UpstreamSessionID: "upstream-1"})
skewedNow := time.Now().Add(-6 * time.Hour)
first := NewDurableCompatPlaybackStore(pool, time.Hour, func() time.Time { return skewedNow })
second := NewDurableCompatPlaybackStore(pool, time.Hour, nil)
if _, ok := first.Get(id); !ok {
t.Fatal("first instance did not load session")
}
if _, ok := second.Get(id); !ok {
t.Fatal("second instance did not load session")
}
event := watchsync.ScrobbleEvent{PlaybackSessionID: "upstream-1"}
if _, err := seed.StageTerminal(id, "owner", event, true); err != nil {
t.Fatal("failed to stage durable terminal event")
}
var dbBefore time.Time
if err := pool.QueryRow(ctx, `SELECT clock_timestamp()`).Scan(&dbBefore); err != nil {
t.Fatalf("read database clock: %v", err)
}
claimUntil := skewedNow.Add(time.Minute)
claimed, err := first.ClaimTerminal(id, "owner", claimUntil)
if err != nil {
t.Fatal("first instance did not claim session")
}
var dbAfter time.Time
if err := pool.QueryRow(ctx, `SELECT clock_timestamp()`).Scan(&dbAfter); err != nil {
t.Fatalf("read database clock after claim: %v", err)
}
if claimed.TerminalClaimUntil.Before(dbBefore.Add(55*time.Second)) ||
claimed.TerminalClaimUntil.After(dbAfter.Add(65*time.Second)) {
t.Fatalf(
"claim deadline %s was not anchored to database clock interval [%s, %s]",
claimed.TerminalClaimUntil,
dbBefore.Add(55*time.Second),
dbAfter.Add(65*time.Second),
)
}
if _, err := second.ClaimTerminal(id, "owner", time.Now().Add(time.Minute)); err == nil {
t.Fatal("second instance claimed an already leased durable session")
}
}
func TestDurableCompatPlaybackStoreTerminalStageSurvivesInstanceBoundary(t *testing.T) {
pool := newCompatTestPool(t)
ctx := context.Background()
id := fmt.Sprintf("compat-deactivate-%d", time.Now().UnixNano())
t.Cleanup(func() { _, _ = pool.Exec(ctx, `DELETE FROM jellycompat_playback_sessions WHERE id = $1`, id) })
now := time.Now()
clock := func() time.Time { return now }
seed := NewDurableCompatPlaybackStore(pool, time.Hour, clock)
seed.Put(PlaybackSession{
ID: id,
CompatToken: "owner",
ClientPlaySessionID: "client-play-1",
UpstreamSessionID: "upstream-1",
})
fresh := NewDurableCompatPlaybackStore(pool, time.Hour, clock)
if _, ok := fresh.Get(id); !ok {
t.Fatal("fresh instance did not preload the active session")
}
if _, ok := fresh.FindByClientPlaySessionID("owner", "client-play-1"); !ok {
t.Fatal("fresh instance did not preload the active alias")
}
event := watchsync.ScrobbleEvent{PlaybackSessionID: "upstream-1"}
if terminal, err := seed.StageTerminal(id, "owner", event, false); err != nil || !terminal.Terminal {
t.Fatalf("durable terminal stage failed: err=%v session=%+v", err, terminal)
}
// Cross-process invalidation is deliberately bounded rather than putting a
// DB query on every segment request. Advance past that window before the
// other instance revalidates its preloaded cache.
now = now.Add(compatCacheRevalidationInterval)
if _, ok := fresh.Get(id); ok {
t.Fatal("instance routed a terminal session from its stale active cache")
}
if _, ok := fresh.FindByClientPlaySessionID("owner", "client-play-1"); ok {
t.Fatal("instance routed a terminal alias from its stale active cache")
}
if _, ok := fresh.GetFinalizable(id, "owner"); !ok {
t.Fatal("fresh instance could not resolve terminal session for final report")
}
if _, ok := fresh.FindFinalizableByClientPlaySessionID("owner", "client-play-1", "", ""); !ok {
t.Fatal("fresh instance could not resolve terminal alias for final report")
}
claimUntil := time.Now().UTC().Truncate(time.Microsecond).Add(time.Minute)
claimed, err := fresh.ClaimTerminal(id, "owner", claimUntil)
if err != nil || !claimed.Terminal {
t.Fatalf("fresh instance could not claim terminal session: err=%v session=%+v", err, claimed)
}
fresh.ReleaseTerminalClaim(id, "owner", claimed.TerminalClaimUntil, claimed.TerminalClaimVersion, true)
if claimed, err := fresh.ClaimTerminal(id, "owner", claimUntil.Add(time.Minute)); err == nil || claimed != nil {
t.Fatalf("delivered fallback was claimed again: err=%v session=%+v", err, claimed)
}
}
func TestDurableCompatPlaybackStorePreservesCachedSessionOnValidationFailure(t *testing.T) {
pool := newCompatTestPool(t)
now := time.Now()
clock := func() time.Time { return now }
store := NewDurableCompatPlaybackStore(pool, time.Hour, clock)
pool.Close()
store.Put(PlaybackSession{ID: "cached-session", CompatToken: "owner"})
// A request inside the validation window must be served entirely from cache.
if _, ok := store.Get("cached-session"); !ok {
t.Fatal("hot cache lookup consulted the closed database")
}
// Once validation is due, the failed query must not evict a last-known-good
// active session and interrupt playback.
now = now.Add(compatCacheRevalidationInterval)
if _, ok := store.Get("cached-session"); !ok {
t.Fatal("database validation failure evicted the cached active session")
}
}
func TestDurableCompatPlaybackStoreRepairsFailedInitialPersistence(t *testing.T) {
pool := newCompatTestPool(t)
ctx := context.Background()
id := fmt.Sprintf("compat-repair-%d", time.Now().UnixNano())
t.Cleanup(func() { _, _ = pool.Exec(ctx, `DELETE FROM jellycompat_playback_sessions WHERE id = $1`, id) })
now := time.Now()
clock := func() time.Time { return now }
store := NewDurableCompatPlaybackStore(pool, time.Hour, clock)
stored := store.mem.putNormalized(PlaybackSession{ID: id, CompatToken: "owner", UpstreamSessionID: "upstream-1"})
store.markIDValidated(id)
store.markUnpersisted(id)
now = now.Add(compatCacheRevalidationInterval)
if got, ok := store.Get(id); !ok || got.UpstreamSessionID != "upstream-1" {
t.Fatalf("unpersisted cache entry was not retained and repaired: ok=%v session=%+v", ok, got)
}
if store.isUnpersisted(id) {
t.Fatal("successfully repaired session remained marked unpersisted")
}
fresh := NewDurableCompatPlaybackStore(pool, time.Hour, clock)
if got, ok := fresh.Get(stored.ID); !ok || got.UpstreamSessionID != "upstream-1" {
t.Fatalf("repaired session was not durable: ok=%v session=%+v", ok, got)
}
}
func TestDurableCompatPlaybackStorePreservesUnpersistedTerminalOnRevalidation(t *testing.T) {
pool := newCompatTestPool(t)
id := fmt.Sprintf("compat-unpersisted-terminal-%d", time.Now().UnixNano())
now := time.Now()
clock := func() time.Time { return now }
store := NewDurableCompatPlaybackStore(pool, time.Hour, clock)
store.mem.Put(PlaybackSession{ID: id, CompatToken: "owner"})
if err := store.mem.HideFromRouting(id, "owner"); err != nil {
t.Fatalf("hide terminal: %v", err)
}
store.markUnpersisted(id)
store.markIDValidated(id)
now = now.Add(compatCacheRevalidationInterval)
if _, ok := store.Get(id); ok {
t.Fatal("terminal session became routable during missing-row revalidation")
}
terminal, ok := store.GetFinalizable(id, "owner")
if !ok || !terminal.Terminal || !store.isUnpersisted(id) {
t.Fatalf("unpersisted terminal state was lost: ok=%v session=%+v", ok, terminal)
}
}
func TestDurableCompatPlaybackStoreDiscardsTokenSnapshotAfterLocalMutation(t *testing.T) {
store := NewDurableCompatPlaybackStore(nil, time.Hour, nil)
generation := store.tokenGenerationSnapshot("owner")
store.cacheMutationMu.Lock()
store.mem.Put(PlaybackSession{ID: "new-session", CompatToken: "owner", RouteItemID: "route-1"})
store.bumpCacheGenerations("new-session", "owner")
store.cacheMutationMu.Unlock()
if store.applyCompatTokenSnapshot("owner", nil, generation) {
t.Fatal("stale token snapshot applied after a concurrent local mutation")
}
if _, _, ok := store.mem.FindByRoute("owner", "route-1"); !ok {
t.Fatal("stale token snapshot deleted the concurrent local session")
}
}
func TestDurableCompatPlaybackStoreAppliesTokenSnapshotAfterUnrelatedMutation(t *testing.T) {
store := NewDurableCompatPlaybackStore(nil, time.Hour, nil)
store.mem.Put(PlaybackSession{ID: "stale-session", CompatToken: "owner", RouteItemID: "route-1"})
generation := store.tokenGenerationSnapshot("owner")
store.cacheMutationMu.Lock()
store.mem.Put(PlaybackSession{ID: "other-session", CompatToken: "other"})
store.bumpCacheGenerations("other-session", "other")
store.cacheMutationMu.Unlock()
if !store.applyCompatTokenSnapshot("owner", nil, generation) {
t.Fatal("unrelated local mutation incorrectly invalidated the token snapshot")
}
if _, _, ok := store.mem.FindByRoute("owner", "route-1"); ok {
t.Fatal("valid token snapshot was not applied after unrelated mutation")
}
}
func TestDurableCompatPlaybackStoreDoesNotReviveLocalTerminalFromActiveSnapshot(t *testing.T) {
store := NewDurableCompatPlaybackStore(nil, time.Hour, nil)
active := PlaybackSession{ID: "play-1", CompatToken: "owner", RouteItemID: "route-1"}
store.mem.Put(active)
if err := store.mem.HideFromRouting("play-1", "owner"); err != nil {
t.Fatalf("hide local terminal: %v", err)
}
generation := store.tokenGenerationSnapshot("owner")
if !store.applyCompatTokenSnapshot("owner", []PlaybackSession{active}, generation) {
t.Fatal("active durable snapshot was unexpectedly discarded")
}
if _, ok := store.mem.Get("play-1"); ok {
t.Fatal("active durable snapshot revived a locally terminal session")
}
if terminal, ok := store.mem.GetFinalizable("play-1", "owner"); !ok || !terminal.Terminal {
t.Fatalf("local terminal marker was lost: ok=%v session=%+v", ok, terminal)
}
}
func TestDurableCompatPlaybackStorePreservesPendingUpdateFromOlderSnapshot(t *testing.T) {
store := NewDurableCompatPlaybackStore(nil, time.Hour, nil)
local := PlaybackSession{ID: "play-1", CompatToken: "owner", UpstreamSessionID: "upstream-new"}
store.mem.Put(local)
store.appendPendingUpdate("play-1", "owner", func(session *PlaybackSession) error {
session.UpstreamSessionID = "upstream-new"
return nil
})
generation := store.tokenGenerationSnapshot("owner")
durable := local
durable.UpstreamSessionID = "upstream-old"
if !store.applyCompatTokenSnapshot("owner", []PlaybackSession{durable}, generation) {
t.Fatal("durable snapshot was unexpectedly discarded")
}
if got, ok := store.mem.Get("play-1"); !ok || got.UpstreamSessionID != "upstream-new" {
t.Fatalf("pending local update was overwritten: ok=%v session=%+v", ok, got)
}
}
func TestDurableCompatPlaybackStoreDropsPendingSessionMissingFromSnapshot(t *testing.T) {
store := NewDurableCompatPlaybackStore(nil, time.Hour, nil)
store.mem.Put(PlaybackSession{ID: "play-1", CompatToken: "owner", RouteItemID: "route-1"})
store.appendPendingUpdate("play-1", "owner", func(session *PlaybackSession) error {
session.UpstreamSessionID = "upstream-new"
return nil
})
generation := store.tokenGenerationSnapshot("owner")
if !store.applyCompatTokenSnapshot("owner", nil, generation) {
t.Fatal("durable deletion snapshot was unexpectedly discarded")
}
if _, _, ok := store.mem.FindByRoute("owner", "route-1"); ok {
t.Fatal("missing durable row remained routable due to a pending update")
}
if store.hasPendingUpdates("play-1") {
t.Fatal("pending update survived authoritative durable deletion")
}
}
func TestDurableCompatPlaybackStoreTokenSnapshotKeepsOtherTokenPendingUpdates(t *testing.T) {
store := NewDurableCompatPlaybackStore(nil, time.Hour, nil)
store.mem.Put(PlaybackSession{ID: "play-a", CompatToken: "owner-a"})
store.mem.Put(PlaybackSession{ID: "play-b", CompatToken: "owner-b"})
store.appendPendingUpdate("play-b", "owner-b", func(session *PlaybackSession) error {
session.UpstreamSessionID = "upstream-b"
return nil
})
generation := store.tokenGenerationSnapshot("owner-a")
if !store.applyCompatTokenSnapshot(
"owner-a", []PlaybackSession{{ID: "play-a", CompatToken: "owner-a"}}, generation,
) {
t.Fatal("token A snapshot was unexpectedly discarded")
}
if !store.hasPendingUpdates("play-b") {
t.Fatal("token A snapshot discarded token B's pending update")
}
}
func TestDurableCompatPlaybackStoreSerializesSameSessionMutations(t *testing.T) {
store := NewDurableCompatPlaybackStore(nil, time.Hour, nil)
unlock := store.lockSessionMutation("play-1")
acquired := make(chan struct{})
released := make(chan struct{})
go func() {
secondUnlock := store.lockSessionMutation("play-1")
close(acquired)
<-released
secondUnlock()
}()
select {
case <-acquired:
t.Fatal("same-session mutation lock was acquired concurrently")
case <-time.After(20 * time.Millisecond):
}
unlock()
select {
case <-acquired:
close(released)
case <-time.After(time.Second):
t.Fatal("same-session mutation did not resume after release")
}
}
func TestDurableCompatPlaybackStorePersistsTerminalShell(t *testing.T) {
pool := newCompatTestPool(t)
ctx := context.Background()
id := fmt.Sprintf("compat-terminal-shell-%d", time.Now().UnixNano())
t.Cleanup(func() { _, _ = pool.Exec(ctx, `DELETE FROM jellycompat_playback_sessions WHERE id = $1`, id) })
store := NewDurableCompatPlaybackStore(pool, time.Hour, nil)
store.Put(PlaybackSession{ID: id, CompatToken: "owner", UpstreamSessionID: "upstream-1"})
if err := store.HideFromRouting(id, "owner"); err != nil {
t.Fatalf("persist terminal shell: %v", err)
}
fresh := NewDurableCompatPlaybackStore(pool, time.Hour, nil)
if _, ok := fresh.Get(id); ok {
t.Fatal("fresh process routed a durably hidden terminal shell")
}
terminal, ok := fresh.GetFinalizable(id, "owner")
if !ok || !terminal.Terminal || terminal.TerminalScrobbleEvent != nil {
t.Fatalf("terminal shell = ok=%v session=%+v", ok, terminal)
}
pending, err := fresh.ListPendingTerminals(context.Background(), 100)
if err != nil {
t.Fatalf("list terminal shells: %v", err)
}
if len(pending) != 0 {
t.Fatalf("terminal shell appeared as a pending event: %+v", pending)
}
claimUntil := time.Now().UTC().Truncate(time.Microsecond).Add(time.Minute)
if claimed, err := fresh.ClaimTerminal(id, "owner", claimUntil); err == nil || claimed != nil {
t.Fatalf("terminal shell without an event was claimable: err=%v session=%+v", err, claimed)
}
}
func TestDurableCompatPlaybackStoreTerminalizesConflictingUnpersistedShell(t *testing.T) {
pool := newCompatTestPool(t)
ctx := context.Background()
id := fmt.Sprintf("compat-terminal-conflict-%d", time.Now().UnixNano())
t.Cleanup(func() { _, _ = pool.Exec(ctx, `DELETE FROM jellycompat_playback_sessions WHERE id = $1`, id) })
seed := NewDurableCompatPlaybackStore(pool, time.Hour, nil)
seed.Put(PlaybackSession{ID: id, CompatToken: "owner", UpstreamSessionID: "upstream-1"})
local := NewDurableCompatPlaybackStore(pool, time.Hour, nil)
local.mem.Put(PlaybackSession{ID: id, CompatToken: "owner", UpstreamSessionID: "upstream-1"})
local.markUnpersisted(id)
if err := local.HideFromRouting(id, "owner"); err != nil {
t.Fatalf("hide conflicting terminal shell: %v", err)
}
fresh := NewDurableCompatPlaybackStore(pool, time.Hour, nil)
if _, ok := fresh.Get(id); ok {
t.Fatal("insert conflict left the durable session routable")
}
}
func TestDurableCompatPlaybackStoreReplaysPendingUpdates(t *testing.T) {
pool := newCompatTestPool(t)
ctx := context.Background()
id := fmt.Sprintf("compat-pending-update-%d", time.Now().UnixNano())
t.Cleanup(func() { _, _ = pool.Exec(ctx, `DELETE FROM jellycompat_playback_sessions WHERE id = $1`, id) })
store := NewDurableCompatPlaybackStore(pool, time.Hour, nil)
store.Put(PlaybackSession{ID: id, CompatToken: "owner", UpstreamSessionID: "upstream-old"})
if err := store.mem.Update(id, func(session *PlaybackSession) error {
session.UpstreamSessionID = "upstream-new"
return nil
}); err != nil {
t.Fatalf("seed pending cache update: %v", err)
}
store.appendPendingUpdate(id, "owner", func(session *PlaybackSession) error {
session.UpstreamSessionID = "upstream-new"
return nil
})
if err := store.Update(id, func(session *PlaybackSession) error {
session.TranscodeStarted = true
return nil
}); err != nil {
t.Fatalf("update with pending replay: %v", err)
}
if store.hasPendingUpdates(id) {
t.Fatal("successfully replayed update remained pending")
}
fresh := NewDurableCompatPlaybackStore(pool, time.Hour, nil)
got, ok := fresh.Get(id)
if !ok || got.UpstreamSessionID != "upstream-new" || !got.TranscodeStarted {
t.Fatalf("durable replay lost updates: ok=%v session=%+v", ok, got)
}
}
func TestDurableCompatPlaybackStoreColdLookupIgnoresReservedThrottle(t *testing.T) {
pool := newCompatTestPool(t)
ctx := context.Background()
id := fmt.Sprintf("compat-cold-load-%d", time.Now().UnixNano())
t.Cleanup(func() { _, _ = pool.Exec(ctx, `DELETE FROM jellycompat_playback_sessions WHERE id = $1`, id) })
seed := NewDurableCompatPlaybackStore(pool, time.Hour, nil)
seed.Put(PlaybackSession{ID: id, CompatToken: "owner", RouteItemID: "route-1"})
fresh := NewDurableCompatPlaybackStore(pool, time.Hour, nil)
_ = fresh.shouldRevalidateID(id)
_ = fresh.shouldRevalidateToken("owner")
if _, ok := fresh.Get(id); !ok {
t.Fatal("cold ID lookup treated an in-flight validation reservation as not-found")
}
other := NewDurableCompatPlaybackStore(pool, time.Hour, nil)
_ = other.shouldRevalidateToken("owner")
if _, _, ok := other.FindByRoute("owner", "route-1"); !ok {
t.Fatal("cold token lookup treated an in-flight validation reservation as not-found")
}
}
+2
View File
@@ -120,6 +120,8 @@ func NewRouter(deps Dependencies) chi.Router {
playbackHandler.SettingsRepo = deps.SettingsRepo
playbackHandler.RecipeNodeStore = deps.RecipeNodeStore
playbackHandler.SessionSyncer = deps.SessionSyncer
playbackHandler.WatchScrobbler = deps.WatchScrobbler
playbackHandler.StableIdentityResolver = deps.StableIdentityResolver
if subtitleRepo != nil {
playbackHandler.SubtitleRepo = subtitleRepo
playbackHandler.S3Client = deps.S3Client
+12 -8
View File
@@ -20,6 +20,7 @@ import (
"github.com/Silo-Server/silo-server/internal/subtitles"
"github.com/Silo-Server/silo-server/internal/userstore"
"github.com/Silo-Server/silo-server/internal/watchstate"
"github.com/Silo-Server/silo-server/internal/watchsync"
)
// Dependencies holds the pluggable pieces used by the compat server.
@@ -92,14 +93,16 @@ type Dependencies struct {
// admin live-session table right after compat playback starts/stops, so
// the activity dashboard doesn't wait for the periodic reconciler tick.
// Optional.
SessionSyncer PlaybackSessionSyncer
FileResolver FilePathResolver
UserStoreProvider userstore.UserStoreProvider
AccessFilterFn AccessFilterResolver
NodePlanner nodepool.SessionPlanner
JWTSecret string
Recommender recommendations.Recommender
RecWorker *recommendations.Worker
SessionSyncer PlaybackSessionSyncer
FileResolver FilePathResolver
UserStoreProvider userstore.UserStoreProvider
WatchScrobbler PlaybackWatchScrobbler
StableIdentityResolver watchsync.ScrobbleIdentityResolver
AccessFilterFn AccessFilterResolver
NodePlanner nodepool.SessionPlanner
JWTSecret string
Recommender recommendations.Recommender
RecWorker *recommendations.Worker
// Settings (optional; reads server_settings for watched threshold, etc.)
SettingsRepo SettingsReader
@@ -173,6 +176,7 @@ func (s *Server) StartBackgroundTasks(ctx context.Context) {
repo := NewSessionRepository(s.deps.DB, s.deps.SecretCipher)
StartSessionCleanupWithPlaybackStore(ctx, repo, s.deps.PlaybackStore, 1*time.Hour)
}
StartTerminalScrobbleRecovery(ctx, s.deps.PlaybackStore, s.deps.WatchScrobbler, 30*time.Second)
}
// NewDependencies fills in sensible defaults for optional compat dependencies.
+393 -25
View File
@@ -25,6 +25,7 @@ import (
"github.com/Silo-Server/silo-server/internal/nodepool"
"github.com/Silo-Server/silo-server/internal/playback"
"github.com/Silo-Server/silo-server/internal/subtitles"
"github.com/Silo-Server/silo-server/internal/watchsync"
)
// Jellyfin Web is sensitive to startup latency. Use shorter compat segments
@@ -40,7 +41,7 @@ type sessionReportRequest struct {
ItemID string `json:"ItemId"`
MediaSourceID string `json:"MediaSourceId"`
PlaySessionID string `json:"PlaySessionId"`
PositionTicks int64 `json:"PositionTicks"`
PositionTicks *int64 `json:"PositionTicks,omitempty"`
IsPaused bool `json:"IsPaused"`
AudioStreamIndex *compatIntValue `json:"AudioStreamIndex,omitempty"`
SubtitleStreamIndex *compatIntValue `json:"SubtitleStreamIndex,omitempty"`
@@ -778,22 +779,98 @@ func (h *PlaybackHandler) HandleDeleteActiveEncodings(w http.ResponseWriter, r *
return
}
h.teardownPlaySession(r.Context(), playSession)
fallback := compatScrobbleFallbackSession(session, playSession, nil, 0, false, false)
upstreamSession, transcodeNodeURL := h.compatStopSnapshot(playSession, fallback)
if event, ok := h.compatScrobbleEvent(
r.Context(), compatScrobbleStop, playSession, upstreamSession, nil, nil,
); ok {
h.stageCompatTerminal(r.Context(), playSession, upstreamSession, transcodeNodeURL, event, false, false, 0)
} else if upstreamSession == nil {
// With no native session and no reported position, publishing a zero-value
// fallback could move provider progress backwards. Keep only the terminal
// authenticated mapping for a possible later Stopped report.
if err := h.playbackStore.HideFromRouting(playSession.ID, playSession.CompatToken); err != nil &&
!errors.Is(err, ErrSessionNotFound) {
h.scheduleCompatTerminalHide(playSession.ID, playSession.CompatToken, playSession.ExpiresAt, 1)
}
h.cleanupPlaySession(r.Context(), playSession, nil, transcodeNodeURL)
} else {
h.playbackStore.Delete(playSession.ID)
h.cleanupPlaySession(r.Context(), playSession, upstreamSession, transcodeNodeURL)
}
w.WriteHeader(http.StatusNoContent)
}
// teardownPlaySession stops the upstream playback session and removes the compat
// play session from the store. Every step is idempotent, so it is safe to call
// from both the explicit ActiveEncodings teardown and a Stopped playback report
// (which may race). It is a no-op-safe teardown for an already-gone session.
func (h *PlaybackHandler) teardownPlaySession(ctx context.Context, playSession *PlaybackSession) {
// teardownPlaySession stages the authoritative stop before resource cleanup,
// then delivers it through a leased durable record. The record is removed only
// after watch-sync accepts the event, so a provider-queue failure remains
// retryable by the client or the delayed ActiveEncodings fallback.
func (h *PlaybackHandler) teardownPlaySession(
ctx context.Context,
playSession *PlaybackSession,
fallbackSession *playback.Session,
positionOverride *float64,
) {
upstreamSession, transcodeNodeURL := h.compatStopSnapshot(playSession, fallbackSession)
if event, ok := h.compatScrobbleEvent(
ctx, compatScrobbleStop, playSession, upstreamSession, nil, positionOverride,
); ok {
h.stageCompatTerminal(ctx, playSession, upstreamSession, transcodeNodeURL, event, true, false, 0)
} else if playSession.Terminal {
// A late Stopped report without PositionTicks cannot replace a staged
// fallback after ActiveEncodings already removed the native session. Keep
// that durable event (or terminal shell) and retry its delivery instead of
// deleting the only recoverable stop position.
h.cleanupPlaySession(ctx, playSession, upstreamSession, transcodeNodeURL)
if playSession.TerminalScrobbleEvent != nil {
h.deliverCompatTerminal(
ctx,
playSession.ID,
playSession.CompatToken,
playSession.TerminalAuthoritative,
playSession.ExpiresAt,
0,
true,
)
}
} else {
h.playbackStore.Delete(playSession.ID)
h.cleanupPlaySession(ctx, playSession, upstreamSession, transcodeNodeURL)
}
}
func (h *PlaybackHandler) compatStopSnapshot(
playSession *PlaybackSession,
fallbackSession *playback.Session,
) (*playback.Session, string) {
transcodeNodeURL := ""
var upstreamSession *playback.Session
if h.sessionMgr != nil {
if upstreamSession, err := h.sessionMgr.GetSession(playSession.UpstreamSessionID); err == nil {
if current, err := h.sessionMgr.GetSession(playSession.UpstreamSessionID); err == nil {
upstreamSession = current
transcodeNodeURL = upstreamSession.TranscodeNodeURL
}
}
if upstreamSession == nil && fallbackSession != nil {
copy := *fallbackSession
copy.ID = playSession.UpstreamSessionID
if source := compatScrobbleSource(playSession, &copy, nil); source != nil {
copy.MediaFileID = source.FileID
}
upstreamSession = &copy
}
return upstreamSession, transcodeNodeURL
}
// cleanupPlaySession performs idempotent process/resource cleanup after the
// terminal provider event has been staged (or intentionally omitted).
func (h *PlaybackHandler) cleanupPlaySession(
ctx context.Context,
playSession *PlaybackSession,
upstreamSession *playback.Session,
transcodeNodeURL string,
) {
h.tm.CloseTranscodeSession(playSession.UpstreamSessionID, transcodeNodeURL)
if h.sessionMgr != nil {
_ = h.sessionMgr.StopSession(playSession.UpstreamSessionID)
@@ -809,17 +886,232 @@ func (h *PlaybackHandler) teardownPlaySession(ctx context.Context, playSession *
"playback_session_id", playSession.UpstreamSessionID)
}
}
h.playbackStore.Delete(playSession.ID)
// Clients often drop the connection right after reporting a stop, so detach
// the sync from request cancellation to keep the admin view accurate.
h.syncSessionsNow(context.WithoutCancel(ctx), "compat_stop")
}
const (
compatTerminalClaimLease = 10 * time.Second
compatTerminalInitialRetryDelay = 250 * time.Millisecond
compatTerminalMaxRetryDelay = 30 * time.Second
defaultCompatTerminalFallbackDelay = 2 * time.Second
)
func (h *PlaybackHandler) compatTerminalFallbackDelay() time.Duration {
if h != nil && h.terminalFallbackDelay > 0 {
return h.terminalFallbackDelay
}
return defaultCompatTerminalFallbackDelay
}
func compatTerminalRetryDelay(attempt int) time.Duration {
delay := compatTerminalInitialRetryDelay
for i := 0; i < attempt && delay < compatTerminalMaxRetryDelay; i++ {
delay *= 2
if delay > compatTerminalMaxRetryDelay {
return compatTerminalMaxRetryDelay
}
}
return delay
}
func (h *PlaybackHandler) stageCompatTerminal(
ctx context.Context,
playSession *PlaybackSession,
upstreamSession *playback.Session,
transcodeNodeURL string,
event watchsync.ScrobbleEvent,
authoritative bool,
cleanupDone bool,
attempt int,
) {
staged, err := h.playbackStore.StageTerminal(playSession.ID, playSession.CompatToken, event, authoritative)
if err != nil {
// Production durable staging installs its local marker before I/O. Keep
// the interface invariant for alternate stores that fail before doing so.
_ = h.playbackStore.HideFromRouting(playSession.ID, playSession.CompatToken)
if errors.Is(err, ErrSessionNotFound) {
if !cleanupDone {
h.cleanupPlaySession(ctx, playSession, upstreamSession, transcodeNodeURL)
}
return
}
if !cleanupDone {
h.cleanupPlaySession(ctx, playSession, upstreamSession, transcodeNodeURL)
cleanupDone = true
}
if playSession.ExpiresAt.IsZero() || time.Now().Before(playSession.ExpiresAt) {
h.scheduleCompatTerminalStage(
playSession, upstreamSession, transcodeNodeURL, event, authoritative, cleanupDone, attempt+1,
)
} else if !cleanupDone {
h.cleanupPlaySession(ctx, playSession, upstreamSession, transcodeNodeURL)
}
return
}
if !cleanupDone {
h.cleanupPlaySession(ctx, staged, upstreamSession, transcodeNodeURL)
}
if authoritative {
h.deliverCompatTerminal(ctx, staged.ID, staged.CompatToken, true, staged.ExpiresAt, 0, true)
return
}
h.scheduleCompatTerminalDelivery(
staged.ID, staged.CompatToken, false, staged.ExpiresAt, h.compatTerminalFallbackDelay(), 0,
)
}
func (h *PlaybackHandler) scheduleCompatTerminalHide(
playSessionID string,
compatToken string,
expiresAt time.Time,
attempt int,
) {
time.AfterFunc(compatTerminalRetryDelay(attempt), func() {
if !expiresAt.IsZero() && !time.Now().Before(expiresAt) {
return
}
err := h.playbackStore.HideFromRouting(playSessionID, compatToken)
if err != nil && !errors.Is(err, ErrSessionNotFound) {
h.scheduleCompatTerminalHide(playSessionID, compatToken, expiresAt, attempt+1)
}
})
}
func (h *PlaybackHandler) scheduleCompatTerminalStage(
playSession *PlaybackSession,
upstreamSession *playback.Session,
transcodeNodeURL string,
event watchsync.ScrobbleEvent,
authoritative bool,
cleanupDone bool,
attempt int,
) {
playSessionCopy := *playSession
var upstreamCopy *playback.Session
if upstreamSession != nil {
copy := *upstreamSession
upstreamCopy = &copy
}
time.AfterFunc(compatTerminalRetryDelay(attempt), func() {
h.stageCompatTerminal(
context.Background(), &playSessionCopy, upstreamCopy, transcodeNodeURL,
event, authoritative, cleanupDone, attempt,
)
})
}
func (h *PlaybackHandler) scheduleCompatTerminalDelivery(
playSessionID string,
compatToken string,
requireAuthoritative bool,
expiresAt time.Time,
delay time.Duration,
attempt int,
) {
time.AfterFunc(delay, func() {
h.deliverCompatTerminal(
context.Background(), playSessionID, compatToken, requireAuthoritative, expiresAt, attempt, true,
)
})
}
// deliverCompatTerminal leases the staged event, persists it into watch-sync's
// durable queue, and only then completes the compat terminal record. A
// provisional ActiveEncodings fallback remains available for a later
// authoritative Stopped replacement.
func (h *PlaybackHandler) deliverCompatTerminal(
ctx context.Context,
playSessionID string,
compatToken string,
requireAuthoritative bool,
expiresAt time.Time,
attempt int,
retry bool,
) {
if h == nil || h.playbackStore == nil || h.WatchScrobbler == nil {
return
}
if !expiresAt.IsZero() && !time.Now().Before(expiresAt) {
return
}
now := time.Now().UTC().Truncate(time.Microsecond)
claimUntil := now.Add(compatTerminalClaimLease)
playSession, err := h.playbackStore.ClaimTerminal(playSessionID, compatToken, claimUntil)
if err != nil {
if !requireAuthoritative && errors.Is(err, ErrTerminalClaimUnavailable) {
if pending, ok := h.playbackStore.GetFinalizable(playSessionID, compatToken); ok &&
pending.TerminalFallbackSent && !pending.TerminalAuthoritative {
return
}
}
if retry && !errors.Is(err, ErrSessionNotFound) {
h.scheduleCompatTerminalDelivery(
playSessionID, compatToken, requireAuthoritative, expiresAt,
compatTerminalRetryDelay(attempt), attempt+1,
)
}
return
}
ownedClaimUntil := playSession.TerminalClaimUntil
if playSession.TerminalScrobbleEvent == nil || (requireAuthoritative && !playSession.TerminalAuthoritative) {
h.playbackStore.ReleaseTerminalClaim(
playSessionID, compatToken, ownedClaimUntil, playSession.TerminalClaimVersion, false,
)
if retry {
h.scheduleCompatTerminalDelivery(
playSessionID, compatToken, requireAuthoritative, expiresAt,
compatTerminalRetryDelay(attempt), attempt+1,
)
}
return
}
err = h.dispatchCompatScrobbleEvent(ctx, compatScrobbleStop, *playSession.TerminalScrobbleEvent)
if err != nil {
h.playbackStore.ReleaseTerminalClaim(
playSessionID, compatToken, ownedClaimUntil, playSession.TerminalClaimVersion, false,
)
if retry {
h.scheduleCompatTerminalDelivery(
playSessionID, compatToken, requireAuthoritative, expiresAt,
compatTerminalRetryDelay(attempt), attempt+1,
)
}
return
}
if playSession.TerminalAuthoritative {
h.playbackStore.CompleteTerminal(
playSessionID, compatToken, ownedClaimUntil, playSession.TerminalClaimVersion,
)
// If a newer authoritative report replaced this event while it was in
// flight, completion intentionally failed. Release the old lease so the
// replacement can be claimed immediately instead of waiting for expiry.
h.playbackStore.ReleaseTerminalClaim(
playSessionID, compatToken, ownedClaimUntil, playSession.TerminalClaimVersion, false,
)
return
}
h.playbackStore.ReleaseTerminalClaim(
playSessionID, compatToken, ownedClaimUntil, playSession.TerminalClaimVersion, true,
)
}
// compatSessionSyncTimeout bounds the immediate session sync issued from
// request paths, so a stalled database degrades to the periodic reconciler
// tick instead of pinning request goroutines.
const compatSessionSyncTimeout = 5 * time.Second
func compatDetachedContext(ctx context.Context) (context.Context, context.CancelFunc) {
if ctx == nil {
ctx = context.Background()
} else {
ctx = context.WithoutCancel(ctx)
}
return context.WithTimeout(ctx, compatSessionSyncTimeout)
}
// syncSessionsNow flushes the native-session snapshot to the shared admin
// live-session table so compat start/stop events are visible immediately
// instead of on the next reconciler tick.
@@ -851,11 +1143,16 @@ func (h *PlaybackHandler) handlePlaybackReport(w http.ResponseWriter, r *http.Re
return
}
playSession, ok := h.playbackStore.Get(req.PlaySessionID)
if ok && playSession.CompatToken != session.Token {
playSession, ok = nil, false
var playSession *PlaybackSession
var ok bool
if stop {
playSession, ok = h.playbackStore.GetFinalizable(req.PlaySessionID, session.Token)
} else {
playSession, ok = h.playbackStore.Get(req.PlaySessionID)
if ok && playSession.CompatToken != session.Token {
playSession, ok = nil, false
}
}
matchedByRouteOnly := false
if !ok {
// Static=true direct play (Infuse, SenPlayer) skips PlaybackInfo, so the
// client reports progress under its own generated PlaySessionId. The
@@ -864,7 +1161,13 @@ func (h *PlaybackHandler) handlePlaybackReport(w http.ResponseWriter, r *http.Re
// route-scoped lookup the stream path uses (see resolvePlaybackRoute).
// Without either, these reports silently no-op, the admin activity view
// position freezes, and stale cleanup drops the still-active session.
playSession, ok = h.playbackStore.FindByClientPlaySessionID(session.Token, req.PlaySessionID)
if stop {
playSession, ok = h.playbackStore.FindFinalizableByClientPlaySessionID(
session.Token, req.PlaySessionID, req.ItemID, req.MediaSourceID,
)
} else {
playSession, ok = h.playbackStore.FindByClientPlaySessionID(session.Token, req.PlaySessionID)
}
if ok && !reportMatchesPlaySession(playSession, req) {
playSession, ok = nil, false
}
@@ -874,10 +1177,15 @@ func (h *PlaybackHandler) handlePlaybackReport(w http.ResponseWriter, r *http.Re
if routeID == "" {
continue
}
if playSession, _, ok = h.playbackStore.FindByRoute(session.Token, routeID); ok {
matchedByRouteOnly = true
if stop {
playSession, _, ok = h.playbackStore.FindFinalizableByRoute(session.Token, routeID)
} else {
playSession, _, ok = h.playbackStore.FindByRoute(session.Token, routeID)
}
if ok && reportMatchesPlaySession(playSession, req) {
break
}
playSession, ok = nil, false
}
}
if !ok || playSession.UpstreamSessionID == "" {
@@ -885,7 +1193,14 @@ func (h *PlaybackHandler) handlePlaybackReport(w http.ResponseWriter, r *http.Re
return
}
positionSeconds := float64(req.PositionTicks) / 10_000_000
positionSeconds := 0.0
positionReported := req.PositionTicks != nil
if positionReported {
positionSeconds = float64(*req.PositionTicks) / 10_000_000
if positionSeconds < 0 {
positionSeconds = 0
}
}
audioTrackIndex := 0
audioRestarted := false
// Jellyfin web/mobile clients send AudioStreamIndex on every progress
@@ -928,8 +1243,15 @@ func (h *PlaybackHandler) handlePlaybackReport(w http.ResponseWriter, r *http.Re
)
}
}
if positionSeconds > 0 && h.sessionMgr != nil {
var previousSession *playback.Session
progressUpdated := false
if positionReported && h.sessionMgr != nil {
if current, err := h.sessionMgr.GetSession(playSession.UpstreamSessionID); err == nil && current != nil {
copy := *current
previousSession = &copy
}
err := h.sessionMgr.UpdateProgress(playSession.UpstreamSessionID, positionSeconds, req.IsPaused)
progressUpdated = err == nil
if errors.Is(err, playback.ErrSessionNotFound) && !stop {
// The upstream session was reaped as stale (e.g. the client buffered
// far ahead and went quiet between range requests). The report proves
@@ -937,10 +1259,24 @@ func (h *PlaybackHandler) handlePlaybackReport(w http.ResponseWriter, r *http.Re
// dropping it from session tracking for the rest of playback.
if revived := h.reviveUpstreamForReport(r.Context(), session, playSession, req.MediaSourceID); revived != nil {
playSession = revived
_ = h.sessionMgr.UpdateProgress(playSession.UpstreamSessionID, positionSeconds, req.IsPaused)
progressUpdated = h.sessionMgr.UpdateProgress(playSession.UpstreamSessionID, positionSeconds, req.IsPaused) == nil
previousSession = nil
}
}
}
if progressUpdated && !stop && previousSession != nil && previousSession.IsPaused != req.IsPaused {
updatedSession := *previousSession
updatedSession.Position = positionSeconds
updatedSession.IsPaused = req.IsPaused
action := compatScrobbleStart
if req.IsPaused {
action = compatScrobblePause
}
h.dispatchCompatScrobbleAt(
r.Context(), action, playSession, &updatedSession,
findMediaSource(playSession, req.MediaSourceID), &positionSeconds,
)
}
// Persist progress to user store
if positionSeconds > 0 && h.storeProvider != nil && playSession.ItemID != "" {
if store, storeErr := h.storeProvider.ForUser(r.Context(), session.StreamAppUserID); storeErr == nil {
@@ -957,12 +1293,19 @@ func (h *PlaybackHandler) handlePlaybackReport(w http.ResponseWriter, r *http.Re
}
}
}
if stop && !matchedByRouteOnly {
// A bare item/source route match is ambiguous when the same item plays
// twice under one token, so never tear down a session the report may
// not own. A session that really stopped emits no further reports or
// transport, so stale cleanup reaps it shortly anyway.
h.teardownPlaySession(r.Context(), playSession)
if stop {
// Direct ids and aliases are caller-owned; route-only stopped reports are
// accepted only when FindFinalizableByRoute proves the token-scoped match
// is unique.
source := findMediaSource(playSession, req.MediaSourceID)
fallback := compatScrobbleFallbackSession(
session, playSession, source, positionSeconds, positionReported, req.IsPaused,
)
var positionOverride *float64
if positionReported {
positionOverride = &positionSeconds
}
h.teardownPlaySession(r.Context(), playSession, fallback, positionOverride)
}
w.WriteHeader(http.StatusNoContent)
@@ -1051,7 +1394,12 @@ func (h *PlaybackHandler) ensureUpstreamPlayback(ctx context.Context, compatSess
card.ClientName, card.ClientVersion, card.ClientUserAgent = info.Name, info.Version, info.UserAgent
}
if reconstructed := h.tm.ReconstructSession(ctx, playSession.UpstreamSessionID, compatSession.StreamAppUserID, card); reconstructed != nil {
if !playSession.ProgressPersistenceKnown ||
playSession.DisableProgressPersistence != reconstructed.DisableProgressPersistence {
h.recordCompatProgressPersistence(playSession.ID, reconstructed.DisableProgressPersistence)
}
_ = h.syncUpstreamAudioSelection(playSession, source)
h.dispatchCompatScrobble(ctx, compatScrobbleStart, playSession, reconstructed, &source)
return playSession, nil
}
}
@@ -1067,6 +1415,11 @@ func (h *PlaybackHandler) ensureUpstreamPlayback(ctx context.Context, compatSess
playSession.UpstreamPlayMethod = ""
playSession.TranscodeStarted = false
} else {
if current, currentErr := h.sessionMgr.GetSession(playSession.UpstreamSessionID); currentErr == nil &&
(!playSession.ProgressPersistenceKnown ||
playSession.DisableProgressPersistence != current.DisableProgressPersistence) {
h.recordCompatProgressPersistence(playSession.ID, current.DisableProgressPersistence)
}
_ = h.syncUpstreamAudioSelection(playSession, source)
return playSession, nil
}
@@ -1093,6 +1446,7 @@ func (h *PlaybackHandler) ensureUpstreamPlayback(ctx context.Context, compatSess
transcodeNodeURL := ""
if current, err := h.sessionMgr.GetSession(oldUpstreamSessionID); err == nil {
transcodeNodeURL = current.TranscodeNodeURL
h.dispatchCompatScrobble(ctx, compatScrobblePause, playSession, current, nil)
}
_ = h.sessionMgr.StopSession(oldUpstreamSessionID)
h.tm.CloseTranscodeSession(oldUpstreamSessionID, transcodeNodeURL)
@@ -1134,6 +1488,8 @@ func (h *PlaybackHandler) ensureUpstreamPlayback(ctx context.Context, compatSess
current.UpstreamSessionID = session.ID
current.UpstreamPlayMethod = method
current.TranscodeStarted = false
current.ProgressPersistenceKnown = true
current.DisableProgressPersistence = session.DisableProgressPersistence
return nil
}); updateErr != nil {
_ = h.sessionMgr.StopSession(session.ID)
@@ -1154,9 +1510,21 @@ func (h *PlaybackHandler) ensureUpstreamPlayback(ctx context.Context, compatSess
return nil, ErrSessionNotFound
}
h.syncSessionsNow(ctx, "compat_start")
h.dispatchCompatScrobble(ctx, compatScrobbleStart, updated, session, &source)
return updated, nil
}
func (h *PlaybackHandler) recordCompatProgressPersistence(playSessionID string, disabled bool) {
if h == nil || h.playbackStore == nil || playSessionID == "" {
return
}
_ = h.playbackStore.Update(playSessionID, func(session *PlaybackSession) error {
session.ProgressPersistenceKnown = true
session.DisableProgressPersistence = disabled
return nil
})
}
func (h *PlaybackHandler) ensureTranscodeManifest(ctx context.Context, compatSession *Session, playSessionID string, source PlaybackMediaSource) ([]byte, error) {
playSession, err := h.ensureUpstreamPlayback(ctx, compatSession, playSessionID, source, "transcode")
if err != nil {
+1 -1
View File
@@ -198,7 +198,7 @@ func TestTeardownPlaySession_DeletesNodeRecipe(t *testing.T) {
if !ok {
t.Fatal("expected play session")
}
h.teardownPlaySession(context.Background(), playSession)
h.teardownPlaySession(context.Background(), playSession, nil, nil)
if _, ok := recipeStore.Get("upstream-1"); ok {
t.Fatal("node recipe should be deleted on deliberate teardown")
@@ -0,0 +1,65 @@
package jellycompat
import (
"context"
"log/slog"
"time"
)
const compatTerminalRecoveryBatchSize = 100
// StartTerminalScrobbleRecovery resumes terminal provider events that survived
// a server exit after staging but before delivery. The first scan runs at
// startup; periodic scans also recover expired leases from crashed peers.
func StartTerminalScrobbleRecovery(
ctx context.Context,
store CompatPlaybackStore,
scrobbler PlaybackWatchScrobbler,
interval time.Duration,
) {
if store == nil || scrobbler == nil {
return
}
if interval <= 0 {
interval = 30 * time.Second
}
handler := &PlaybackHandler{playbackStore: store, WatchScrobbler: scrobbler}
run := func() {
if err := recoverPendingTerminalScrobbles(ctx, handler); err != nil {
slog.WarnContext(ctx, "recover jellycompat terminal scrobbles failed",
"component", "jellycompat", "error", err)
}
}
go func() {
run()
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
run()
}
}
}()
}
func recoverPendingTerminalScrobbles(ctx context.Context, handler *PlaybackHandler) error {
pending, err := handler.playbackStore.ListPendingTerminals(ctx, compatTerminalRecoveryBatchSize)
if err != nil {
return err
}
for _, session := range pending {
handler.deliverCompatTerminal(
ctx,
session.ID,
session.CompatToken,
session.TerminalAuthoritative,
session.ExpiresAt,
0,
false,
)
}
return nil
}
+55
View File
@@ -0,0 +1,55 @@
package watchsync
import (
"context"
"github.com/Silo-Server/silo-server/internal/historyimport"
"github.com/Silo-Server/silo-server/internal/userstore"
)
// ScrobbleIdentityResolver resolves a local media item to the stable provider
// identity required by external scrobble APIs.
type ScrobbleIdentityResolver interface {
ResolveHistoryIdentity(ctx context.Context, mediaItemID string) userstore.WatchIdentity
}
// ResolveScrobbleIdentity enriches an event with stable movie or episode IDs.
// Unknown identities retain the native playback fallback of treating the item
// as a movie; providers then report their normal missing-identity error instead
// of silently dropping the lifecycle event.
func ResolveScrobbleIdentity(ctx context.Context, resolver ScrobbleIdentityResolver, event ScrobbleEvent) ScrobbleEvent {
if resolver == nil {
if event.Kind == "" {
event.Kind = historyimport.KindMovie
}
return event
}
identity := resolver.ResolveHistoryIdentity(ctx, event.MediaItemID)
if identity.StableType != "" {
event.Kind = identity.StableType
}
if event.Kind == "" {
event.Kind = historyimport.KindMovie
}
event.SeasonNumber = optionalIntValue(identity.Season)
event.EpisodeNumber = optionalIntValue(identity.Episode)
if identity.ProviderIDs != nil {
event.IMDbID = identity.ProviderIDs["imdb"]
event.TMDBID = identity.ProviderIDs["tmdb"]
event.TVDBID = identity.ProviderIDs["tvdb"]
}
if identity.SeriesProviderIDs != nil {
event.SeriesIMDbID = identity.SeriesProviderIDs["imdb"]
event.SeriesTMDBID = identity.SeriesProviderIDs["tmdb"]
event.SeriesTVDBID = identity.SeriesProviderIDs["tvdb"]
}
return event
}
func optionalIntValue(value *int) int {
if value == nil {
return 0
}
return *value
}
@@ -0,0 +1,44 @@
package watchsync
import (
"context"
"testing"
"github.com/Silo-Server/silo-server/internal/userstore"
)
type fixedScrobbleIdentityResolver struct {
identity userstore.WatchIdentity
}
func (r fixedScrobbleIdentityResolver) ResolveHistoryIdentity(context.Context, string) userstore.WatchIdentity {
return r.identity
}
func TestResolveScrobbleIdentityEpisode(t *testing.T) {
season := 2
episode := 7
event := ResolveScrobbleIdentity(context.Background(), fixedScrobbleIdentityResolver{
identity: userstore.WatchIdentity{
StableType: "episode",
ProviderIDs: map[string]string{"tmdb": "episode-tmdb"},
SeriesProviderIDs: map[string]string{"tvdb": "series-tvdb"},
Season: &season,
Episode: &episode,
},
}, ScrobbleEvent{MediaItemID: "episode-1"})
if event.Kind != "episode" || event.TMDBID != "episode-tmdb" || event.SeriesTVDBID != "series-tvdb" {
t.Fatalf("resolved identity = %+v", event)
}
if event.SeasonNumber != season || event.EpisodeNumber != episode {
t.Fatalf("resolved episode numbers = S%dE%d, want S%dE%d", event.SeasonNumber, event.EpisodeNumber, season, episode)
}
}
func TestResolveScrobbleIdentityDefaultsUnknownItemToMovie(t *testing.T) {
event := ResolveScrobbleIdentity(context.Background(), nil, ScrobbleEvent{MediaItemID: "movie-1"})
if event.Kind != "movie" {
t.Fatalf("kind = %q, want movie", event.Kind)
}
}