diff --git a/internal/jellycompat/playback_static_directplay_test.go b/internal/jellycompat/playback_static_directplay_test.go new file mode 100644 index 00000000..46e6b651 --- /dev/null +++ b/internal/jellycompat/playback_static_directplay_test.go @@ -0,0 +1,141 @@ +package jellycompat + +import ( + "context" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "testing" + "time" + + "github.com/go-chi/chi/v5" + + "github.com/Silo-Server/silo-server/internal/catalog" + "github.com/Silo-Server/silo-server/internal/models" +) + +// newStaticDirectPlayHandler builds a PlaybackHandler wired for an end-to-end +// HandleVideoStream direct-play serve: a real temp file (so ServeDirectPlay +// returns 200), an empty playback store (so resolvePlaybackRoute fails and the +// Static fallback is exercised), and a stub session manager (HandleVideoStream +// calls ensureUpstreamPlayback after the static session is created). +// +// NodePlanner/JWTSecret are left as zero values so the proxy-redirect branch is +// skipped and the handler serves directly. An empty DeviceProfile yields +// SupportsDirectPlay=true (no DirectPlayProfiles to reject), so the serve path +// stays "direct". +func newStaticDirectPlayHandler(t *testing.T) (*PlaybackHandler, string, string) { + t.Helper() + dir := t.TempDir() + filePath := filepath.Join(dir, "movie.mkv") + if err := os.WriteFile(filePath, []byte("fake media bytes"), 0o644); err != nil { + t.Fatal(err) + } + + codec := NewResourceIDCodec() + contentID := "movie-1" + detail := &upstreamItemDetail{ + ContentID: contentID, + Type: "movie", + Versions: []catalog.FileVersion{{ + FileID: 42, + FilePath: filePath, + Container: "mkv", + Duration: 3600, + AddedAt: time.Now(), + }}, + } + handler := &PlaybackHandler{ + codec: codec, + content: &stubContentService{detail: detail}, + fileResolver: testCompatFileResolver{file: &models.MediaFile{ID: 42, FilePath: filePath}}, + playbackStore: NewPlaybackSessionStore(time.Hour, nil), + sessionMgr: &testCompatSessionManager{}, + } + encodedID := codec.EncodeStringID(EncodedIDItem, contentID) + return handler, encodedID, "fake media bytes" +} + +// TestHandleVideoStream_LowercaseStaticServesFile proves the case-insensitive +// Static guard now matches SenPlayer's lowercase static=true: with an empty +// playback store the route resolves only via the static fallback, and the file +// is served end-to-end. Without the fix this returns 404 "Playback session not +// found". +func TestHandleVideoStream_LowercaseStaticServesFile(t *testing.T) { + handler, encodedID, body := newStaticDirectPlayHandler(t) + rec := serveStaticStream(handler, encodedID, "static=true") + + if rec.Code != 200 { + t.Fatalf("expected status 200; got %d, body=%s", rec.Code, rec.Body.String()) + } + if got := rec.Body.String(); got != body { + t.Errorf("expected file content %q; got %q", body, got) + } +} + +// TestHandleVideoStream_LowercaseStaticWithMediaSourceId closes the +// SenPlayer-exact call shape: lowercase static=true alongside a mediaSourceId +// query param matching the source. The handler must still serve the file +// end-to-end (200 + body). +func TestHandleVideoStream_LowercaseStaticWithMediaSourceId(t *testing.T) { + handler, encodedID, body := newStaticDirectPlayHandler(t) + // FileID 42 (see newStaticDirectPlayHandler) -> the deterministic media + // source id the static play session builds for this version. + mediaSourceID := NewResourceIDCodec().EncodeIntID(EncodedIDMediaSource, 42) + rawQuery := "static=true&mediaSourceId=" + url.QueryEscape(mediaSourceID) + rec := serveStaticStream(handler, encodedID, rawQuery) + + if rec.Code != 200 { + t.Fatalf("expected status 200; got %d, body=%s", rec.Code, rec.Body.String()) + } + if got := rec.Body.String(); got != body { + t.Errorf("expected file content %q; got %q", body, got) + } +} + +// TestHandleVideoStream_UppercaseStaticServesFile regression-guards the +// original Infuse path (Static=true, uppercase key): it must keep serving so a +// future over-narrow change to the guard is caught. +func TestHandleVideoStream_UppercaseStaticServesFile(t *testing.T) { + handler, encodedID, body := newStaticDirectPlayHandler(t) + rec := serveStaticStream(handler, encodedID, "Static=true") + + if rec.Code != 200 { + t.Fatalf("expected status 200; got %d, body=%s", rec.Code, rec.Body.String()) + } + if got := rec.Body.String(); got != body { + t.Errorf("expected file content %q; got %q", body, got) + } +} + +// TestHandleVideoStream_NoStaticNoSessionReturns404 proves the static fallback +// does not fire unconditionally: with no Static param and an empty playback +// store, resolvePlaybackRoute correctly 404s. +func TestHandleVideoStream_NoStaticNoSessionReturns404(t *testing.T) { + handler, encodedID, _ := newStaticDirectPlayHandler(t) + rec := serveStaticStream(handler, encodedID, "") + + if rec.Code != 404 { + t.Fatalf("expected status 404; got %d, body=%s", rec.Code, rec.Body.String()) + } +} + +// serveStaticStream issues a GET /Videos/{id}/stream with the given raw query +// (no leading "?"), the chi "id" route param, and a compat session in context. +func serveStaticStream(handler *PlaybackHandler, encodedID, rawQuery string) *httptest.ResponseRecorder { + target := "/Videos/" + encodedID + "/stream" + if rawQuery != "" { + target += "?" + rawQuery + } + req := httptest.NewRequest("GET", target, nil) + routeCtx := chi.NewRouteContext() + routeCtx.URLParams.Add("id", encodedID) + ctx := context.WithValue(req.Context(), chi.RouteCtxKey, routeCtx) + ctx = context.WithValue(ctx, compatSessionKey, &Session{Token: "token-1", StreamAppUserID: 1, ProfileID: "profile-1"}) + req = req.WithContext(ctx) + + rec := httptest.NewRecorder() + handler.HandleVideoStream(rec, req) + return rec +} diff --git a/internal/jellycompat/streams.go b/internal/jellycompat/streams.go index f7e6677d..62318f36 100644 --- a/internal/jellycompat/streams.go +++ b/internal/jellycompat/streams.go @@ -53,9 +53,12 @@ func (h *PlaybackHandler) HandleVideoStream(w http.ResponseWriter, r *http.Reque routeID := chiURLParam(r, "id") mediaSourceID := firstNonEmpty(r.URL.Query().Get("mediaSourceId"), r.URL.Query().Get("MediaSourceId")) playSession, source, err := h.resolvePlaybackRoute(r, session, routeID, mediaSourceID) - if err != nil && strings.EqualFold(r.URL.Query().Get("Static"), "true") { + if err != nil && strings.EqualFold(newCaseInsensitiveQuery(r.URL.Query()).Get("Static"), "true") { // Infuse uses Static=true for direct play without calling PlaybackInfo first. - // Create an on-the-fly play session so the stream can proceed. + // Create an on-the-fly play session so the stream can proceed. The key + // lookup must be case-insensitive: SenPlayer sends "static=true" + // (lowercase) and a case-sensitive Get("Static") would miss it, dropping + // the client to a 404 "Playback session not found" on every direct play. playSession, source, err = h.createStaticPlaySession(r.Context(), session, routeID, mediaSourceID) } if err != nil {