package jellycompat import ( "context" "fmt" "log/slog" "net/url" "sort" "strings" "time" "github.com/Silo-Server/silo-server/internal/access" "github.com/Silo-Server/silo-server/internal/catalog" "github.com/Silo-Server/silo-server/internal/models" "github.com/Silo-Server/silo-server/internal/userstore" ) // AccessFilterResolver resolves catalog access constraints for a compat user. type AccessFilterResolver func(ctx context.Context, userID int, profileID string) catalog.AccessFilter // LibraryPosterPresigner generates presigned URLs for library poster S3 keys. type LibraryPosterPresigner interface { PresignGetURL(ctx context.Context, bucket, key string, expiry time.Duration) (string, error) Bucket() string } // browseSource is the subset of *catalog.BrowseRepository that // directContentService relies on. Defined as an interface so tests can // substitute a stub without standing up a Postgres pool. type browseSource interface { BrowsePage(ctx context.Context, filters catalog.BrowseFilters, includeTotal bool) (*catalog.BrowseResult, error) BrowseRecentlyAddedAcrossLibraries(ctx context.Context, base catalog.BrowseFilters, libraryIDs []int) (*catalog.BrowseResult, error) ListGenres(ctx context.Context, filters catalog.BrowseFilters) ([]string, error) } const ( compatBrowseChunkLimit = 100 compatBrowseMaxLimit = 1000 // compatDefaultBrowseLimit is the page size used when a client omits Limit. // Shared by BrowseItems and the /Items/Latest section fast path so the two // paths always agree on the default page size. compatDefaultBrowseLimit = 24 ) // compatVideoTypes is the media_items type scope the Jellyfin compat surface // exposes when a request carries no explicit type filter. Silo serves // audiobooks and podcasts through the audiobookshelf-compat API instead, so // those rows must never leak into jellycompat responses. const compatVideoTypes = "movie,series,episode" // compatVideoTypeList is compatVideoTypes as a slice for APIs that take // []string (e.g. ItemRepository.Search). var compatVideoTypeList = strings.Split(compatVideoTypes, ",") // compatExcludedMediaTypes lists the media_items.type values the Jellyfin // compat surface never exposes. Injected into every catalog.AccessFilter the // compat layer resolves (see withCompatAccessExclusions) so access-filtered // queries — search, favorites, recommendations, detail, batch hydration — // inherit the exclusion without per-call-site guards. var compatExcludedMediaTypes = []string{"audiobook", "podcast"} func isCompatExcludedMediaType(mediaType string) bool { for _, excluded := range compatExcludedMediaTypes { if strings.EqualFold(strings.TrimSpace(mediaType), excluded) { return true } } return false } // isCompatHiddenLibraryType reports whether a media folder type marks a // library the Jellyfin compat surface hides. Audiobook ('audiobooks' // canonical, 'audiobook' legacy — mirrors internal/audiobooks/media_store.go) // and podcast libraries are served by the ABS-compat API instead. func isCompatHiddenLibraryType(folderType string) bool { switch strings.ToLower(strings.TrimSpace(folderType)) { case "audiobooks", "audiobook", "podcasts", "podcast": return true } return false } // withCompatAccessExclusions merges the compat surface's media-type // exclusions into a resolved access filter, preserving any exclusions the // base resolver already supplied. Centralizing the exclusion here lets // catalog query builders enforce it everywhere an AccessFilter flows. func withCompatAccessExclusions(filter catalog.AccessFilter) catalog.AccessFilter { merged := make([]string, 0, len(filter.ExcludedMediaTypes)+len(compatExcludedMediaTypes)) merged = append(merged, filter.ExcludedMediaTypes...) for _, excluded := range compatExcludedMediaTypes { present := false for _, existing := range merged { if strings.EqualFold(existing, excluded) { present = true break } } if !present { merged = append(merged, excluded) } } filter.ExcludedMediaTypes = merged return filter } // compatAccessFilterResolver wraps an AccessFilterResolver so every filter it // returns carries the compat media-type exclusions. A nil base resolver still // yields exclusion-only filters. func compatAccessFilterResolver(base AccessFilterResolver) AccessFilterResolver { return func(ctx context.Context, userID int, profileID string) catalog.AccessFilter { if base == nil { return withCompatAccessExclusions(catalog.AccessFilter{}) } return withCompatAccessExclusions(base(ctx, userID, profileID)) } } // compatNoMatchType is a sentinel media type that matches no media_items row. // Used when a caller's explicit type filter reduces to nothing exposable so // the query returns empty instead of silently broadening to all video types. const compatNoMatchType = "__compat_none__" // compatAllowedTypeSet is the closed set of catalog types an explicit compat // type filter may pass through ("season" matches no media_items row but is // kept so season-typed filters keep their empty-result semantics). var compatAllowedTypeSet = map[string]struct{}{ "movie": {}, "series": {}, "episode": {}, "season": {}, } // compatScopedSearchTypes clamps an item-type filter to the types the compat // surface exposes: empty defaults to all video types, explicit filters keep // only allowlisted entries, and a filter that reduces to nothing returns the // no-match sentinel. func compatScopedSearchTypes(itemTypes []string) []string { if len(itemTypes) == 0 { return compatVideoTypeList } kept := make([]string, 0, len(itemTypes)) for _, t := range itemTypes { t = strings.ToLower(strings.TrimSpace(t)) if _, ok := compatAllowedTypeSet[t]; !ok { continue } kept = append(kept, t) } if len(kept) == 0 { return []string{compatNoMatchType} } return kept } // compatScopedTypes is compatScopedSearchTypes for comma-separated filters. func compatScopedTypes(raw string) string { raw = strings.TrimSpace(raw) if raw == "" { return compatVideoTypes } return strings.Join(compatScopedSearchTypes(strings.Split(raw, ",")), ",") } // itemAccessSource is the subset of *catalog.ItemRepository that // directContentService season/episode handlers rely on. Defined as an // interface so tests can substitute a stub without standing up a Postgres // pool. type itemAccessSource interface { EnsureAccessible(ctx context.Context, contentID string, filter catalog.AccessFilter) error Search(ctx context.Context, query string, itemTypes []string, limit, offset int, filter catalog.AccessFilter) ([]*models.MediaItem, int, error) GetByIDs(ctx context.Context, contentIDs []string) ([]*models.MediaItem, error) } // folderListSource is the subset of *catalog.FolderRepository that // directContentService relies on. Defined as an interface so tests can // substitute a stub without standing up a Postgres pool. type folderListSource interface { GetEnabled(ctx context.Context) ([]*models.MediaFolder, error) ListByIDs(ctx context.Context, ids []int) ([]*models.MediaFolder, error) } // seasonListSource is the subset of *catalog.SeasonRepository that // directContentService relies on. type seasonListSource interface { ListBySeries(ctx context.Context, seriesID string) ([]*models.Season, error) GetBySeriesAndNumber(ctx context.Context, seriesID string, seasonNum int) (*models.Season, error) GetByID(ctx context.Context, contentID string) (*models.Season, error) } // episodeListSource is the subset of *catalog.EpisodeRepository that // directContentService relies on. ListBySeriesGroupedBySeason replaces the // previous N+1 per-season ListBySeason loop in ListSeasons (audit // 2026-05-01 §2.2). type episodeListSource interface { ListBySeason(ctx context.Context, seriesID string, seasonNum int) ([]*models.Episode, error) ListBySeriesGroupedBySeason(ctx context.Context, seriesID string) (map[int][]*models.Episode, error) ListBySeriesIDs(ctx context.Context, seriesIDs []string) (map[string][]*models.Episode, error) GetByIDs(ctx context.Context, contentIDs []string) ([]*models.Episode, error) } // directContentService implements ContentService by calling catalog repos directly. type directContentService struct { browseRepo browseSource itemRepo itemAccessSource searchProvider catalog.CatalogSearchProvider seasonRepo seasonListSource episodeRepo episodeListSource detailSvc *catalog.DetailService folderRepo folderListSource storeProvider userstore.UserStoreProvider accessFilter AccessFilterResolver posterPresigner LibraryPosterPresigner presignTTL time.Duration } func newDirectContentService( browseRepo *catalog.BrowseRepository, itemRepo *catalog.ItemRepository, seasonRepo *catalog.SeasonRepository, episodeRepo *catalog.EpisodeRepository, detailSvc *catalog.DetailService, folderRepo folderListSource, storeProvider userstore.UserStoreProvider, accessFilter AccessFilterResolver, searchProvider catalog.CatalogSearchProvider, ) *directContentService { return &directContentService{ browseRepo: browseRepo, itemRepo: itemRepo, searchProvider: searchProvider, seasonRepo: seasonRepo, episodeRepo: episodeRepo, detailSvc: detailSvc, folderRepo: folderRepo, storeProvider: storeProvider, accessFilter: accessFilter, } } func (s *directContentService) resolveFilter(ctx context.Context, session *Session) catalog.AccessFilter { if s.accessFilter != nil { return withCompatAccessExclusions(s.accessFilter(ctx, session.StreamAppUserID, session.ProfileID)) } return withCompatAccessExclusions(catalog.AccessFilter{}) } func applyCompatPresentationLibrary(filter catalog.AccessFilter, libraryID *int) catalog.AccessFilter { if libraryID != nil && *libraryID > 0 { filter.PresentationLibraryID = libraryID } return filter } func clampMaxContentRating(existing, requested string) string { existing = strings.TrimSpace(existing) requested = strings.TrimSpace(requested) switch { case existing == "": return requested case requested == "": return existing case access.RatingAllowed(existing, requested): return existing case access.RatingAllowed(requested, existing): return requested default: return existing } } func (s *directContentService) ListUserLibraries(ctx context.Context, session *Session) ([]upstreamUserLibrary, error) { filter := s.resolveFilter(ctx, session) folders, err := s.accessibleFolders(ctx, filter) if err != nil { return nil, fmt.Errorf("list libraries: %w", err) } disabled := make(map[int]struct{}, len(filter.DisabledLibraryIDs)) for _, id := range filter.DisabledLibraryIDs { disabled[id] = struct{}{} } libraries := make([]upstreamUserLibrary, 0, len(folders)) for _, f := range folders { if _, ok := disabled[f.ID]; ok { continue } // Audiobook and podcast libraries are served by the ABS-compat API, // never here. if isCompatHiddenLibraryType(f.Type) { continue } lib := upstreamUserLibrary{ ID: f.ID, Name: f.Name, Type: f.Type, PosterPath: f.PosterPath, } if f.PosterPath != "" && s.posterPresigner != nil { ttl := s.presignTTL if ttl <= 0 { ttl = 4 * time.Hour } if u, err := s.posterPresigner.PresignGetURL(ctx, s.posterPresigner.Bucket(), f.PosterPath, ttl); err == nil { lib.PosterURL = u } } libraries = append(libraries, lib) } return libraries, nil } // accessibleFolders resolves the media folders the viewer may browse, honoring // the allowlist semantics shared by the library and browse endpoints. A nil // AllowedLibraryIDs means unrestricted (all enabled libraries); a non-nil but // empty allowlist grants access to no libraries at all. func (s *directContentService) accessibleFolders(ctx context.Context, filter catalog.AccessFilter) ([]*models.MediaFolder, error) { if filter.AllowedLibraryIDs != nil { if len(filter.AllowedLibraryIDs) == 0 { return nil, nil } return s.folderRepo.ListByIDs(ctx, filter.AllowedLibraryIDs) } return s.folderRepo.GetEnabled(ctx) } // accessibleLibraryIDs resolves the library IDs the viewer may browse, applying // the same allowlist/disabled-list semantics as ListUserLibraries. It is used to // fan a no-parentId recently_added browse into one fast single-library query per // library. func (s *directContentService) accessibleLibraryIDs(ctx context.Context, filter catalog.AccessFilter) ([]int, error) { folders, err := s.accessibleFolders(ctx, filter) if err != nil { return nil, err } disabled := make(map[int]struct{}, len(filter.DisabledLibraryIDs)) for _, id := range filter.DisabledLibraryIDs { disabled[id] = struct{}{} } ids := make([]int, 0, len(folders)) for _, f := range folders { if _, ok := disabled[f.ID]; ok { continue } // Audiobook and podcast libraries are served by the ABS-compat API and // are never exposed on the Jellyfin surface. ListUserLibraries skips // them; skip them here too so the recently_added fan-out can't pull // compat-hidden libraries into /Items/Latest and so we don't waste a // per-library query on folders that yield no Jellyfin-visible rows. if isCompatHiddenLibraryType(f.Type) { continue } ids = append(ids, f.ID) } return ids, nil } func (s *directContentService) BrowseItems(ctx context.Context, session *Session, params url.Values) (*upstreamBrowseResponse, error) { filter := s.resolveFilter(ctx, session) isPlayedFilter := params.Get("is_played") // "", "true", or "false" contentIDs := parseContentIDParam(params.Get("content_ids")) includeTotal := parseBool(params.Get("include_total"), true) requestedLimit := catalog.ParseIntParam(params.Get("limit")) if requestedLimit <= 0 { requestedLimit = compatDefaultBrowseLimit } if requestedLimit > compatBrowseMaxLimit { requestedLimit = compatBrowseMaxLimit } requestedOffset := max(catalog.ParseIntParam(params.Get("offset")), 0) fetchLimit := requestedLimit if isPlayedFilter != "" { // Played status is a profile overlay, so browse over-fetches bounded // chunks and filters them locally instead of asking catalog for every // row in a large library. fetchLimit = min(max(requestedLimit*3, compatBrowseChunkLimit), compatBrowseMaxLimit) } filters := catalog.BrowseFilters{ Type: compatScopedTypes(params.Get("type")), Genre: params.Get("genre"), NamePrefix: params.Get("name_prefix"), ContentIDs: contentIDs, LibraryID: catalog.ParseIntParam(params.Get("library_id")), LibraryIDs: filter.AllowedLibraryIDs, DisabledLibraryIDs: filter.DisabledLibraryIDs, MaxContentRating: clampMaxContentRating(filter.MaxContentRating, params.Get("max_content_rating")), PersonID: catalog.ParseInt64Param(params.Get("person_id")), Sort: params.Get("sort"), Order: params.Get("order"), Limit: fetchLimit, MaxLimit: compatBrowseMaxLimit, Offset: requestedOffset, RequireBackdrop: parseBool(params.Get("require_backdrop"), false), } // A no-parentId recently_added browse (the /Items/Latest hot path) would // otherwise run a multi-library MIN(first_seen_at) + GROUP BY scan over the // whole catalog. Fan it into one fast single-library index walk per library // and merge in memory instead. Only offset 0 is served this way; deeper // pages fall back to the multi-library query below. crossLibraryRecentlyAdded := filters.Sort == "recently_added" && filters.LibraryID == 0 var crossLibraryIDs []int if crossLibraryRecentlyAdded { ids, err := s.accessibleLibraryIDs(ctx, filter) if err != nil { return nil, fmt.Errorf("browse items: %w", err) } // With 0 or 1 accessible libraries the single multi-library query is // already the fast path, so leave behavior unchanged. if len(ids) >= 2 { crossLibraryIDs = ids } else { crossLibraryRecentlyAdded = false } } var collected []upstreamListItem totalFromCatalog := 0 totalKnown := false hasMore := false scannedRows := 0 maxScannedRows := requestedLimit if isPlayedFilter != "" { maxScannedRows = max(requestedLimit*5, compatBrowseChunkLimit) } for len(collected) < requestedLimit && scannedRows < maxScannedRows { wantTotal := includeTotal && !totalKnown var result *catalog.BrowseResult var err error if crossLibraryRecentlyAdded && filters.Offset == 0 { // The merge helper returns a Total/HasMore consistent with offset 0, // so wantTotal is satisfied without the expensive cross-library count. // // The fast path cannot serve a global offset — a per-library top-N walk // can't satisfy a deep merged offset. When the isPlayed overlay drops // items, the offset-paging loop would advance filters.Offset on the next // iteration and fall through to BrowsePage's whole-catalog // MIN(first_seen_at) + GROUP BY (~0.8s+ on a large catalog). Instead, // pull the entire over-fetch budget in this single merged index walk so // the loop fills from one fast call. // // Caveat: BrowseRecentlyAddedAcrossLibraries clamps its limit to // MaxLimit (compatBrowseMaxLimit=1000), so this fully avoids the // BrowsePage fall-through only while maxScannedRows <= 1000 — i.e. for // requestedLimit <= 200, which covers the /Items/Latest hot path // (limit ~24-50). For very large requestedLimit (201-1000) the clamp // can still leave a 2nd chunk that pages into BrowsePage; that case is // rare for recently-added rails and remains correct, just not optimized. fastFilters := filters if isPlayedFilter != "" && fastFilters.Limit < maxScannedRows { fastFilters.Limit = maxScannedRows } result, err = s.browseRepo.BrowseRecentlyAddedAcrossLibraries(ctx, fastFilters, crossLibraryIDs) } else { result, err = s.browseRepo.BrowsePage(ctx, filters, wantTotal) } if err != nil { return nil, fmt.Errorf("browse items: %w", err) } if wantTotal { totalFromCatalog = result.Total totalKnown = true } hasMore = result.HasMore localizedItems := result.Items if s.detailSvc != nil { if localized, locErr := s.detailSvc.LocalizeItemModels(ctx, result.Items, filter); locErr == nil && localized != nil { localizedItems = localized } } batch := make([]upstreamListItem, 0, len(localizedItems)) for _, mi := range localizedItems { batch = append(batch, mediaItemToListItem(mi)) } presignCompatListItems(ctx, s.detailSvc, batch) if isPlayedFilter != "" { // Need user data to filter by played status. The handler's // resolveUserStateForContentIDs call covers UserData on the wire // for the unfiltered case, so we only fetch here when filtering. s.enrichListItemsUserData(ctx, session, batch) wantPlayed := isPlayedFilter == "true" for _, item := range batch { played := item.UserData != nil && item.UserData.Played if played == wantPlayed { collected = append(collected, item) } } } else { collected = append(collected, batch...) } scannedRows += len(result.Items) if len(result.Items) == 0 || !result.HasMore { break } filters.Offset += len(result.Items) } // Trim to requested limit. if len(collected) > requestedLimit { collected = collected[:requestedLimit] } fillListItemDurations(ctx, s.detailSvc, collected) // The handler's resolveUserStateForContentIDs overlay only covers items // with their own progress rows, which a series never has — aggregate // series watch state here so library views carry Played/UnplayedItemCount. // The is_played path already did this via enrichListItemsUserData. // That same absence is what keeps this safe from the handler overlay: // userDataDTO only overrides item-level UserData when a direct progress // row exists, and one never does for a series id. if isPlayedFilter == "" { s.EnrichSeriesUserData(ctx, session, collected) } if totalKnown { hasMore = totalFromCatalog > requestedOffset+requestedLimit } return &upstreamBrowseResponse{ Total: totalFromCatalog, HasMore: hasMore, Items: collected, }, nil } func parseContentIDParam(raw string) []string { raw = strings.TrimSpace(raw) if raw == "" { return nil } ids := make([]string, 0) seen := make(map[string]struct{}) for part := range strings.SplitSeq(raw, ",") { id := strings.TrimSpace(part) if id == "" { continue } if _, ok := seen[id]; ok { continue } seen[id] = struct{}{} ids = append(ids, id) } return ids } func (s *directContentService) SearchItems(ctx context.Context, session *Session, opts SearchItemsOptions) (*upstreamBrowseResponse, error) { filter := applyCompatPresentationLibrary(s.resolveFilter(ctx, session), opts.LibraryID) itemTypes := compatScopedSearchTypes(opts.ItemTypes) var items []*models.MediaItem var total int var hasMore bool if s.searchProvider != nil { result, err := s.searchProvider.Search(ctx, catalog.CatalogSearchRequest{ Query: opts.Query, ItemTypes: itemTypes, Limit: opts.Limit, Offset: opts.Offset, Access: filter, SkipTotal: opts.SkipTotal, }) if err != nil { return nil, fmt.Errorf("search items: %w", err) } if result != nil { items = result.Items total = result.Total hasMore = result.HasMore } } else { var err error items, total, err = s.itemRepo.Search(ctx, opts.Query, itemTypes, opts.Limit, opts.Offset, filter) if err != nil { return nil, fmt.Errorf("search items: %w", err) } hasMore = opts.Offset+len(items) < total } localizedItems := items if s.detailSvc != nil { if localized, locErr := s.detailSvc.LocalizeItemModels(ctx, items, filter); locErr == nil && localized != nil { localizedItems = localized } } listItems := make([]upstreamListItem, 0, len(localizedItems)) for _, mi := range localizedItems { listItems = append(listItems, mediaItemToListItem(mi)) } presignCompatListItems(ctx, s.detailSvc, listItems) fillListItemDurations(ctx, s.detailSvc, listItems) s.enrichListItemsUserData(ctx, session, listItems) return &upstreamBrowseResponse{ Total: total, HasMore: hasMore, Items: listItems, }, nil } func (s *directContentService) GetItemDetail(ctx context.Context, session *Session, contentID string, libraryID *int) (*upstreamItemDetail, error) { filter := applyCompatPresentationLibrary(s.resolveFilter(ctx, session), libraryID) detail, err := s.detailSvc.GetItemDetail(ctx, contentID, filter) if err != nil { return nil, wrapCatalogError(err) } // ABS-surface media types are not exposed on the Jellyfin compat surface // (this guard also blocks PlaybackInfo, which resolves items through // GetItemDetail). if isCompatExcludedMediaType(detail.Type) { return nil, &HTTPError{StatusCode: 404, Message: "item not found"} } result := itemDetailToUpstream(detail) // Enrich with user data if s.storeProvider != nil { if store, storeErr := s.storeProvider.ForUser(ctx, session.StreamAppUserID); storeErr == nil { s.enrichDetailUserData(ctx, store, session.ProfileID, contentID, &result) } } return &result, nil } // enrichDetailUserData populates the per-profile UserData on an item detail from // the user store: leaf progress for movies/episodes, and an episode rollup for // series (which never own a progress row of their own). Shared by GetItemDetail // and GetItemDetailsByIDs so both surfaces report identical Played/position/ // UnplayedItemCount values. func (s *directContentService) enrichDetailUserData(ctx context.Context, store userstore.UserStore, profileID, contentID string, result *upstreamItemDetail) { progress, _ := userstore.GetProgressWithCompletedHistory(ctx, store, profileID, contentID) result.UserData = seasonUserDataFromProgress(progress) // A series never has a progress row of its own, so roll watch state up from // its episodes (mirrors applySeasonUserData) to give clients Played/ // UnplayedItemCount at the series level. Postgres-backed stores aggregate // the rollup in SQL; the chunked per-episode path remains as fallback. if result.UserData == nil && strings.EqualFold(result.Type, "series") && s.episodeRepo != nil { if rollupStore, ok := store.(userstore.SeriesEpisodeRollupStore); ok { counts, err := rollupStore.SeriesEpisodeWatchCounts(ctx, profileID, []string{contentID}) if err == nil { // A series absent from counts has no available episodes; the // zero-value counts produce the same empty rollup the // episode-list path returned for it. result.UserData = catalog.SeasonUserDataFromCounts(counts[contentID]) return } slog.Warn("series watch rollup query failed, falling back to per-episode rollup", "error", err) } if episodesBySeries, epErr := s.episodeRepo.ListBySeriesIDs(ctx, []string{contentID}); epErr == nil { episodes := episodesBySeries[contentID] episodeIDs := modelEpisodeContentIDs(episodes) progressMap := chunkedProgressByMediaItems(ctx, store, profileID, episodeIDs) result.UserData = catalog.EpisodeRollupUserData(episodes, progressMap) } } } // GetItemDetailsByIDs is the batched form of GetItemDetail for a page of content // IDs. It returns upstream item details keyed by content ID, each identical to // what GetItemDetail would return for that id: the catalog detail is built by // the batched DetailService path (or GetEpisodeDetailsForSeries for episodes), // then run through the same compat-excluded-type guard, itemDetailToUpstream // conversion, and UserData enrichment. IDs that are excluded media types, // access-filtered, or otherwise unresolved are simply absent from the map so the // caller renders them from list data, mirroring GetItemDetail's error path. func (s *directContentService) GetItemDetailsByIDs(ctx context.Context, session *Session, contentIDs []string, libraryID *int) (map[string]*upstreamItemDetail, error) { out := make(map[string]*upstreamItemDetail, len(contentIDs)) if len(contentIDs) == 0 || s.detailSvc == nil { return out, nil } filter := applyCompatPresentationLibrary(s.resolveFilter(ctx, session), libraryID) details := make(map[string]*catalog.ItemDetail, len(contentIDs)) // Movies/series (and any other media_items rows) in one batched call. itemDetails, err := s.detailSvc.GetItemDetailsByIDs(ctx, contentIDs, filter) if err != nil { return nil, wrapCatalogError(err) } for id, detail := range itemDetails { details[id] = detail } // Episodes: group the remaining ids by series and reuse the series-hoisted // batch path, which is the episode equivalent of GetItemDetailsByIDs. remaining := make([]string, 0, len(contentIDs)) for _, id := range contentIDs { if _, ok := details[id]; !ok { remaining = append(remaining, id) } } if len(remaining) > 0 && s.episodeRepo != nil { if episodeIDsBySeries, err := s.groupEpisodesBySeries(ctx, remaining); err == nil { for seriesID, epIDs := range episodeIDsBySeries { epDetails, epErr := s.detailSvc.GetEpisodeDetailsForSeries(ctx, seriesID, epIDs, filter) if epErr != nil { // Series inaccessible or a transient error: leave these ids // unresolved so the caller renders them from list data, // matching per-item GetItemDetail erroring for each. continue } for id, detail := range epDetails { details[id] = detail } } } } if len(details) == 0 { return out, nil } // Resolve the user store once for the whole page. var store userstore.UserStore if s.storeProvider != nil { if resolved, storeErr := s.storeProvider.ForUser(ctx, session.StreamAppUserID); storeErr == nil { store = resolved } } // Batch the leaf-item progress lookup. enrichDetailUserData issues // GetProgressWithCompletedHistory per item (two point queries each), so a // 50-item page cost ~100 sequential round-trips. Movies/episodes own a // progress row, so their played/position state is resolved here in a single // ListProgressWithCompletedHistory call — semantically identical to the // per-item path (absent id → nil, completed-history synthesized). Series own // no progress row and need a per-series episode rollup, so they stay on the // per-item enrichDetailUserData path in the loop below. var leafProgress map[string]userstore.WatchProgress leafBatchFailed := false if store != nil { leafIDs := make([]string, 0, len(details)) for id, detail := range details { if isCompatExcludedMediaType(detail.Type) || strings.EqualFold(detail.Type, "series") { continue } leafIDs = append(leafIDs, id) } if len(leafIDs) > 0 { if pm, err := userstore.ListProgressWithCompletedHistory(ctx, store, session.ProfileID, leafIDs); err == nil { leafProgress = pm } else { // A transient store error must not blank played/position state // for the whole page at once: fall back to the per-item lookups // below, which degrade one id at a time exactly like the // pre-batch path did. leafBatchFailed = true slog.WarnContext(ctx, "jellycompat: batch leaf progress lookup failed; falling back to per-item lookups", "component", "jellycompat", "error", err, "leaf_count", len(leafIDs)) } } } for id, detail := range details { if isCompatExcludedMediaType(detail.Type) { continue } upstream := itemDetailToUpstream(detail) if store != nil { switch { case strings.EqualFold(detail.Type, "series"), leafBatchFailed: s.enrichDetailUserData(ctx, store, session.ProfileID, id, &upstream) default: var wp *userstore.WatchProgress if entry, ok := leafProgress[id]; ok { wp = &entry } upstream.UserData = seasonUserDataFromProgress(wp) } } out[id] = &upstream } return out, nil } // groupEpisodesBySeries maps the subset of contentIDs that are episodes to their // series IDs, returning seriesID → episode content IDs. Non-episode ids are // silently skipped. func (s *directContentService) groupEpisodesBySeries(ctx context.Context, contentIDs []string) (map[string][]string, error) { episodes, err := s.episodeRepo.GetByIDs(ctx, contentIDs) if err != nil { return nil, err } grouped := make(map[string][]string, len(episodes)) for _, ep := range episodes { if ep == nil || ep.SeriesID == "" { continue } grouped[ep.SeriesID] = append(grouped[ep.SeriesID], ep.ContentID) } return grouped, nil } func (s *directContentService) ListSeasons(ctx context.Context, session *Session, seriesID string, libraryID *int) ([]upstreamSeason, error) { filter := applyCompatPresentationLibrary(s.resolveFilter(ctx, session), libraryID) if err := s.itemRepo.EnsureAccessible(ctx, seriesID, filter); err != nil { return nil, wrapCatalogError(err) } seasons, err := s.seasonRepo.ListBySeries(ctx, seriesID) if err != nil { return nil, fmt.Errorf("list seasons: %w", err) } // Single batched fetch for every available episode in the series, replacing // the previous per-season ListBySeason loop (audit 2026-05-01 §2.2). groupedEpisodes, err := s.episodeRepo.ListBySeriesGroupedBySeason(ctx, seriesID) if err != nil { return nil, fmt.Errorf("list seasons episodes: %w", err) } // Collect every episode ID for a single batched progress fetch. totalEpisodes := 0 for _, eps := range groupedEpisodes { totalEpisodes += len(eps) } allEpisodeIDs := make([]string, 0, totalEpisodes) for _, eps := range groupedEpisodes { for _, ep := range eps { if ep != nil && ep.ContentID != "" { allEpisodeIDs = append(allEpisodeIDs, ep.ContentID) } } } progressMap := s.batchProgressForEpisodes(ctx, session, allEpisodeIDs) if len(seasons) > 0 { if s.detailSvc != nil { if localized, locErr := s.detailSvc.LocalizeSeasonModels(ctx, seasons, filter); locErr == nil && len(localized) == len(seasons) { seasons = localized } } result := make([]upstreamSeason, 0, len(seasons)) for _, season := range seasons { eps := groupedEpisodes[season.SeasonNumber] us := modelSeasonToUpstream(season, len(eps)) applySeasonUserData(&us, eps, progressMap) result = append(result, us) } s.presignSeasons(ctx, result) return result, nil } // Fallback: seasons table is empty for this series; derive summaries from // the already-fetched grouped episodes (no extra aggregation query). seasonNumbers := make([]int, 0, len(groupedEpisodes)) for sn := range groupedEpisodes { seasonNumbers = append(seasonNumbers, sn) } sort.Ints(seasonNumbers) result := make([]upstreamSeason, 0, len(seasonNumbers)) for _, sn := range seasonNumbers { eps := groupedEpisodes[sn] us := syntheticSeason(seriesID, sn, len(eps)) applySeasonUserData(&us, eps, progressMap) result = append(result, us) } return result, nil } func (s *directContentService) GetSeason(ctx context.Context, session *Session, seriesID string, seasonNumber int, libraryID *int) (*upstreamSeason, error) { filter := applyCompatPresentationLibrary(s.resolveFilter(ctx, session), libraryID) if err := s.itemRepo.EnsureAccessible(ctx, seriesID, filter); err != nil { return nil, wrapCatalogError(err) } season, err := s.seasonRepo.GetBySeriesAndNumber(ctx, seriesID, seasonNumber) if err == nil { episodes, _ := s.episodeRepo.ListBySeason(ctx, seriesID, seasonNumber) if s.detailSvc != nil { if localized, locErr := s.detailSvc.LocalizeSeasonModel(ctx, season, filter); locErr == nil && localized != nil { season = localized } } result := modelSeasonToUpstream(season, len(episodes)) s.presignSeason(ctx, &result) s.enrichSeasonUserData(ctx, session, &result, episodes) return &result, nil } if !strings.Contains(err.Error(), "not found") { return nil, wrapCatalogError(err) } // Fallback: season row missing; synthesize from episodes. episodes, epErr := s.episodeRepo.ListBySeason(ctx, seriesID, seasonNumber) if epErr != nil || len(episodes) == 0 { return nil, &HTTPError{StatusCode: 404, Message: "season not found"} } us := syntheticSeason(seriesID, seasonNumber, len(episodes)) s.enrichSeasonUserData(ctx, session, &us, episodes) return &us, nil } func (s *directContentService) ListEpisodes(ctx context.Context, session *Session, seriesID string, seasonNumber int, libraryID *int) ([]upstreamEpisode, error) { filter := applyCompatPresentationLibrary(s.resolveFilter(ctx, session), libraryID) if err := s.itemRepo.EnsureAccessible(ctx, seriesID, filter); err != nil { return nil, wrapCatalogError(err) } episodes, err := s.episodeRepo.ListBySeason(ctx, seriesID, seasonNumber) if err != nil { return nil, fmt.Errorf("list episodes: %w", err) } progressMap := map[string]userstore.WatchProgress{} if store, err := s.userStore(ctx, session); err == nil { episodeIDs := make([]string, 0, len(episodes)) for _, ep := range episodes { episodeIDs = append(episodeIDs, ep.ContentID) } if progressEntries, progressErr := userstore.ListProgressWithCompletedHistory(ctx, store, session.ProfileID, episodeIDs); progressErr == nil { progressMap = progressEntries } } if s.detailSvc != nil { if localized, locErr := s.detailSvc.LocalizeEpisodeModels(ctx, episodes, filter); locErr == nil && len(localized) == len(episodes) { episodes = localized } } result := make([]upstreamEpisode, 0, len(episodes)) for _, ep := range episodes { ue := modelEpisodeToUpstream(ep, seriesID) if progress, ok := progressMap[ep.ContentID]; ok { progressCopy := progress ue.UserData = seasonUserDataFromProgress(&progressCopy) } result = append(result, ue) } s.presignEpisodes(ctx, result) return result, nil } func (s *directContentService) ListEpisodesBySeasonID(ctx context.Context, session *Session, seasonID string, libraryID *int) ([]upstreamEpisode, error) { season, err := s.seasonRepo.GetByID(ctx, seasonID) if err != nil { return nil, wrapCatalogError(err) } return s.ListEpisodes(ctx, session, season.SeriesID, season.SeasonNumber, libraryID) } func (s *directContentService) ListItemFilters(ctx context.Context, session *Session, params url.Values) (*upstreamItemFiltersResponse, error) { filter := s.resolveFilter(ctx, session) filters := catalog.BrowseFilters{ Type: compatScopedTypes(params.Get("type")), LibraryID: catalog.ParseIntParam(params.Get("library_id")), LibraryIDs: filter.AllowedLibraryIDs, DisabledLibraryIDs: filter.DisabledLibraryIDs, MaxContentRating: clampMaxContentRating(filter.MaxContentRating, params.Get("max_content_rating")), } genres, err := s.browseRepo.ListGenres(ctx, filters) if err != nil { return nil, fmt.Errorf("list genres: %w", err) } return &upstreamItemFiltersResponse{Genres: genres}, nil } // enrichListItemsUserData adds user data to a batch of list items. func (s *directContentService) enrichListItemsUserData(ctx context.Context, session *Session, items []upstreamListItem) { if s.storeProvider == nil || len(items) == 0 { return } store, err := s.userStore(ctx, session) if err != nil { return } contentIDs := make([]string, 0, len(items)) for _, item := range items { if item.ContentID != "" { contentIDs = append(contentIDs, item.ContentID) } } progressMap, err := userstore.ListProgressWithCompletedHistory(ctx, store, session.ProfileID, contentIDs) if err != nil { return } for i := range items { progress, ok := progressMap[items[i].ContentID] if ok { progressCopy := progress items[i].UserData = seasonUserDataFromProgress(&progressCopy) } } s.enrichSeriesListUserData(ctx, session, store, items) } // EnrichSeriesUserData is enrichSeriesListUserData with store acquisition, // for callers that haven't already resolved the user store. It is exported on // the ContentService interface so the native /Items/Latest fast path can apply // the same series watch-state rollup the BrowseItems path applies (a series has // no progress row of its own, so this rollup is the only source of its // Played/UnplayedItemCount). func (s *directContentService) EnrichSeriesUserData(ctx context.Context, session *Session, items []upstreamListItem) { if s.storeProvider == nil || len(items) == 0 { return } store, err := s.userStore(ctx, session) if err != nil { return } s.enrichSeriesListUserData(ctx, session, store, items) } // enrichSeriesListUserData fills aggregated user data for series rows in a // list response. A series never has a progress row of its own, so Played and // UnplayedItemCount are rolled up from episode progress, mirroring // applySeasonUserData semantics. func (s *directContentService) enrichSeriesListUserData(ctx context.Context, session *Session, store userstore.UserStore, items []upstreamListItem) { if s.episodeRepo == nil { return } seriesIDs := make([]string, 0, len(items)) for i := range items { if items[i].UserData == nil && items[i].ContentID != "" && strings.EqualFold(items[i].Type, "series") { seriesIDs = append(seriesIDs, items[i].ContentID) } } if len(seriesIDs) == 0 { return } // Cap the rollup so a max-limit browse (compatBrowseMaxLimit) of a // series-only library can't materialize hundreds of thousands of episode // rows in one request. Series past the cap keep a nil UserData — the same // shape they had before series aggregation existed. Real clients page at // 20-100 items, well under the cap. const maxSeriesUserDataRollups = 250 if len(seriesIDs) > maxSeriesUserDataRollups { seriesIDs = seriesIDs[:maxSeriesUserDataRollups] } // Fast path: Postgres-backed stores aggregate the rollup in one SQL query // instead of loading every episode of every series and batching progress // lookups (a 50-series page of an episode-heavy library expanded to 32k // episode rows and ~65 sequential queries). A rollup failure falls through // to the chunked path below. if rollupStore, ok := store.(userstore.SeriesEpisodeRollupStore); ok { counts, err := rollupStore.SeriesEpisodeWatchCounts(ctx, session.ProfileID, seriesIDs) if err == nil { for i := range items { if items[i].UserData != nil || !strings.EqualFold(items[i].Type, "series") { continue } if c, ok := counts[items[i].ContentID]; ok { items[i].UserData = catalog.SeasonUserDataFromCounts(c) } } return } slog.Warn("series watch rollup query failed, falling back to per-episode rollup", "error", err) } episodesBySeries, err := s.episodeRepo.ListBySeriesIDs(ctx, seriesIDs) if err != nil { return } episodeIDs := make([]string, 0, 256) for _, episodes := range episodesBySeries { episodeIDs = append(episodeIDs, modelEpisodeContentIDs(episodes)...) } progressMap := chunkedProgressByMediaItems(ctx, store, session.ProfileID, episodeIDs) for i := range items { if items[i].UserData != nil || !strings.EqualFold(items[i].Type, "series") { continue } if episodes, ok := episodesBySeries[items[i].ContentID]; ok { items[i].UserData = catalog.EpisodeRollupUserData(episodes, progressMap) } } } // modelEpisodeContentIDs returns the non-empty content ids of the given episodes. func modelEpisodeContentIDs(episodes []*models.Episode) []string { ids := make([]string, 0, len(episodes)) for _, ep := range episodes { if ep != nil && ep.ContentID != "" { ids = append(ids, ep.ContentID) } } return ids } // chunkedProgressByMediaItems batches ListProgressByMediaItems calls when a // series list expands to thousands of episode ids: per-user stores may be // SQLite-backed (999 bind-variable default), and chunking also keeps // Postgres IN-lists at a sane size. Returns an empty map (never nil); chunks // that error are skipped. func chunkedProgressByMediaItems(ctx context.Context, store userstore.UserStore, profileID string, mediaItemIDs []string) map[string]userstore.WatchProgress { const chunkSize = 500 result := make(map[string]userstore.WatchProgress, len(mediaItemIDs)) for start := 0; start < len(mediaItemIDs); start += chunkSize { chunk, err := userstore.ListProgressWithCompletedHistory(ctx, store, profileID, mediaItemIDs[start:min(start+chunkSize, len(mediaItemIDs))]) if err != nil { continue } for id, progress := range chunk { result[id] = progress } } return result } // enrichSeasonUserData adds aggregated user data for a season. Used by // callers that handle a single season (GetSeason paths). For batched // callers like ListSeasons, prefer applySeasonUserData with a shared // progressMap to avoid per-season fetches. func (s *directContentService) enrichSeasonUserData(ctx context.Context, session *Session, season *upstreamSeason, episodes []*models.Episode) { if s.storeProvider == nil { return } store, err := s.userStore(ctx, session) if err != nil { return } episodeIDs := make([]string, 0, len(episodes)) for _, ep := range episodes { if ep != nil && ep.ContentID != "" { episodeIDs = append(episodeIDs, ep.ContentID) } } progressMap, err := userstore.ListProgressWithCompletedHistory(ctx, store, session.ProfileID, episodeIDs) if err != nil { return } applySeasonUserData(season, episodes, progressMap) } // applySeasonUserData computes WatchedCount/UnplayedCount/Played for a season // using a pre-fetched progressMap. Pure function — no I/O. func applySeasonUserData(season *upstreamSeason, episodes []*models.Episode, progressMap map[string]userstore.WatchProgress) { season.UserData = catalog.EpisodeRollupUserData(episodes, progressMap) if season.UserData == nil { season.UserData = &catalog.SeasonUserData{} } } // batchProgressForEpisodes fetches a progress map for every episode in the // list with a single ListProgressByMediaItems call. Returns an empty map // (never nil) on any error so callers can index unconditionally. func (s *directContentService) batchProgressForEpisodes(ctx context.Context, session *Session, episodeIDs []string) map[string]userstore.WatchProgress { if s.storeProvider == nil || len(episodeIDs) == 0 { return map[string]userstore.WatchProgress{} } store, err := s.userStore(ctx, session) if err != nil { return map[string]userstore.WatchProgress{} } progressMap, err := userstore.ListProgressWithCompletedHistory(ctx, store, session.ProfileID, episodeIDs) if err != nil || progressMap == nil { return map[string]userstore.WatchProgress{} } return progressMap } // enrichEpisodeUserData adds user data for a single episode. func (s *directContentService) enrichEpisodeUserData(ctx context.Context, session *Session, ep *upstreamEpisode) { if progressMap, err := s.progressForContentIDs(ctx, session, []string{ep.ContentID}); err == nil { if progress, ok := progressMap[ep.ContentID]; ok { progressCopy := progress ep.UserData = seasonUserDataFromProgress(&progressCopy) } } } func (s *directContentService) userStore(ctx context.Context, session *Session) (userstore.UserStore, error) { if s.storeProvider == nil { return nil, fmt.Errorf("user store is not configured") } return s.storeProvider.ForUser(ctx, session.StreamAppUserID) } func (s *directContentService) progressForContentIDs(ctx context.Context, session *Session, contentIDs []string) (map[string]userstore.WatchProgress, error) { store, err := s.userStore(ctx, session) if err != nil { return nil, err } progressMap, err := userstore.ListProgressWithCompletedHistory(ctx, store, session.ProfileID, contentIDs) if err != nil { return nil, err } return progressMap, nil } func seasonUserDataFromProgress(progress *userstore.WatchProgress) *catalog.SeasonUserData { if progress == nil { return nil } return &catalog.SeasonUserData{ PositionSeconds: progress.PositionSeconds, DurationSeconds: progress.DurationSeconds, Played: progress.Completed, IsInProgress: progress.PositionSeconds > 0, } } // --- Image URL presigning helpers --- // // The list-item presign implementation (presignCompatListItems) and its shared // collect/resolve helpers live in presign_list.go so directContentService and // ItemsHandler share one batched path. Seasons and episodes carry a single // relevant image type each, so they // get their own bounded batch helpers below rather than reusing the four-type // list presigner. // presignSeason presigns a single season poster. It delegates to the batched // presignSeasons so genuinely-singular callers (GetSeason) share one code path; // page callers must use presignSeasons directly. func (s *directContentService) presignSeason(ctx context.Context, season *upstreamSeason) { if season == nil { return } seasons := []upstreamSeason{*season} s.presignSeasons(ctx, seasons) *season = seasons[0] } // presignSeasons presigns every season poster in a single batched // PresignImageURLsWithExpiry call for the whole collection instead of one // singular call per season. func (s *directContentService) presignSeasons(ctx context.Context, seasons []upstreamSeason) { if s.detailSvc == nil || len(seasons) == 0 { return } posterURLs := s.detailSvc.PresignImageURLsWithExpiry(ctx, collectImagePaths(seasons, func(se upstreamSeason) string { return se.PosterURL }), "poster", compatCardImageSize) for i := range seasons { seasons[i].PosterURL = resolvedListImageURL(posterURLs, seasons[i].PosterURL) } } // presignEpisodes presigns every episode still in a single batched // PresignImageURLsWithExpiry call for the whole collection instead of one // singular call per episode. func (s *directContentService) presignEpisodes(ctx context.Context, episodes []upstreamEpisode) { if s.detailSvc == nil || len(episodes) == 0 { return } stillURLs := s.detailSvc.PresignImageURLsWithExpiry(ctx, collectImagePaths(episodes, func(ep upstreamEpisode) string { return ep.StillURL }), "still", compatCardImageSize) for i := range episodes { episodes[i].StillURL = resolvedListImageURL(stillURLs, episodes[i].StillURL) } } // --- Model-to-upstream mapping helpers --- func mediaItemToListItem(mi *models.MediaItem) upstreamListItem { item := upstreamListItem{ ContentID: mi.ContentID, Type: mi.Type, Title: mi.Title, SortTitle: mi.SortTitle, Year: mi.Year, Genres: mi.Genres, ContentRating: mi.ContentRating, Status: mi.Status, RatingIMDB: mi.RatingIMDB, RatingTMDB: mi.RatingTMDB, Overview: mi.Overview, Tagline: mi.Tagline, PosterURL: mi.PosterPath, BackdropURL: mi.BackdropPath, LogoURL: mi.LogoPath, PosterPath: mi.PosterPath, PosterThumbhash: mi.PosterThumbhash, BackdropPath: mi.BackdropPath, BackdropThumbhash: mi.BackdropThumbhash, LogoPath: mi.LogoPath, UpdatedAt: mi.UpdatedAt, Runtime: mi.Runtime, SeasonCount: mi.SeasonCount, Studios: mi.Studios, Countries: mi.Countries, } if premiereDate := compatPremiereDateString(mi.ReleaseDate, mi.FirstAirDate); premiereDate != "" { item.AirDate = premiereDate } return item } func itemDetailToUpstream(d *catalog.ItemDetail) upstreamItemDetail { detail := upstreamItemDetail{ ContentID: d.ContentID, Type: d.Type, Title: d.Title, SortTitle: d.SortTitle, OriginalTitle: d.OriginalTitle, Year: d.Year, Overview: d.Overview, Tagline: d.Tagline, Runtime: d.Runtime, ContentRating: d.ContentRating, Genres: d.Genres, RatingIMDB: d.RatingIMDB, RatingTMDB: d.RatingTMDB, PosterURL: d.PosterURL, BackdropURL: d.BackdropURL, LogoURL: d.LogoURL, Studios: d.Studios, Countries: d.Countries, SeasonCount: d.SeasonCount, SeriesID: d.SeriesID, SeriesTitle: d.SeriesTitle, SeasonNumber: d.SeasonNumber, EpisodeNumber: d.EpisodeNumber, EpisodeCount: d.EpisodeCount, AirDate: compatPremiereDatePtr(d.ReleaseDate, d.FirstAirDate, d.AirDate), IsSpecials: d.IsSpecials, UserData: d.SeasonUserData, Versions: d.Versions, Cast: d.Cast, Crew: d.Crew, Videos: d.Videos, Extras: d.Extras, } if detail.Genres == nil { detail.Genres = []string{} } if detail.Studios == nil { detail.Studios = []string{} } if detail.Countries == nil { detail.Countries = []string{} } if detail.Versions == nil { detail.Versions = []catalog.FileVersion{} } if detail.Cast == nil { detail.Cast = []catalog.CastCredit{} } if detail.Crew == nil { detail.Crew = []catalog.CrewCredit{} } return detail } func compatPremiereDateString(dates ...*string) string { for _, date := range dates { if date == nil { continue } if normalized := strings.TrimSpace(*date); normalized != "" { return normalized } } return "" } func compatPremiereDatePtr(dates ...*string) *string { if date := compatPremiereDateString(dates...); date != "" { return &date } return nil } func modelSeasonToUpstream(s *models.Season, episodeCount int) upstreamSeason { us := upstreamSeason{ ContentID: s.ContentID, SeasonNumber: s.SeasonNumber, Title: s.Title, Overview: s.Overview, EpisodeCount: episodeCount, PosterURL: s.PosterPath, PosterPath: s.PosterPath, PosterThumbhash: s.PosterThumbhash, UpdatedAt: s.UpdatedAt, IsSpecials: s.SeasonNumber == 0, } if s.AirDate != nil { us.AirDate = s.AirDate.Format(time.DateOnly) } return us } // syntheticSeason creates an upstreamSeason from episode aggregation when no // row exists in the seasons table for the given series/season number. func syntheticSeason(seriesID string, seasonNumber, episodeCount int) upstreamSeason { title := fmt.Sprintf("Season %d", seasonNumber) if seasonNumber == 0 { title = "Specials" } return upstreamSeason{ ContentID: fmt.Sprintf("%s-S%02d", seriesID, seasonNumber), SeasonNumber: seasonNumber, Title: title, EpisodeCount: episodeCount, IsSpecials: seasonNumber == 0, } } func modelEpisodeToUpstream(ep *models.Episode, seriesID string) upstreamEpisode { ue := upstreamEpisode{ ContentID: ep.ContentID, SeasonNumber: ep.SeasonNumber, EpisodeNumber: ep.EpisodeNumber, Title: ep.Title, Overview: ep.Overview, Runtime: ep.Runtime, StillURL: ep.StillPath, StillPath: ep.StillPath, StillThumbhash: ep.StillThumbhash, UpdatedAt: ep.UpdatedAt, SeriesID: seriesID, SeasonID: ep.SeasonID, } if ep.AirDate != nil { ue.AirDate = ep.AirDate.Format(time.DateOnly) } return ue } // wrapCatalogError converts catalog-level errors to compat HTTPError. func wrapCatalogError(err error) error { if err == nil { return nil } errMsg := err.Error() if strings.Contains(errMsg, "not found") || strings.Contains(errMsg, "not playable") { return &HTTPError{StatusCode: 404, Message: errMsg} } return err }