fix(jellycompat): Wholphin (jellyfin-sdk-kotlin) playback & genre compatibility (#100)

* fix(jellycompat): match MediaSourceId across UUID formats (compact vs dashed)

* fix(jellycompat): honor ImageTypes=Backdrop as a filter on /Items

Wholphin genre cards request /Items?imageTypes=Backdrop&limit=1&sortBy=Random
and assume every returned item has a backdrop. Silo ignored ImageTypes, so a
random pick could lack a backdrop (BackdropImageTags: null), crashing Wholphin.

Push the filter down to the catalog browse SQL
(NULLIF(BTRIM(backdrop_path),'') IS NOT NULL) so random/limited selections only
ever consider backdrop-having items; empty genres correctly return [].

* fix(jellycompat): case-insensitive PlaySessionId + api_key in stream auth

Wholphin's jellyfin-sdk-kotlin builds its own direct-play URL
(/Videos/{id}/stream?static=true&playSessionId=...&mediaSourceId=...) with a
lowercase 'playSessionId', no api_key, and no auth header (ExoPlayer's data
source drops it). PlaybackSessionAuth read 'PlaySessionId'/'PlaySessionID'
case-sensitively, so the fallback never matched -> 401 on every direct-play
stream -> forced (often failing) transcode fallback. Resolve PlaySessionId via
newCaseInsensitiveQuery, and likewise accept case-variant api_key in
ExtractToken.

* fix(jellycompat): support Wholphin season item queries

---------

Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
This commit is contained in:
d3v1l1989
2026-06-08 21:05:33 -04:00
committed by GitHub
co-authored by Quick
parent 339ed20074
commit cf4e080bf4
17 changed files with 404 additions and 15 deletions
+8
View File
@@ -36,6 +36,7 @@ type BrowseFilters struct {
MaxLimit int // optional caller-specific cap; zero keeps the default browse cap
Offset int
SnapshotAt *time.Time // pagination fence: exclude items created after this timestamp
RequireBackdrop bool // only return items with a non-empty backdrop_path (Jellyfin ImageTypes=Backdrop filter)
}
// BrowseResult contains the paginated result of a browse query.
@@ -295,6 +296,13 @@ func (r *BrowseRepository) buildBrowsePlan(filters BrowseFilters) (browseQueryPl
argIdx++
}
// Backdrop presence filter (Jellyfin ImageTypes=Backdrop). Pushed down so
// random/limited selections only ever pick items that actually have a
// backdrop — filtering after the LIMIT would wrongly return empty pages.
if filters.RequireBackdrop {
conditions = append(conditions, "NULLIF(BTRIM(mi.backdrop_path), '') IS NOT NULL")
}
// Library access control: restrict to user's accessible libraries.
needsLibJoin := filters.LibraryID > 0 || filters.LibraryIDs != nil || len(filters.DisabledLibraryIDs) > 0 || filters.Sort == "recently_added"
needsPersonJoin := filters.PersonID > 0
+31
View File
@@ -0,0 +1,31 @@
package catalog
import (
"strings"
"testing"
)
// TestBuildBrowsePlan_RequireBackdrop asserts the ImageTypes=Backdrop filter
// (BrowseFilters.RequireBackdrop) renders the backdrop-presence predicate into
// the WHERE clause, and is absent otherwise. Guards against a future refactor
// of buildBrowsePlan silently dropping the condition.
func TestBuildBrowsePlan_RequireBackdrop(t *testing.T) {
const predicate = "NULLIF(BTRIM(mi.backdrop_path), '') IS NOT NULL"
repo := &BrowseRepository{}
plan, earlyEmpty, err := repo.buildBrowsePlan(BrowseFilters{Type: "movie", RequireBackdrop: true})
if err != nil || earlyEmpty {
t.Fatalf("buildBrowsePlan(RequireBackdrop) err=%v earlyEmpty=%v", err, earlyEmpty)
}
if !strings.Contains(plan.whereClause, predicate) {
t.Fatalf("RequireBackdrop=true: whereClause missing predicate.\ngot: %s", plan.whereClause)
}
plan, _, err = repo.buildBrowsePlan(BrowseFilters{Type: "movie"})
if err != nil {
t.Fatalf("buildBrowsePlan err=%v", err)
}
if strings.Contains(plan.whereClause, predicate) {
t.Fatalf("RequireBackdrop unset: predicate should be absent.\ngot: %s", plan.whereClause)
}
}
+8 -2
View File
@@ -62,7 +62,8 @@ func ExtractToken(r *http.Request) (string, bool) {
if token := strings.TrimSpace(r.Header.Get("X-Mediabrowser-Token")); token != "" {
return token, true
}
if token := strings.TrimSpace(r.URL.Query().Get("api_key")); token != "" {
// Case-insensitive: Jellyfin clients vary the casing (api_key / Api_Key / API_KEY).
if token := strings.TrimSpace(newCaseInsensitiveQuery(r.URL.Query()).Get("api_key")); token != "" {
return token, true
}
@@ -185,7 +186,12 @@ func PlaybackSessionAuth(sessions *SessionStore, playbackStore *PlaybackSessionS
// no auth header or api_key. Resolve the negotiated session's
// CompatToken — which for an API-key stream is itself the sa_ key,
// so it must go through the same session-or-API-key resolution.
if playSessionID := firstNonEmpty(r.URL.Query().Get("PlaySessionId"), r.URL.Query().Get("PlaySessionID")); playSessionID != "" {
//
// The lookup must be case-insensitive: Wholphin's jellyfin-sdk-kotlin
// builds its own direct-play URL with a lowercase "playSessionId"
// (and no api_key / auth header), so a case-sensitive match would
// miss it and 401 the stream — forcing a needless transcode fallback.
if playSessionID := newCaseInsensitiveQuery(r.URL.Query()).Get("PlaySessionId"); playSessionID != "" {
if playSession, found := playbackStore.Get(playSessionID); found {
if session, ok := resolveCompatToken(r.Context(), sessions, keyAuth, playSession.CompatToken); ok {
serveWithSession(next, w, r, session)
+54
View File
@@ -119,6 +119,60 @@ func TestRequireSession_NoAuthService_PassesThroughExpiredStreamAppToken(t *test
}
}
func TestPlaybackSessionAuth_CaseInsensitivePlaySessionId(t *testing.T) {
now := fixedNow()
clock := func() time.Time { return now }
sessions := NewSessionStore(30*24*time.Hour, clock)
_ = sessions.Put(Session{Token: "compat-tok", StreamAppUserID: 1})
playbackStore := NewPlaybackSessionStore(time.Hour, clock)
playbackStore.Put(PlaybackSession{ID: "ps-abc", CompatToken: "compat-tok"})
mw := PlaybackSessionAuth(sessions, playbackStore, nil)
cases := []struct {
name string
rawQuery string
wantCode int
}{
// Wholphin's jellyfin-sdk-kotlin direct-play URL: lowercase playSessionId,
// no api_key and no auth header. Previously 401'd (case-sensitive lookup).
{"lowercase playSessionId (Wholphin)", "static=true&mediaSourceId=x&playSessionId=ps-abc", http.StatusOK},
{"canonical PlaySessionId", "PlaySessionId=ps-abc", http.StatusOK},
{"legacy PlaySessionID", "PlaySessionID=ps-abc", http.StatusOK},
{"unknown play session", "playSessionId=does-not-exist", http.StatusUnauthorized},
{"no auth at all", "static=true", http.StatusUnauthorized},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest("GET", "/Videos/itm/stream?"+tc.rawQuery, nil)
rec := httptest.NewRecorder()
gotSession := false
mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if SessionFromContext(r.Context()) != nil {
gotSession = true
}
w.WriteHeader(http.StatusOK)
})).ServeHTTP(rec, req)
if rec.Code != tc.wantCode {
t.Fatalf("status = %d, want %d (body: %s)", rec.Code, tc.wantCode, rec.Body.String())
}
if tc.wantCode == http.StatusOK && !gotSession {
t.Fatal("expected authenticated session in context")
}
})
}
}
func TestExtractToken_CaseInsensitiveAPIKey(t *testing.T) {
for _, key := range []string{"api_key", "Api_Key", "API_KEY"} {
req := httptest.NewRequest("GET", "/Videos/itm/stream?"+key+"=tok123", nil)
if got, ok := ExtractToken(req); !ok || got != "tok123" {
t.Fatalf("%s: ExtractToken = (%q, %v), want (tok123, true)", key, got, ok)
}
}
}
func TestRequireAdminAPIKey_AcceptsAdminKey(t *testing.T) {
authn := newAdminAPIKeyAuthForTest(
&fakeAPIKeyValidator{key: &models.APIKey{ID: 1, UserID: 2, Key: "sa_test"}},
+1
View File
@@ -226,6 +226,7 @@ func (s *directContentService) BrowseItems(ctx context.Context, session *Session
Limit: fetchLimit,
MaxLimit: compatBrowseMaxLimit,
Offset: requestedOffset,
RequireBackdrop: parseBool(params.Get("require_backdrop"), false),
}
var collected []upstreamListItem
+1
View File
@@ -73,6 +73,7 @@ type baseItemDTO struct {
SpecialFeatureCount int `json:"SpecialFeatureCount,omitempty"`
MovieCount int `json:"MovieCount,omitempty"`
SeriesCount int `json:"SeriesCount,omitempty"`
SeasonCount int `json:"SeasonCount,omitempty"`
EpisodeCount int `json:"EpisodeCount,omitempty"`
LockedFields []string `json:"LockedFields,omitempty"`
LockData bool `json:"LockData,omitempty"`
+49 -2
View File
@@ -140,6 +140,8 @@ func (h *ItemsHandler) HandleItems(w http.ResponseWriter, r *http.Request) {
h.handleSearchItems(w, r, session, query)
case query.isFavorite:
h.handleFavoriteItems(w, r, session, query)
case isSeasonChildItemsQuery(query):
h.handleSeasonChildItems(w, r, session, query)
case query.parentLibraryID == 0 && len(query.itemTypes) == 0:
// No ParentId and no type filter: return top-level library views.
// Jellyfin clients (e.g. Findroid "My Media") call GET /Items?userId=...
@@ -150,6 +152,18 @@ func (h *ItemsHandler) HandleItems(w http.ResponseWriter, r *http.Request) {
}
}
func isSeasonChildItemsQuery(query itemsQuery) bool {
if query.parentItemID == "" {
return false
}
for _, itemType := range query.itemTypes {
if itemType == "season" {
return true
}
}
return false
}
// HandleItem serves GET /Items/{id}.
func (h *ItemsHandler) HandleItem(w http.ResponseWriter, r *http.Request) {
session := SessionFromContext(r.Context())
@@ -209,6 +223,7 @@ func (h *ItemsHandler) HandleItem(w http.ResponseWriter, r *http.Request) {
browsableSeasons := filterBrowsableSeasons(seasons)
dto.ChildCount = len(browsableSeasons)
dto.RecursiveItemCount = len(browsableSeasons)
dto.SeasonCount = len(browsableSeasons)
}
}
if strings.EqualFold(detail.Type, "episode") && detail.SeriesID != "" {
@@ -919,9 +934,35 @@ func (h *ItemsHandler) HandleSeasons(w http.ResponseWriter, r *http.Request) {
writeCompatUpstreamError(w, err)
return
}
h.writeSeasonItemsResponse(w, r, session, seriesID, seasons, query, false)
}
func (h *ItemsHandler) handleSeasonChildItems(w http.ResponseWriter, r *http.Request, session *Session, query itemsQuery) {
seasons, err := h.content.ListSeasons(r.Context(), session, query.parentItemID, nil)
if err != nil {
writeCompatUpstreamError(w, err)
return
}
h.writeSeasonItemsResponse(w, r, session, query.parentItemID, seasons, query, true)
}
func (h *ItemsHandler) writeSeasonItemsResponse(w http.ResponseWriter, r *http.Request, session *Session, seriesID string, seasons []upstreamSeason, query itemsQuery, page bool) {
seasons = filterBrowsableSeasons(seasons)
h.rememberSeasonImages(seasons, seriesID)
total := len(seasons)
if page {
start := query.startIndex
if start > total {
start = total
}
end := total
if query.limit > 0 && start+query.limit < end {
end = start + query.limit
}
seasons = seasons[start:end]
}
favorites, err := resolveFavoritesForContentIDs(r.Context(), session, h.userData, seasonContentIDs(seasons))
if err != nil {
writeCompatUpstreamError(w, err)
@@ -944,10 +985,16 @@ func (h *ItemsHandler) HandleSeasons(w http.ResponseWriter, r *http.Request) {
}
items = append(items, h.mapper.seasonFromUpstream(season, seriesID, favorites[season.ContentID]))
}
applyImageTypeLimit(items, query.imageTypeLimit)
startIndex := 0
if page {
startIndex = query.startIndex
}
writeJSON(w, http.StatusOK, queryResultDTO{
Items: items,
TotalRecordCount: len(items),
StartIndex: 0,
TotalRecordCount: total,
StartIndex: startIndex,
})
}
+72 -2
View File
@@ -2,6 +2,7 @@ package jellycompat
import (
"context"
"encoding/json"
"net/http/httptest"
"net/url"
"strings"
@@ -19,7 +20,10 @@ import (
type countingContentService struct {
episodeDetail *upstreamItemDetail
seriesDetail *upstreamItemDetail
seasons []upstreamSeason
getItemDetailCalls int
listSeasonsCalls int
listSeasonsSeries string
}
func (s *countingContentService) GetItemDetail(_ context.Context, _ *Session, contentID string, _ *int) (*upstreamItemDetail, error) {
@@ -50,8 +54,15 @@ func (s *countingContentService) SearchItems(context.Context, *Session, string,
panic("unused")
}
func (s *countingContentService) ListSeasons(context.Context, *Session, string, *int) ([]upstreamSeason, error) {
panic("unused")
func (s *countingContentService) ListSeasons(_ context.Context, _ *Session, seriesID string, _ *int) ([]upstreamSeason, error) {
s.listSeasonsCalls++
s.listSeasonsSeries = seriesID
if s.seasons == nil {
panic("unused")
}
out := make([]upstreamSeason, len(s.seasons))
copy(out, s.seasons)
return out, nil
}
func (s *countingContentService) GetSeason(context.Context, *Session, string, int, *int) (*upstreamSeason, error) {
@@ -70,6 +81,65 @@ func (s *countingContentService) ListItemFilters(context.Context, *Session, url.
panic("unused")
}
func TestHandleItems_SeriesParentSeasonFilterReturnsPagedSeasons(t *testing.T) {
codec := NewResourceIDCodec()
seriesContentID := "series-1"
encodedSeriesID := codec.EncodeStringID(EncodedIDItem, seriesContentID)
contentSvc := &countingContentService{
seasons: []upstreamSeason{
{ContentID: "season-1", SeasonNumber: 1, Title: "Season 1", EpisodeCount: 10},
{ContentID: "season-2", SeasonNumber: 2, Title: "Season 2", EpisodeCount: 8},
},
}
h := &ItemsHandler{
content: contentSvc,
userData: &mockUserDataService{},
codec: codec,
mapper: newMapper(codec, &config.Config{}),
images: NewImageCache(time.Hour, time.Now),
}
req := httptest.NewRequest("GET", "/Users/test/Items?ParentId="+encodedSeriesID+
"&IncludeItemTypes=Season&Recursive=false&SortBy=IndexNumber&SortOrder=Ascending"+
"&Fields=PrimaryImageAspectRatio,CanDelete&StartIndex=1&Limit=1", nil)
req = req.WithContext(context.WithValue(req.Context(), compatSessionKey, &Session{
StreamAppUserID: 1,
ProfileID: "profile-1",
}))
rec := httptest.NewRecorder()
h.HandleItems(rec, req)
if rec.Code != 200 {
t.Fatalf("expected status 200; got %d, body=%s", rec.Code, rec.Body.String())
}
if contentSvc.listSeasonsCalls != 1 || contentSvc.listSeasonsSeries != seriesContentID {
t.Fatalf("ListSeasons calls = %d for %q, want 1 for %q",
contentSvc.listSeasonsCalls, contentSvc.listSeasonsSeries, seriesContentID)
}
var result queryResultDTO
if err := json.NewDecoder(rec.Body).Decode(&result); err != nil {
t.Fatalf("decode response: %v", err)
}
if result.TotalRecordCount != 2 || result.StartIndex != 1 {
t.Fatalf("TotalRecordCount/StartIndex = %d/%d, want 2/1",
result.TotalRecordCount, result.StartIndex)
}
if len(result.Items) != 1 {
t.Fatalf("len(Items) = %d, want 1", len(result.Items))
}
item := result.Items[0]
if item.Type != "Season" || item.Name != "Season 2" {
t.Fatalf("item = {%q %q}, want Season/Season 2", item.Type, item.Name)
}
if item.ParentID != encodedSeriesID || item.SeriesID != encodedSeriesID {
t.Fatalf("ParentID/SeriesID = %q/%q, want %q/%q",
item.ParentID, item.SeriesID, encodedSeriesID, encodedSeriesID)
}
}
// TestHandleItem_Episode_FetchesSeriesDetailForStableParentImageTags verifies
// that episode detail responses fetch parent series image metadata even when
// image URLs are already cached. Cached URLs are not enough to build stable
+1 -1
View File
@@ -439,7 +439,7 @@ func (h *PlaybackHandler) HandlePlaybackInfo(w http.ResponseWriter, r *http.Requ
allow4KTranscode := h.allow4KVideoTranscode(r.Context())
for _, version := range detail.Versions {
source := h.buildPlaybackSource(routeItemID, playSessionID, version, profile, req, allow4KTranscode)
if req.MediaSourceID != "" && source.ID != req.MediaSourceID {
if req.MediaSourceID != "" && !mediaSourceIDsEqual(source.ID, req.MediaSourceID) {
continue
}
sources = append(sources, source)
+15
View File
@@ -141,6 +141,21 @@ func (c *ResourceIDCodec) LookupMediaSourceOwner(fileID int64) (string, bool) {
return contentID, ok
}
// mediaSourceIDsEqual reports whether two media-source IDs refer to the same
// source, tolerating UUID format differences. Silo exposes the canonical
// dashed compat UUID (e.g. "03000000-0000-0000-0000-00000019e8c2"), but some
// Jellyfin clients (e.g. Wholphin) echo it back in the compact 32-char hex
// form ("0300000000000000000000000019e8c2"). Both parse to the same UUID, so
// matching must compare the parsed values rather than the raw strings.
func mediaSourceIDsEqual(a, b string) bool {
if a == b {
return true
}
ua, errA := uuid.Parse(a)
ub, errB := uuid.Parse(b)
return errA == nil && errB == nil && ua == ub
}
// DecodeID unpacks a compat UUID into its original numeric value.
func DecodeID(raw string) (DecodedID, error) {
parsed, err := uuid.Parse(raw)
@@ -0,0 +1,63 @@
package jellycompat
import "testing"
func TestMediaSourceIDsEqual(t *testing.T) {
cases := []struct {
name string
a string
b string
want bool
}{
{
name: "identical dashed",
a: "03000000-0000-0000-0000-00000019e8c2",
b: "03000000-0000-0000-0000-00000019e8c2",
want: true,
},
{
name: "dashed vs compact (Wholphin)",
a: "03000000-0000-0000-0000-00000019e8c2",
b: "0300000000000000000000000019e8c2",
want: true,
},
{
name: "compact vs dashed",
a: "0300000000000000000000000019e8c2",
b: "03000000-0000-0000-0000-00000019e8c2",
want: true,
},
{
name: "case insensitive hex",
a: "03000000-0000-0000-0000-00000019E8C2",
b: "0300000000000000000000000019e8c2",
want: true,
},
{
name: "different uuids",
a: "03000000-0000-0000-0000-00000019e8c2",
b: "0300000000000000000000000019e8c3",
want: false,
},
{
name: "non-uuid falls back to exact match",
a: "abc",
b: "abc",
want: true,
},
{
name: "non-uuid mismatch",
a: "abc",
b: "def",
want: false,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := mediaSourceIDsEqual(tc.a, tc.b); got != tc.want {
t.Fatalf("mediaSourceIDsEqual(%q, %q) = %v, want %v", tc.a, tc.b, got, tc.want)
}
})
}
}
+4 -2
View File
@@ -106,8 +106,10 @@ func (m *mapper) itemFromList(item upstreamListItem, isFavorite bool, progress *
dto.RecursiveItemCount = *item.EpisodeCount
}
if item.SeasonCount != nil {
dto.ChildCount = *item.SeasonCount
dto.RecursiveItemCount = *item.SeasonCount
seasonCount := *item.SeasonCount
dto.ChildCount = seasonCount
dto.RecursiveItemCount = seasonCount
dto.SeasonCount = seasonCount
}
primaryPath, primaryThumbhash := listItemPrimaryImageSeedParts(item)
if tags := imageTagsWithSeed(m.imageTagSigner,
+36
View File
@@ -35,3 +35,39 @@ func TestItemListSortNamePrefersSortTitle(t *testing.T) {
t.Fatalf("SortName = %q, want %q", dto.SortName, "Matrix, The")
}
}
func TestSeriesListIncludesSeasonCount(t *testing.T) {
m := newMapper(NewResourceIDCodec(), &config.Config{})
seasonCount := 4
dto := m.itemFromList(upstreamListItem{
ContentID: "series-1",
Type: "series",
Title: "Snowpiercer",
SeasonCount: &seasonCount,
}, false, nil, nil)
if dto.SeasonCount != 4 {
t.Fatalf("SeasonCount = %d, want 4", dto.SeasonCount)
}
if dto.ChildCount != 4 || dto.RecursiveItemCount != 4 {
t.Fatalf("ChildCount/RecursiveItemCount = %d/%d, want 4/4", dto.ChildCount, dto.RecursiveItemCount)
}
}
func TestSeriesDetailIncludesSeasonCount(t *testing.T) {
m := newMapper(NewResourceIDCodec(), &config.Config{})
seasonCount := 4
dto := m.itemFromDetail(upstreamItemDetail{
ContentID: "series-1",
Type: "series",
Title: "Snowpiercer",
SeasonCount: &seasonCount,
}, false, nil)
if dto.SeasonCount != 4 {
t.Fatalf("SeasonCount = %d, want 4", dto.SeasonCount)
}
if dto.ChildCount != 4 || dto.RecursiveItemCount != 4 {
t.Fatalf("ChildCount/RecursiveItemCount = %d/%d, want 4/4", dto.ChildCount, dto.RecursiveItemCount)
}
}
+1 -1
View File
@@ -140,7 +140,7 @@ func (s *PlaybackSessionStore) FindByRoute(compatToken, routeID string) (*Playba
return &cp, nil, true
}
for _, source := range session.MediaSources {
if source.ID == routeID {
if mediaSourceIDsEqual(source.ID, routeID) {
cp := session
sourceCopy := source
return &cp, &sourceCopy, true
+18
View File
@@ -19,6 +19,7 @@ type itemsQuery struct {
namePrefix string
maxOfficialRating string
parentLibraryID int
parentItemID string
specificIDs []string
itemTypes []string
genreName string
@@ -32,6 +33,7 @@ type itemsQuery struct {
personID int64
isPlayed *bool // nil = not specified
imageTypeLimit *int // nil = not specified
requireBackdrop bool // true when ImageTypes includes Backdrop (filter, not just a hint)
mediaTypes []string
mediaTypesSet map[string]bool
mediaTypesExplicit bool
@@ -55,6 +57,8 @@ func parseItemsQuery(r *http.Request, codec *ResourceIDCodec) itemsQuery {
if parentID := strings.TrimSpace(q.Get("ParentId")); parentID != "" {
if libraryID, err := codec.DecodeIntID(EncodedIDLibrary, parentID); err == nil {
result.parentLibraryID = int(libraryID)
} else if contentID, itemErr := decodeItemID(codec, parentID); itemErr == nil && contentID != "" {
result.parentItemID = contentID
}
}
@@ -111,6 +115,17 @@ func parseItemsQuery(r *http.Request, codec *ResourceIDCodec) itemsQuery {
}
}
// ImageTypes acts as a filter: clients (e.g. Wholphin genre cards) request
// ImageTypes=Backdrop and assume every returned item has a backdrop. Only
// Backdrop is enforced — the catalog browse path can filter on backdrop_path.
for _, raw := range q.Values("ImageTypes") {
for part := range strings.SplitSeq(raw, ",") {
if strings.EqualFold(strings.TrimSpace(part), "Backdrop") {
result.requireBackdrop = true
}
}
}
mediaTypesRaw := q.Values("MediaTypes")
result.mediaTypes = parseMediaTypes(mediaTypesRaw)
result.mediaTypesExplicit = len(mediaTypesRaw) > 0 && strings.TrimSpace(strings.Join(mediaTypesRaw, "")) != ""
@@ -211,6 +226,9 @@ func buildBrowseParams(query itemsQuery) url.Values {
params.Set("is_played", "false")
}
}
if query.requireBackdrop {
params.Set("require_backdrop", "true")
}
return params
}
@@ -0,0 +1,37 @@
package jellycompat
import (
"net/http"
"net/http/httptest"
"testing"
)
func TestParseItemsQuery_ImageTypesBackdropFilter(t *testing.T) {
codec := NewResourceIDCodec()
cases := []struct {
name string
rawQuery string
wantRequire bool
}{
{"backdrop requested", "ImageTypes=Backdrop", true},
{"lowercase param and value", "imagetypes=backdrop", true},
{"backdrop among several", "ImageTypes=Primary,Backdrop,Logo", true},
{"bracket array variant", "ImageTypes[]=Backdrop", true},
{"primary only does not filter", "ImageTypes=Primary", false},
{"absent does not filter", "Limit=1", false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/Items?"+tc.rawQuery, nil)
query := parseItemsQuery(req, codec)
if query.requireBackdrop != tc.wantRequire {
t.Fatalf("requireBackdrop = %v, want %v", query.requireBackdrop, tc.wantRequire)
}
params := buildBrowseParams(query)
gotParam := params.Get("require_backdrop") == "true"
if gotParam != tc.wantRequire {
t.Fatalf("require_backdrop param = %q, want emitted=%v", params.Get("require_backdrop"), tc.wantRequire)
}
})
}
}
+5 -5
View File
@@ -123,7 +123,7 @@ func (h *PlaybackHandler) HandleMasterManifest(w http.ResponseWriter, r *http.Re
return
}
playSessionID := firstNonEmpty(r.URL.Query().Get("PlaySessionId"), r.URL.Query().Get("PlaySessionID"))
playSessionID := newCaseInsensitiveQuery(r.URL.Query()).Get("PlaySessionId")
if playSessionID == "" {
writeError(w, http.StatusBadRequest, "BadRequest", "PlaySessionId is required")
return
@@ -906,7 +906,7 @@ func audioSelectionChanged(session *PlaybackSession, mediaSourceID string, incom
return true
}
for _, source := range session.MediaSources {
if mediaSourceID != "" && source.ID != mediaSourceID {
if mediaSourceID != "" && !mediaSourceIDsEqual(source.ID, mediaSourceID) {
continue
}
if source.SelectedAudioStreamIndex == nil {
@@ -925,7 +925,7 @@ func (h *PlaybackHandler) setSelectedAudioStream(playSessionID, mediaSourceID st
if mediaSourceID != "" {
sourceIndex = -1
for index := range current.MediaSources {
if current.MediaSources[index].ID == mediaSourceID {
if mediaSourceIDsEqual(current.MediaSources[index].ID, mediaSourceID) {
sourceIndex = index
break
}
@@ -1061,7 +1061,7 @@ func (h *PlaybackHandler) createStaticPlaySession(ctx context.Context, session *
}
func (h *PlaybackHandler) resolvePlaybackRoute(r *http.Request, compatSession *Session, routeID, mediaSourceID string) (*PlaybackSession, *PlaybackMediaSource, error) {
if playSessionID := firstNonEmpty(r.URL.Query().Get("PlaySessionId"), r.URL.Query().Get("PlaySessionID")); playSessionID != "" {
if playSessionID := newCaseInsensitiveQuery(r.URL.Query()).Get("PlaySessionId"); playSessionID != "" {
playSession, ok := h.playbackStore.Get(playSessionID)
if !ok || playSession.CompatToken != compatSession.Token {
return nil, nil, ErrSessionNotFound
@@ -1099,7 +1099,7 @@ func findMediaSource(session *PlaybackSession, mediaSourceID string) *PlaybackMe
return nil
}
for _, source := range session.MediaSources {
if source.ID == mediaSourceID {
if mediaSourceIDsEqual(source.ID, mediaSourceID) {
copy := source
return &copy
}