diff --git a/internal/catalog/browse.go b/internal/catalog/browse.go index 2f0bc859..f87bd908 100644 --- a/internal/catalog/browse.go +++ b/internal/catalog/browse.go @@ -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 diff --git a/internal/catalog/browse_backdrop_test.go b/internal/catalog/browse_backdrop_test.go new file mode 100644 index 00000000..b5bac9d6 --- /dev/null +++ b/internal/catalog/browse_backdrop_test.go @@ -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) + } +} diff --git a/internal/jellycompat/auth.go b/internal/jellycompat/auth.go index 10b99237..1b7531b5 100644 --- a/internal/jellycompat/auth.go +++ b/internal/jellycompat/auth.go @@ -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) diff --git a/internal/jellycompat/auth_test.go b/internal/jellycompat/auth_test.go index bef63ea0..d2e964ea 100644 --- a/internal/jellycompat/auth_test.go +++ b/internal/jellycompat/auth_test.go @@ -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"}}, diff --git a/internal/jellycompat/content_direct.go b/internal/jellycompat/content_direct.go index 1b30d730..7d369052 100644 --- a/internal/jellycompat/content_direct.go +++ b/internal/jellycompat/content_direct.go @@ -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 diff --git a/internal/jellycompat/dto.go b/internal/jellycompat/dto.go index c81307be..eeb21054 100644 --- a/internal/jellycompat/dto.go +++ b/internal/jellycompat/dto.go @@ -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"` diff --git a/internal/jellycompat/handlers_items.go b/internal/jellycompat/handlers_items.go index cd729145..e55a5757 100644 --- a/internal/jellycompat/handlers_items.go +++ b/internal/jellycompat/handlers_items.go @@ -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, }) } diff --git a/internal/jellycompat/handlers_items_test.go b/internal/jellycompat/handlers_items_test.go index 91006012..7826ab85 100644 --- a/internal/jellycompat/handlers_items_test.go +++ b/internal/jellycompat/handlers_items_test.go @@ -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 diff --git a/internal/jellycompat/handlers_playback.go b/internal/jellycompat/handlers_playback.go index 6c64bf85..aa90ebe2 100644 --- a/internal/jellycompat/handlers_playback.go +++ b/internal/jellycompat/handlers_playback.go @@ -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) diff --git a/internal/jellycompat/idcodec.go b/internal/jellycompat/idcodec.go index ccf3d167..bcae3dce 100644 --- a/internal/jellycompat/idcodec.go +++ b/internal/jellycompat/idcodec.go @@ -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) diff --git a/internal/jellycompat/idcodec_mediasource_test.go b/internal/jellycompat/idcodec_mediasource_test.go new file mode 100644 index 00000000..c24802e1 --- /dev/null +++ b/internal/jellycompat/idcodec_mediasource_test.go @@ -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) + } + }) + } +} diff --git a/internal/jellycompat/mapping.go b/internal/jellycompat/mapping.go index cc2591d9..50b27fcb 100644 --- a/internal/jellycompat/mapping.go +++ b/internal/jellycompat/mapping.go @@ -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, diff --git a/internal/jellycompat/mapping_sort_test.go b/internal/jellycompat/mapping_sort_test.go index f04c7551..08c73758 100644 --- a/internal/jellycompat/mapping_sort_test.go +++ b/internal/jellycompat/mapping_sort_test.go @@ -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) + } +} diff --git a/internal/jellycompat/playback_sessions.go b/internal/jellycompat/playback_sessions.go index 4b075a9c..2c8c156e 100644 --- a/internal/jellycompat/playback_sessions.go +++ b/internal/jellycompat/playback_sessions.go @@ -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 diff --git a/internal/jellycompat/query.go b/internal/jellycompat/query.go index 0e89c8be..48dedc0a 100644 --- a/internal/jellycompat/query.go +++ b/internal/jellycompat/query.go @@ -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 } diff --git a/internal/jellycompat/query_imagetypes_test.go b/internal/jellycompat/query_imagetypes_test.go new file mode 100644 index 00000000..18255227 --- /dev/null +++ b/internal/jellycompat/query_imagetypes_test.go @@ -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) + } + }) + } +} diff --git a/internal/jellycompat/streams.go b/internal/jellycompat/streams.go index 8b8ecfd0..558f6dc7 100644 --- a/internal/jellycompat/streams.go +++ b/internal/jellycompat/streams.go @@ -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 © }