Files
silo-server/internal/jellycompat/handlers_items.go
T
Quick 578100f5ef fix(jellycompat): stabilize signed image tags across restarts
{"subject":"fix(jellycompat): stabilize signed image tags across restarts","body":"- Sign library poster and episode parent series image tags from canonical paths/thumbhashes instead of presigned URLs so tags survive restarts\n- Accept signed canonical tags in the image handler without a session and fall back to legacy URL-derived cache tags\n- Always fetch series detail for episodes to build stable parent image tags"}
2026-05-26 22:29:49 -04:00

2251 lines
73 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package jellycompat
import (
"context"
"errors"
"fmt"
"log/slog"
"net/http"
"net/url"
"runtime/debug"
"sort"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/Silo-Server/silo-server/internal/catalog"
"github.com/Silo-Server/silo-server/internal/config"
"github.com/Silo-Server/silo-server/internal/models"
"github.com/Silo-Server/silo-server/internal/playback"
"github.com/Silo-Server/silo-server/internal/recommendations"
"github.com/Silo-Server/silo-server/internal/subtitles"
)
// ItemsHandler serves Jellyfin browse/search/item endpoints.
type ItemsHandler struct {
content ContentService
userData UserDataService
codec *ResourceIDCodec
mapper *mapper
images *ImageCache
nextUpRepo *catalog.NextUpRepository
browseRepo *catalog.BrowseRepository
personRepo *catalog.PersonRepository
detailSvc *catalog.DetailService
itemRepo itemRepoForBatchLoader
episodeRepo episodeRepoForBatchLoader
accessFilter AccessFilterResolver
subtitleRepo subtitles.Repository
recommender recommendations.Recommender
// FileResolver is optional; when set, /MediaSegments returns real intro/
// credits/recap/preview segments for any file that has them.
FileResolver FilePathResolver
}
// NewItemsHandler creates a new items handler.
func NewItemsHandler(content ContentService, userData UserDataService, codec *ResourceIDCodec, cfg *config.Config, images *ImageCache, nextUpRepo *catalog.NextUpRepository, browseRepo *catalog.BrowseRepository, personRepo *catalog.PersonRepository, detailSvc *catalog.DetailService, itemRepo *catalog.ItemRepository, episodeRepo *catalog.EpisodeRepository, accessFilter AccessFilterResolver, subtitleRepo subtitles.Repository) *ItemsHandler {
return &ItemsHandler{
content: content,
userData: userData,
codec: codec,
mapper: newMapper(codec, cfg),
images: images,
nextUpRepo: nextUpRepo,
browseRepo: browseRepo,
personRepo: personRepo,
detailSvc: detailSvc,
itemRepo: itemRepo,
episodeRepo: episodeRepo,
accessFilter: accessFilter,
subtitleRepo: subtitleRepo,
}
}
// HandleViews serves GET /Users/{userId}/Views.
func (h *ItemsHandler) HandleViews(w http.ResponseWriter, r *http.Request) {
session := SessionFromContext(r.Context())
if session == nil {
writeError(w, http.StatusUnauthorized, "Unauthorized", "Missing authentication token")
return
}
if userID := firstNonEmpty(chi.URLParam(r, "userId"), r.URL.Query().Get("userId")); userID != "" && !validatePseudoUser(w, userID, session) {
return
}
libraries, err := h.content.ListUserLibraries(r.Context(), session)
if err != nil {
writeCompatUpstreamError(w, err)
return
}
items := make([]baseItemDTO, 0, len(libraries))
for _, library := range libraries {
dto := h.mapper.viewFromLibrary(library)
h.rememberLibraryImages(library, dto.ID)
items = append(items, dto)
}
writeJSON(w, http.StatusOK, queryResultDTO{
Items: items,
TotalRecordCount: len(items),
StartIndex: 0,
})
}
// handleViewsResponse returns the user's library views as CollectionFolder items.
func (h *ItemsHandler) handleViewsResponse(w http.ResponseWriter, r *http.Request, session *Session) {
libraries, err := h.content.ListUserLibraries(r.Context(), session)
if err != nil {
writeCompatUpstreamError(w, err)
return
}
items := make([]baseItemDTO, 0, len(libraries))
for _, library := range libraries {
dto := h.mapper.viewFromLibrary(library)
h.rememberLibraryImages(library, dto.ID)
items = append(items, dto)
}
writeJSON(w, http.StatusOK, queryResultDTO{
Items: items,
TotalRecordCount: len(items),
StartIndex: 0,
})
}
// HandleItems serves GET /Items.
func (h *ItemsHandler) HandleItems(w http.ResponseWriter, r *http.Request) {
session := SessionFromContext(r.Context())
if session == nil {
writeError(w, http.StatusUnauthorized, "Unauthorized", "Missing authentication token")
return
}
if userID := firstNonEmpty(chi.URLParam(r, "userId"), chi.URLParam(r, "id")); userID != "" && !validatePseudoUser(w, userID, session) {
return
}
query := parseItemsQuery(r, h.codec)
switch {
case len(query.specificIDs) > 0:
h.handleSpecificItems(w, r, session, query)
case query.isResumable:
h.handleResumeResponse(w, r, session, query)
case query.isPlayed != nil && *query.isPlayed:
h.handlePlayedItems(w, r, session, query)
case query.searchTerm != "":
h.handleSearchItems(w, r, session, query)
case query.isFavorite:
h.handleFavoriteItems(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=...
// and expect CollectionFolder items representing the user's libraries.
h.handleViewsResponse(w, r, session)
default:
h.handleBrowseItems(w, r, session, query)
}
}
// HandleItem serves GET /Items/{id}.
func (h *ItemsHandler) HandleItem(w http.ResponseWriter, r *http.Request) {
session := SessionFromContext(r.Context())
if session == nil {
writeError(w, http.StatusUnauthorized, "Unauthorized", "Missing authentication token")
return
}
if userID := chi.URLParam(r, "userId"); userID != "" && !validatePseudoUser(w, userID, session) {
return
}
rawID := chi.URLParam(r, "id")
// Handle library IDs — clients like Infuse request /Items/{id} for
// CollectionFolder items using the library UUID from /UserViews.
if libraryID, err := h.codec.DecodeIntID(EncodedIDLibrary, rawID); err == nil {
h.handleLibraryItem(w, r, session, int(libraryID))
return
}
if mediaSourceID, err := h.codec.DecodeIntID(EncodedIDMediaSource, rawID); err == nil {
contentID, ok := h.codec.LookupMediaSourceOwner(mediaSourceID)
if !ok {
writeError(w, http.StatusNotFound, "NotFound", "Item not found")
return
}
rawID = h.codec.EncodeStringID(EncodedIDItem, contentID)
}
if personID, err := h.codec.DecodeIntID(EncodedIDPerson, rawID); err == nil {
h.handlePersonItem(w, r, session, rawID, personID)
return
}
contentID, err := decodeContentID(h.codec, rawID)
if err != nil {
writeError(w, http.StatusNotFound, "NotFound", "Item not found")
return
}
detail, err := h.content.GetItemDetail(r.Context(), session, contentID, nil)
if err != nil {
writeCompatUpstreamError(w, err)
return
}
h.rememberDetailImages(*detail)
favorites, progress, err := resolveUserStateForContentIDs(r.Context(), session, h.userData, []string{detail.ContentID})
if err != nil {
writeCompatUpstreamError(w, err)
return
}
dto := h.mapper.itemFromDetail(*detail, favorites[detail.ContentID], progress[detail.ContentID])
h.appendDownloadedSubtitlesToDetailDTO(r.Context(), detail.ContentID, detail.Versions, &dto)
if strings.EqualFold(detail.Type, "series") {
if seasons, seasonErr := h.content.ListSeasons(r.Context(), session, detail.ContentID, nil); seasonErr == nil {
browsableSeasons := filterBrowsableSeasons(seasons)
dto.ChildCount = len(browsableSeasons)
dto.RecursiveItemCount = len(browsableSeasons)
}
}
if strings.EqualFold(detail.Type, "episode") && detail.SeriesID != "" {
seriesImgCache := make(map[string]seriesImageSet)
h.enrichEpisodeSeriesImages(r.Context(), session, &dto, detail.SeriesID, seriesImgCache)
if detail.SeasonNumber != nil {
season, seasonErr := h.content.GetSeason(r.Context(), session, detail.SeriesID, *detail.SeasonNumber, nil)
if seasonErr == nil && season != nil {
dto.SeasonID = h.codec.EncodeStringID(EncodedIDSeason, season.ContentID)
dto.SeasonName = season.Title
dto.ParentID = dto.SeasonID
}
}
}
writeJSON(w, http.StatusOK, dto)
}
func (h *ItemsHandler) appendDownloadedSubtitlesToDetailDTO(ctx context.Context, contentID string, versions []catalog.FileVersion, dto *baseItemDTO) {
if h == nil || h.subtitleRepo == nil || dto == nil || len(dto.MediaSources) == 0 || len(versions) == 0 {
return
}
routeItemID := h.codec.EncodeStringID(EncodedIDItem, contentID)
appendedAny := false
for i, version := range versions {
if i >= len(dto.MediaSources) {
break
}
downloaded, err := h.subtitleRepo.ListDownloadedSubtitles(ctx, version.FileID)
if err != nil || len(downloaded) == 0 {
continue
}
sourceID := h.codec.EncodeIntID(EncodedIDMediaSource, int64(version.FileID))
baseIndex := nextDownloadedSubtitleIndex(version)
for j, dl := range downloaded {
streamIndex := baseIndex + j
format := subtitleRouteFormat(string(dl.Format))
displayTitle := downloadedSubtitleDisplayTitle(dl)
stream := mediaStreamDTO{
Index: streamIndex,
Type: "Subtitle",
Codec: string(dl.Format),
Language: dl.Language,
DisplayTitle: displayTitle,
Title: displayTitle,
IsDefault: false,
IsExternal: true,
IsForced: false,
IsHearingImpaired: dl.HearingImpaired,
IsTextSubtitleStream: true,
SupportsExternalStream: true,
DeliveryURL: fmt.Sprintf("/Videos/%s/%s/Subtitles/%d/stream.%s", routeItemID, sourceID, streamIndex, format),
DeliveryMethod: "External",
Path: downloadedSubtitlePath(version, dl),
IsExternalURL: boolPtr(false),
}
dto.MediaSources[i].MediaStreams = append(dto.MediaSources[i].MediaStreams, stream)
dto.MediaStreams = append(dto.MediaStreams, stream)
appendedAny = true
}
}
if appendedAny {
dto.HasSubtitles = true
}
}
// handlePersonItem serves GET /Items/{id} when the ID decodes as a person.
func (h *ItemsHandler) handlePersonItem(w http.ResponseWriter, r *http.Request, session *Session, routeID string, personID int64) {
if h.personRepo == nil {
writeError(w, http.StatusNotFound, "NotFound", "Item not found")
return
}
person, err := h.personRepo.Get(r.Context(), personID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
writeError(w, http.StatusNotFound, "NotFound", "Person not found")
} else {
writeCompatUpstreamError(w, err)
}
return
}
var photoURL string
if h.detailSvc != nil && person.PhotoPath != "" {
photoURL = compatPresignImage(h.detailSvc, r.Context(), person.PhotoPath, "poster", compatCardImageSize)
}
if photoURL != "" {
h.images.RememberSized(routeID, "Primary", photoURL, compatCardImageSize)
}
dto := baseItemDTO{
ID: routeID,
Name: person.Name,
Type: "Person",
ServerID: h.mapper.serverID,
SortName: firstNonEmpty(person.SortName, person.Name),
Overview: person.Bio,
}
if person.BirthDate != nil {
dto.PremiereDate = person.BirthDate.Format(time.RFC3339)
}
providerIDs := map[string]string{}
var externalURLs []map[string]any
if person.TmdbID != "" {
providerIDs["Tmdb"] = person.TmdbID
externalURLs = append(externalURLs, map[string]any{
"Name": "TMDB",
"Url": "https://www.themoviedb.org/person/" + person.TmdbID,
})
}
if person.ImdbID != "" {
providerIDs["Imdb"] = person.ImdbID
externalURLs = append(externalURLs, map[string]any{
"Name": "IMDb",
"Url": "https://www.imdb.com/name/" + person.ImdbID,
})
}
if person.TvdbID != "" {
providerIDs["Tvdb"] = person.TvdbID
externalURLs = append(externalURLs, map[string]any{
"Name": "TheTVDB",
"Url": "https://www.thetvdb.com/people/" + person.TvdbID,
})
}
if len(providerIDs) > 0 {
dto.ProviderIDs = providerIDs
}
if len(externalURLs) > 0 {
dto.ExternalURLs = externalURLs
}
if person.Birthplace != "" {
dto.ProductionLocations = []string{person.Birthplace}
}
if photoURL != "" {
tag := tagValue(photoURL)
dto.ImageTags = map[string]string{"Primary": tag}
ratio := 2.0 / 3.0
dto.PrimaryImageAspectRatio = &ratio
}
dto.UserData = &itemUserDataDTO{
Key: routeID,
ItemID: routeID,
}
dto.People = []personDTO{}
dto.Genres = []string{}
dto.Tags = []string{}
dto.LockedFields = []string{}
dto.BackdropImageTags = []string{}
if counts, err := h.personRepo.CountItemsByType(r.Context(), personID); err != nil {
slog.Warn("failed to load filmography counts", "person_id", personID, "error", err)
} else {
dto.MovieCount = counts["movie"]
dto.SeriesCount = counts["series"]
dto.EpisodeCount = counts["episode"]
}
writeJSON(w, http.StatusOK, dto)
}
// HandleSimilar serves GET /Items/{id}/Similar, /Movies/{id}/Similar, /Shows/{id}/Similar.
func (h *ItemsHandler) HandleSimilar(w http.ResponseWriter, r *http.Request) {
session := SessionFromContext(r.Context())
if session == nil {
writeError(w, http.StatusUnauthorized, "Unauthorized", "Missing authentication token")
return
}
rawID := chi.URLParam(r, "id")
contentID, err := h.codec.DecodeStringID(EncodedIDItem, rawID)
if err != nil {
writeJSON(w, http.StatusOK, queryResultDTO{Items: []baseItemDTO{}, TotalRecordCount: 0})
return
}
qp := newCaseInsensitiveQuery(r.URL.Query())
limit := 12
if v := qp.Get("Limit"); v != "" {
if n, parseErr := strconv.Atoi(v); parseErr == nil && n > 0 {
limit = min(n, 24)
}
}
// Tier 1: embedding-based recommendations.
if h.recommender != nil {
scored, recErr := h.recommender.SimilarItems(r.Context(), contentID, limit)
if recErr == nil && len(scored) > 0 {
if h.writeSimilarFromScored(w, r, session, scored, limit) {
return
}
}
}
// Tier 2: genre-based fallback.
h.writeSimilarFromGenre(w, r, session, contentID, limit)
}
// writeSimilarFromScored converts recommender ScoredItem results into a Jellyfin query result.
// It returns false when all scored candidates are filtered out so the caller can
// fall back to the genre-based browse path.
func (h *ItemsHandler) writeSimilarFromScored(w http.ResponseWriter, r *http.Request, session *Session, scored []recommendations.ScoredItem, limit int) bool {
contentIDs := make([]string, 0, len(scored))
for _, s := range scored {
contentIDs = append(contentIDs, s.MediaItemID)
}
itemsByID, err := h.fetchCompatItemsByContentIDs(r.Context(), session, contentIDs, nil)
if err != nil {
writeJSON(w, http.StatusOK, queryResultDTO{Items: []baseItemDTO{}, TotalRecordCount: 0})
return true
}
favorites, progress, _ := resolveUserStateForContentIDs(r.Context(), session, h.userData, contentIDs)
// Build DTOs preserving recommendation order.
items := make([]baseItemDTO, 0, len(scored))
listItems := make([]upstreamListItem, 0, len(scored))
for _, s := range scored {
li, ok := itemsByID[s.MediaItemID]
if !ok {
continue
}
dto := h.mapper.itemFromList(li, favorites[s.MediaItemID], progress[s.MediaItemID], nil)
dto.MediaType = ""
dto.LocationType = ""
dto.VideoType = ""
items = append(items, dto)
listItems = append(listItems, li)
if len(items) >= limit {
break
}
}
h.rememberListImages(listItems)
if len(items) == 0 {
return false
}
writeJSON(w, http.StatusOK, queryResultDTO{
Items: items,
TotalRecordCount: len(items),
StartIndex: 0,
})
return true
}
// writeSimilarFromGenre finds similar items by genre when the recommender is unavailable.
func (h *ItemsHandler) writeSimilarFromGenre(w http.ResponseWriter, r *http.Request, session *Session, contentID string, limit int) {
empty := queryResultDTO{Items: []baseItemDTO{}, TotalRecordCount: 0}
detail, err := h.content.GetItemDetail(r.Context(), session, contentID, nil)
if err != nil || detail == nil {
writeJSON(w, http.StatusOK, empty)
return
}
genre := ""
if len(detail.Genres) > 0 {
genre = strings.TrimSpace(detail.Genres[0])
}
if genre == "" {
writeJSON(w, http.StatusOK, empty)
return
}
params := url.Values{}
params.Set("type", detail.Type)
params.Set("genre", genre)
params.Set("sort", "rating_tmdb")
params.Set("order", "desc")
params.Set("limit", strconv.Itoa(limit+1))
result, err := h.content.BrowseItems(r.Context(), session, params)
if err != nil {
writeJSON(w, http.StatusOK, empty)
return
}
browseIDs := make([]string, len(result.Items))
for i, li := range result.Items {
browseIDs[i] = li.ContentID
}
favorites, progress, _ := resolveUserStateForContentIDs(r.Context(), session, h.userData, browseIDs)
items := make([]baseItemDTO, 0, limit)
listItems := make([]upstreamListItem, 0, limit)
for _, li := range result.Items {
if li.ContentID == contentID {
continue
}
dto := h.mapper.itemFromList(li, favorites[li.ContentID], progress[li.ContentID], nil)
dto.MediaType = ""
dto.LocationType = ""
dto.VideoType = ""
items = append(items, dto)
listItems = append(listItems, li)
if len(items) >= limit {
break
}
}
h.rememberListImages(listItems)
writeJSON(w, http.StatusOK, queryResultDTO{
Items: items,
TotalRecordCount: len(items),
StartIndex: 0,
})
}
// HandleItemStub serves stub responses for unimplemented sub-item endpoints
// like /Items/{id}/ThemeMedia, /Items/{id}/SpecialFeatures, /Items/{id}/Intros.
func (h *ItemsHandler) HandleItemStub(w http.ResponseWriter, r *http.Request) {
session := SessionFromContext(r.Context())
if session == nil {
writeError(w, http.StatusUnauthorized, "Unauthorized", "Missing authentication token")
return
}
writeJSON(w, http.StatusOK, queryResultDTO{
Items: []baseItemDTO{},
TotalRecordCount: 0,
StartIndex: 0,
})
}
// mediaSegmentDTO mirrors Jellyfin's MediaSegmentDto shape. Times are
// expressed in 100-nanosecond ticks (the convention shared with RunTimeTicks
// and chapter position fields).
type mediaSegmentDTO struct {
Id string `json:"Id"`
ItemId string `json:"ItemId"`
Type string `json:"Type"`
StartTicks int64 `json:"StartTicks"`
EndTicks int64 `json:"EndTicks"`
}
// mediaSegmentsResultDTO is the paged envelope for /MediaSegments responses.
type mediaSegmentsResultDTO struct {
Items []mediaSegmentDTO `json:"Items"`
TotalRecordCount int `json:"TotalRecordCount"`
StartIndex int `json:"StartIndex"`
}
// HandleMediaSegments returns the intro/credits/recap/preview ranges for an
// item as a Jellyfin MediaSegments payload. Used by Jellyfin clients
// (JellyCon, Findroid, Infuse) to render skip buttons.
func (h *ItemsHandler) HandleMediaSegments(w http.ResponseWriter, r *http.Request) {
session := SessionFromContext(r.Context())
if session == nil {
writeError(w, http.StatusUnauthorized, "Unauthorized", "Missing authentication token")
return
}
raw := chiURLParam(r, "id")
if raw == "" {
writeError(w, http.StatusBadRequest, "BadRequest", "Missing item id")
return
}
contentID, err := h.codec.DecodeStringID(EncodedIDItem, raw)
var requestedFileID int
if err != nil {
if fileID, fileErr := h.codec.DecodeIntID(EncodedIDMediaSource, raw); fileErr == nil {
if owner, ok := h.codec.LookupMediaSourceOwner(fileID); ok {
contentID = owner
requestedFileID = int(fileID)
}
}
}
if contentID == "" {
slog.Debug("jellycompat: media segments lookup with undecodable id", "raw_id", raw)
writeJSON(w, http.StatusOK, mediaSegmentsResultDTO{Items: []mediaSegmentDTO{}})
return
}
detail, err := h.content.GetItemDetail(r.Context(), session, contentID, nil)
if err != nil {
slog.Warn("jellycompat: media segments item lookup failed",
"content_id", contentID,
"error", err)
writeJSON(w, http.StatusOK, mediaSegmentsResultDTO{Items: []mediaSegmentDTO{}})
return
}
if detail == nil {
writeJSON(w, http.StatusOK, mediaSegmentsResultDTO{Items: []mediaSegmentDTO{}})
return
}
// Versions carry the same Intro/Credits/Recap/Preview that the native API
// surfaces; the default-playback version owns the segments shown to clients.
var version *catalog.FileVersion
if requestedFileID > 0 {
for i := range detail.Versions {
if detail.Versions[i].FileID == requestedFileID {
version = &detail.Versions[i]
break
}
}
}
if requestedFileID == 0 {
for i := range detail.Versions {
if version == nil || (detail.Versions[i].FileID != 0 && version.FileID == 0) {
version = &detail.Versions[i]
}
}
}
if version == nil {
writeJSON(w, http.StatusOK, mediaSegmentsResultDTO{Items: []mediaSegmentDTO{}})
return
}
segments := buildMediaSegmentDTOs(raw, version)
writeJSON(w, http.StatusOK, mediaSegmentsResultDTO{
Items: segments,
TotalRecordCount: len(segments),
StartIndex: 0,
})
}
// buildMediaSegmentDTOs converts the four optional marker ranges on a file
// version into the flat segment list shape Jellyfin clients expect.
func buildMediaSegmentDTOs(itemUUID string, version *catalog.FileVersion) []mediaSegmentDTO {
if version == nil {
return nil
}
segments := make([]mediaSegmentDTO, 0, 4)
add := func(kind string, marker *catalog.Marker) {
if marker == nil {
return
}
segments = append(segments, mediaSegmentDTO{
Id: deriveSegmentID(itemUUID, kind),
ItemId: itemUUID,
Type: kind,
StartTicks: secondsToTicks(marker.Start),
EndTicks: secondsToTicks(marker.End),
})
}
add("Intro", version.Intro)
add("Outro", version.Credits)
add("Recap", version.Recap)
add("Preview", version.Preview)
return segments
}
// mediaSegmentIDNamespace is the fixed UUIDv5 namespace under which segment
// IDs are minted. A bespoke namespace ensures these IDs never collide with
// other UUIDs minted elsewhere in the codec, while remaining deterministic
// across processes and restarts.
var mediaSegmentIDNamespace = uuid.MustParse("9f1b2f4a-3c0d-5e16-9a87-2c4f8d0a1d9b")
// deriveSegmentID produces a stable UUID for a (item, kind) pair so repeated
// GETs return the same Id (Jellyfin clients cache by Id).
func deriveSegmentID(itemUUID, kind string) string {
return uuid.NewSHA1(mediaSegmentIDNamespace, []byte(itemUUID+":"+kind)).String()
}
// HandleGroupingOptionsStub serves GET /UserViews/GroupingOptions with an empty array.
// Jellyfin returns []SpecialViewOptionDto; Silo doesn't support library grouping.
func (h *ItemsHandler) HandleGroupingOptionsStub(w http.ResponseWriter, r *http.Request) {
session := SessionFromContext(r.Context())
if session == nil {
writeError(w, http.StatusUnauthorized, "Unauthorized", "Missing authentication token")
return
}
writeJSON(w, http.StatusOK, []struct{}{})
}
// HandleVirtualFolders serves GET /Library/VirtualFolders.
// Returns library metadata so clients like Infuse know library collection types.
func (h *ItemsHandler) HandleVirtualFolders(w http.ResponseWriter, r *http.Request) {
session := SessionFromContext(r.Context())
if session == nil {
writeError(w, http.StatusUnauthorized, "Unauthorized", "Missing authentication token")
return
}
libraries, err := h.content.ListUserLibraries(r.Context(), session)
if err != nil {
writeCompatUpstreamError(w, err)
return
}
folders := make([]virtualFolderDTO, 0, len(libraries))
for _, lib := range libraries {
folders = append(folders, virtualFolderDTO{
Name: lib.Name,
Locations: []string{},
CollectionType: libraryCollectionType(lib.Type),
ItemID: h.codec.EncodeIntID(EncodedIDLibrary, int64(lib.ID)),
LibraryOptions: virtualLibraryOptDTO{
Enabled: true,
EnableRealtimeMonitor: true,
EnableInternetProviders: true,
SeasonZeroDisplayName: "Specials",
TypeOptions: []string{},
},
})
}
writeJSON(w, http.StatusOK, folders)
}
// HandleFiltersStub serves GET /Items/Filters with empty filter facets.
func (h *ItemsHandler) HandleFiltersStub(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string][]string{
"Genres": {},
"Tags": {},
"OfficialRatings": {},
"Years": {},
})
}
// HandleLatest serves GET /Items/Latest.
func (h *ItemsHandler) HandleLatest(w http.ResponseWriter, r *http.Request) {
session := SessionFromContext(r.Context())
if session == nil {
writeError(w, http.StatusUnauthorized, "Unauthorized", "Missing authentication token")
return
}
if userID := firstNonEmpty(chi.URLParam(r, "userId"), chi.URLParam(r, "id")); userID != "" && !validatePseudoUser(w, userID, session) {
return
}
query := parseItemsQuery(r, h.codec)
// Jellyfin's /Items/Latest auto-infers item types from the parent
// library's collection type when no explicit IncludeItemTypes is given.
// Without this, clients like Infuse may not display "Latest Movies"
// sections because they rely on the library-type-appropriate item filter.
if query.parentLibraryID > 0 && len(query.itemTypes) == 0 {
if inferredType := h.inferLibraryItemType(r.Context(), session, query.parentLibraryID); inferredType != "" {
query.itemTypes = []string{inferredType}
}
}
params := buildLatestBrowseParams(query)
result, err := h.content.BrowseItems(r.Context(), session, params)
if err != nil {
writeCompatUpstreamError(w, err)
return
}
h.rememberListImages(result.Items)
contentIDs := contentIDsFromListItems(result.Items)
favorites, progress, err := resolveUserStateForContentIDs(r.Context(), session, h.userData, contentIDs)
if err != nil {
writeCompatUpstreamError(w, err)
return
}
episodeTargets, err := h.fetchCompatEpisodeTargetsByContentIDs(r.Context(), session, episodeContentIDsFromListItems(result.Items), libraryIDPtr(query.parentLibraryID))
if err != nil {
writeCompatUpstreamError(w, err)
return
}
// Encode library ID for the ParentId field on each item.
var libraryParentID string
if query.parentLibraryID > 0 {
libraryParentID = h.codec.EncodeIntID(EncodedIDLibrary, int64(query.parentLibraryID))
}
items := make([]baseItemDTO, 0, len(result.Items))
for _, item := range result.Items {
if query.needsDetailFields {
detail, detailErr := h.content.GetItemDetail(r.Context(), session, item.ContentID, libraryIDPtr(query.parentLibraryID))
if detailErr == nil {
h.rememberDetailImages(*detail)
dto := h.mapper.itemFromDetailWithFields(*detail, favorites[detail.ContentID], progress[detail.ContentID], query.requestedFields)
if libraryParentID != "" {
dto.ParentID = libraryParentID
}
if target, ok := episodeTargets[detail.ContentID]; ok {
h.applyCompatEpisodeTarget(&dto, target)
}
items = append(items, dto)
continue
}
}
dto := h.mapper.itemFromList(item, favorites[item.ContentID], progress[item.ContentID], query.requestedFields)
if libraryParentID != "" {
dto.ParentID = libraryParentID
}
if target, ok := episodeTargets[item.ContentID]; ok {
h.applyCompatEpisodeTarget(&dto, target)
}
items = append(items, dto)
}
applyImageTypeLimit(items, query.imageTypeLimit)
writeJSON(w, http.StatusOK, items)
}
// HandleSuggestions serves GET /Items/Suggestions.
func (h *ItemsHandler) HandleSuggestions(w http.ResponseWriter, r *http.Request) {
session := SessionFromContext(r.Context())
if session == nil {
writeError(w, http.StatusUnauthorized, "Unauthorized", "Missing authentication token")
return
}
query := parseSuggestionsQuery(r, h.codec)
params := buildBrowseParams(query)
params.Set("sort", "rating_imdb")
params.Set("order", "desc")
result, err := h.content.BrowseItems(r.Context(), session, params)
if err != nil {
writeCompatUpstreamError(w, err)
return
}
h.rememberListImages(result.Items)
contentIDs := contentIDsFromListItems(result.Items)
favorites, progress, err := resolveUserStateForContentIDs(r.Context(), session, h.userData, contentIDs)
if err != nil {
writeCompatUpstreamError(w, err)
return
}
episodeTargets, err := h.fetchCompatEpisodeTargetsByContentIDs(r.Context(), session, episodeContentIDsFromListItems(result.Items), nil)
if err != nil {
writeCompatUpstreamError(w, err)
return
}
items := make([]baseItemDTO, 0, len(result.Items))
for _, item := range result.Items {
dto := h.mapper.itemFromList(item, favorites[item.ContentID], progress[item.ContentID], nil)
if target, ok := episodeTargets[item.ContentID]; ok {
h.applyCompatEpisodeTarget(&dto, target)
}
items = append(items, dto)
}
writeJSON(w, http.StatusOK, queryResultDTO{
Items: items,
TotalRecordCount: len(items),
StartIndex: 0,
})
}
// HandleGenres serves GET /Genres.
func (h *ItemsHandler) HandleGenres(w http.ResponseWriter, r *http.Request) {
session := SessionFromContext(r.Context())
if session == nil {
writeError(w, http.StatusUnauthorized, "Unauthorized", "Missing authentication token")
return
}
if userID := r.URL.Query().Get("UserId"); userID != "" && !validatePseudoUser(w, userID, session) {
return
}
params := urlValuesFromItemsQuery(parseItemsQuery(r, h.codec))
filters, err := h.content.ListItemFilters(r.Context(), session, params)
if err != nil {
writeCompatUpstreamError(w, err)
return
}
items := make([]baseItemDTO, 0, len(filters.Genres))
for _, genre := range filters.Genres {
if strings.TrimSpace(genre) == "" {
continue
}
items = append(items, baseItemDTO{
ID: h.codec.EncodeStringID(EncodedIDGenre, genre),
Type: "Genre",
Name: genre,
})
}
writeJSON(w, http.StatusOK, queryResultDTO{
Items: items,
TotalRecordCount: len(items),
StartIndex: 0,
})
}
// HandleSeasons serves GET /Shows/{id}/Seasons.
func (h *ItemsHandler) HandleSeasons(w http.ResponseWriter, r *http.Request) {
defer func() {
if rv := recover(); rv != nil {
slog.Error("jellycompat HandleSeasons panic",
"error", fmt.Sprint(rv),
"path", r.URL.Path,
"query", r.URL.RawQuery,
"stack", string(debug.Stack()),
)
writeError(w, http.StatusInternalServerError, "ServerError", "Internal error")
}
}()
session := SessionFromContext(r.Context())
if session == nil {
writeError(w, http.StatusUnauthorized, "Unauthorized", "Missing authentication token")
return
}
query := parseItemsQuery(r, h.codec)
seriesID, err := h.codec.DecodeStringID(EncodedIDItem, chi.URLParam(r, "id"))
if err != nil {
writeError(w, http.StatusNotFound, "NotFound", "Series not found")
return
}
seasons, err := h.content.ListSeasons(r.Context(), session, seriesID, nil)
if err != nil {
writeCompatUpstreamError(w, err)
return
}
seasons = filterBrowsableSeasons(seasons)
h.rememberSeasonImages(seasons, seriesID)
favorites, err := resolveFavoritesForContentIDs(r.Context(), session, h.userData, seasonContentIDs(seasons))
if err != nil {
writeCompatUpstreamError(w, err)
return
}
items := make([]baseItemDTO, 0, len(seasons))
for _, season := range seasons {
if query.needsDetailFields {
detail, detailErr := h.content.GetItemDetail(r.Context(), session, season.ContentID, nil)
if detailErr == nil {
h.rememberDetailImages(*detail)
dto := h.mapper.itemFromDetailWithFields(*detail, favorites[season.ContentID], nil, query.requestedFields)
dto.ParentID = h.codec.EncodeStringID(EncodedIDItem, seriesID)
dto.SeriesID = h.codec.EncodeStringID(EncodedIDItem, seriesID)
dto.IndexNumber = &season.SeasonNumber
dto.ParentIndexNumber = nil
items = append(items, dto)
continue
}
}
items = append(items, h.mapper.seasonFromUpstream(season, seriesID, favorites[season.ContentID]))
}
writeJSON(w, http.StatusOK, queryResultDTO{
Items: items,
TotalRecordCount: len(items),
StartIndex: 0,
})
}
func filterBrowsableSeasons(seasons []upstreamSeason) []upstreamSeason {
filtered := make([]upstreamSeason, 0, len(seasons))
for _, season := range seasons {
if season.EpisodeCount <= 0 {
continue
}
filtered = append(filtered, season)
}
return filtered
}
// HandleEpisodes serves GET /Shows/{id}/Episodes.
func (h *ItemsHandler) HandleEpisodes(w http.ResponseWriter, r *http.Request) {
session := SessionFromContext(r.Context())
if session == nil {
writeError(w, http.StatusUnauthorized, "Unauthorized", "Missing authentication token")
return
}
query := parseItemsQuery(r, h.codec)
seriesID, err := h.codec.DecodeStringID(EncodedIDItem, chi.URLParam(r, "id"))
if err != nil {
writeError(w, http.StatusNotFound, "NotFound", "Series not found")
return
}
seasons, err := h.content.ListSeasons(r.Context(), session, seriesID, nil)
if err != nil {
writeCompatUpstreamError(w, err)
return
}
h.rememberSeasonImages(seasons, seriesID)
qp := newCaseInsensitiveQuery(r.URL.Query())
var requestedSeasonID string
if rawSeasonID := strings.TrimSpace(qp.Get("SeasonId")); rawSeasonID != "" {
decodedSeasonID, decodeErr := h.codec.DecodeStringID(EncodedIDSeason, rawSeasonID)
if decodeErr != nil {
writeError(w, http.StatusNotFound, "NotFound", "Season not found")
return
}
requestedSeasonID = decodedSeasonID
}
seasonTitleByID := make(map[string]string, len(seasons))
seasonTitleByNumber := make(map[int]string, len(seasons))
seasonIDByNumber := make(map[int]string, len(seasons))
for _, season := range seasons {
seasonTitleByID[season.ContentID] = season.Title
seasonTitleByNumber[season.SeasonNumber] = season.Title
seasonIDByNumber[season.SeasonNumber] = season.ContentID
}
episodeModels, err := h.listSeriesEpisodes(r.Context(), session, seriesID, seasons, requestedSeasonID)
if err != nil {
writeCompatUpstreamError(w, err)
return
}
contentIDs := contentIDsFromEpisodes(episodeModels)
favorites, progress, err := resolveUserStateForContentIDs(r.Context(), session, h.userData, contentIDs)
if err != nil {
writeCompatUpstreamError(w, err)
return
}
episodeTargets, err := h.fetchCompatEpisodeTargetsByContentIDs(r.Context(), session, contentIDs, nil)
if err != nil {
writeCompatUpstreamError(w, err)
return
}
// When detail-level fields are requested (Fields=MediaSources, MediaStreams,
// Chapters, People), fetch every episode's detail in a single batched call
// via the catalog's hoisted path, instead of fanning out one
// GetItemDetail per episode. This eliminates ~5×N redundant series-level
// lookups (parent series row, localization, credits, version preference,
// backdrop presign) for an N-episode series.
var episodeDetails map[string]*upstreamItemDetail
if query.needsDetailFields && h.detailSvc != nil {
filter := h.resolveAccessFilter(r.Context(), session)
details, detailErr := h.detailSvc.GetEpisodeDetailsForSeries(r.Context(), seriesID, contentIDs, filter)
if detailErr != nil {
writeCompatUpstreamError(w, detailErr)
return
}
episodeDetails = make(map[string]*upstreamItemDetail, len(details))
for contentID, detail := range details {
upstream := itemDetailToUpstream(detail)
episodeDetails[contentID] = &upstream
}
}
items := make([]baseItemDTO, 0, len(episodeModels))
for _, episode := range episodeModels {
if query.needsDetailFields {
if detail, ok := episodeDetails[episode.ContentID]; ok && detail != nil {
h.rememberDetailImages(*detail)
dto := h.mapper.itemFromDetailWithFields(*detail, favorites[episode.ContentID], progress[episode.ContentID], query.requestedFields)
dto.SeasonName = firstNonEmpty(seasonTitleByID[episode.SeasonID], seasonTitleByNumber[episode.SeasonNumber])
if dto.SeasonID == "" {
if seasonID := firstNonEmpty(episode.SeasonID, seasonIDByNumber[episode.SeasonNumber]); seasonID != "" {
dto.SeasonID = h.codec.EncodeStringID(EncodedIDSeason, seasonID)
}
}
if dto.ParentID == "" {
dto.ParentID = dto.SeasonID
}
if dto.SeriesID == "" {
dto.SeriesID = h.codec.EncodeStringID(EncodedIDItem, seriesID)
}
if target, ok := episodeTargets[episode.ContentID]; ok {
h.applyCompatEpisodeTarget(&dto, target)
}
items = append(items, dto)
continue
}
}
upstreamEpisode := modelEpisodeToUpstream(episode, seriesID)
if target, ok := episodeTargets[episode.ContentID]; ok {
upstreamEpisode.StillURL = firstNonEmpty(target.Item.StillURL, target.Item.PosterURL, upstreamEpisode.StillURL)
upstreamEpisode.SeriesTitle = firstNonEmpty(target.Item.SeriesTitle, upstreamEpisode.SeriesTitle)
}
dto := h.mapper.episodeFromUpstream(upstreamEpisode, favorites[episode.ContentID], progress[episode.ContentID])
dto.SeasonName = firstNonEmpty(seasonTitleByID[episode.SeasonID], seasonTitleByNumber[episode.SeasonNumber])
if dto.SeasonID == "" {
if seasonID := seasonIDByNumber[episode.SeasonNumber]; seasonID != "" {
dto.SeasonID = h.codec.EncodeStringID(EncodedIDSeason, seasonID)
}
}
dto.ParentID = dto.SeasonID
dto.SeriesID = h.codec.EncodeStringID(EncodedIDItem, seriesID)
if target, ok := episodeTargets[episode.ContentID]; ok {
h.applyCompatEpisodeTarget(&dto, target)
}
items = append(items, dto)
}
sort.SliceStable(items, func(i, j int) bool {
leftSeason := 0
rightSeason := 0
if items[i].ParentIndexNumber != nil {
leftSeason = *items[i].ParentIndexNumber
}
if items[j].ParentIndexNumber != nil {
rightSeason = *items[j].ParentIndexNumber
}
if leftSeason == rightSeason {
leftEpisode := 0
rightEpisode := 0
if items[i].IndexNumber != nil {
leftEpisode = *items[i].IndexNumber
}
if items[j].IndexNumber != nil {
rightEpisode = *items[j].IndexNumber
}
return leftEpisode < rightEpisode
}
return leftSeason < rightSeason
})
writeJSON(w, http.StatusOK, queryResultDTO{
Items: items,
TotalRecordCount: len(items),
StartIndex: 0,
})
}
// HandleNextUp serves GET /Shows/NextUp.
func (h *ItemsHandler) HandleNextUp(w http.ResponseWriter, r *http.Request) {
session := SessionFromContext(r.Context())
if session == nil {
writeError(w, http.StatusUnauthorized, "Unauthorized", "Missing authentication token")
return
}
qp := newCaseInsensitiveQuery(r.URL.Query())
if userID := qp.Get("UserId"); userID != "" && !validatePseudoUser(w, userID, session) {
return
}
query := parseItemsQuery(r, h.codec)
q := catalog.NextUpQuery{
UserID: session.StreamAppUserID,
ProfileID: session.ProfileID,
Limit: query.limit + query.startIndex, // fetch enough to paginate
EnableResumable: parseBool(qp.Get("enableResumable"), false),
}
// Parse SeriesId filter
if rawSeriesID := strings.TrimSpace(qp.Get("SeriesId")); rawSeriesID != "" {
seriesID, err := decodeItemID(h.codec, rawSeriesID)
if err != nil {
writeError(w, http.StatusNotFound, "NotFound", "Series not found")
return
}
q.SeriesID = seriesID
}
// Parse date cutoff
if rawCutoff := qp.Get("nextUpDateCutoff"); rawCutoff != "" {
if t, err := time.Parse(time.RFC3339, rawCutoff); err == nil {
q.DateCutoff = &t
}
}
results, err := h.nextUpRepo.ListNextUp(r.Context(), q)
if err != nil {
writeCompatUpstreamError(w, err)
return
}
h.writeNextUpResponse(w, r, session, results, query)
}
// writeNextUpResponse renders a slice of catalog.NextUpResult into the
// shared NextUp/Upcoming response shape. Used by HandleNextUp and
// HandleUpcoming so the two endpoints stay in lockstep.
func (h *ItemsHandler) writeNextUpResponse(w http.ResponseWriter, r *http.Request, session *Session, results []catalog.NextUpResult, query itemsQuery) {
contentIDs := contentIDsFromNextUpResults(results)
favorites, progress, err := resolveUserStateForContentIDs(r.Context(), session, h.userData, contentIDs)
if err != nil {
writeCompatUpstreamError(w, err)
return
}
episodeTargets, err := h.fetchCompatEpisodeTargetsByContentIDs(r.Context(), session, contentIDs, nil)
if err != nil {
writeCompatUpstreamError(w, err)
return
}
items := make([]baseItemDTO, 0, len(results))
for _, res := range results {
target, ok := episodeTargets[res.ContentID]
if !ok {
continue
}
// NextUp deliberately serves list-level data even when the client
// requests Fields=People|Chapters|MediaStreams|MediaSources, mirroring
// the Resume hydrator (handlers_items.go ~L1721). Per-item
// GetItemDetail fanout was 5N DB roundtrips for users with long
// next-up lists, and the detail data was never load-bearing for the
// row UX — clients refetch MediaSources via /Items/{id}/PlaybackInfo
// on play.
dto := h.mapper.itemFromList(target.Item, favorites[res.ContentID], progress[res.ContentID], query.requestedFields)
h.applyCompatEpisodeTarget(&dto, target)
stubDetailListFields(&dto, query.requestedFields)
items = append(items, dto)
}
total := len(items)
items = sliceBaseItems(items, query.startIndex, query.limit)
applyImageTypeLimit(items, query.imageTypeLimit)
writeJSON(w, http.StatusOK, queryResultDTO{
Items: items,
TotalRecordCount: total,
StartIndex: query.startIndex,
})
}
// HandleUpcoming serves GET /Shows/Upcoming.
//
// Android TV's show-detail page calls this endpoint to populate a single
// "Upcoming" tile scoped to the currently open series. Without it the
// client falls back to global /Shows/NextUp and leaks unrelated shows
// onto the page. We model the response as NextUp scoped to a SeriesId
// (in-progress episode → next aired → next season's episode 1).
//
// SeriesId/ParentId is required, but we return 200 with an empty result
// instead of 404 when it's missing — a 404 here re-triggers the
// Android TV fallback we are trying to suppress.
func (h *ItemsHandler) HandleUpcoming(w http.ResponseWriter, r *http.Request) {
session := SessionFromContext(r.Context())
if session == nil {
writeError(w, http.StatusUnauthorized, "Unauthorized", "Missing authentication token")
return
}
qp := newCaseInsensitiveQuery(r.URL.Query())
if userID := qp.Get("UserId"); userID != "" && !validatePseudoUser(w, userID, session) {
return
}
query := parseItemsQuery(r, h.codec)
if query.limit <= 0 {
query.limit = 1
}
rawSeriesID := strings.TrimSpace(qp.Get("SeriesId"))
if rawSeriesID == "" {
rawSeriesID = strings.TrimSpace(qp.Get("ParentId"))
}
if rawSeriesID == "" {
writeJSON(w, http.StatusOK, queryResultDTO{Items: []baseItemDTO{}, TotalRecordCount: 0, StartIndex: query.startIndex})
return
}
seriesID, err := decodeItemID(h.codec, rawSeriesID)
if err != nil {
writeJSON(w, http.StatusOK, queryResultDTO{Items: []baseItemDTO{}, TotalRecordCount: 0, StartIndex: query.startIndex})
return
}
q := catalog.NextUpQuery{
UserID: session.StreamAppUserID,
ProfileID: session.ProfileID,
SeriesID: seriesID,
Limit: query.limit + query.startIndex,
EnableResumable: parseBool(qp.Get("enableResumable"), true),
}
results, err := h.nextUpRepo.ListNextUp(r.Context(), q)
if err != nil {
writeCompatUpstreamError(w, err)
return
}
h.writeNextUpResponse(w, r, session, results, query)
}
// HandleResume serves GET /UserItems/Resume.
func (h *ItemsHandler) HandleResume(w http.ResponseWriter, r *http.Request) {
session := SessionFromContext(r.Context())
if session == nil {
writeError(w, http.StatusUnauthorized, "Unauthorized", "Missing authentication token")
return
}
query := parseItemsQuery(r, h.codec)
h.handleResumeResponse(w, r, session, query)
}
// HandleSearchHints serves GET /Search/Hints.
func (h *ItemsHandler) HandleSearchHints(w http.ResponseWriter, r *http.Request) {
session := SessionFromContext(r.Context())
if session == nil {
writeError(w, http.StatusUnauthorized, "Unauthorized", "Missing authentication token")
return
}
q := newCaseInsensitiveQuery(r.URL.Query())
query := strings.TrimSpace(q.Get("SearchTerm"))
if query == "" {
writeJSON(w, http.StatusOK, searchHintResultDTO{})
return
}
limit := parsePositiveInt(q.Get("Limit"), 20)
result, err := h.content.SearchItems(r.Context(), session, query, nil, limit, 0, nil)
if err != nil {
writeCompatUpstreamError(w, err)
return
}
h.rememberListImages(result.Items)
hints := make([]searchHintDTO, 0, len(result.Items))
for _, item := range result.Items {
id := h.mapper.itemFromList(item, false, nil, nil).ID
hints = append(hints, searchHintDTO{
ItemID: id,
ID: id,
Name: item.Title,
Type: jellyfinItemType(item.Type),
ProductionYear: item.Year,
RunTimeTicks: minutesToTicks(item.Runtime),
PrimaryImageTag: tagValue(item.PosterURL),
BackdropImageTag: tagValue(item.BackdropURL),
Series: item.SeriesTitle,
Genres: item.Genres,
})
}
writeJSON(w, http.StatusOK, searchHintResultDTO{
SearchHints: hints,
TotalRecordCount: result.Total,
})
}
func (h *ItemsHandler) handleLibraryItem(w http.ResponseWriter, r *http.Request, session *Session, libraryID int) {
libraries, err := h.content.ListUserLibraries(r.Context(), session)
if err != nil {
writeCompatUpstreamError(w, err)
return
}
for _, library := range libraries {
if library.ID == libraryID {
dto := h.mapper.viewFromLibrary(library)
h.rememberLibraryImages(library, dto.ID)
writeJSON(w, http.StatusOK, dto)
return
}
}
writeError(w, http.StatusNotFound, "NotFound", "Item not found")
}
func (h *ItemsHandler) handleBrowseItems(w http.ResponseWriter, r *http.Request, session *Session, query itemsQuery) {
result, err := h.content.BrowseItems(r.Context(), session, buildBrowseParams(query))
if err != nil {
writeCompatUpstreamError(w, err)
return
}
h.rememberListImages(result.Items)
contentIDs := contentIDsFromListItems(result.Items)
favorites, progress, err := resolveUserStateForContentIDs(r.Context(), session, h.userData, contentIDs)
if err != nil {
writeCompatUpstreamError(w, err)
return
}
episodeTargets, err := h.fetchCompatEpisodeTargetsByContentIDs(r.Context(), session, episodeContentIDsFromListItems(result.Items), libraryIDPtr(query.parentLibraryID))
if err != nil {
writeCompatUpstreamError(w, err)
return
}
// Encode library ID for the ParentId field on each item.
var libraryParentID string
if query.parentLibraryID > 0 {
libraryParentID = h.codec.EncodeIntID(EncodedIDLibrary, int64(query.parentLibraryID))
}
items := make([]baseItemDTO, 0, len(result.Items))
for _, item := range result.Items {
if query.needsDetailFields {
detail, detailErr := h.content.GetItemDetail(r.Context(), session, item.ContentID, libraryIDPtr(query.parentLibraryID))
if detailErr == nil {
h.rememberDetailImages(*detail)
dto := h.mapper.itemFromDetailWithFields(*detail, favorites[detail.ContentID], progress[detail.ContentID], query.requestedFields)
if libraryParentID != "" {
dto.ParentID = libraryParentID
}
if target, ok := episodeTargets[detail.ContentID]; ok {
h.applyCompatEpisodeTarget(&dto, target)
}
items = append(items, dto)
continue
}
}
dto := h.mapper.itemFromList(item, favorites[item.ContentID], progress[item.ContentID], query.requestedFields)
if libraryParentID != "" {
dto.ParentID = libraryParentID
}
if target, ok := episodeTargets[item.ContentID]; ok {
h.applyCompatEpisodeTarget(&dto, target)
}
items = append(items, dto)
}
applyImageTypeLimit(items, query.imageTypeLimit)
writeJSON(w, http.StatusOK, queryResultDTO{
Items: items,
TotalRecordCount: result.Total,
StartIndex: query.startIndex,
})
}
func (h *ItemsHandler) handleFavoriteItems(w http.ResponseWriter, r *http.Request, session *Session, query itemsQuery) {
// Client requested specific types (e.g. Playlist, BoxSet, Video) that all
// mapped to nothing — return empty rather than returning all favorites.
if query.hasItemTypeFilter && len(query.itemTypes) == 0 {
writeJSON(w, http.StatusOK, queryResultDTO{
Items: []baseItemDTO{},
TotalRecordCount: 0,
StartIndex: query.startIndex,
})
return
}
// Push the supported browse filters (type, library, sort, genre, name
// prefix, max content rating) into a single SQL query that JOINs
// user_favorites with media_items. This replaces the legacy fetch-all-
// then-filter path that pulled up to 10,000 favorites into memory before
// applying browse filters (audit 2026-05-01 §3.6 / catalog SQL plan
// task 4.2).
if favoriteItemsNeedBrowseFilters(query) && h.browseRepo != nil && favoriteBrowseFiltersSupportedBySQL(query) {
access := h.resolveAccessFilter(r.Context(), session)
filters := catalog.BrowseFavoritesFilters{
UserID: session.StreamAppUserID,
ProfileID: session.ProfileID,
ItemType: strings.Join(query.itemTypes, ","),
Genre: query.genreName,
NamePrefix: query.namePrefix,
LibraryID: query.parentLibraryID,
AllowedLibraryIDs: access.AllowedLibraryIDs,
DisabledLibraryIDs: access.DisabledLibraryIDs,
MaxContentRating: clampMaxContentRating(access.MaxContentRating, query.maxOfficialRating),
SortField: query.sort,
SortOrder: query.order,
Limit: query.limit,
Offset: query.startIndex,
}
result, err := h.browseRepo.BrowseFavorites(r.Context(), filters)
if err != nil {
writeCompatUpstreamError(w, err)
return
}
listItems := make([]upstreamListItem, 0, len(result.Items))
for _, mi := range result.Items {
listItem := mediaItemToListItem(mi)
h.presignCompatListItem(r.Context(), &listItem)
listItems = append(listItems, listItem)
}
h.rememberListImages(listItems)
progress, progressErr := resolveProgressForContentIDs(r.Context(), session, h.userData, contentIDsFromListItems(listItems))
if progressErr != nil {
writeCompatUpstreamError(w, progressErr)
return
}
items := make([]baseItemDTO, 0, len(listItems))
for _, item := range listItems {
items = append(items, h.mapper.itemFromList(item, true, progress[item.ContentID], query.requestedFields))
}
applyImageTypeLimit(items, query.imageTypeLimit)
writeJSON(w, http.StatusOK, queryResultDTO{
Items: items,
TotalRecordCount: result.Total,
StartIndex: query.startIndex,
})
return
}
favoriteLimit := max(query.limit+query.startIndex, 200)
if favoriteItemsNeedBrowseFilters(query) {
favoriteLimit = 10000
}
favoriteItems, err := h.userData.ListFavorites(r.Context(), session, favoriteLimit, 0)
if err != nil {
writeCompatUpstreamError(w, err)
return
}
if favoriteItemsNeedBrowseFilters(query) {
contentIDs := contentIDsFromListItems(favoriteItems)
if len(contentIDs) == 0 {
writeJSON(w, http.StatusOK, queryResultDTO{
Items: []baseItemDTO{},
TotalRecordCount: 0,
StartIndex: query.startIndex,
})
return
}
params := buildBrowseParams(query)
params.Del("offset")
params.Set("offset", strconv.Itoa(query.startIndex))
params.Set("content_ids", strings.Join(contentIDs, ","))
result, browseErr := h.content.BrowseItems(r.Context(), session, params)
if browseErr != nil {
writeCompatUpstreamError(w, browseErr)
return
}
h.rememberListImages(result.Items)
progress, progressErr := resolveProgressForContentIDs(r.Context(), session, h.userData, contentIDsFromListItems(result.Items))
if progressErr != nil {
writeCompatUpstreamError(w, progressErr)
return
}
items := make([]baseItemDTO, 0, len(result.Items))
for _, item := range result.Items {
items = append(items, h.mapper.itemFromList(item, true, progress[item.ContentID], query.requestedFields))
}
applyImageTypeLimit(items, query.imageTypeLimit)
writeJSON(w, http.StatusOK, queryResultDTO{
Items: items,
TotalRecordCount: result.Total,
StartIndex: query.startIndex,
})
return
}
if len(query.itemTypes) > 0 {
typeSet := make(map[string]bool, len(query.itemTypes))
for _, t := range query.itemTypes {
typeSet[strings.ToLower(t)] = true
}
filtered := favoriteItems[:0]
for _, item := range favoriteItems {
if typeSet[strings.ToLower(item.Type)] {
filtered = append(filtered, item)
}
}
favoriteItems = filtered
}
h.rememberListImages(favoriteItems)
progress, err := resolveProgressForContentIDs(r.Context(), session, h.userData, contentIDsFromListItems(favoriteItems))
if err != nil {
writeCompatUpstreamError(w, err)
return
}
items := make([]baseItemDTO, 0, len(favoriteItems))
for _, item := range favoriteItems {
items = append(items, h.mapper.itemFromList(item, true, progress[item.ContentID], nil))
}
total := len(items)
items = sliceBaseItems(items, query.startIndex, query.limit)
applyImageTypeLimit(items, query.imageTypeLimit)
writeJSON(w, http.StatusOK, queryResultDTO{
Items: items,
TotalRecordCount: total,
StartIndex: query.startIndex,
})
}
func (h *ItemsHandler) handleSearchItems(w http.ResponseWriter, r *http.Request, session *Session, query itemsQuery) {
result, err := h.content.SearchItems(r.Context(), session, query.searchTerm, query.itemTypes, query.limit, query.startIndex, libraryIDPtr(query.parentLibraryID))
if err != nil {
writeCompatUpstreamError(w, err)
return
}
h.rememberListImages(result.Items)
contentIDs := contentIDsFromListItems(result.Items)
favorites, progress, err := resolveUserStateForContentIDs(r.Context(), session, h.userData, contentIDs)
if err != nil {
writeCompatUpstreamError(w, err)
return
}
episodeTargets, err := h.fetchCompatEpisodeTargetsByContentIDs(r.Context(), session, episodeContentIDsFromListItems(result.Items), nil)
if err != nil {
writeCompatUpstreamError(w, err)
return
}
// Encode library ID for the ParentId field on each item (empty for global search).
var libraryParentID string
if query.parentLibraryID > 0 {
libraryParentID = h.codec.EncodeIntID(EncodedIDLibrary, int64(query.parentLibraryID))
}
items := make([]baseItemDTO, 0, len(result.Items))
for _, item := range result.Items {
if query.needsDetailFields {
detail, detailErr := h.content.GetItemDetail(r.Context(), session, item.ContentID, libraryIDPtr(query.parentLibraryID))
if detailErr == nil {
h.rememberDetailImages(*detail)
dto := h.mapper.itemFromDetailWithFields(*detail, favorites[detail.ContentID], progress[detail.ContentID], query.requestedFields)
if libraryParentID != "" {
dto.ParentID = libraryParentID
}
if target, ok := episodeTargets[detail.ContentID]; ok {
h.applyCompatEpisodeTarget(&dto, target)
}
items = append(items, dto)
continue
}
}
dto := h.mapper.itemFromList(item, favorites[item.ContentID], progress[item.ContentID], query.requestedFields)
if libraryParentID != "" {
dto.ParentID = libraryParentID
}
if target, ok := episodeTargets[item.ContentID]; ok {
h.applyCompatEpisodeTarget(&dto, target)
}
items = append(items, dto)
}
applyImageTypeLimit(items, query.imageTypeLimit)
writeJSON(w, http.StatusOK, queryResultDTO{
Items: items,
TotalRecordCount: result.Total,
StartIndex: query.startIndex,
})
}
func (h *ItemsHandler) handleSpecificItems(w http.ResponseWriter, r *http.Request, session *Session, query itemsQuery) {
favorites, progress, err := resolveUserStateForContentIDs(r.Context(), session, h.userData, query.specificIDs)
if err != nil {
writeCompatUpstreamError(w, err)
return
}
items := make([]baseItemDTO, 0, len(query.specificIDs))
for _, contentID := range query.specificIDs {
detail, itemErr := h.content.GetItemDetail(r.Context(), session, contentID, libraryIDPtr(query.parentLibraryID))
if itemErr != nil {
if isHTTPStatus(itemErr, http.StatusNotFound) {
continue
}
writeCompatUpstreamError(w, itemErr)
return
}
h.rememberDetailImages(*detail)
dto := h.mapper.itemFromDetailWithFields(*detail, favorites[detail.ContentID], progress[detail.ContentID], query.requestedFields)
h.appendDownloadedSubtitlesToDetailDTO(r.Context(), detail.ContentID, detail.Versions, &dto)
items = append(items, dto)
}
writeJSON(w, http.StatusOK, queryResultDTO{
Items: items,
TotalRecordCount: len(items),
StartIndex: query.startIndex,
})
}
// handlePlayedItems serves IsPlayed=True requests by querying completed
// progress entries directly, rather than browsing the entire catalog and
// post-filtering. This avoids the pathological 3+ second scan when few
// items match.
func (h *ItemsHandler) handlePlayedItems(w http.ResponseWriter, r *http.Request, session *Session, query itemsQuery) {
if h.userData == nil {
writeJSON(w, http.StatusOK, queryResultDTO{
Items: []baseItemDTO{},
TotalRecordCount: 0,
StartIndex: query.startIndex,
})
return
}
if query.mediaTypesExplicit && !query.mediaTypesSet["video"] {
writeJSON(w, http.StatusOK, queryResultDTO{
Items: []baseItemDTO{},
TotalRecordCount: 0,
StartIndex: query.startIndex,
})
return
}
typeSet := make(map[string]bool, len(query.itemTypes))
for _, t := range query.itemTypes {
typeSet[strings.ToLower(t)] = true
}
libraryID := libraryIDPtr(query.parentLibraryID)
items, total, err := h.loadProgressPage(r.Context(), session, "completed", query, typeSet, libraryID)
if err != nil {
writeCompatUpstreamError(w, err)
return
}
applyImageTypeLimit(items, query.imageTypeLimit)
writeJSON(w, http.StatusOK, queryResultDTO{
Items: items,
TotalRecordCount: total,
StartIndex: query.startIndex,
})
}
func (h *ItemsHandler) handleResumeResponse(w http.ResponseWriter, r *http.Request, session *Session, query itemsQuery) {
if h.userData == nil {
writeJSON(w, http.StatusOK, queryResultDTO{
Items: []baseItemDTO{},
TotalRecordCount: 0,
StartIndex: query.startIndex,
})
return
}
if query.mediaTypesExplicit && !query.mediaTypesSet["video"] {
writeJSON(w, http.StatusOK, queryResultDTO{
Items: []baseItemDTO{},
TotalRecordCount: 0,
StartIndex: query.startIndex,
})
return
}
typeSet := make(map[string]bool, len(query.itemTypes))
for _, t := range query.itemTypes {
typeSet[strings.ToLower(t)] = true
}
items, total, err := h.loadProgressPage(r.Context(), session, "in_progress", query, typeSet, nil)
if err != nil {
writeCompatUpstreamError(w, err)
return
}
applyImageTypeLimit(items, query.imageTypeLimit)
writeJSON(w, http.StatusOK, queryResultDTO{
Items: items,
TotalRecordCount: total,
StartIndex: query.startIndex,
})
}
type progressHydratedItem struct {
itemType string
dto baseItemDTO
}
func (h *ItemsHandler) loadProgressPage(ctx context.Context, session *Session, status string, query itemsQuery, typeSet map[string]bool, libraryID *int) ([]baseItemDTO, int, error) {
if len(typeSet) == 0 && libraryID == nil && !query.enableTotalRecordCount {
batchSize := min(max(query.limit*2, 48), 200)
if batchSize <= 0 {
batchSize = 48
}
result := make([]baseItemDTO, 0, query.limit)
offset := query.startIndex
for {
progressEntries, err := h.userData.ListProgress(ctx, session, status, batchSize, offset)
if err != nil {
return nil, 0, err
}
if len(progressEntries) == 0 {
break
}
items, err := h.hydrateProgressItems(ctx, session, progressEntries, query.requestedFields, libraryID)
if err != nil {
return nil, 0, err
}
for _, item := range items {
if len(result) >= query.limit {
break
}
result = append(result, item.dto)
}
if len(result) >= query.limit || len(progressEntries) < batchSize {
break
}
offset += len(progressEntries)
}
return result, 0, nil
}
batchSize := min(max(query.limit*3, 48), 200)
if batchSize <= 0 {
batchSize = 48
}
items := make([]baseItemDTO, 0, query.limit)
matchedCount := 0
offset := 0
for {
progressEntries, err := h.userData.ListProgress(ctx, session, status, batchSize, offset)
if err != nil {
return nil, 0, err
}
if len(progressEntries) == 0 {
break
}
hydrated, err := h.hydrateProgressItems(ctx, session, progressEntries, query.requestedFields, libraryID)
if err != nil {
return nil, 0, err
}
for _, item := range hydrated {
if len(typeSet) > 0 && !typeSet[item.itemType] {
continue
}
if matchedCount >= query.startIndex && len(items) < query.limit {
items = append(items, item.dto)
}
matchedCount++
}
offset += len(progressEntries)
if len(progressEntries) < batchSize {
break
}
if !query.enableTotalRecordCount && len(items) >= query.limit {
break
}
}
total := 0
if query.enableTotalRecordCount {
total = matchedCount
}
return items, total, nil
}
func (h *ItemsHandler) hydrateProgressItems(ctx context.Context, session *Session, entries []upstreamProgress, fields map[string]bool, libraryID *int) ([]progressHydratedItem, error) {
result := make([]progressHydratedItem, 0, len(entries))
contentIDs := contentIDsFromProgressEntries(entries)
if len(contentIDs) == 0 {
return result, nil
}
favorites, err := resolveFavoritesForContentIDs(ctx, session, h.userData, contentIDs)
if err != nil {
return nil, err
}
itemsByID, err := h.fetchCompatItemsByContentIDs(ctx, session, contentIDs, libraryID)
if err != nil {
return nil, err
}
episodesByID, err := h.fetchCompatEpisodeTargetsByContentIDs(ctx, session, contentIDs, libraryID)
if err != nil {
return nil, err
}
// Resume / progress views deliberately serve list-level data even when the
// client requests Fields=People|Chapters|MediaStreams|MediaSources. The
// per-entry GetItemDetail fanout this loop used to do scaled to ~5N DB
// roundtrips for users with large in-progress lists and was the cause of
// the 38s timeout in error-report-2026-05-08.md §6. Standard Jellyfin
// clients (Streamyfin, Infuse, Wholphin, Jellyfin-web) refetch
// MediaSources via /Items/{id}/PlaybackInfo on play, so the detail data
// served here was never load-bearing for the Resume row UX.
for _, entry := range entries {
if item, ok := itemsByID[entry.MediaItemID]; ok {
dto := h.mapper.itemFromList(item, favorites[entry.MediaItemID], &entry, fields)
stubDetailListFields(&dto, fields)
result = append(result, progressHydratedItem{
itemType: strings.ToLower(item.Type),
dto: dto,
})
continue
}
if target, ok := episodesByID[entry.MediaItemID]; ok {
dto := h.mapper.itemFromList(target.Item, favorites[entry.MediaItemID], &entry, fields)
h.applyCompatEpisodeTarget(&dto, target)
stubDetailListFields(&dto, fields)
result = append(result, progressHydratedItem{
itemType: "episode",
dto: dto,
})
}
}
return result, nil
}
func (h *ItemsHandler) resolveAccessFilter(ctx context.Context, session *Session) catalog.AccessFilter {
if h.accessFilter != nil {
return h.accessFilter(ctx, session.StreamAppUserID, session.ProfileID)
}
return catalog.AccessFilter{}
}
func (h *ItemsHandler) presignCompatListItem(ctx context.Context, item *upstreamListItem) {
if item.PosterPath == "" {
item.PosterPath = item.PosterURL
}
if item.BackdropPath == "" {
item.BackdropPath = item.BackdropURL
}
if item.LogoPath == "" {
item.LogoPath = item.LogoURL
}
if item.StillPath == "" {
item.StillPath = item.StillURL
}
item.PosterURL = h.presignCompatImagePath(ctx, item.PosterURL, "poster")
item.BackdropURL = h.presignCompatImagePath(ctx, item.BackdropURL, "backdrop")
item.LogoURL = h.presignCompatImagePath(ctx, item.LogoURL, "logo")
item.StillURL = h.presignCompatImagePath(ctx, item.StillURL, "still")
}
func (h *ItemsHandler) presignCompatImagePath(ctx context.Context, path, imageType string) string {
return compatPresignImage(h.detailSvc, ctx, path, imageType, compatCardImageSize)
}
func (h *ItemsHandler) rememberCompatEpisodeImages(dto baseItemDTO, stillURL string, series seriesImageSet) {
if h.images == nil {
return
}
h.images.RememberSized(dto.ID, "Primary", stillURL, compatCardImageSize)
h.images.RememberSized(dto.ID, "Backdrop", series.BackdropURL, compatCardImageSize)
if dto.SeriesID != "" {
h.images.RememberSized(dto.SeriesID, "Primary", series.PosterURL, compatCardImageSize)
h.images.RememberSized(dto.SeriesID, "Backdrop", series.BackdropURL, compatCardImageSize)
h.images.RememberSized(dto.SeriesID, "Thumb", series.BackdropURL, compatCardImageSize)
}
}
func (h *ItemsHandler) applyCompatEpisodeTarget(dto *baseItemDTO, target compatEpisodeTarget) {
h.mapper.applySeriesImages(dto, target.SeriesImages)
h.rememberCompatEpisodeImages(*dto, firstNonEmpty(target.Item.StillURL, target.Item.PosterURL), target.SeriesImages)
}
func (h *ItemsHandler) listSeriesEpisodes(ctx context.Context, session *Session, seriesID string, seasons []upstreamSeason, requestedSeasonID string) ([]*models.Episode, error) {
if h.episodeRepo != nil {
if requestedSeasonID != "" {
for _, season := range seasons {
if season.ContentID == requestedSeasonID {
return h.episodeRepo.ListBySeason(ctx, seriesID, season.SeasonNumber)
}
}
return []*models.Episode{}, nil
}
return h.episodeRepo.ListBySeries(ctx, seriesID)
}
episodes := make([]*models.Episode, 0)
for _, season := range seasons {
if requestedSeasonID != "" && season.ContentID != requestedSeasonID {
continue
}
upstreamEpisodes, err := h.content.ListEpisodes(ctx, session, seriesID, season.SeasonNumber, nil)
if err != nil {
return nil, err
}
for _, episode := range upstreamEpisodes {
episodes = append(episodes, &models.Episode{
ContentID: episode.ContentID,
SeriesID: seriesID,
SeasonID: firstNonEmpty(episode.SeasonID, season.ContentID),
SeasonNumber: episode.SeasonNumber,
EpisodeNumber: episode.EpisodeNumber,
Title: episode.Title,
Overview: episode.Overview,
Runtime: episode.Runtime,
StillPath: episode.StillURL,
})
}
}
return episodes, nil
}
func intPtr(value int) *int {
return &value
}
func contentIDsFromListItems(items []upstreamListItem) []string {
contentIDs := make([]string, 0, len(items))
for _, item := range items {
contentIDs = append(contentIDs, item.ContentID)
}
return normalizeContentIDs(contentIDs)
}
func episodeContentIDsFromListItems(items []upstreamListItem) []string {
contentIDs := make([]string, 0, len(items))
for _, item := range items {
if strings.EqualFold(item.Type, "episode") {
contentIDs = append(contentIDs, item.ContentID)
}
}
return normalizeContentIDs(contentIDs)
}
func seasonContentIDs(seasons []upstreamSeason) []string {
contentIDs := make([]string, 0, len(seasons))
for _, season := range seasons {
contentIDs = append(contentIDs, season.ContentID)
}
return normalizeContentIDs(contentIDs)
}
func contentIDsFromEpisodes(episodes []*models.Episode) []string {
contentIDs := make([]string, 0, len(episodes))
for _, episode := range episodes {
if episode != nil {
contentIDs = append(contentIDs, episode.ContentID)
}
}
return normalizeContentIDs(contentIDs)
}
func contentIDsFromNextUpResults(results []catalog.NextUpResult) []string {
contentIDs := make([]string, 0, len(results))
for _, result := range results {
contentIDs = append(contentIDs, result.ContentID)
}
return normalizeContentIDs(contentIDs)
}
func contentIDsFromProgressEntries(entries []upstreamProgress) []string {
contentIDs := make([]string, 0, len(entries))
for _, entry := range entries {
contentIDs = append(contentIDs, entry.MediaItemID)
}
return normalizeContentIDs(contentIDs)
}
func libraryIDPtr(libraryID int) *int {
if libraryID <= 0 {
return nil
}
return &libraryID
}
func urlValuesFromItemsQuery(query itemsQuery) url.Values {
return buildBrowseParams(query)
}
func progressMap(entries []upstreamProgress) map[string]*upstreamProgress {
result := make(map[string]*upstreamProgress, len(entries))
for i := range entries {
entry := entries[i]
result[entry.MediaItemID] = &entry
}
return result
}
func decodeContentID(codec *ResourceIDCodec, raw string) (string, error) {
if id, err := decodeItemID(codec, raw); err == nil {
return id, nil
}
return codec.DecodeStringID(EncodedIDSeason, raw)
}
func decodeItemID(codec *ResourceIDCodec, raw string) (string, error) {
return codec.DecodeStringID(EncodedIDItem, raw)
}
func validatePseudoUser(w http.ResponseWriter, userID string, session *Session) bool {
if session == nil {
writeError(w, http.StatusUnauthorized, "Unauthorized", "Missing authentication token")
return false
}
if userID == "" || userID == session.PseudoUserID.String() {
return true
}
writeError(w, http.StatusNotFound, "NotFound", "User not found")
return false
}
func sliceBaseItems(items []baseItemDTO, startIndex, limit int) []baseItemDTO {
if startIndex < 0 {
startIndex = 0
}
if startIndex >= len(items) {
return []baseItemDTO{}
}
if limit <= 0 {
limit = len(items)
}
end := min(startIndex+limit, len(items))
return items[startIndex:end]
}
func (h *ItemsHandler) rememberLibraryImages(library upstreamUserLibrary, routeID string) {
if h.images == nil || library.PosterURL == "" {
return
}
h.images.RememberSized(routeID, "Primary", library.PosterURL, compatCardImageSize)
}
func (h *ItemsHandler) rememberListImages(items []upstreamListItem) {
if h.images == nil {
return
}
for _, item := range items {
routeID := h.codec.EncodeStringID(EncodedIDItem, item.ContentID)
h.images.RememberSized(routeID, "Primary", firstNonEmpty(item.StillURL, item.PosterURL), compatCardImageSize)
h.images.RememberSized(routeID, "Backdrop", item.BackdropURL, compatCardImageSize)
h.images.RememberSized(routeID, "Logo", item.LogoURL, compatCardImageSize)
}
}
func (h *ItemsHandler) rememberDetailImages(detail upstreamItemDetail) {
if h.images == nil {
return
}
// Detail payloads carry featured-sized artwork: catalog/detail.go presigns
// at size="" which maps to w500 for poster/logo and w1920 for backdrop in
// cachedImageVariantKey. Both match the "medium" compat bucket — seeding at
// compatCardImageSize would mislabel the route bucket and pollute card-size
// entries learned from list paths; seeding at "original" would shadow the
// genuine original asset.
const detailImageSize = "medium"
routeIDs := []string{h.codec.EncodeStringID(EncodedIDItem, detail.ContentID)}
if strings.EqualFold(detail.Type, "season") {
routeIDs = append(routeIDs, h.codec.EncodeStringID(EncodedIDSeason, detail.ContentID))
}
primaryURL := firstNonEmpty(detail.PosterURL, detail.BackdropURL)
for _, routeID := range routeIDs {
if primaryURL != "" {
h.images.RememberSized(routeID, "Primary", primaryURL, detailImageSize)
}
if detail.BackdropURL != "" {
h.images.RememberSized(routeID, "Backdrop", detail.BackdropURL, detailImageSize)
}
if detail.LogoURL != "" {
h.images.RememberSized(routeID, "Logo", detail.LogoURL, detailImageSize)
}
}
for _, cast := range detail.Cast {
if cast.PhotoURL != "" {
if pid, _ := strconv.ParseInt(cast.PersonID, 10, 64); pid > 0 {
h.images.RememberSized(h.codec.EncodeIntID(EncodedIDPerson, pid), "Primary", cast.PhotoURL, compatCardImageSize)
}
}
}
for _, crew := range detail.Crew {
if crew.PhotoURL != "" {
if pid, _ := strconv.ParseInt(crew.PersonID, 10, 64); pid > 0 {
h.images.RememberSized(h.codec.EncodeIntID(EncodedIDPerson, pid), "Primary", crew.PhotoURL, compatCardImageSize)
}
}
}
}
func (h *ItemsHandler) rememberSeasonImages(seasons []upstreamSeason, seriesID string) {
if h.images == nil {
return
}
for _, season := range seasons {
h.images.RememberSized(h.codec.EncodeStringID(EncodedIDSeason, season.ContentID), "Primary", season.PosterURL, compatCardImageSize)
h.images.RememberSized(h.codec.EncodeStringID(EncodedIDItem, season.ContentID), "Primary", season.PosterURL, compatCardImageSize)
if seriesID != "" {
h.images.RememberSized(h.codec.EncodeStringID(EncodedIDItem, seriesID), "Primary", season.PosterURL, compatCardImageSize)
}
}
}
func (h *ItemsHandler) rememberEpisodeImages(episodes []upstreamEpisode) {
if h.images == nil {
return
}
for _, episode := range episodes {
h.images.RememberSized(h.codec.EncodeStringID(EncodedIDItem, episode.ContentID), "Primary", episode.StillURL, compatCardImageSize)
}
}
// enrichEpisodeSeriesImages looks up the parent series poster/backdrop and
// applies them to an episode DTO. The cache avoids repeated lookups when
// multiple episodes belong to the same series.
func (h *ItemsHandler) enrichEpisodeSeriesImages(ctx context.Context, session *Session, dto *baseItemDTO, seriesContentID string, cache map[string]seriesImageSet) {
if seriesContentID == "" || dto.SeriesID == "" {
return
}
imgs, ok := cache[seriesContentID]
if !ok {
detail, err := h.content.GetItemDetail(ctx, session, seriesContentID, nil)
if err == nil {
imgs = seriesImageSet{
ContentID: detail.ContentID,
PosterURL: detail.PosterURL,
PosterPath: detail.PosterPath,
PosterThumbhash: detail.PosterThumbhash,
BackdropURL: detail.BackdropURL,
BackdropPath: detail.BackdropPath,
BackdropThumbhash: detail.BackdropThumbhash,
UpdatedAt: detail.UpdatedAt,
}
h.rememberDetailImages(*detail)
}
cache[seriesContentID] = imgs
}
h.mapper.applySeriesImages(dto, imgs)
if imgs.BackdropURL != "" && h.images != nil {
h.images.RememberSized(dto.SeriesID, "Thumb", imgs.BackdropURL, compatCardImageSize)
}
}
func max(value, fallback int) int {
if value > 0 {
return value
}
return fallback
}
func writeCompatUpstreamError(w http.ResponseWriter, err error) {
if errors.Is(err, playback.ErrTooManyStreams) {
writeError(w, http.StatusTooManyRequests, "TooManyStreams", "Too many concurrent streams")
return
}
if errors.Is(err, playback.ErrTooManyTranscodes) {
writeError(w, http.StatusTooManyRequests, "TooManyTranscodes", "Too many concurrent transcodes")
return
}
var httpErr *HTTPError
if errors.As(err, &httpErr) {
switch httpErr.StatusCode {
case http.StatusNotFound:
writeError(w, http.StatusNotFound, "NotFound", "Resource not found")
case http.StatusUnauthorized:
writeError(w, http.StatusUnauthorized, "Unauthorized", "Authentication failed")
default:
writeError(w, http.StatusBadGateway, "UpstreamError", httpErr.Error())
}
return
}
writeError(w, http.StatusInternalServerError, "ServerError", "Unexpected compat error")
}
// inferLibraryItemType looks up the collection type for a library and returns
// the matching browse item type. Jellyfin's /Items/Latest uses this to return
// Movie items for movies libraries and Series items for tvshows libraries.
func (h *ItemsHandler) inferLibraryItemType(ctx context.Context, session *Session, libraryID int) string {
libraries, err := h.content.ListUserLibraries(ctx, session)
if err != nil {
return ""
}
for _, lib := range libraries {
if lib.ID == libraryID {
switch lib.Type {
case "movies":
return "movie"
case "series":
return "series"
default:
return ""
}
}
}
return ""
}
func isHTTPStatus(err error, status int) bool {
var httpErr *HTTPError
return errors.As(err, &httpErr) && httpErr.StatusCode == status
}
// applyImageTypeLimit clears image tags from DTOs when ImageTypeLimit=0.
func applyImageTypeLimit(items []baseItemDTO, limit *int) {
if limit == nil || *limit > 0 {
return
}
for i := range items {
items[i].ImageTags = map[string]string{}
items[i].BackdropImageTags = nil
items[i].PrimaryImageAspectRatio = nil
}
}