Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e006553335 | ||
|
|
2947d58836 |
@@ -21,6 +21,7 @@ type testCompatSessionManager struct {
|
|||||||
sessions map[string]*playback.Session
|
sessions map[string]*playback.Session
|
||||||
audioTrackCalls []compatAudioTrackCall
|
audioTrackCalls []compatAudioTrackCall
|
||||||
progressCalls int
|
progressCalls int
|
||||||
|
stopCalls []string
|
||||||
}
|
}
|
||||||
|
|
||||||
type compatAudioTrackCall struct {
|
type compatAudioTrackCall struct {
|
||||||
@@ -67,7 +68,10 @@ func (m *testCompatSessionManager) UpdateAudioTrack(sessionID string, audioTrack
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *testCompatSessionManager) StopSession(string) error { return nil }
|
func (m *testCompatSessionManager) StopSession(sessionID string) error {
|
||||||
|
m.stopCalls = append(m.stopCalls, sessionID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (m *testCompatSessionManager) GetSession(sessionID string) (*playback.Session, error) {
|
func (m *testCompatSessionManager) GetSession(sessionID string) (*playback.Session, error) {
|
||||||
if session, ok := m.sessions[sessionID]; ok {
|
if session, ok := m.sessions[sessionID]; ok {
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ import (
|
|||||||
type compatEpisodeTarget struct {
|
type compatEpisodeTarget struct {
|
||||||
Item upstreamListItem
|
Item upstreamListItem
|
||||||
SeriesImages seriesImageSet
|
SeriesImages seriesImageSet
|
||||||
|
SeasonID string
|
||||||
|
SeasonName string
|
||||||
}
|
}
|
||||||
|
|
||||||
type libraryMembershipChecker interface {
|
type libraryMembershipChecker interface {
|
||||||
@@ -166,7 +168,11 @@ func (h *ItemsHandler) fetchCompatEpisodeTargetsByContentIDs(ctx context.Context
|
|||||||
}
|
}
|
||||||
|
|
||||||
access := h.resolveAccessFilter(ctx, session)
|
access := h.resolveAccessFilter(ctx, session)
|
||||||
fromClause := "episodes e JOIN media_items si ON e.series_id = si.content_id"
|
fromClause := strings.Join([]string{
|
||||||
|
"episodes e",
|
||||||
|
"JOIN media_items si ON e.series_id = si.content_id",
|
||||||
|
"LEFT JOIN seasons se ON se.content_id = e.season_id",
|
||||||
|
}, " ")
|
||||||
conditions := []string{"e.content_id = ANY($1)"}
|
conditions := []string{"e.content_id = ANY($1)"}
|
||||||
args := []any{normalized}
|
args := []any{normalized}
|
||||||
argIdx := 2
|
argIdx := 2
|
||||||
@@ -208,6 +214,8 @@ func (h *ItemsHandler) fetchCompatEpisodeTargetsByContentIDs(ctx context.Context
|
|||||||
e.updated_at,
|
e.updated_at,
|
||||||
e.season_number,
|
e.season_number,
|
||||||
e.episode_number,
|
e.episode_number,
|
||||||
|
COALESCE(e.season_id, ''),
|
||||||
|
COALESCE(se.title, ''),
|
||||||
si.content_id,
|
si.content_id,
|
||||||
si.title,
|
si.title,
|
||||||
si.genres,
|
si.genres,
|
||||||
@@ -249,6 +257,8 @@ func (h *ItemsHandler) fetchCompatEpisodeTargetsByContentIDs(ctx context.Context
|
|||||||
updatedAt time.Time
|
updatedAt time.Time
|
||||||
seasonNumber int
|
seasonNumber int
|
||||||
episodeNumber int
|
episodeNumber int
|
||||||
|
seasonID string
|
||||||
|
seasonName string
|
||||||
seriesID string
|
seriesID string
|
||||||
seriesTitle string
|
seriesTitle string
|
||||||
genres []string
|
genres []string
|
||||||
@@ -275,6 +285,8 @@ func (h *ItemsHandler) fetchCompatEpisodeTargetsByContentIDs(ctx context.Context
|
|||||||
&updatedAt,
|
&updatedAt,
|
||||||
&seasonNumber,
|
&seasonNumber,
|
||||||
&episodeNumber,
|
&episodeNumber,
|
||||||
|
&seasonID,
|
||||||
|
&seasonName,
|
||||||
&seriesID,
|
&seriesID,
|
||||||
&seriesTitle,
|
&seriesTitle,
|
||||||
&genres,
|
&genres,
|
||||||
@@ -335,6 +347,8 @@ func (h *ItemsHandler) fetchCompatEpisodeTargetsByContentIDs(ctx context.Context
|
|||||||
BackdropThumbhash: seriesBackdropTH,
|
BackdropThumbhash: seriesBackdropTH,
|
||||||
UpdatedAt: seriesUpdatedAt,
|
UpdatedAt: seriesUpdatedAt,
|
||||||
},
|
},
|
||||||
|
SeasonID: seasonID,
|
||||||
|
SeasonName: seasonName,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err := rows.Err(); err != nil {
|
if err := rows.Err(); err != nil {
|
||||||
@@ -446,6 +460,7 @@ func (h *ItemsHandler) fetchCompatEpisodeTargetsByContentIDsFallback(ctx context
|
|||||||
BackdropThumbhash: series.BackdropThumbhash,
|
BackdropThumbhash: series.BackdropThumbhash,
|
||||||
UpdatedAt: series.UpdatedAt,
|
UpdatedAt: series.UpdatedAt,
|
||||||
},
|
},
|
||||||
|
SeasonID: episode.SeasonID,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2170,6 +2170,18 @@ func (h *ItemsHandler) rememberCompatEpisodeImages(dto baseItemDTO, stillURL str
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *ItemsHandler) applyCompatEpisodeTarget(dto *baseItemDTO, target compatEpisodeTarget) {
|
func (h *ItemsHandler) applyCompatEpisodeTarget(dto *baseItemDTO, target compatEpisodeTarget) {
|
||||||
|
if target.SeasonID != "" && h.codec != nil {
|
||||||
|
encodedSeasonID := h.codec.EncodeStringID(EncodedIDSeason, target.SeasonID)
|
||||||
|
if dto.SeasonID == "" {
|
||||||
|
dto.SeasonID = encodedSeasonID
|
||||||
|
}
|
||||||
|
if dto.ParentID == "" {
|
||||||
|
dto.ParentID = encodedSeasonID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if dto.SeasonName == "" && target.SeasonName != "" {
|
||||||
|
dto.SeasonName = target.SeasonName
|
||||||
|
}
|
||||||
h.mapper.applySeriesImages(dto, target.SeriesImages)
|
h.mapper.applySeriesImages(dto, target.SeriesImages)
|
||||||
h.rememberCompatEpisodeImages(*dto, firstNonEmpty(target.Item.StillURL, target.Item.PosterURL), target.SeriesImages)
|
h.rememberCompatEpisodeImages(*dto, firstNonEmpty(target.Item.StillURL, target.Item.PosterURL), target.SeriesImages)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -152,3 +152,65 @@ func TestHydrateProgressItems_NoFieldsLeavesDetailFieldsNil(t *testing.T) {
|
|||||||
t.Errorf("MediaSources should be nil with no Fields; got %v", got[0].dto.MediaSources)
|
t.Errorf("MediaSources should be nil with no Fields; got %v", got[0].dto.MediaSources)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHydrateProgressItems_EpisodeIncludesSeasonParentMetadata(t *testing.T) {
|
||||||
|
codec := NewResourceIDCodec()
|
||||||
|
episodeID := "episode-1"
|
||||||
|
seasonID := "season-1"
|
||||||
|
seriesID := "series-1"
|
||||||
|
|
||||||
|
itemRepo := &countingItemRepo{
|
||||||
|
itemsByID: map[string]*models.MediaItem{
|
||||||
|
seriesID: {ContentID: seriesID, Type: "series", Title: "Test Series"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
episodeRepo := &countingEpisodeRepo{
|
||||||
|
episodesByID: map[string]*models.Episode{
|
||||||
|
episodeID: {
|
||||||
|
ContentID: episodeID,
|
||||||
|
SeriesID: seriesID,
|
||||||
|
SeasonID: seasonID,
|
||||||
|
Title: "Episode Four",
|
||||||
|
SeasonNumber: 1,
|
||||||
|
EpisodeNumber: 4,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
h := &ItemsHandler{
|
||||||
|
content: &panicOnGetItemDetailContent{},
|
||||||
|
userData: &mockUserDataService{},
|
||||||
|
itemRepo: itemRepo,
|
||||||
|
episodeRepo: episodeRepo,
|
||||||
|
codec: codec,
|
||||||
|
mapper: newMapper(codec, &config.Config{}),
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := h.hydrateProgressItems(
|
||||||
|
context.Background(),
|
||||||
|
&Session{StreamAppUserID: 1, ProfileID: "profile-1"},
|
||||||
|
[]upstreamProgress{{MediaItemID: episodeID, PositionSeconds: 120, DurationSeconds: 1800}},
|
||||||
|
nil,
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("hydrateProgressItems returned error: %v", err)
|
||||||
|
}
|
||||||
|
if len(got) != 1 {
|
||||||
|
t.Fatalf("expected 1 hydrated item; got %d (%v)", len(got), got)
|
||||||
|
}
|
||||||
|
|
||||||
|
wantSeasonID := codec.EncodeStringID(EncodedIDSeason, seasonID)
|
||||||
|
if got[0].dto.SeasonID != wantSeasonID {
|
||||||
|
t.Fatalf("SeasonId = %q, want %q", got[0].dto.SeasonID, wantSeasonID)
|
||||||
|
}
|
||||||
|
if got[0].dto.ParentID != wantSeasonID {
|
||||||
|
t.Fatalf("ParentId = %q, want season id %q", got[0].dto.ParentID, wantSeasonID)
|
||||||
|
}
|
||||||
|
if got[0].dto.IndexNumber == nil || *got[0].dto.IndexNumber != 4 {
|
||||||
|
t.Fatalf("IndexNumber = %v, want 4", got[0].dto.IndexNumber)
|
||||||
|
}
|
||||||
|
if got[0].dto.ParentIndexNumber == nil || *got[0].dto.ParentIndexNumber != 1 {
|
||||||
|
t.Fatalf("ParentIndexNumber = %v, want 1", got[0].dto.ParentIndexNumber)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -202,6 +202,7 @@ func NewRouter(deps Dependencies) chi.Router {
|
|||||||
r.Post("/Sessions/Playing", playbackHandler.HandleSessionPlaying)
|
r.Post("/Sessions/Playing", playbackHandler.HandleSessionPlaying)
|
||||||
r.Post("/Sessions/Playing/Progress", playbackHandler.HandleSessionPlayingProgress)
|
r.Post("/Sessions/Playing/Progress", playbackHandler.HandleSessionPlayingProgress)
|
||||||
r.Post("/Sessions/Playing/Stopped", playbackHandler.HandleSessionPlayingStopped)
|
r.Post("/Sessions/Playing/Stopped", playbackHandler.HandleSessionPlayingStopped)
|
||||||
|
r.Delete("/Videos/ActiveEncodings", playbackHandler.HandleDeleteActiveEncodings)
|
||||||
r.Post("/Sessions/Logout", authHandler.HandleLogout)
|
r.Post("/Sessions/Logout", authHandler.HandleLogout)
|
||||||
r.Get("/socket", HandleSocket)
|
r.Get("/socket", HandleSocket)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -672,6 +672,62 @@ func (h *PlaybackHandler) HandleSessionPlayingStopped(w http.ResponseWriter, r *
|
|||||||
h.handlePlaybackReport(w, r, true)
|
h.handlePlaybackReport(w, r, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HandleDeleteActiveEncodings handles DELETE /Videos/ActiveEncodings.
|
||||||
|
//
|
||||||
|
// Jellyfin clients (e.g. JellyCon) call this endpoint when playback stops to
|
||||||
|
// signal the server to tear down any running HLS transcode for the session.
|
||||||
|
// Without it, the transcode process keeps running until the playback session
|
||||||
|
// TTL expires (default 6 h). We honour the request by stopping the transcode
|
||||||
|
// identified by the playSessionId query parameter.
|
||||||
|
func (h *PlaybackHandler) HandleDeleteActiveEncodings(w http.ResponseWriter, r *http.Request) {
|
||||||
|
session := SessionFromContext(r.Context())
|
||||||
|
if session == nil {
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
q := newCaseInsensitiveQuery(r.URL.Query())
|
||||||
|
// DeviceId is intentionally ignored: Silo's playback store is keyed by
|
||||||
|
// PlaySessionId, clients always send it, and Jellyfin's own teardown matches
|
||||||
|
// by playSessionId (ignoring deviceId) whenever playSessionId is non-empty.
|
||||||
|
playSessionID := q.Get("PlaySessionId")
|
||||||
|
if playSessionID == "" {
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ownership guard (mirrors the ownership check of the Stopped report path):
|
||||||
|
// only the session's own caller may tear it down. An unknown OR not-owned
|
||||||
|
// PlaySessionId is an idempotent 204 no-op, so the response never reveals
|
||||||
|
// which case it was (no cross-session teardown, no ownership oracle).
|
||||||
|
playSession, ok := h.playbackStore.Get(playSessionID)
|
||||||
|
if !ok || playSession.CompatToken != session.Token {
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.teardownPlaySession(playSession)
|
||||||
|
|
||||||
|
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(playSession *PlaybackSession) {
|
||||||
|
transcodeNodeURL := ""
|
||||||
|
if h.sessionMgr != nil {
|
||||||
|
if upstreamSession, err := h.sessionMgr.GetSession(playSession.UpstreamSessionID); err == nil {
|
||||||
|
transcodeNodeURL = upstreamSession.TranscodeNodeURL
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h.closeTranscodeSession(playSession.UpstreamSessionID, transcodeNodeURL)
|
||||||
|
if h.sessionMgr != nil {
|
||||||
|
_ = h.sessionMgr.StopSession(playSession.UpstreamSessionID)
|
||||||
|
}
|
||||||
|
h.playbackStore.Delete(playSession.ID)
|
||||||
|
}
|
||||||
|
|
||||||
func (h *PlaybackHandler) handlePlaybackReport(w http.ResponseWriter, r *http.Request, stop bool) {
|
func (h *PlaybackHandler) handlePlaybackReport(w http.ResponseWriter, r *http.Request, stop bool) {
|
||||||
session := SessionFromContext(r.Context())
|
session := SessionFromContext(r.Context())
|
||||||
if session == nil {
|
if session == nil {
|
||||||
@@ -755,17 +811,7 @@ func (h *PlaybackHandler) handlePlaybackReport(w http.ResponseWriter, r *http.Re
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if stop {
|
if stop {
|
||||||
transcodeNodeURL := ""
|
h.teardownPlaySession(playSession)
|
||||||
if h.sessionMgr != nil {
|
|
||||||
if upstreamSession, err := h.sessionMgr.GetSession(playSession.UpstreamSessionID); err == nil {
|
|
||||||
transcodeNodeURL = upstreamSession.TranscodeNodeURL
|
|
||||||
}
|
|
||||||
}
|
|
||||||
h.closeTranscodeSession(playSession.UpstreamSessionID, transcodeNodeURL)
|
|
||||||
if h.sessionMgr != nil {
|
|
||||||
_ = h.sessionMgr.StopSession(playSession.UpstreamSessionID)
|
|
||||||
}
|
|
||||||
h.playbackStore.Delete(playSession.ID)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
|||||||
@@ -1,10 +1,22 @@
|
|||||||
package jellycompat
|
package jellycompat
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/Silo-Server/silo-server/internal/playback"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// withCompatSession attaches a compat session carrying tok to req, so the
|
||||||
|
// ActiveEncodings ownership guard (CompatToken == session.Token) is satisfied.
|
||||||
|
func withCompatSession(req *http.Request, tok string) *http.Request {
|
||||||
|
return req.WithContext(context.WithValue(req.Context(), compatSessionKey, &Session{Token: tok}))
|
||||||
|
}
|
||||||
|
|
||||||
func TestAudioSelectionChanged(t *testing.T) {
|
func TestAudioSelectionChanged(t *testing.T) {
|
||||||
selected := 2
|
selected := 2
|
||||||
session := &PlaybackSession{
|
session := &PlaybackSession{
|
||||||
@@ -90,3 +102,147 @@ func TestRewriteManifest_PreservesPlaybackAndMediaSourceIDs(t *testing.T) {
|
|||||||
t.Fatalf("expected nested manifest to include media and playback session ids, got:\n%s", got)
|
t.Fatalf("expected nested manifest to include media and playback session ids, got:\n%s", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// newActiveEncodingsHandler builds a PlaybackHandler literal directly (not
|
||||||
|
// NewPlaybackHandler, which touches the filesystem) with the transcodes map
|
||||||
|
// initialized — closeTranscodeSession writes/deletes it and would nil-map-panic
|
||||||
|
// otherwise.
|
||||||
|
func newActiveEncodingsHandler(mgr *testCompatSessionManager) (*PlaybackHandler, *PlaybackSessionStore) {
|
||||||
|
store := NewPlaybackSessionStore(time.Hour, nil)
|
||||||
|
h := &PlaybackHandler{
|
||||||
|
playbackStore: store,
|
||||||
|
sessionMgr: mgr,
|
||||||
|
transcodes: make(map[string]*playback.TranscodeSession),
|
||||||
|
}
|
||||||
|
return h, store
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandleDeleteActiveEncodings_StopsTranscodeAndDeletesSession verifies the
|
||||||
|
// happy path: the upstream session is stopped and the compat play session is
|
||||||
|
// removed from the store, returning 204.
|
||||||
|
func TestHandleDeleteActiveEncodings_StopsTranscodeAndDeletesSession(t *testing.T) {
|
||||||
|
mgr := &testCompatSessionManager{sessions: map[string]*playback.Session{"upstream-1": {ID: "upstream-1"}}}
|
||||||
|
h, store := newActiveEncodingsHandler(mgr)
|
||||||
|
store.Put(PlaybackSession{ID: "ps-1", UpstreamSessionID: "upstream-1", CompatToken: "tok"})
|
||||||
|
|
||||||
|
req := withCompatSession(httptest.NewRequest("DELETE", "/Videos/ActiveEncodings?PlaySessionId=ps-1", nil), "tok")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.HandleDeleteActiveEncodings(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != 204 {
|
||||||
|
t.Fatalf("status = %d, body = %s; want 204", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if _, ok := store.Get("ps-1"); ok {
|
||||||
|
t.Fatal("play session should be deleted")
|
||||||
|
}
|
||||||
|
if len(mgr.stopCalls) != 1 || mgr.stopCalls[0] != "upstream-1" {
|
||||||
|
t.Fatalf("expected StopSession(upstream-1); got %v", mgr.stopCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandleDeleteActiveEncodings_MissingPlaySessionIdReturns204 verifies a
|
||||||
|
// request with no PlaySessionId is a 204 no-op (no teardown).
|
||||||
|
func TestHandleDeleteActiveEncodings_MissingPlaySessionIdReturns204(t *testing.T) {
|
||||||
|
mgr := &testCompatSessionManager{sessions: map[string]*playback.Session{"upstream-1": {ID: "upstream-1"}}}
|
||||||
|
h, store := newActiveEncodingsHandler(mgr)
|
||||||
|
store.Put(PlaybackSession{ID: "ps-1", UpstreamSessionID: "upstream-1", CompatToken: "tok"})
|
||||||
|
|
||||||
|
req := withCompatSession(httptest.NewRequest("DELETE", "/Videos/ActiveEncodings", nil), "tok")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.HandleDeleteActiveEncodings(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != 204 {
|
||||||
|
t.Fatalf("status = %d, body = %s; want 204", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if _, ok := store.Get("ps-1"); !ok {
|
||||||
|
t.Fatal("unrelated play session must not be torn down")
|
||||||
|
}
|
||||||
|
if len(mgr.stopCalls) != 0 {
|
||||||
|
t.Fatalf("expected no StopSession calls; got %v", mgr.stopCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandleDeleteActiveEncodings_UnknownPlaySessionReturns204 verifies an
|
||||||
|
// unknown PlaySessionId is a 204 no-op (idempotent "already gone" semantics).
|
||||||
|
func TestHandleDeleteActiveEncodings_UnknownPlaySessionReturns204(t *testing.T) {
|
||||||
|
mgr := &testCompatSessionManager{}
|
||||||
|
h, _ := newActiveEncodingsHandler(mgr)
|
||||||
|
|
||||||
|
req := withCompatSession(httptest.NewRequest("DELETE", "/Videos/ActiveEncodings?PlaySessionId=does-not-exist", nil), "tok")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.HandleDeleteActiveEncodings(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != 204 {
|
||||||
|
t.Fatalf("status = %d, body = %s; want 204", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if len(mgr.stopCalls) != 0 {
|
||||||
|
t.Fatalf("expected no StopSession calls; got %v", mgr.stopCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandleDeleteActiveEncodings_CaseInsensitivePlaySessionId verifies a
|
||||||
|
// lowercase playSessionId key (as Wholphin sends) still resolves and tears down
|
||||||
|
// the session — the reason newCaseInsensitiveQuery is used.
|
||||||
|
func TestHandleDeleteActiveEncodings_CaseInsensitivePlaySessionId(t *testing.T) {
|
||||||
|
mgr := &testCompatSessionManager{sessions: map[string]*playback.Session{"upstream-1": {ID: "upstream-1"}}}
|
||||||
|
h, store := newActiveEncodingsHandler(mgr)
|
||||||
|
store.Put(PlaybackSession{ID: "ps-1", UpstreamSessionID: "upstream-1", CompatToken: "tok"})
|
||||||
|
|
||||||
|
req := withCompatSession(httptest.NewRequest("DELETE", "/Videos/ActiveEncodings?playSessionId=ps-1", nil), "tok")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.HandleDeleteActiveEncodings(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != 204 {
|
||||||
|
t.Fatalf("status = %d, body = %s; want 204", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if _, ok := store.Get("ps-1"); ok {
|
||||||
|
t.Fatal("lowercase playSessionId should still resolve and delete the session")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandleDeleteActiveEncodings_ForeignPlaySessionNotTornDown proves the
|
||||||
|
// ownership guard: a caller whose token differs from the play session's
|
||||||
|
// CompatToken gets a uniform 204 no-op and does NOT tear down the foreign
|
||||||
|
// session (no cross-session IDOR teardown).
|
||||||
|
func TestHandleDeleteActiveEncodings_ForeignPlaySessionNotTornDown(t *testing.T) {
|
||||||
|
mgr := &testCompatSessionManager{sessions: map[string]*playback.Session{"upstream-1": {ID: "upstream-1"}}}
|
||||||
|
h, store := newActiveEncodingsHandler(mgr)
|
||||||
|
store.Put(PlaybackSession{ID: "ps-1", UpstreamSessionID: "upstream-1", CompatToken: "owner"})
|
||||||
|
|
||||||
|
req := withCompatSession(httptest.NewRequest("DELETE", "/Videos/ActiveEncodings?PlaySessionId=ps-1", nil), "attacker")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.HandleDeleteActiveEncodings(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != 204 {
|
||||||
|
t.Fatalf("status = %d, body = %s; want 204", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if _, ok := store.Get("ps-1"); !ok {
|
||||||
|
t.Fatal("foreign play session must not be torn down")
|
||||||
|
}
|
||||||
|
if len(mgr.stopCalls) != 0 {
|
||||||
|
t.Fatalf("expected no StopSession calls; got %v", mgr.stopCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandleDeleteActiveEncodings_RealClientShape exercises the dominant real
|
||||||
|
// JellyCon call shape (DeviceId present alongside PlaySessionId): with a
|
||||||
|
// matching-token session the session is still torn down (DeviceId ignored).
|
||||||
|
func TestHandleDeleteActiveEncodings_RealClientShape(t *testing.T) {
|
||||||
|
mgr := &testCompatSessionManager{sessions: map[string]*playback.Session{"upstream-1": {ID: "upstream-1"}}}
|
||||||
|
h, store := newActiveEncodingsHandler(mgr)
|
||||||
|
store.Put(PlaybackSession{ID: "ps-1", UpstreamSessionID: "upstream-1", CompatToken: "tok"})
|
||||||
|
|
||||||
|
req := withCompatSession(httptest.NewRequest("DELETE", "/Videos/ActiveEncodings?DeviceId=dev1&PlaySessionId=ps-1", nil), "tok")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.HandleDeleteActiveEncodings(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != 204 {
|
||||||
|
t.Fatalf("status = %d, body = %s; want 204", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if _, ok := store.Get("ps-1"); ok {
|
||||||
|
t.Fatal("play session should be torn down when DeviceId accompanies a matching PlaySessionId")
|
||||||
|
}
|
||||||
|
if len(mgr.stopCalls) != 1 || mgr.stopCalls[0] != "upstream-1" {
|
||||||
|
t.Fatalf("expected StopSession(upstream-1); got %v", mgr.stopCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user