Files
silo-server/internal/jellycompat/playback_negotiation_dedup_test.go
Puks The PirateandGitHub 20c6b146eb fix(jellycompat): repair episode handoff (#529)
Continue Watching clients can retain stale user and media-source values across episode transitions. Keep token-derived authorization authoritative and fall back to the requested item files.

Strip NUL code points across durable playback-session JSON so PostgreSQL persistence cannot leave negotiated sessions cache-only.
2026-08-11 12:15:59 -04:00

191 lines
5.9 KiB
Go

package jellycompat
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/go-chi/chi/v5"
)
func TestPlaybackSessionStorePutNegotiatedReplacesUnstartedSameDevice(t *testing.T) {
store := NewPlaybackSessionStore(0, nil)
store.PutNegotiated(PlaybackSession{
ID: "first",
CompatToken: "token",
ClientDeviceID: "web-device",
RouteItemID: "route",
})
store.PutNegotiated(PlaybackSession{
ID: "second",
CompatToken: "token",
ClientDeviceID: "web-device",
RouteItemID: "route",
})
if _, ok := store.Get("first"); ok {
t.Fatal("superseded unstarted negotiation was retained")
}
if _, ok := store.Get("second"); !ok {
t.Fatal("new negotiation was not stored")
}
}
func TestPlaybackSessionStorePutNegotiatedPreservesDistinctOrStartedPlays(t *testing.T) {
tests := []struct {
name string
first PlaybackSession
}{
{
name: "different device",
first: PlaybackSession{
ID: "first",
CompatToken: "token",
ClientDeviceID: "other-device",
RouteItemID: "route",
},
},
{
name: "already started",
first: PlaybackSession{
ID: "first",
CompatToken: "token",
ClientDeviceID: "web-device",
RouteItemID: "route",
UpstreamSessionID: "upstream-first",
},
},
{
name: "terminal session",
first: PlaybackSession{
ID: "first",
CompatToken: "token",
ClientDeviceID: "web-device",
RouteItemID: "route",
Terminal: true,
},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
store := NewPlaybackSessionStore(0, nil)
store.PutNegotiated(tc.first)
store.PutNegotiated(PlaybackSession{
ID: "second",
CompatToken: "token",
ClientDeviceID: "web-device",
RouteItemID: "route",
})
if _, ok := store.GetFinalizable("first", "token"); !ok {
t.Fatal("distinct or already-started play was replaced")
}
if _, ok := store.Get("second"); !ok {
t.Fatal("new negotiation was not stored")
}
})
}
}
func TestHandlePlaybackInfoReplacesDuplicateJellyfinWebNegotiation(t *testing.T) {
handler, routeID := newSubtitleSelectionHandler(t)
first := postPlaybackInfoForDevice(t, handler, routeID, "web-device")
second := postPlaybackInfoForDevice(t, handler, routeID, "web-device")
store := handler.playbackStore.(*PlaybackSessionStore)
if _, ok := store.Get(first.PlaySessionID); ok {
t.Fatal("first Jellyfin Web negotiation remained routable")
}
stored, ok := store.Get(second.PlaySessionID)
if !ok {
t.Fatal("second Jellyfin Web negotiation was not routable")
}
if stored.ClientDeviceID != "web-device" {
t.Fatalf("ClientDeviceID = %q, want web-device", stored.ClientDeviceID)
}
}
func TestHandlePlaybackInfoStripsNULFromClientDeviceID(t *testing.T) {
handler, routeID := newSubtitleSelectionHandler(t)
result := postPlaybackInfoForDevice(t, handler, routeID, "vidhub\x00device")
store := handler.playbackStore.(*PlaybackSessionStore)
stored, ok := store.Get(result.PlaySessionID)
if !ok {
t.Fatal("negotiated play session was not stored")
}
if stored.ClientDeviceID != "vidhubdevice" {
t.Fatalf("ClientDeviceID = %q, want NUL-free identifier", stored.ClientDeviceID)
}
}
func TestHandlePlaybackInfoIgnoresStaleRequestUserID(t *testing.T) {
handler, routeID := newSubtitleSelectionHandler(t)
req := httptest.NewRequest(http.MethodPost, "/Items/"+routeID+"/PlaybackInfo", strings.NewReader(`{"UserId":"stale-client-user"}`))
routeCtx := chi.NewRouteContext()
routeCtx.URLParams.Add("id", routeID)
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, routeCtx))
req = req.WithContext(context.WithValue(req.Context(), compatSessionKey, &Session{Token: "token-1"}))
recorder := httptest.NewRecorder()
handler.HandlePlaybackInfo(recorder, req)
if recorder.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", recorder.Code, recorder.Body.String())
}
}
func TestHandlePlaybackInfoFallsBackFromStaleMediaSourceID(t *testing.T) {
handler, routeID := newSubtitleSelectionHandler(t)
req := httptest.NewRequest(http.MethodPost, "/Items/"+routeID+"/PlaybackInfo", strings.NewReader(`{"MediaSourceId":"previous-episode-source"}`))
routeCtx := chi.NewRouteContext()
routeCtx.URLParams.Add("id", routeID)
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, routeCtx))
req = req.WithContext(context.WithValue(req.Context(), compatSessionKey, &Session{Token: "token-1"}))
recorder := httptest.NewRecorder()
handler.HandlePlaybackInfo(recorder, req)
if recorder.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", recorder.Code, recorder.Body.String())
}
var response playbackInfoResponseDTO
if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil {
t.Fatalf("unmarshal response: %v", err)
}
if len(response.MediaSources) == 0 {
t.Fatal("stale media source fallback returned no playable sources")
}
}
func postPlaybackInfoForDevice(
t *testing.T,
handler *PlaybackHandler,
routeID string,
deviceID string,
) playbackInfoResponseDTO {
t.Helper()
req := httptest.NewRequest(http.MethodPost, "/Items/"+routeID+"/PlaybackInfo", strings.NewReader(`{}`))
req.Header.Set(
"X-Emby-Authorization",
`MediaBrowser Client="Jellyfin Web", Device="Chrome", DeviceId="`+deviceID+`", Version="10.11.6"`,
)
routeCtx := chi.NewRouteContext()
routeCtx.URLParams.Add("id", routeID)
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, routeCtx))
req = req.WithContext(context.WithValue(req.Context(), compatSessionKey, &Session{Token: "token-1"}))
recorder := httptest.NewRecorder()
handler.HandlePlaybackInfo(recorder, req)
if recorder.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", recorder.Code, recorder.Body.String())
}
var response playbackInfoResponseDTO
if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil {
t.Fatalf("unmarshal response: %v", err)
}
return response
}