* fix(jellycompat): resolve item duration probed-first with runtime fallback Jellyfin-protocol clients received no runtime at all for items whose catalog runtime is 0. RunTimeTicks is omitempty, so a zero value is dropped from the JSON entirely rather than sent as 0, and strict clients (Infuse) abandon playback on those items. On the production deployment 5,245 movies have media_items.runtime = 0 while 5,239 of them have a correct probed media_files.duration. Resolve duration at read time the way /api/v1 already does: probed file duration first, catalog runtime as the fallback. The item row is deliberately not backfilled — one item can have several versions of different lengths, so per-file data does not belong there. - scanner: FirstDurationsByContentIDs / FirstDurationsByEpisodeIDs, batched lookups using the same "first live file with duration > 0, ordered by id" rule as the v1 API's contentDurationSeconds. The episode_id IS NULL guard on the content-id query is load-bearing: every episode file carries its series' content_id, so without it a series row would report an episode's duration. - catalog: optional batchDurationFetcher extension on DetailService, following the existing extraFileFetcher pattern so test fakes need no changes. Nil-receiver safe and fail-soft — a failed lookup logs and degrades to the catalog runtime rather than failing the page. - jellycompat: DurationSeconds on upstreamListItem/upstreamEpisode, a shared runtimeTicks resolver, and fillListItemDurations wired into the nine page producers. Fixes the three sites that had no fallback (itemFromList, episodeFromUpstream, HandleSearchHints); the detail and PlaybackInfo paths were already correct. This is additive within the v1 rules — it populates a field that was previously omitted. No field is renamed, removed, retyped, or repurposed. * fix(jellycompat): avoid duplicate duration lookups --------- Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
3383 lines
121 KiB
Go
3383 lines
121 KiB
Go
package jellycompat
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"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/access"
|
||
"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/sections"
|
||
"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
|
||
durationSrc probedDurationSource
|
||
itemRepo itemRepoForBatchLoader
|
||
episodeRepo episodeRepoForBatchLoader
|
||
seasonRepo imageSeasonRepository
|
||
accessFilter AccessFilterResolver
|
||
subtitleRepo subtitles.Repository
|
||
recommender recommendations.Recommender
|
||
// collections is optional; when set, library collections are exposed as
|
||
// Jellyfin BoxSets. posterPresigner/presignTTL resolve their artwork keys.
|
||
collections collectionSource
|
||
// queryExecutor is optional; when set, smart (live-query) collections
|
||
// resolve their BoxSet children at read time instead of from stored items.
|
||
queryExecutor smartCollectionQueryExecutor
|
||
posterPresigner LibraryPosterPresigner
|
||
presignTTL time.Duration
|
||
// FileResolver is optional; when set, /MediaSegments returns real intro/
|
||
// credits/recap/preview segments for any file that has them.
|
||
FileResolver FilePathResolver
|
||
// sectionsFetcher is optional; when set, the Continue Watching (Resume) path
|
||
// is served through the capped native continue-watching fetcher instead of an
|
||
// unbounded progress scan. It is the section subsystem's read-time fetcher and
|
||
// is independent of any virtual-library/hub-section exposure.
|
||
sectionsFetcher *sections.Fetcher
|
||
}
|
||
|
||
// 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, seasonRepo *catalog.SeasonRepository, accessFilter AccessFilterResolver, subtitleRepo subtitles.Repository) *ItemsHandler {
|
||
h := &ItemsHandler{
|
||
content: content,
|
||
userData: userData,
|
||
codec: codec,
|
||
mapper: newMapper(codec, cfg),
|
||
images: images,
|
||
nextUpRepo: nextUpRepo,
|
||
browseRepo: browseRepo,
|
||
personRepo: personRepo,
|
||
detailSvc: detailSvc,
|
||
durationSrc: detailSvc,
|
||
itemRepo: itemRepo,
|
||
episodeRepo: episodeRepo,
|
||
accessFilter: accessFilter,
|
||
subtitleRepo: subtitleRepo,
|
||
}
|
||
// Assign through the typed pointer only when present so the interface field
|
||
// stays a true nil (a typed nil would defeat the nil check in
|
||
// handleSeasonEpisodeChildren and panic on GetByID).
|
||
if seasonRepo != nil {
|
||
h.seasonRepo = seasonRepo
|
||
}
|
||
return h
|
||
}
|
||
|
||
// 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
|
||
}
|
||
|
||
h.handleViewsResponse(w, r, session)
|
||
}
|
||
|
||
// handleViewsResponse returns the user's library views as CollectionFolder items.
|
||
func (h *ItemsHandler) handleViewsResponse(w http.ResponseWriter, r *http.Request, session *Session) {
|
||
items, err := h.userViews(r.Context(), session)
|
||
if err != nil {
|
||
writeCompatUpstreamError(w, err)
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, queryResultDTO{
|
||
Items: items,
|
||
TotalRecordCount: len(items),
|
||
StartIndex: 0,
|
||
})
|
||
}
|
||
|
||
// userViews builds the session's library views as CollectionFolder items. The
|
||
// synthetic "Collections" view is prepended (first library) when the session
|
||
// can see at least one collection; see collectionsView/collectionsViewVisible.
|
||
func (h *ItemsHandler) userViews(ctx context.Context, session *Session) ([]baseItemDTO, error) {
|
||
libraries, err := h.content.ListUserLibraries(ctx, session)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
items := make([]baseItemDTO, 0, len(libraries)+1)
|
||
if h.collectionsViewVisible(ctx, libraries) {
|
||
items = append(items, h.collectionsView())
|
||
}
|
||
for _, library := range libraries {
|
||
dto := h.mapper.viewFromLibrary(library)
|
||
h.rememberLibraryImages(library, dto.ID)
|
||
items = append(items, dto)
|
||
}
|
||
return items, nil
|
||
}
|
||
|
||
// 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)
|
||
|
||
// Browsing the synthetic Collections view lists its BoxSets. The view ID is
|
||
// a fixed Jellyfin sentinel (not a codec-encoded ID), so it is matched here
|
||
// at the router rather than decoded in parseItemsQuery. With no
|
||
// parentLibraryID set, handleBoxSetsList lists every visible collection
|
||
// across libraries.
|
||
if isCollectionsViewID(newCaseInsensitiveQuery(r.URL.Query()).Get("ParentId")) {
|
||
// The view's only children are BoxSets. Clients commonly omit
|
||
// IncludeItemTypes when browsing a library by ParentId, so an absent
|
||
// filter still lists BoxSets; but an explicit filter that excludes
|
||
// BoxSet (e.g. IncludeItemTypes=Movie) has no direct children here and
|
||
// returns empty rather than the BoxSet list.
|
||
if query.hasItemTypeFilter && !query.wantsBoxSets {
|
||
writeJSON(w, http.StatusOK, emptyQueryResult(query.startIndex))
|
||
return
|
||
}
|
||
h.handleBoxSetsList(w, r, session, query)
|
||
return
|
||
}
|
||
|
||
switch {
|
||
case len(query.specificIDs) > 0 || len(query.specificCollectionIDs) > 0 || idsRequestCollectionsView(r):
|
||
h.handleSpecificItems(w, r, session, query)
|
||
case query.parentCollectionID != "":
|
||
h.handleBoxSetChildren(w, r, session, query)
|
||
case query.hasItemTypeFilter && len(query.itemTypes) == 0:
|
||
// Every requested type is one catalog browse cannot serve. BoxSet has
|
||
// its own listing (unless a user-state filter applies — collections
|
||
// carry no favorite/played state, so those return empty, matching the
|
||
// pre-existing favorites guard); CollectionFolder means the library
|
||
// views; anything else (Playlist, MusicAlbum, ...) is empty.
|
||
hasUserStateFilter := query.isFavorite || query.isResumable || query.isPlayed != nil
|
||
switch {
|
||
case query.wantsBoxSets && !hasUserStateFilter:
|
||
h.handleBoxSetsList(w, r, session, query)
|
||
case query.wantsViews && !hasUserStateFilter && query.searchTerm == "":
|
||
h.handleViewsResponse(w, r, session)
|
||
default:
|
||
writeJSON(w, http.StatusOK, emptyQueryResult(query.startIndex))
|
||
}
|
||
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.parentSeasonID != "":
|
||
// ParentId is a season: list that season's episodes. Clients that browse
|
||
// a show through generic /Items?ParentId= (e.g. Void's getEpisodes) send
|
||
// no IncludeItemTypes, so this must not depend on a type filter.
|
||
h.handleSeasonEpisodeChildren(w, r, session, query)
|
||
case query.parentItemID != "":
|
||
// ParentId is a series (or movie): list the series' seasons, or its
|
||
// episodes when IncludeItemTypes=Episode is requested. Without this, a
|
||
// series ParentId fell through to the library-views fallback below, so
|
||
// clients browsing a show via /Items?ParentId= (e.g. Void's getSeasons)
|
||
// saw the top-level libraries rendered as season tabs.
|
||
h.handleItemParentChildren(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)
|
||
}
|
||
}
|
||
|
||
// emptyQueryResult is the canonical empty /Items page.
|
||
func emptyQueryResult(startIndex int) queryResultDTO {
|
||
return queryResultDTO{
|
||
Items: []baseItemDTO{},
|
||
TotalRecordCount: 0,
|
||
StartIndex: startIndex,
|
||
}
|
||
}
|
||
|
||
// handleItemParentChildren serves GET /Items?ParentId={seriesId}. A series parent
|
||
// lists the series' seasons by default (and for an explicit IncludeItemTypes=Season);
|
||
// IncludeItemTypes=Episode lists every episode of the series instead. A type filter
|
||
// a series parent cannot satisfy (e.g. Movie) returns an empty page rather than a
|
||
// wrong-typed seasons listing. Movie/other item parents have no seasons, so the
|
||
// default path's ListSeasons yields an empty set and writes an empty page.
|
||
func (h *ItemsHandler) handleItemParentChildren(w http.ResponseWriter, r *http.Request, session *Session, query itemsQuery) {
|
||
switch {
|
||
case itemTypesContain(query.itemTypes, "episode"):
|
||
h.writeSeriesEpisodesResponse(w, r, session, query, query.parentItemID, "", true)
|
||
case !query.hasItemTypeFilter || itemTypesContain(query.itemTypes, "season"):
|
||
h.handleSeasonChildItems(w, r, session, query)
|
||
default:
|
||
writeJSON(w, http.StatusOK, emptyQueryResult(query.startIndex))
|
||
}
|
||
}
|
||
|
||
// handleSeasonEpisodeChildren serves GET /Items?ParentId={seasonId} by listing the
|
||
// episodes of that season. The season is resolved to its owning series so the
|
||
// shared episode-listing path (also used by /Shows/{id}/Episodes) can be reused.
|
||
func (h *ItemsHandler) handleSeasonEpisodeChildren(w http.ResponseWriter, r *http.Request, session *Session, query itemsQuery) {
|
||
// A season's only children are its episodes; a type filter that excludes
|
||
// Episode (e.g. IncludeItemTypes=Movie) yields nothing, mirroring the
|
||
// series-parent path's handling of unsatisfiable type filters.
|
||
if query.hasItemTypeFilter && !itemTypesContain(query.itemTypes, "episode") {
|
||
writeJSON(w, http.StatusOK, emptyQueryResult(query.startIndex))
|
||
return
|
||
}
|
||
if h.seasonRepo == nil {
|
||
writeJSON(w, http.StatusOK, emptyQueryResult(query.startIndex))
|
||
return
|
||
}
|
||
season, err := h.seasonRepo.GetByID(r.Context(), query.parentSeasonID)
|
||
if err != nil || season == nil {
|
||
writeJSON(w, http.StatusOK, emptyQueryResult(query.startIndex))
|
||
return
|
||
}
|
||
h.writeSeriesEpisodesResponse(w, r, session, query, season.SeriesID, query.parentSeasonID, true)
|
||
}
|
||
|
||
// itemTypesContain reports whether the mapped IncludeItemTypes contain target.
|
||
func itemTypesContain(itemTypes []string, target string) bool {
|
||
for _, itemType := range itemTypes {
|
||
if itemType == target {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func searchItemTypesForQuery(query itemsQuery) []string {
|
||
itemTypes := append([]string(nil), query.itemTypes...)
|
||
if query.mediaTypesExplicit && !query.mediaTypesSet["video"] {
|
||
return []string{compatNoMatchType}
|
||
}
|
||
if query.hasItemTypeFilter && len(itemTypes) == 0 {
|
||
return []string{compatNoMatchType}
|
||
}
|
||
return itemTypes
|
||
}
|
||
|
||
// 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")
|
||
|
||
// The synthetic Collections view is a fixed sentinel ID, not a codec-encoded
|
||
// one; clients fetch the CollectionFolder by ID (e.g. Infuse) before browsing
|
||
// its children, so resolve it before the codec decode attempts.
|
||
if isCollectionsViewID(rawID) {
|
||
writeJSON(w, http.StatusOK, h.collectionsView())
|
||
return
|
||
}
|
||
|
||
// 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 collectionID, err := h.codec.DecodeStringID(EncodedIDCollection, rawID); err == nil {
|
||
h.handleBoxSetItem(w, r, session, collectionID)
|
||
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)
|
||
dto.SeasonCount = 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.WarnContext(r.Context(), "failed to load filmography counts", "component", "jellycompat", "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,
|
||
})
|
||
}
|
||
|
||
// HandleThemeSongsStub serves an empty ThemeMediaResult for
|
||
// /Items/{id}/ThemeSongs. It cannot share HandleItemStub because this
|
||
// response shape additionally requires OwnerId (see themeMediaResultDTO).
|
||
func (h *ItemsHandler) HandleThemeSongsStub(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, themeMediaResultDTO{
|
||
Items: []baseItemDTO{},
|
||
TotalRecordCount: 0,
|
||
StartIndex: 0,
|
||
OwnerID: chi.URLParam(r, "id"),
|
||
})
|
||
}
|
||
|
||
// 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.DebugContext(r.Context(), "jellycompat: media segments lookup with undecodable id", "component", "jellycompat", "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.WarnContext(r.Context(), "jellycompat: media segments item lookup failed", "component", "jellycompat",
|
||
"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": {},
|
||
})
|
||
}
|
||
|
||
// HandleFilters2Stub serves GET /Items/Filters2, Jellyfin's v2 query-filters
|
||
// endpoint. Clients like Fladder call it to populate their filter UI; without a
|
||
// route it fell through to GET /Items/{id} and 404'd. Jellyfin returns a
|
||
// QueryFilters object whose fields default to empty arrays, so an empty (but
|
||
// correctly-shaped) result is contract-faithful and never blocks the UI.
|
||
func (h *ItemsHandler) HandleFilters2Stub(w http.ResponseWriter, r *http.Request) {
|
||
writeJSON(w, http.StatusOK, queryFiltersDTO{
|
||
Genres: []nameGuidPair{},
|
||
Tags: []string{},
|
||
AudioLanguages: []nameValuePair{},
|
||
SubtitleLanguages: []nameValuePair{},
|
||
})
|
||
}
|
||
|
||
// HandleLocalTrailers serves GET /Items/{id}/LocalTrailers (and the legacy
|
||
// /Users/{userId}/Items/{id}/LocalTrailers alias). Jellyfin returns a bare
|
||
// BaseItemDto array — not the {Items,TotalRecordCount,StartIndex} envelope, so
|
||
// this cannot reuse HandleItemStub. Returns the item's local extras of
|
||
// trailer/teaser kind as playable items; [] when it has none, which matches
|
||
// Jellyfin's contract and stops the chi 404 that clients (Infuse, Moonfin)
|
||
// otherwise hit on every item-detail load.
|
||
func (h *ItemsHandler) HandleLocalTrailers(w http.ResponseWriter, r *http.Request) {
|
||
h.writeLocalExtras(w, r, true)
|
||
}
|
||
|
||
// HandleSpecialFeatures serves GET /Items/{id}/SpecialFeatures (and the
|
||
// /Users/{userId}/... alias): the item's non-trailer local extras
|
||
// (featurettes, deleted scenes, ...) as a bare BaseItemDto array.
|
||
func (h *ItemsHandler) HandleSpecialFeatures(w http.ResponseWriter, r *http.Request) {
|
||
h.writeLocalExtras(w, r, false)
|
||
}
|
||
|
||
// writeLocalExtras is the shared body of LocalTrailers/SpecialFeatures:
|
||
// Jellyfin splits one extras concept across two endpoints by kind.
|
||
func (h *ItemsHandler) writeLocalExtras(w http.ResponseWriter, r *http.Request, trailersOnly bool) {
|
||
session := SessionFromContext(r.Context())
|
||
if session == nil {
|
||
writeError(w, http.StatusUnauthorized, "Unauthorized", "Missing authentication token")
|
||
return
|
||
}
|
||
|
||
if h.codec == nil || h.content == nil {
|
||
writeJSON(w, http.StatusOK, []baseItemDTO{})
|
||
return
|
||
}
|
||
|
||
rawID := chi.URLParam(r, "id")
|
||
contentID, err := h.codec.DecodeStringID(EncodedIDItem, rawID)
|
||
if err != nil {
|
||
writeJSON(w, http.StatusOK, []baseItemDTO{})
|
||
return
|
||
}
|
||
|
||
detail, err := h.content.GetItemDetail(r.Context(), session, contentID, nil)
|
||
if err != nil {
|
||
writeJSON(w, http.StatusOK, []baseItemDTO{})
|
||
return
|
||
}
|
||
|
||
items := []baseItemDTO{}
|
||
for _, extra := range detail.Extras {
|
||
if isLocalTrailerKind(extra.Kind) != trailersOnly {
|
||
continue
|
||
}
|
||
items = append(items, h.extraToBaseItem(extra, rawID))
|
||
}
|
||
writeJSON(w, http.StatusOK, items)
|
||
}
|
||
|
||
// extraToBaseItem maps a local extra onto a minimal playable BaseItemDto. The
|
||
// extra's content_id is a first-class watch target (GetWatchDetail resolves
|
||
// it through the extras fallback tier), so PlaybackInfo and the stream
|
||
// endpoints work on the encoded id with no extra plumbing.
|
||
func (h *ItemsHandler) extraToBaseItem(extra catalog.ItemExtraInfo, parentEncodedID string) baseItemDTO {
|
||
name := extra.Title
|
||
if name == "" {
|
||
name = extraKindDisplayName(extra.Kind)
|
||
}
|
||
itemType := "Video"
|
||
if isLocalTrailerKind(extra.Kind) {
|
||
itemType = "Trailer"
|
||
}
|
||
dto := baseItemDTO{
|
||
ID: h.codec.EncodeStringID(EncodedIDItem, extra.ContentID),
|
||
Type: itemType,
|
||
IsFolder: false,
|
||
Name: name,
|
||
ServerID: h.mapper.serverID,
|
||
MediaType: "Video",
|
||
ParentID: parentEncodedID,
|
||
ImageTags: map[string]string{},
|
||
}
|
||
if extra.DurationSeconds > 0 {
|
||
dto.RunTimeTicks = secondsToTicks(float64(extra.DurationSeconds))
|
||
}
|
||
applyPlayableLocation(&dto, true)
|
||
return dto
|
||
}
|
||
|
||
// extraKindDisplayName is the fallback item name for an untitled extra.
|
||
func extraKindDisplayName(kind string) string {
|
||
switch models.ExtraKind(kind) {
|
||
case models.ExtraKindTrailer:
|
||
return "Trailer"
|
||
case models.ExtraKindTeaser:
|
||
return "Teaser"
|
||
case models.ExtraKindFeaturette:
|
||
return "Featurette"
|
||
case models.ExtraKindClip:
|
||
return "Clip"
|
||
case models.ExtraKindBehindTheScenes:
|
||
return "Behind the Scenes"
|
||
case models.ExtraKindBloopers:
|
||
return "Bloopers"
|
||
case models.ExtraKindDeletedScene:
|
||
return "Deleted Scene"
|
||
default:
|
||
return "Extra"
|
||
}
|
||
}
|
||
|
||
// 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.
|
||
// libraryItemType is "movie", "series", or "" (any other/mixed type) — it
|
||
// also decides fast-path eligibility below, so it is resolved once here.
|
||
var libraryItemType string
|
||
if query.parentLibraryID > 0 {
|
||
libraryItemType = h.inferLibraryItemType(r.Context(), session, query.parentLibraryID)
|
||
if len(query.itemTypes) == 0 && libraryItemType != "" {
|
||
query.itemTypes = []string{libraryItemType}
|
||
}
|
||
}
|
||
|
||
// Build the fallback's browse params FIRST: fast-path eligibility is
|
||
// decided off these actual params (see latestFastPathEligible), so a filter
|
||
// later added to buildBrowseParams can never be silently ignored by the
|
||
// cached path.
|
||
params := buildLatestBrowseParams(query)
|
||
|
||
// Fast path: a per-library Latest is the same user-agnostic list as the native
|
||
// "recently added" library rail (both order by mil.first_seen_at DESC). When
|
||
// eligible, serve it through the native section fetch so it reuses the shared
|
||
// resolved-list cache instead of re-running BrowseItems; otherwise fall back.
|
||
if h.sectionsFetcher != nil && latestFastPathEligible(params, libraryItemType) {
|
||
items, err := h.loadLatestViaSections(r.Context(), session, query)
|
||
if err == nil {
|
||
applyImageTypeLimit(items, query.imageTypeLimit)
|
||
writeJSON(w, http.StatusOK, items)
|
||
return
|
||
}
|
||
// errLatestNotEligible is an expected "this request can't use the cached
|
||
// path" signal (e.g. a client requesting a type other than the library's
|
||
// own), not a failure — fall back quietly.
|
||
if !errors.Is(err, errLatestNotEligible) {
|
||
slog.WarnContext(r.Context(), "jellycompat: latest via sections failed, falling back to browse", "component", "jellycompat", "error", err)
|
||
}
|
||
}
|
||
result, err := h.content.BrowseItems(r.Context(), session, params)
|
||
if err != nil {
|
||
writeCompatUpstreamError(w, err)
|
||
return
|
||
}
|
||
|
||
items, err := h.buildLatestItemDTOs(r.Context(), session, query, result.Items)
|
||
if err != nil {
|
||
writeCompatUpstreamError(w, err)
|
||
return
|
||
}
|
||
applyImageTypeLimit(items, query.imageTypeLimit)
|
||
writeJSON(w, http.StatusOK, items)
|
||
}
|
||
|
||
// buildLatestItemDTOs maps a resolved list of upstream list items to the
|
||
// /Items/Latest wire response, applying the per-user overlay (favorites,
|
||
// progress, episode targets), the library ParentId, and the optional detail-
|
||
// field upgrade. It is the shared tail of both the native section fast path and
|
||
// the BrowseItems fallback so the overlay logic lives in exactly one place.
|
||
func (h *ItemsHandler) buildLatestItemDTOs(ctx context.Context, session *Session, query itemsQuery, listItems []upstreamListItem) ([]baseItemDTO, error) {
|
||
h.rememberListImages(listItems)
|
||
|
||
contentIDs := contentIDsFromListItems(listItems)
|
||
favorites, progress, err := resolveUserStateForContentIDs(ctx, session, h.userData, contentIDs)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
episodeTargets, err := h.fetchCompatEpisodeTargetsByContentIDs(ctx, session, episodeContentIDsFromListItems(listItems), libraryIDPtr(query.parentLibraryID))
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
// 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))
|
||
}
|
||
|
||
var detailsByID map[string]*upstreamItemDetail
|
||
if query.needsDetailFields {
|
||
detailsByID = h.batchListItemDetails(ctx, session, contentIDs, libraryIDPtr(query.parentLibraryID))
|
||
}
|
||
|
||
items := make([]baseItemDTO, 0, len(listItems))
|
||
for _, item := range listItems {
|
||
if query.needsDetailFields {
|
||
if detail, ok := detailsByID[item.ContentID]; ok && detail != 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)
|
||
}
|
||
return items, nil
|
||
}
|
||
|
||
// errLatestNotEligible signals that a /Items/Latest request cannot be served
|
||
// through the native section path without diverging from the BrowseItems
|
||
// fallback, so HandleLatest should fall back quietly rather than log a failure.
|
||
var errLatestNotEligible = errors.New("jellycompat: latest not eligible for native section path")
|
||
|
||
// latestFastPathReproducibleParams is the closed set of browse params the
|
||
// synthetic recently-added section reproduces exactly:
|
||
// - limit / offset — offset must be 0 (checked below); limit is applied by
|
||
// slicing the shared list;
|
||
// - type / library_id — expressed by the section config + library scope;
|
||
// - sort / order — buildLatestBrowseParams pins recently_added/desc on both
|
||
// paths;
|
||
// - include_total — Latest returns a bare array, so the total is unused;
|
||
// - max_content_rating — folded into the access filter (and the cache key).
|
||
//
|
||
// Any OTHER param buildBrowseParams emits — today's filters (genre,
|
||
// name_prefix, person_id, is_played, require_backdrop) and any filter added in
|
||
// the future — disqualifies the fast path, because the shared cached list
|
||
// cannot honor it. Deciding off the actual params (not a hand-mirrored field
|
||
// list) is what keeps this gate and the fallback from silently drifting apart.
|
||
var latestFastPathReproducibleParams = map[string]struct{}{
|
||
"limit": {},
|
||
"offset": {},
|
||
"type": {},
|
||
"library_id": {},
|
||
"sort": {},
|
||
"order": {},
|
||
"include_total": {},
|
||
"max_content_rating": {},
|
||
}
|
||
|
||
// latestFastPathEligible reports whether a /Items/Latest request can be served
|
||
// through the native recently-added section (and its shared cache) with results
|
||
// identical to the BrowseItems fallback. It inspects the browse params the
|
||
// fallback would actually receive: every param must be one the section path
|
||
// reproduces, the request must be for the first page of a movies/series
|
||
// library, the limit must fit the fixed shared fetch budget, and a client
|
||
// rating cap must be a known rating (an unknown string matches nothing in
|
||
// BrowseItems and must not mint arbitrary cache keys).
|
||
func latestFastPathEligible(params url.Values, libraryItemType string) bool {
|
||
if libraryItemType != "movie" && libraryItemType != "series" {
|
||
return false
|
||
}
|
||
for key := range params {
|
||
if _, ok := latestFastPathReproducibleParams[key]; !ok {
|
||
return false
|
||
}
|
||
}
|
||
if params.Get("offset") != "0" {
|
||
return false
|
||
}
|
||
if limit := catalog.ParseIntParam(params.Get("limit")); limit > compatLatestCacheFetchLimit {
|
||
return false
|
||
}
|
||
if rating := strings.TrimSpace(params.Get("max_content_rating")); rating != "" {
|
||
if _, known := access.RatingRank(rating); !known {
|
||
return false
|
||
}
|
||
}
|
||
return true
|
||
}
|
||
|
||
// loadLatestViaSections serves a per-library /Items/Latest through the native
|
||
// recently-added section fetch, reusing the shared resolved-list cache. The
|
||
// synthetic section carries the same type+config+limit+scope the native library
|
||
// rail uses, so with the identity-independent cache key both surfaces collapse
|
||
// to one shared entry. The cached *models.MediaItem values are treated
|
||
// read-only; LocalizeItemModels deep-copies before any presign mutation.
|
||
func (h *ItemsHandler) loadLatestViaSections(ctx context.Context, session *Session, query itemsQuery) ([]baseItemDTO, error) {
|
||
// The caller has already restricted this to movies/series libraries. Pin the
|
||
// exact single type the BrowseItems fallback would use (movie or series) so
|
||
// the two paths return byte-identical membership; if a client asked for some
|
||
// other type against this library, fall back rather than risk a mismatch.
|
||
cfg := latestRecentlyAddedConfig(query.itemTypes)
|
||
if cfg == nil {
|
||
return nil, errLatestNotEligible
|
||
}
|
||
|
||
filter := h.resolveAccessFilter(ctx, session)
|
||
// Fold the client's clamped max content rating into the access filter so the
|
||
// shared cached list respects the client's cap AND the cache key captures it
|
||
// — identical to the clamp BrowseItems applies.
|
||
filter.MaxContentRating = clampMaxContentRating(filter.MaxContentRating, query.maxOfficialRating)
|
||
|
||
limit := query.limit
|
||
if limit <= 0 {
|
||
limit = compatDefaultBrowseLimit
|
||
}
|
||
// Eligibility already rejected limits beyond the shared fetch budget, but
|
||
// this path must stay safe if called directly.
|
||
if limit > compatLatestCacheFetchLimit {
|
||
return nil, errLatestNotEligible
|
||
}
|
||
libraryID := query.parentLibraryID
|
||
// Always fetch the FIXED compatLatestCacheFetchLimit budget and slice down
|
||
// to the requested page below. ItemLimit is a cache-key component, so
|
||
// echoing the raw client Limit would let one client mint a distinct
|
||
// process-global cache entry per Limit value; with the fixed budget every
|
||
// compat Latest request for a scope+library shares exactly one entry.
|
||
resolved := sections.ResolvedSection{
|
||
ID: "compat-latest",
|
||
SectionType: sections.SectionRecentlyAdded,
|
||
Title: "Latest",
|
||
ItemLimit: compatLatestCacheFetchLimit,
|
||
Config: cfg,
|
||
}
|
||
|
||
withItems, err := h.sectionsFetcher.FetchOne(ctx, resolved, &libraryID, nil, session.StreamAppUserID, session.ProfileID, filter)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
// The shared list is ordered by first_seen_at DESC, so its first N entries
|
||
// are exactly what a direct limit-N fetch would return.
|
||
sharedItems := withItems.Items
|
||
if len(sharedItems) > limit {
|
||
sharedItems = sharedItems[:limit]
|
||
}
|
||
|
||
listItems := h.compatListItemsFromModels(ctx, filter, sharedItems)
|
||
// The BrowseItems fallback aggregates series watch state via
|
||
// EnrichSeriesUserData; a series has no progress row of its own, so without
|
||
// this its Latest rail would drop Played/UnplayedItemCount and diverge from
|
||
// page 2. Enrich the freshly-built per-request list items (never the cached
|
||
// models) before the DTOs are built so userDataDTO sees the populated
|
||
// UserData. It runs under the request session, so counts are per-profile, and
|
||
// only touches series rows (movies are left untouched).
|
||
h.content.EnrichSeriesUserData(ctx, session, listItems)
|
||
return h.buildLatestItemDTOs(ctx, session, query, listItems)
|
||
}
|
||
|
||
// compatLatestCacheFetchLimit is the fixed number of rows the Latest fast path
|
||
// asks the shared recently-added cache for, regardless of the client's Limit
|
||
// (the response is sliced down afterwards). Fixing the fetch size keeps the
|
||
// client's Limit out of the cache key — one entry per scope+library instead of
|
||
// one per distinct Limit value — and comfortably covers the Latest hot path
|
||
// (clients request ~16-50); larger limits fall back to BrowseItems.
|
||
const compatLatestCacheFetchLimit = 100
|
||
|
||
// latestRecentlyAddedConfig encodes the inferred item-type filter into the
|
||
// recently-added section Config (the filter_type field buildRecentlyAddedQuery
|
||
// reads). It normalizes through compatScopedTypes — the SAME helper BrowseItems
|
||
// uses — and only encodes a concrete single type (movie or series); an empty or
|
||
// mixed library yields a nil config (all types).
|
||
func latestRecentlyAddedConfig(itemTypes []string) json.RawMessage {
|
||
scoped := compatScopedTypes(strings.Join(itemTypes, ","))
|
||
var filterType string
|
||
switch scoped {
|
||
case "movie", "series":
|
||
filterType = scoped
|
||
default:
|
||
return nil
|
||
}
|
||
cfg, err := json.Marshal(sections.SectionConfigFilters{FilterType: filterType})
|
||
if err != nil {
|
||
return nil
|
||
}
|
||
return cfg
|
||
}
|
||
|
||
// compatListItemsFromModels localizes cached media-item models, converts them to
|
||
// the compat list-item wire shape, and presigns their image URLs. The cached
|
||
// models are never mutated in place: LocalizeItemModels deep-copies, and the
|
||
// per-item presign operates on the freshly-built upstreamListItem values.
|
||
func (h *ItemsHandler) compatListItemsFromModels(ctx context.Context, filter catalog.AccessFilter, items []*models.MediaItem) []upstreamListItem {
|
||
localized := items
|
||
if h.detailSvc != nil {
|
||
if loc, err := h.detailSvc.LocalizeItemModels(ctx, items, filter); err == nil && loc != nil {
|
||
localized = loc
|
||
}
|
||
}
|
||
listItems := make([]upstreamListItem, 0, len(localized))
|
||
for _, mi := range localized {
|
||
if mi == nil {
|
||
continue
|
||
}
|
||
listItems = append(listItems, mediaItemToListItem(mi))
|
||
}
|
||
presignCompatListItems(ctx, h.detailSvc, listItems)
|
||
fillListItemDurations(ctx, h.durationSrc, listItems)
|
||
return listItems
|
||
}
|
||
|
||
// 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,
|
||
})
|
||
}
|
||
|
||
// HandleGenreByName serves GET /Genres/{name}. Jellyfin addresses genres by
|
||
// (URL-escaped) display name; clients use the returned Id for GenreIds= item
|
||
// queries.
|
||
func (h *ItemsHandler) HandleGenreByName(w http.ResponseWriter, r *http.Request) {
|
||
session := SessionFromContext(r.Context())
|
||
if session == nil {
|
||
writeError(w, http.StatusUnauthorized, "Unauthorized", "Missing authentication token")
|
||
return
|
||
}
|
||
|
||
name := strings.TrimSpace(chi.URLParam(r, "name"))
|
||
if decoded, err := url.PathUnescape(name); err == nil {
|
||
name = strings.TrimSpace(decoded)
|
||
}
|
||
if name == "" {
|
||
writeError(w, http.StatusNotFound, "NotFound", "Genre not found")
|
||
return
|
||
}
|
||
|
||
// Confirm the genre exists within the caller's visible scope so the
|
||
// response carries the canonical casing.
|
||
filters, err := h.content.ListItemFilters(r.Context(), session, urlValuesFromItemsQuery(parseItemsQuery(r, h.codec)))
|
||
if err != nil {
|
||
writeCompatUpstreamError(w, err)
|
||
return
|
||
}
|
||
for _, genre := range filters.Genres {
|
||
if strings.EqualFold(strings.TrimSpace(genre), name) {
|
||
writeJSON(w, http.StatusOK, baseItemDTO{
|
||
ID: h.codec.EncodeStringID(EncodedIDGenre, genre),
|
||
Type: "Genre",
|
||
Name: genre,
|
||
ServerID: h.mapper.serverID,
|
||
})
|
||
return
|
||
}
|
||
}
|
||
writeError(w, http.StatusNotFound, "NotFound", "Genre not found")
|
||
}
|
||
|
||
// HandleSeasons serves GET /Shows/{id}/Seasons.
|
||
func (h *ItemsHandler) HandleSeasons(w http.ResponseWriter, r *http.Request) {
|
||
defer func() {
|
||
if rv := recover(); rv != nil {
|
||
slog.ErrorContext(r.Context(), "jellycompat HandleSeasons panic", "component", "jellycompat",
|
||
"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
|
||
}
|
||
h.writeSeasonItemsResponse(w, r, session, seriesID, seasons, query, false)
|
||
}
|
||
|
||
func (h *ItemsHandler) handleSeasonChildItems(w http.ResponseWriter, r *http.Request, session *Session, query itemsQuery) {
|
||
seasons, err := h.content.ListSeasons(r.Context(), session, query.parentItemID, nil)
|
||
if err != nil {
|
||
writeCompatUpstreamError(w, err)
|
||
return
|
||
}
|
||
h.writeSeasonItemsResponse(w, r, session, query.parentItemID, seasons, query, true)
|
||
}
|
||
|
||
func (h *ItemsHandler) writeSeasonItemsResponse(w http.ResponseWriter, r *http.Request, session *Session, seriesID string, seasons []upstreamSeason, query itemsQuery, page bool) {
|
||
seasons = filterBrowsableSeasons(seasons)
|
||
h.rememberSeasonImages(seasons, seriesID)
|
||
|
||
total := len(seasons)
|
||
if page {
|
||
start := query.startIndex
|
||
if start > total {
|
||
start = total
|
||
}
|
||
end := total
|
||
if query.limit > 0 && start+query.limit < end {
|
||
end = start + query.limit
|
||
}
|
||
seasons = seasons[start:end]
|
||
}
|
||
|
||
favorites, err := resolveFavoritesForContentIDs(r.Context(), session, h.userData, seasonContentIDs(seasons))
|
||
if err != nil {
|
||
writeCompatUpstreamError(w, err)
|
||
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]))
|
||
}
|
||
applyImageTypeLimit(items, query.imageTypeLimit)
|
||
|
||
startIndex := 0
|
||
if page {
|
||
startIndex = query.startIndex
|
||
}
|
||
writeJSON(w, http.StatusOK, queryResultDTO{
|
||
Items: items,
|
||
TotalRecordCount: total,
|
||
StartIndex: startIndex,
|
||
})
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
// AdjacentTo (Wholphin autoplay/skip) only needs the requested episode plus
|
||
// its immediate neighbors. Serve it from a bounded index seek instead of
|
||
// materializing the whole series, which is multi-second for long soaps.
|
||
if query.adjacentTo != "" {
|
||
h.writeAdjacentEpisodesResponse(w, r, session, query, seriesID)
|
||
return
|
||
}
|
||
|
||
var requestedSeasonID string
|
||
if rawSeasonID := strings.TrimSpace(newCaseInsensitiveQuery(r.URL.Query()).Get("SeasonId")); rawSeasonID != "" {
|
||
decodedSeasonID, decodeErr := h.codec.DecodeStringID(EncodedIDSeason, rawSeasonID)
|
||
if decodeErr != nil {
|
||
writeError(w, http.StatusNotFound, "NotFound", "Season not found")
|
||
return
|
||
}
|
||
requestedSeasonID = decodedSeasonID
|
||
}
|
||
|
||
h.writeSeriesEpisodesResponse(w, r, session, query, seriesID, requestedSeasonID, false)
|
||
}
|
||
|
||
// writeSeriesEpisodesResponse lists a series' episodes (optionally scoped to a
|
||
// single season via requestedSeasonID) and writes them as a Jellyfin /Items page.
|
||
// It is the shared core of GET /Shows/{id}/Episodes and the generic
|
||
// /Items?ParentId= series/season browse paths. page applies StartIndex/Limit to
|
||
// the result: the generic /Items browse paths page (matching the /Items contract
|
||
// and the sibling seasons listing); /Shows/{id}/Episodes passes false to keep its
|
||
// long-standing whole-season response.
|
||
func (h *ItemsHandler) writeSeriesEpisodesResponse(w http.ResponseWriter, r *http.Request, session *Session, query itemsQuery, seriesID, requestedSeasonID string, page bool) {
|
||
seasons, err := h.content.ListSeasons(r.Context(), session, seriesID, nil)
|
||
if err != nil {
|
||
writeCompatUpstreamError(w, err)
|
||
return
|
||
}
|
||
|
||
episodeModels, err := h.listSeriesEpisodes(r.Context(), session, seriesID, seasons, requestedSeasonID)
|
||
if err != nil {
|
||
writeCompatUpstreamError(w, err)
|
||
return
|
||
}
|
||
h.writeEpisodeModelsPage(w, r, session, query, seriesID, seasons, episodeModels, page)
|
||
}
|
||
|
||
// writeEpisodeModelsPage maps a resolved set of episode models to Jellyfin
|
||
// baseItemDTOs and writes them as an /Items page. It is the shared downstream
|
||
// half of the episode-listing paths: writeSeriesEpisodesResponse feeds it the
|
||
// whole series (or a single season), while writeAdjacentEpisodesResponse feeds
|
||
// it only the bounded prev/self/next window. seasons is used solely for season
|
||
// title/ID hydration; episodeModels carries the rows actually rendered.
|
||
func (h *ItemsHandler) writeEpisodeModelsPage(w http.ResponseWriter, r *http.Request, session *Session, query itemsQuery, seriesID string, seasons []upstreamSeason, episodeModels []*models.Episode, page bool) {
|
||
h.rememberSeasonImages(seasons, seriesID)
|
||
|
||
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
|
||
}
|
||
|
||
sort.SliceStable(episodeModels, func(i, j int) bool {
|
||
if episodeModels[i] == nil || episodeModels[j] == nil {
|
||
return episodeModels[i] != nil
|
||
}
|
||
if episodeModels[i].SeasonNumber == episodeModels[j].SeasonNumber {
|
||
return episodeModels[i].EpisodeNumber < episodeModels[j].EpisodeNumber
|
||
}
|
||
return episodeModels[i].SeasonNumber < episodeModels[j].SeasonNumber
|
||
})
|
||
episodeModels = compactEpisodeModels(episodeModels)
|
||
episodeModels = trimEpisodesFromStartItem(episodeModels, query.startItemID, h.codec)
|
||
|
||
contentIDs := contentIDsFromEpisodes(episodeModels)
|
||
favorites, progress, err := resolveUserStateForContentIDs(r.Context(), session, h.userData, contentIDs)
|
||
if err != nil {
|
||
writeCompatUpstreamError(w, err)
|
||
return
|
||
}
|
||
episodeTargets, err := h.fetchCompatEpisodeTargetsByContentIDsWithDurations(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)
|
||
upstreamEpisode.HasMediaFiles = target.Item.HasMediaFiles
|
||
upstreamEpisode.DurationSeconds = target.Item.DurationSeconds
|
||
}
|
||
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
|
||
})
|
||
|
||
total := len(items)
|
||
startIndex := 0
|
||
if page {
|
||
startIndex = query.startIndex
|
||
items = slicePage(items, query.startIndex, query.limit)
|
||
if items == nil {
|
||
items = []baseItemDTO{}
|
||
}
|
||
}
|
||
writeJSON(w, http.StatusOK, queryResultDTO{
|
||
Items: items,
|
||
TotalRecordCount: total,
|
||
StartIndex: startIndex,
|
||
})
|
||
}
|
||
|
||
// writeAdjacentEpisodesResponse serves the AdjacentTo case of
|
||
// GET /Shows/{id}/Episodes: it resolves the referenced episode and returns it
|
||
// together with its immediate previous/next neighbors (at most three items,
|
||
// crossing season boundaries) via a bounded index seek. This intentionally
|
||
// returns a prev/self/next window rather than Jellyfin's exact AdjacentTo
|
||
// shape — it satisfies Wholphin's autoplay/skip use without paying the cost of
|
||
// loading and mapping every episode of the series.
|
||
//
|
||
// When the bounded repo path is unavailable (no direct episode repo) or the
|
||
// AdjacentTo id cannot be decoded/resolved, it falls back to the full-series
|
||
// listing so behavior is never worse than before AdjacentTo was honored.
|
||
func (h *ItemsHandler) writeAdjacentEpisodesResponse(w http.ResponseWriter, r *http.Request, session *Session, query itemsQuery, seriesID string) {
|
||
if h.episodeRepo == nil {
|
||
h.writeSeriesEpisodesResponse(w, r, session, query, seriesID, "", false)
|
||
return
|
||
}
|
||
|
||
targetContentID, err := decodeItemID(h.codec, query.adjacentTo)
|
||
if err != nil || targetContentID == "" {
|
||
h.writeSeriesEpisodesResponse(w, r, session, query, seriesID, "", false)
|
||
return
|
||
}
|
||
|
||
targets, err := h.episodeRepo.GetByIDs(r.Context(), []string{targetContentID})
|
||
if err != nil {
|
||
writeCompatUpstreamError(w, err)
|
||
return
|
||
}
|
||
var target *models.Episode
|
||
for _, ep := range targets {
|
||
if ep != nil && ep.ContentID == targetContentID {
|
||
target = ep
|
||
break
|
||
}
|
||
}
|
||
if target == nil {
|
||
// Unknown episode: return an empty page rather than the whole series.
|
||
writeJSON(w, http.StatusOK, queryResultDTO{Items: []baseItemDTO{}, TotalRecordCount: 0, StartIndex: 0})
|
||
return
|
||
}
|
||
if target.SeriesID != seriesID {
|
||
// Malformed request: the AdjacentTo episode belongs to a different series
|
||
// than the {id} path. Returning that other series' neighbors mislabeled
|
||
// with this series' ID would be silently wrong, so return an empty page.
|
||
writeJSON(w, http.StatusOK, queryResultDTO{Items: []baseItemDTO{}, TotalRecordCount: 0, StartIndex: 0})
|
||
return
|
||
}
|
||
|
||
episodeModels, err := h.episodeRepo.ListAdjacentInSeries(r.Context(), target.SeriesID, target.SeasonNumber, target.EpisodeNumber)
|
||
if err != nil {
|
||
writeCompatUpstreamError(w, err)
|
||
return
|
||
}
|
||
|
||
seasons, err := h.content.ListSeasons(r.Context(), session, seriesID, nil)
|
||
if err != nil {
|
||
writeCompatUpstreamError(w, err)
|
||
return
|
||
}
|
||
|
||
h.writeEpisodeModelsPage(w, r, session, query, seriesID, seasons, episodeModels, false)
|
||
}
|
||
|
||
// 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.fetchCompatEpisodeTargetsByContentIDsWithDurations(r.Context(), session, contentIDs, nil)
|
||
if err != nil {
|
||
writeCompatUpstreamError(w, err)
|
||
return
|
||
}
|
||
|
||
// The scan stays on list-level data (per-item GetItemDetail fanout over
|
||
// the FULL next-up list was 5N DB roundtrips for users with long lists);
|
||
// only the sliced page below is upgraded to detail when the client asked
|
||
// for detail-level Fields — see upgradeProgressPageToDetail for why the
|
||
// listing's MediaSources are load-bearing for Infuse/SenPlayer.
|
||
pageIDs := make([]string, 0, len(results))
|
||
items := make([]baseItemDTO, 0, len(results))
|
||
for _, res := range results {
|
||
target, ok := episodeTargets[res.ContentID]
|
||
if !ok {
|
||
continue
|
||
}
|
||
dto := h.mapper.itemFromList(target.Item, favorites[res.ContentID], progress[res.ContentID], query.requestedFields)
|
||
h.applyCompatEpisodeTarget(&dto, target)
|
||
stubDetailListFields(&dto, query.requestedFields)
|
||
pageIDs = append(pageIDs, res.ContentID)
|
||
items = append(items, dto)
|
||
}
|
||
|
||
total := len(items)
|
||
items = sliceBaseItems(items, query.startIndex, query.limit)
|
||
pageIDs = slicePageIDs(pageIDs, query.startIndex, query.limit)
|
||
if query.needsDetailFields {
|
||
for i := range items {
|
||
if i >= maxDetailUpgrades || i >= len(pageIDs) {
|
||
break
|
||
}
|
||
detail, detailErr := h.content.GetItemDetail(r.Context(), session, pageIDs[i], nil)
|
||
if detailErr != nil {
|
||
continue
|
||
}
|
||
h.rememberDetailImages(*detail)
|
||
dto := h.mapper.itemFromDetailWithFields(*detail, favorites[pageIDs[i]], progress[pageIDs[i]], query.requestedFields)
|
||
if target, ok := episodeTargets[pageIDs[i]]; ok {
|
||
h.applyCompatEpisodeTarget(&dto, target)
|
||
}
|
||
items[i] = dto
|
||
}
|
||
}
|
||
applyImageTypeLimit(items, query.imageTypeLimit)
|
||
writeJSON(w, http.StatusOK, queryResultDTO{
|
||
Items: items,
|
||
TotalRecordCount: total,
|
||
StartIndex: query.startIndex,
|
||
})
|
||
}
|
||
|
||
// slicePageIDs applies sliceBaseItems' bounds to the parallel content-id
|
||
// slice so page positions stay aligned after StartIndex/Limit slicing.
|
||
func slicePageIDs(ids []string, startIndex, limit int) []string {
|
||
if startIndex < 0 {
|
||
startIndex = 0
|
||
}
|
||
if startIndex >= len(ids) {
|
||
return []string{}
|
||
}
|
||
if limit <= 0 {
|
||
limit = len(ids)
|
||
}
|
||
end := min(startIndex+limit, len(ids))
|
||
return ids[startIndex:end]
|
||
}
|
||
|
||
// 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"))
|
||
// Search hints are served by the catalog search provider (the same
|
||
// Meilisearch-backed path as /Items media search), which does its own
|
||
// short-term handling and result bounding, so the aux short-term gate is
|
||
// intentionally NOT applied here — short titles ("Up", "It") stay
|
||
// discoverable through type-ahead. The result cap is still clamped below.
|
||
if query == "" {
|
||
writeJSON(w, http.StatusOK, searchHintResultDTO{})
|
||
return
|
||
}
|
||
|
||
limit := clampAuxSearchLimit(parsePositiveInt(q.Get("Limit"), auxSearchMaxResults))
|
||
result, err := h.content.SearchItems(r.Context(), session, SearchItemsOptions{
|
||
Query: query,
|
||
Limit: limit,
|
||
})
|
||
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: runtimeTicks(item.DurationSeconds, 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")
|
||
}
|
||
|
||
// batchListItemDetails resolves detail payloads for a page of content IDs using
|
||
// the batched content path, falling back to per-item GetItemDetail only if the
|
||
// batch call itself errors. The returned map is keyed by content ID; ids absent
|
||
// from it could not be resolved to a detail and must be rendered from list data
|
||
// by the caller — matching the historical per-item GetItemDetail error → list
|
||
// fallback behavior. Individually-unresolvable ids (e.g. season rows, which the
|
||
// item-detail batch path does not handle) are simply absent and likewise fall
|
||
// back to list rendering rather than a per-item detail fetch. Returns nil for an
|
||
// empty input.
|
||
func (h *ItemsHandler) batchListItemDetails(ctx context.Context, session *Session, contentIDs []string, libraryID *int) map[string]*upstreamItemDetail {
|
||
if len(contentIDs) == 0 {
|
||
return nil
|
||
}
|
||
if details, err := h.content.GetItemDetailsByIDs(ctx, session, contentIDs, libraryID); err == nil {
|
||
return details
|
||
}
|
||
details := make(map[string]*upstreamItemDetail, len(contentIDs))
|
||
for _, id := range contentIDs {
|
||
detail, derr := h.content.GetItemDetail(ctx, session, id, libraryID)
|
||
if derr != nil || detail == nil {
|
||
continue
|
||
}
|
||
details[id] = detail
|
||
}
|
||
return details
|
||
}
|
||
|
||
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))
|
||
}
|
||
|
||
var detailsByID map[string]*upstreamItemDetail
|
||
if query.needsDetailFields {
|
||
detailsByID = h.batchListItemDetails(r.Context(), session, contentIDs, libraryIDPtr(query.parentLibraryID))
|
||
}
|
||
|
||
items := make([]baseItemDTO, 0, len(result.Items))
|
||
for _, item := range result.Items {
|
||
if query.needsDetailFields {
|
||
if detail, ok := detailsByID[item.ContentID]; ok && detail != 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),
|
||
ExcludedMediaTypes: access.ExcludedMediaTypes,
|
||
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 {
|
||
listItems = append(listItems, mediaItemToListItem(mi))
|
||
}
|
||
presignCompatListItems(r.Context(), h.detailSvc, listItems)
|
||
fillListItemDurations(r.Context(), h.durationSrc, listItems)
|
||
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, SearchItemsOptions{
|
||
Query: query.searchTerm,
|
||
ItemTypes: searchItemTypesForQuery(query),
|
||
Limit: query.limit,
|
||
Offset: query.startIndex,
|
||
LibraryID: libraryIDPtr(query.parentLibraryID),
|
||
SkipTotal: !query.enableTotalRecordCount,
|
||
})
|
||
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))
|
||
}
|
||
|
||
var detailsByID map[string]*upstreamItemDetail
|
||
if query.needsDetailFields {
|
||
detailsByID = h.batchListItemDetails(r.Context(), session, contentIDs, libraryIDPtr(query.parentLibraryID))
|
||
}
|
||
|
||
items := make([]baseItemDTO, 0, len(result.Items))
|
||
for _, item := range result.Items {
|
||
if query.needsDetailFields {
|
||
if detail, ok := detailsByID[item.ContentID]; ok && detail != 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)
|
||
}
|
||
|
||
// Ids= may also reference collections (BoxSet route IDs handed out by the
|
||
// collections listing); append their DTOs so clients can re-hydrate them.
|
||
boxSets, err := h.boxSetsByIDs(r.Context(), session, query.specificCollectionIDs)
|
||
if err != nil {
|
||
writeCompatUpstreamError(w, err)
|
||
return
|
||
}
|
||
items = append(items, boxSets...)
|
||
|
||
// Ids= may also reference the synthetic Collections view; prepend its DTO so
|
||
// clients re-hydrate the CollectionFolder the same way as a real library.
|
||
if idsRequestCollectionsView(r) {
|
||
items = append([]baseItemDTO{h.collectionsView()}, items...)
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
// Fast path: when the native sections subsystem is wired, serve Continue
|
||
// Watching through the hard-capped continue-watching fetcher instead of
|
||
// loadProgressPage's unbounded scan. loadProgressPage re-paginates the
|
||
// entire in-progress list (and re-derives superseded/completed history per
|
||
// batch) whenever EnableTotalRecordCount=true or the visible count is under
|
||
// the limit; the fetcher caps scanning at continueProgressMaxScanned and
|
||
// filters via the indexed home-dismissal index. The native "watching" scope
|
||
// returns movies+episodes, so we take the fast path whenever the request is
|
||
// unconstrained OR asks for Movie/Episode (which covers the dominant traffic
|
||
// — clients overwhelmingly send IncludeItemTypes including Movie+Episode);
|
||
// loadResumeViaSections applies the IncludeItemTypes set as a post-filter so
|
||
// narrower requests are still honored. Requests scoped only to types the
|
||
// watching scope cannot serve (e.g. Series/Season-only) fall through to
|
||
// loadProgressPage below.
|
||
if h.sectionsFetcher != nil && (len(typeSet) == 0 || typeSet["episode"] || typeSet["movie"]) {
|
||
items, total, err := h.loadResumeViaSections(r.Context(), session, query, typeSet)
|
||
if err == nil {
|
||
applyImageTypeLimit(items, query.imageTypeLimit)
|
||
writeJSON(w, http.StatusOK, queryResultDTO{
|
||
Items: items,
|
||
TotalRecordCount: total,
|
||
StartIndex: query.startIndex,
|
||
})
|
||
return
|
||
}
|
||
slog.WarnContext(r.Context(), "jellycompat: resume via sections failed, falling back to progress scan", "component", "jellycompat", "error", err)
|
||
}
|
||
|
||
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,
|
||
})
|
||
}
|
||
|
||
// maxResumeItems caps how many Continue Watching entries the sections fast path
|
||
// returns per request, regardless of the client's requested limit. Resume rows
|
||
// are display surfaces, not bulk exports; clamping keeps the capped fetcher's
|
||
// scan bounded and predictable under load.
|
||
const maxResumeItems = 50
|
||
|
||
// resumeScanMaxRows bounds how many progress rows loadProgressPage will scan in
|
||
// a single request. Without a cap, a client sending EnableTotalRecordCount=true
|
||
// (or a Series/Season-only request, which matches no leaf in-progress row) forces
|
||
// the in-progress loop to page through the profile's entire history — an
|
||
// O(history) scan that reached tens of seconds for heavy watchers.
|
||
//
|
||
// In the common case the loop exits far earlier (the page fills from the first
|
||
// batch or two), so this cap only engages for pathological/empty-match requests;
|
||
// its value just bounds that worst case. 300 gives ample headroom to fill a
|
||
// ~20-item Continue Watching page even when the great majority of recent rows are
|
||
// dismissed/superseded, while keeping the wasted scan on empty-match requests
|
||
// small. Progress rows are recency-ordered, so the first resumeScanMaxRows cover
|
||
// every realistically-visible entry; beyond the cap the returned total becomes a
|
||
// clamped lower bound (the "+" suffix behavior clients already tolerate).
|
||
const resumeScanMaxRows = 300
|
||
|
||
// loadResumeViaSections serves Continue Watching through the native
|
||
// continue-watching fetcher. The fetcher applies the same dismissal and
|
||
// superseded-episode filtering as FilterResumeProgress, so visible parity with
|
||
// loadProgressPage is preserved, but scanning is hard-capped. Next-up injection
|
||
// is suppressed (Resume must stay in-progress-only) and the resolved items are
|
||
// re-hydrated through the shared progress path so episode DTOs keep their
|
||
// SeriesId/IndexNumber/SeasonId targeting — metadata the section list mapping
|
||
// (which discards SectionItemMeta) would drop.
|
||
func (h *ItemsHandler) loadResumeViaSections(ctx context.Context, session *Session, query itemsQuery, typeSet map[string]bool) ([]baseItemDTO, int, error) {
|
||
pageSize := query.limit
|
||
if pageSize <= 0 {
|
||
pageSize = maxResumeItems
|
||
}
|
||
if pageSize > maxResumeItems {
|
||
pageSize = maxResumeItems
|
||
}
|
||
|
||
// FetchOne always scans from offset 0; over-fetch by StartIndex so a deep
|
||
// page can be sliced out of the capped result (Resume is normally offset 0).
|
||
// Clamp the fetch to maxResumeItems so a large client-supplied StartIndex
|
||
// can't inflate the fetcher's scan past the cap; a StartIndex at or beyond
|
||
// the cap can never land on a visible row, so return empty up front.
|
||
if query.startIndex >= maxResumeItems {
|
||
return []baseItemDTO{}, 0, nil
|
||
}
|
||
fetchLimit := pageSize + query.startIndex
|
||
if fetchLimit > maxResumeItems {
|
||
fetchLimit = maxResumeItems
|
||
}
|
||
|
||
filter := h.resolveAccessFilter(ctx, session)
|
||
resolved := sections.ResolvedSection{
|
||
ID: "compat-resume",
|
||
SectionType: sections.SectionContinueWatching,
|
||
Title: "Continue Watching",
|
||
ItemLimit: fetchLimit,
|
||
Config: sections.ContinueTypeConfig(sections.ContinueTypeWatching),
|
||
SuppressNextUp: true,
|
||
}
|
||
|
||
result, err := h.sectionsFetcher.FetchOne(ctx, resolved, nil, filter.AllowedLibraryIDs, session.StreamAppUserID, session.ProfileID, filter)
|
||
if err != nil {
|
||
return nil, 0, err
|
||
}
|
||
|
||
entries := make([]upstreamProgress, 0, len(result.Items))
|
||
for _, mi := range result.Items {
|
||
if mi == nil {
|
||
continue
|
||
}
|
||
meta := result.ItemMeta[mi.ContentID]
|
||
// Defensive: only in-progress resume points belong on the Resume row.
|
||
// SuppressNextUp already prevents next-up injection upstream; this
|
||
// guards against any other source slipping in.
|
||
if meta.ItemSource != "" && meta.ItemSource != "in_progress" {
|
||
continue
|
||
}
|
||
// Honor IncludeItemTypes the same way loadProgressPage does. The
|
||
// watching scope only emits movies/episodes, so this trims to a narrower
|
||
// request (e.g. Movie-only); applying it before the StartIndex slice and
|
||
// the page cap keeps paging correct.
|
||
if len(typeSet) > 0 && !typeSet[strings.ToLower(mi.Type)] {
|
||
continue
|
||
}
|
||
entry := upstreamProgress{MediaItemID: mi.ContentID}
|
||
if meta.PositionSeconds != nil {
|
||
entry.PositionSeconds = *meta.PositionSeconds
|
||
}
|
||
if meta.DurationSeconds != nil {
|
||
entry.DurationSeconds = *meta.DurationSeconds
|
||
}
|
||
if meta.ProgressUpdatedAt != nil {
|
||
entry.UpdatedAt = *meta.ProgressUpdatedAt
|
||
}
|
||
entries = append(entries, entry)
|
||
}
|
||
|
||
// Slice out the requested page from the capped, ordered result.
|
||
if query.startIndex > 0 {
|
||
if query.startIndex >= len(entries) {
|
||
entries = nil
|
||
} else {
|
||
entries = entries[query.startIndex:]
|
||
}
|
||
}
|
||
if len(entries) > pageSize {
|
||
entries = entries[:pageSize]
|
||
}
|
||
|
||
hydrated, err := h.hydrateProgressItems(ctx, session, entries, query.requestedFields, nil)
|
||
if err != nil {
|
||
return nil, 0, err
|
||
}
|
||
dtos := h.finishProgressPage(ctx, session, hydrated, query, nil)
|
||
return dtos, len(dtos), nil
|
||
}
|
||
|
||
type progressHydratedItem struct {
|
||
itemType string
|
||
contentID string
|
||
isFavorite bool
|
||
entry upstreamProgress
|
||
target *compatEpisodeTarget
|
||
dto baseItemDTO
|
||
}
|
||
|
||
// maxDetailUpgrades caps how many returned-page items get re-mapped through
|
||
// the per-item detail path when the client requested detail-level Fields.
|
||
// Entries past the cap keep their stubbed list-level DTO. Guardrail against
|
||
// absurd client limits — normal Resume/NextUp requests are 20-40 items.
|
||
const maxDetailUpgrades = 100
|
||
|
||
// sortedTypeSet returns the (already lowercased) keys of a type set in a stable
|
||
// order so the SQL pre-filter binds a deterministic types array.
|
||
func sortedTypeSet(typeSet map[string]bool) []string {
|
||
keys := make([]string, 0, len(typeSet))
|
||
for k := range typeSet {
|
||
keys = append(keys, k)
|
||
}
|
||
sort.Strings(keys)
|
||
return keys
|
||
}
|
||
|
||
func (h *ItemsHandler) loadProgressPage(ctx context.Context, session *Session, status string, query itemsQuery, typeSet map[string]bool, libraryID *int) ([]baseItemDTO, int, error) {
|
||
// Resume views hide dismissed and superseded entries, so the visible list
|
||
// is sparser than the raw store list. The raw-offset fast path below is
|
||
// only safe for the first page; deeper StartIndex values must go through
|
||
// the scan-from-zero branch, which paginates over visible entries.
|
||
resumeFiltered := status == "in_progress"
|
||
if len(typeSet) == 0 && libraryID == nil && !query.enableTotalRecordCount && (!resumeFiltered || query.startIndex == 0) {
|
||
batchSize := min(max(query.limit*2, 48), 200)
|
||
if batchSize <= 0 {
|
||
batchSize = 48
|
||
}
|
||
|
||
result := make([]progressHydratedItem, 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
|
||
}
|
||
rawCount := len(progressEntries)
|
||
if resumeFiltered {
|
||
progressEntries, err = h.userData.FilterResumeProgress(ctx, session, progressEntries)
|
||
if err != nil {
|
||
return nil, 0, err
|
||
}
|
||
}
|
||
|
||
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)
|
||
}
|
||
if len(result) >= query.limit || rawCount < batchSize {
|
||
break
|
||
}
|
||
// Same bound as the general loop below: the resume fast path filters in
|
||
// memory via FilterResumeProgress, so a sparse visible set (heavy
|
||
// watcher with mostly dismissed/superseded recent rows) would otherwise
|
||
// page to the end of history without ever filling the page. This is the
|
||
// default Continue Watching shape and the fallback hit when the sections
|
||
// fetcher is unavailable, so it must be bounded too.
|
||
if resumeFiltered && offset+rawCount >= resumeScanMaxRows {
|
||
break
|
||
}
|
||
offset += rawCount
|
||
}
|
||
return h.finishProgressPage(ctx, session, result, query, libraryID), 0, nil
|
||
}
|
||
|
||
batchSize := min(max(query.limit*3, 48), 200)
|
||
if batchSize <= 0 {
|
||
batchSize = 48
|
||
}
|
||
|
||
// Push the type/library predicate into SQL for the completed (watched-items)
|
||
// path: the store filters before paging, so the scan reads only matching
|
||
// rows instead of the profile's entire completed history. The in-memory type
|
||
// check below and the library-scoped hydration stay as a correctness
|
||
// backstop (access/parental exclusions still apply). The in_progress path is
|
||
// deliberately left on ListProgress — its FilterResumeProgress hiding makes
|
||
// the visible set sparser than any SQL pre-filter could express.
|
||
useFilteredFetch := status == "completed" && (len(typeSet) > 0 || libraryID != nil)
|
||
var filteredTypes []string
|
||
if useFilteredFetch {
|
||
filteredTypes = sortedTypeSet(typeSet)
|
||
}
|
||
fetchProgress := func(off int) ([]upstreamProgress, error) {
|
||
if useFilteredFetch {
|
||
return h.userData.ListProgressFiltered(ctx, session, status, filteredTypes, libraryID, batchSize, off)
|
||
}
|
||
return h.userData.ListProgress(ctx, session, status, batchSize, off)
|
||
}
|
||
|
||
items := make([]progressHydratedItem, 0, query.limit)
|
||
matchedCount := 0
|
||
offset := 0
|
||
|
||
for {
|
||
progressEntries, err := fetchProgress(offset)
|
||
if err != nil {
|
||
return nil, 0, err
|
||
}
|
||
if len(progressEntries) == 0 {
|
||
break
|
||
}
|
||
rawCount := len(progressEntries)
|
||
if resumeFiltered {
|
||
progressEntries, err = h.userData.FilterResumeProgress(ctx, session, progressEntries)
|
||
if err != nil {
|
||
return nil, 0, err
|
||
}
|
||
}
|
||
|
||
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)
|
||
}
|
||
matchedCount++
|
||
}
|
||
|
||
offset += rawCount
|
||
if rawCount < batchSize {
|
||
break
|
||
}
|
||
if !query.enableTotalRecordCount && len(items) >= query.limit {
|
||
break
|
||
}
|
||
// Bound the resume scan: never page through more than resumeScanMaxRows of
|
||
// history in one request, even when the visible (post-filter) page stays
|
||
// sparse. The in-progress path filters in memory via FilterResumeProgress,
|
||
// so a heavy watcher whose recent rows are mostly dismissed/superseded
|
||
// could otherwise keep paging to the end of history without ever filling
|
||
// the page. Progress rows are recency-ordered, so the most recent
|
||
// resumeScanMaxRows already cover every realistically-visible Continue
|
||
// Watching entry; beyond the cap we stop and return what we have (and, for
|
||
// EnableTotalRecordCount, a clamped lower-bound total).
|
||
//
|
||
// Gated on resumeFiltered so the completed (watched-items) path is exempt:
|
||
// there the SQL pre-filter already bounds the scan and clients paginate on
|
||
// an exact TotalRecordCount, so capping would underreport the total and
|
||
// drop deep StartIndex pages.
|
||
if resumeFiltered && offset >= resumeScanMaxRows {
|
||
break
|
||
}
|
||
}
|
||
|
||
total := 0
|
||
if query.enableTotalRecordCount {
|
||
total = matchedCount
|
||
}
|
||
return h.finishProgressPage(ctx, session, items, query, libraryID), total, nil
|
||
}
|
||
|
||
// finishProgressPage upgrades the returned page to detail-mapped DTOs when
|
||
// the client asked for detail-level Fields, then unwraps to the DTO slice.
|
||
func (h *ItemsHandler) finishProgressPage(ctx context.Context, session *Session, page []progressHydratedItem, query itemsQuery, libraryID *int) []baseItemDTO {
|
||
h.upgradeProgressPageToDetail(ctx, session, page, query, libraryID)
|
||
dtos := make([]baseItemDTO, 0, len(page))
|
||
for _, item := range page {
|
||
dtos = append(dtos, item.dto)
|
||
}
|
||
return dtos
|
||
}
|
||
|
||
// upgradeProgressPageToDetail re-maps the returned page of progress rows
|
||
// through the detail path when the client requested detail-level Fields
|
||
// (e.g. MediaSources). The scan loop in loadProgressPage stays on list-level
|
||
// data so type filtering over long in-progress lists never fans out — the
|
||
// per-ENTRY fanout was the 38s timeout in error-report-2026-05-08.md §6 —
|
||
// but the returned page is bounded by the request limit, so this costs the
|
||
// same as HandleLatest's existing per-item detail path. The listing's
|
||
// MediaSources ARE load-bearing for some clients: Infuse and SenPlayer build
|
||
// their Continue Watching rows from them and discard entries served only the
|
||
// list-level stub.
|
||
func (h *ItemsHandler) upgradeProgressPageToDetail(ctx context.Context, session *Session, page []progressHydratedItem, query itemsQuery, libraryID *int) {
|
||
if !query.needsDetailFields {
|
||
return
|
||
}
|
||
limit := len(page)
|
||
if limit > maxDetailUpgrades {
|
||
limit = maxDetailUpgrades
|
||
}
|
||
upgradeIDs := make([]string, 0, limit)
|
||
for i := 0; i < limit; i++ {
|
||
upgradeIDs = append(upgradeIDs, page[i].contentID)
|
||
}
|
||
detailsByID := h.batchListItemDetails(ctx, session, upgradeIDs, libraryID)
|
||
for i := 0; i < limit; i++ {
|
||
detail, ok := detailsByID[page[i].contentID]
|
||
if !ok || detail == nil {
|
||
continue
|
||
}
|
||
h.rememberDetailImages(*detail)
|
||
dto := h.mapper.itemFromDetailWithFields(*detail, page[i].isFavorite, &page[i].entry, query.requestedFields)
|
||
if page[i].target != nil {
|
||
h.applyCompatEpisodeTarget(&dto, *page[i].target)
|
||
}
|
||
page[i].dto = dto
|
||
}
|
||
}
|
||
|
||
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.fetchCompatEpisodeTargetsByContentIDsWithDurations(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),
|
||
contentID: entry.MediaItemID,
|
||
isFavorite: favorites[entry.MediaItemID],
|
||
entry: entry,
|
||
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",
|
||
contentID: entry.MediaItemID,
|
||
isFavorite: favorites[entry.MediaItemID],
|
||
entry: entry,
|
||
target: &target,
|
||
dto: dto,
|
||
})
|
||
}
|
||
}
|
||
|
||
return result, nil
|
||
}
|
||
|
||
func (h *ItemsHandler) resolveAccessFilter(ctx context.Context, session *Session) catalog.AccessFilter {
|
||
if h.accessFilter != nil {
|
||
return withCompatAccessExclusions(h.accessFilter(ctx, session.StreamAppUserID, session.ProfileID))
|
||
}
|
||
return withCompatAccessExclusions(catalog.AccessFilter{})
|
||
}
|
||
|
||
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) {
|
||
if target.SeasonID != "" && h.codec != nil {
|
||
encodedSeasonID := h.codec.EncodeStringID(EncodedIDSeason, target.SeasonID)
|
||
if dto.SeasonID == "" {
|
||
dto.SeasonID = encodedSeasonID
|
||
}
|
||
if dto.ParentID == "" {
|
||
dto.ParentID = encodedSeasonID
|
||
}
|
||
}
|
||
if dto.SeasonName == "" && target.SeasonName != "" {
|
||
dto.SeasonName = target.SeasonName
|
||
}
|
||
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 compactEpisodeModels(episodes []*models.Episode) []*models.Episode {
|
||
write := 0
|
||
for _, episode := range episodes {
|
||
if episode == nil {
|
||
continue
|
||
}
|
||
episodes[write] = episode
|
||
write++
|
||
}
|
||
return episodes[:write]
|
||
}
|
||
|
||
func trimEpisodesFromStartItem(episodes []*models.Episode, rawStartItemID string, codec *ResourceIDCodec) []*models.Episode {
|
||
rawStartItemID = strings.TrimSpace(rawStartItemID)
|
||
if rawStartItemID == "" {
|
||
return episodes
|
||
}
|
||
if codec == nil {
|
||
return []*models.Episode{}
|
||
}
|
||
startContentID, err := decodeItemID(codec, rawStartItemID)
|
||
if err != nil || startContentID == "" {
|
||
return []*models.Episode{}
|
||
}
|
||
for i, episode := range episodes {
|
||
if episode != nil && episode.ContentID == startContentID {
|
||
return episodes[i:]
|
||
}
|
||
}
|
||
return []*models.Episode{}
|
||
}
|
||
|
||
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, errUpstreamReplaced) {
|
||
writeError(w, http.StatusConflict, "Conflict", "Playback session changed concurrently; retry the request")
|
||
return
|
||
}
|
||
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
|
||
}
|
||
if errors.Is(err, playback.ErrTranscodingDisabled) {
|
||
writeError(w, http.StatusForbidden, "TranscodingDisabled", "Transcoding is disabled for your user")
|
||
return
|
||
}
|
||
if errors.Is(err, playback.ErrAudioTranscodingDisabled) {
|
||
writeError(w, http.StatusForbidden, "AudioTranscodingDisabled", "Audio transcoding is disabled for your user")
|
||
return
|
||
}
|
||
if errors.Is(err, playback.ErrPlaybackNotAllowed) {
|
||
writeError(w, http.StatusForbidden, "PlaybackNotAllowed", "Playback denied by server policy")
|
||
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
|
||
}
|
||
}
|