From 4a760160158e96bbebbd56862bd9f07d6e9ee36a Mon Sep 17 00:00:00 2001 From: yammes08 <111231042+yammes08@users.noreply.github.com> Date: Tue, 7 Jul 2026 14:29:18 +0100 Subject: [PATCH] fix(jellycompat): tolerate item-id mediaSourceId on direct-play streams (#313) Native clients that call PlaybackInfo, reuse the returned PlaySessionId on the /Videos/{id}/stream?static=true request, but send the *item* id as mediaSourceId (per the Jellyfin MediaSource.Id == Item.Id convention) rather than Silo's fileID-based source id got a 400 "Media source is required" and could not direct-play at all. The PlaySessionId branch of resolvePlaybackRoute returned findMediaSource's nil result directly, with no fallback -- unlike the FindByRoute and createStaticPlaySession paths. Because resolution returned a nil error, HandleVideoStream's Static fallback never fired either, so every direct-play request 400'd. Fall back to the session's primary source only when mediaSourceId is empty or equals the route item id (the MediaSource.Id == Item.Id convention), matching Jellyfin's StreamingHelpers. Any other unmatched id -- a stale/foreign id, or a wrong version on a multi-version item -- stays rejected so it can't silently stream the wrong file. Exact source-id matches are unchanged. --- .../playback_static_directplay_test.go | 74 +++++++++++++++++++ internal/jellycompat/streams.go | 17 ++++- 2 files changed, 87 insertions(+), 4 deletions(-) diff --git a/internal/jellycompat/playback_static_directplay_test.go b/internal/jellycompat/playback_static_directplay_test.go index 31fb714e..28f860e8 100644 --- a/internal/jellycompat/playback_static_directplay_test.go +++ b/internal/jellycompat/playback_static_directplay_test.go @@ -148,6 +148,80 @@ func TestHandleVideoStream_StaticBypassesNegotiatedCapabilityRejection(t *testin } } +// TestHandleVideoStream_KnownPlaySessionItemIDMediaSourceServesFile covers a +// client that calls PlaybackInfo, reuses the server-minted PlaySessionId on the +// stream request, but sends the *item id* as mediaSourceId (Jellyfin's +// MediaSource.Id == Item.Id convention) instead of the server's fileID-based +// source id. The PlaySessionId lookup hits, but the item id matches no stored +// source, so findMediaSource returns nil. That branch must fall back to the +// session's primary source — as FindByRoute and createStaticPlaySession already +// do — rather than returning a nil source and 400ing "Media source is required". +func TestHandleVideoStream_KnownPlaySessionItemIDMediaSourceServesFile(t *testing.T) { + handler, encodedID, body := newStaticDirectPlayHandler(t) + sourceID := NewResourceIDCodec().EncodeIntID(EncodedIDMediaSource, 42) + handler.playbackStore.Put(PlaybackSession{ + ID: "server-psid", + CompatToken: "token-1", + ItemID: "movie-1", + RouteItemID: encodedID, + UserID: "user-1", + MediaSources: []PlaybackMediaSource{{ + ID: sourceID, + FileID: 42, + Version: catalog.FileVersion{FileID: 42, Container: "mkv", Duration: 3600}, + SupportsDirectPlay: true, + }}, + }) + + // mediaSourceId is the route item id (encodedID), which is NOT the stored + // source id (a distinct EncodedIDMediaSource UUID). Lowercase playSessionId + // mirrors the real client call shape. + rawQuery := "static=true&playSessionId=server-psid&mediaSourceId=" + url.QueryEscape(encodedID) + 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_KnownPlaySessionUnknownMediaSourceRejected guards the +// scope of the item-id fallback: a named mediaSourceId under a known +// PlaySessionId that matches neither a stored source nor the route item id +// (a stale/foreign id, or a wrong version on a multi-version item) must stay +// rejected with 400 rather than silently serving the primary source. Only the +// item-id convention (routeID) falls back; everything else is an error, matching +// Jellyfin's StreamingHelpers. +func TestHandleVideoStream_KnownPlaySessionUnknownMediaSourceRejected(t *testing.T) { + handler, encodedID, _ := newStaticDirectPlayHandler(t) + sourceID := NewResourceIDCodec().EncodeIntID(EncodedIDMediaSource, 42) + handler.playbackStore.Put(PlaybackSession{ + ID: "server-psid", + CompatToken: "token-1", + ItemID: "movie-1", + RouteItemID: encodedID, + UserID: "user-1", + MediaSources: []PlaybackMediaSource{{ + ID: sourceID, + FileID: 42, + Version: catalog.FileVersion{FileID: 42, Container: "mkv", Duration: 3600}, + SupportsDirectPlay: true, + }}, + }) + + // A media source id that is neither the stored source (fileID 42) nor the + // route item id -- e.g. a stale/foreign or wrong-version id. + otherSourceID := NewResourceIDCodec().EncodeIntID(EncodedIDMediaSource, 999) + rawQuery := "static=true&playSessionId=server-psid&mediaSourceId=" + url.QueryEscape(otherSourceID) + rec := serveStaticStream(handler, encodedID, rawQuery) + + if rec.Code != 400 { + t.Fatalf("expected status 400 for an unknown mediaSourceId; got %d, body=%s", rec.Code, rec.Body.String()) + } +} + // TestHandleVideoStream_NoStaticNoSessionReturns404 proves the static fallback // does not fire unconditionally: with no Static param and an empty playback // store, resolvePlaybackRoute correctly 404s. diff --git a/internal/jellycompat/streams.go b/internal/jellycompat/streams.go index 1a3d7ce3..fa7c6e51 100644 --- a/internal/jellycompat/streams.go +++ b/internal/jellycompat/streams.go @@ -1460,11 +1460,20 @@ func (h *PlaybackHandler) resolvePlaybackRoute(r *http.Request, compatSession *S clientPlaySessionID := newCaseInsensitiveQuery(r.URL.Query()).Get("PlaySessionId") if clientPlaySessionID != "" { if playSession, ok := h.playbackStore.Get(clientPlaySessionID); ok && playSession.CompatToken == compatSession.Token { - if mediaSourceID != "" { - source := findMediaSource(playSession, mediaSourceID) - return playSession, source, nil + // Fall back to the primary source only for the Jellyfin + // MediaSource.Id == Item.Id convention: a client that reused the + // server's PlaySessionId may send the item id (== routeID) as + // mediaSourceId, which never matches Silo's fileID-based source ids. + // Any other unmatched id (stale/foreign, or a wrong multi-version + // id) keeps source nil so HandleVideoStream rejects it rather than + // silently serving the wrong file. Mirrors Jellyfin's + // StreamingHelpers, which defaults to the primary source only for an + // empty or item-id mediaSourceId. + source := findMediaSource(playSession, mediaSourceID) + if source == nil && (mediaSourceID == "" || mediaSourceIDsEqual(mediaSourceID, routeID)) { + source = firstMediaSource(playSession) } - return playSession, firstMediaSource(playSession), nil + return playSession, source, nil } // The PlaySessionId is unknown to us (the client never called PlaybackInfo, // so it is the client's own id) or belongs to another caller. Fall through