* feat(jellycompat): support DELETE /Videos/ActiveEncodings transcode teardown * fix(jellycompat): guard ActiveEncodings teardown against not-yet-started sessions HandleDeleteActiveEncodings omitted the UpstreamSessionID == "" guard that the sibling Stopped-report path uses. A PlaybackSession is created by PlaybackInfo with an empty UpstreamSessionID; it is only populated once the first manifest request reaches ensureUpstreamPlayback. A DELETE /Videos/ActiveEncodings arriving in that window (with a matching token) passed the ownership guard and ran teardownPlaySession, which deletes the compat play session from the store. A subsequent HandleMasterManifest then 404s, and the teardown was not the "no-op-safe" operation its doc comment claims. Mirror the Stopped report path by treating an unknown, not-owned, OR not-yet-started PlaySessionId as a uniform idempotent 204 no-op. Add a regression test that fails if the guard is removed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
275 lines
11 KiB
Go
275 lines
11 KiB
Go
package jellycompat
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"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) {
|
|
selected := 2
|
|
session := &PlaybackSession{
|
|
MediaSources: []PlaybackMediaSource{
|
|
{ID: "src-a", SelectedAudioStreamIndex: &selected},
|
|
{ID: "src-b", SelectedAudioStreamIndex: nil},
|
|
},
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
session *PlaybackSession
|
|
mediaSourceID string
|
|
incoming int
|
|
want bool
|
|
}{
|
|
{"same index on known source", session, "src-a", 2, false},
|
|
{"different index on known source", session, "src-a", 3, true},
|
|
{"nil current on known source", session, "src-b", 2, true},
|
|
{"unknown media source id", session, "src-missing", 2, true},
|
|
{"empty media source id uses first match", session, "", 2, false},
|
|
{"nil session", nil, "src-a", 2, true},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
got := audioSelectionChanged(tc.session, tc.mediaSourceID, tc.incoming)
|
|
if got != tc.want {
|
|
t.Errorf("audioSelectionChanged(%q, %d) = %v, want %v", tc.mediaSourceID, tc.incoming, got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestGenerateFullManifest_HLSVersionForResumeStartTag(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
fmp4 bool
|
|
startOffset float64
|
|
wantVersion string
|
|
wantStart bool
|
|
}{
|
|
{"ts no resume", false, 0, "#EXT-X-VERSION:3", false},
|
|
{"ts with resume", false, 5.5, "#EXT-X-VERSION:6", true},
|
|
{"fmp4 no resume", true, 0, "#EXT-X-VERSION:7", false},
|
|
{"fmp4 with resume", true, 5.5, "#EXT-X-VERSION:7", true},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
got := string(generateFullManifest(60, 2, tc.fmp4, tc.startOffset))
|
|
if !strings.Contains(got, tc.wantVersion+"\n") {
|
|
t.Fatalf("missing %s; manifest:\n%s", tc.wantVersion, got)
|
|
}
|
|
hasStart := strings.Contains(got, "#EXT-X-START:")
|
|
if hasStart != tc.wantStart {
|
|
t.Fatalf("EXT-X-START presence = %v, want %v; manifest:\n%s", hasStart, tc.wantStart, got)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRewriteManifest_PreservesPlaybackAndMediaSourceIDs(t *testing.T) {
|
|
manifest := strings.Join([]string{
|
|
"#EXTM3U",
|
|
"#EXT-X-VERSION:7",
|
|
"#EXT-X-MAP:URI=\"init.mp4\"",
|
|
"#EXTINF:2.000000,",
|
|
"seg_00000.m4s",
|
|
"#EXTINF:2.000000,",
|
|
"stream.m3u8",
|
|
"",
|
|
}, "\n")
|
|
|
|
got := string(rewriteManifest([]byte(manifest), "item-1", "play-1", "source-1"))
|
|
|
|
if !strings.Contains(got, "#EXT-X-MAP:URI=\"/Videos/item-1/hls/play-1/init.mp4?MediaSourceId=source-1&PlaySessionId=play-1\"") {
|
|
t.Fatalf("expected init segment to include media and playback session ids, got:\n%s", got)
|
|
}
|
|
if !strings.Contains(got, "/Videos/item-1/hls/play-1/seg_00000.m4s?MediaSourceId=source-1&PlaySessionId=play-1") {
|
|
t.Fatalf("expected media segment to include media and playback session ids, got:\n%s", got)
|
|
}
|
|
if !strings.Contains(got, "/Videos/item-1/hls/play-1/stream.m3u8?MediaSourceId=source-1&PlaySessionId=play-1") {
|
|
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)
|
|
}
|
|
}
|
|
|
|
// TestHandleDeleteActiveEncodings_NotYetStartedNotTornDown guards the early
|
|
// window between PlaybackInfo and the first manifest request, when the play
|
|
// session exists but UpstreamSessionID is still empty. A DELETE that lands then
|
|
// must be a 204 no-op that leaves the session in the store, so the pending
|
|
// manifest request still resolves (mirrors the Stopped report path). Removing
|
|
// the UpstreamSessionID == "" guard makes this test fail.
|
|
func TestHandleDeleteActiveEncodings_NotYetStartedNotTornDown(t *testing.T) {
|
|
mgr := &testCompatSessionManager{}
|
|
h, store := newActiveEncodingsHandler(mgr)
|
|
store.Put(PlaybackSession{ID: "ps-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("not-yet-started play session must survive teardown so the pending manifest still resolves")
|
|
}
|
|
if len(mgr.stopCalls) != 0 {
|
|
t.Fatalf("expected no StopSession calls; got %v", mgr.stopCalls)
|
|
}
|
|
}
|