package handlers import ( "context" "errors" "fmt" "net/http" "strconv" "strings" "time" "github.com/go-chi/chi/v5" "log/slog" "github.com/Silo-Server/silo-server/internal/access" apimw "github.com/Silo-Server/silo-server/internal/api/middleware" "github.com/Silo-Server/silo-server/internal/auth" "github.com/Silo-Server/silo-server/internal/catalog" evt "github.com/Silo-Server/silo-server/internal/events" "github.com/Silo-Server/silo-server/internal/metadata" "github.com/Silo-Server/silo-server/internal/models" "github.com/Silo-Server/silo-server/internal/overlays" "github.com/Silo-Server/silo-server/internal/ratelimit" "github.com/Silo-Server/silo-server/internal/sections" "github.com/Silo-Server/silo-server/internal/userstore" "github.com/Silo-Server/silo-server/internal/watchstate" "github.com/Silo-Server/silo-server/internal/watchsync" ) // EpisodeFileProvider retrieves media files linked to an episode. type EpisodeFileProvider interface { GetByContentID(ctx context.Context, contentID string) ([]*models.MediaFile, error) GetByEpisodeID(ctx context.Context, episodeID string) ([]*models.MediaFile, error) ListByContentIDs(ctx context.Context, contentIDs []string) (map[string][]*models.MediaFile, error) } type batchEpisodeFileProvider interface { ListByEpisodeIDs(ctx context.Context, episodeIDs []string) (map[string][]*models.MediaFile, error) } type overlayFileProvider interface { ListOverlayFilesByContentIDs(ctx context.Context, contentIDs []string) (map[string][]*models.MediaFile, error) ListOverlayFilesByEpisodeIDs(ctx context.Context, episodeIDs []string) (map[string][]*models.MediaFile, error) } type MetadataRefreshRequester interface { RequestStaleMetadataRefresh(ctx context.Context, targetType, contentID string) error } // TrailerRefreshRequester starts a viewer-triggered trailer fetch for one item // and reports whether it was queued, in cooldown, or disabled by every // containing library. Implemented by *metadata.MetadataService. type TrailerRefreshRequester interface { RequestTrailersRefresh(ctx context.Context, contentID string) (metadata.TrailerRefreshOutcome, error) } // trailerItemAccess resolves and authorizes the item behind the trailer // refresh route. The concrete *catalog.ItemRepository satisfies it; the // interface keeps the handler testable without a database. type trailerItemAccess interface { GetByID(ctx context.Context, contentID string) (*models.MediaItem, error) EnsureAccessible(ctx context.Context, contentID string, filter catalog.AccessFilter) error } // trailerSeasonLookup and trailerEpisodeLookup resolve the content IDs that are // not media_items rows. Season and episode detail pages carry their own IDs, so // without these a client that asks for their trailers would get a 404 that // looks like a missing item instead of the contracted "wrong type" answer. type trailerSeasonLookup interface { GetByID(ctx context.Context, contentID string) (*models.Season, error) } type trailerEpisodeLookup interface { GetByID(ctx context.Context, contentID string) (*models.Episode, error) } type LocalWatchEventDispatcher interface { HandleLocalWatchEvent(ctx context.Context, event watchsync.LocalWatchEvent) error } type EbookReaderProgressLister interface { ListByContentIDs(ctx context.Context, userID int, profileID string, contentIDs []string) (map[string]EbookReaderProgress, error) } // ItemsHandler handles browse, search, item detail, and series endpoints. type ItemsHandler struct { browseRepo *catalog.BrowseRepository itemRepo *catalog.ItemRepository episodeRepo *catalog.EpisodeRepository seasonRepo *catalog.SeasonRepository ratingsRepo ratingsRepository catalogResolver *catalog.CatalogResolver fileRepo EpisodeFileProvider detailSvc *catalog.DetailService storeProvider userstore.UserStoreProvider watchState *watchstate.Service profileStaler ProfileStaler profileRefreshRequester ProfileRefreshRequester metadataRefreshRequester MetadataRefreshRequester trailerRefreshRequester TrailerRefreshRequester trailerItemAccess trailerItemAccess trailerSeasonLookup trailerSeasonLookup trailerEpisodeLookup trailerEpisodeLookup trailerRefreshLimiter ratelimit.RateLimiter localWatchDispatcher LocalWatchEventDispatcher ebookProgressStore EbookReaderProgressLister ebookReadStateStore EbookReadStateStore EventsHub *evt.Hub UserRepo *auth.UserRepository } // NewItemsHandler creates a new ItemsHandler. func NewItemsHandler( browseRepo *catalog.BrowseRepository, itemRepo *catalog.ItemRepository, episodeRepo *catalog.EpisodeRepository, seasonRepo *catalog.SeasonRepository, ratingsRepo ratingsRepository, fileRepo EpisodeFileProvider, storeProvider userstore.UserStoreProvider, detailSvc *catalog.DetailService, providerIDRepo *catalog.ProviderIDRepository, ) *ItemsHandler { return &ItemsHandler{ browseRepo: browseRepo, itemRepo: itemRepo, episodeRepo: episodeRepo, seasonRepo: seasonRepo, ratingsRepo: ratingsRepo, catalogResolver: catalog.NewCatalogResolver(browseRepo, itemRepo). WithEpisodeRepository(episodeRepo). WithUserStoreProvider(storeProvider), fileRepo: fileRepo, storeProvider: storeProvider, watchState: watchstate.NewService(storeProvider).WithStableIdentityResolver( watchstate.NewStableIdentityResolver(itemRepo, episodeRepo, providerIDRepo), ), detailSvc: detailSvc, } } // SetCompletionObserver wires an optional observer notified when a watch // completes (used to auto-remove fully-watched items from the watchlist). func (h *ItemsHandler) SetCompletionObserver(obs watchstate.CompletionObserver) { if h == nil || h.watchState == nil || obs == nil { return } h.watchState.WithCompletionObserver(obs) } // SetProfileStaler configures an optional staleness trigger for taste profiles. func (h *ItemsHandler) SetProfileStaler(ps ProfileStaler) { h.profileStaler = ps } // SetProfileRefreshRequester configures an optional background refresh queue for taste profiles. func (h *ItemsHandler) SetProfileRefreshRequester(requester ProfileRefreshRequester) { h.profileRefreshRequester = requester } func (h *ItemsHandler) SetMetadataRefreshRequester(requester MetadataRefreshRequester) { h.metadataRefreshRequester = requester } // SetTrailerRefreshLimiter wires the process's configured rate limiter into the // trailer fetch action, so the per-user budget is shared across instances when // the deployment runs the Redis backend. A private in-memory limiter would give // each instance its own allowance for the same user, and the per-item database // cooldown cannot make up the difference — it bounds one item, while this // budget bounds how many distinct items a user can start refreshes for. // // Call before SetTrailerRefreshRequester, which falls back to a private // in-memory limiter when none is set (single-instance deployments, and any // deployment with rate limiting turned off entirely). func (h *ItemsHandler) SetTrailerRefreshLimiter(limiter ratelimit.RateLimiter) { if h == nil || limiter == nil { return } h.trailerRefreshLimiter = limiter } // SetTrailerRefreshRequester wires the viewer-facing trailer fetch action. // Leaving it unset disables the route's behavior (503), so the router only // registers it when the metadata service is available. func (h *ItemsHandler) SetTrailerRefreshRequester(requester TrailerRefreshRequester) { if h == nil { return } h.trailerRefreshRequester = requester if h.trailerRefreshLimiter == nil { h.trailerRefreshLimiter = ratelimit.NewMemoryLimiter() } if h.trailerItemAccess == nil && h.itemRepo != nil { h.trailerItemAccess = h.itemRepo } // Seasons and episodes are not media_items rows, so the route needs these // to tell "this ID is an episode" from "no such content". if h.trailerSeasonLookup == nil && h.seasonRepo != nil { h.trailerSeasonLookup = h.seasonRepo } if h.trailerEpisodeLookup == nil && h.episodeRepo != nil { h.trailerEpisodeLookup = h.episodeRepo } } func (h *ItemsHandler) SetCatalogSearchProvider(provider catalog.CatalogSearchProvider) { if h == nil || h.catalogResolver == nil || provider == nil { return } h.catalogResolver.WithSearchProvider(provider) } func (h *ItemsHandler) SetLocalWatchEventDispatcher(dispatcher LocalWatchEventDispatcher) { h.localWatchDispatcher = dispatcher } func (h *ItemsHandler) SetEbookReaderProgressStore(store EbookReaderProgressReadWriter) { h.ebookProgressStore = store h.ebookReadStateStore = store } func (h *ItemsHandler) maybeRequestStaleDetailMetadataRefresh(ctx context.Context, detail *catalog.ItemDetail) { if h == nil || detail == nil || h.metadataRefreshRequester == nil { return } switch detail.Type { case "episode": if h.episodeRepo == nil { return } episode, err := h.episodeRepo.GetByID(ctx, detail.ContentID) if err == nil { h.maybeRequestStaleEpisodeMetadataRefresh(ctx, episode) } case "season": if h.episodeRepo == nil { return } episodes, err := h.episodeRepo.ListBySeasonID(ctx, detail.ContentID) if err == nil { h.maybeRequestStaleSeasonMetadataRefresh(ctx, detail.ContentID, episodes) } case "series": if h.itemRepo == nil { return } item, err := h.itemRepo.GetByID(ctx, detail.ContentID) if err == nil && item != nil && item.EpisodeMetadataIncomplete { h.requestStaleMetadataRefresh(ctx, "item", item.ContentID) } } } func (h *ItemsHandler) maybeRequestStaleEpisodeMetadataRefresh(ctx context.Context, episode *models.Episode) { if h == nil || h.metadataRefreshRequester == nil || episode == nil { return } if metadata.EpisodeHasActionableMetadataDebt(episode, time.Now()) { h.requestStaleMetadataRefresh(ctx, "episode", episode.ContentID) } } func (h *ItemsHandler) maybeRequestStaleSeasonMetadataRefresh(ctx context.Context, seasonID string, episodes []*models.Episode) { if h == nil || h.metadataRefreshRequester == nil || strings.TrimSpace(seasonID) == "" { return } now := time.Now() for _, episode := range episodes { if metadata.EpisodeHasActionableMetadataDebt(episode, now) { h.requestStaleMetadataRefresh(ctx, "season", seasonID) return } } } func (h *ItemsHandler) requestStaleMetadataRefresh(ctx context.Context, targetType, contentID string) { if h == nil || h.metadataRefreshRequester == nil || strings.TrimSpace(contentID) == "" { return } if err := h.metadataRefreshRequester.RequestStaleMetadataRefresh(ctx, targetType, contentID); err != nil { slog.WarnContext(ctx, "catalog: failed to request stale metadata refresh", "component", "api", "target_type", targetType, "content_id", contentID, "error", err) } } // --- Response types --- // itemListResponse is the shape of a single item in browse/search list responses. type itemListResponse struct { ContentID string `json:"content_id"` Type string `json:"type"` Title string `json:"title"` SeriesTitle string `json:"series_title,omitempty"` SeasonNumber *int `json:"season_number,omitempty"` EpisodeNumber *int `json:"episode_number,omitempty"` Year int `json:"year,omitempty"` Runtime int `json:"runtime,omitempty"` Genres []string `json:"genres"` Keywords []string `json:"keywords"` Studios []string `json:"studios,omitempty"` Networks []string `json:"networks,omitempty"` ContentRating string `json:"content_rating,omitempty"` Status string `json:"status"` ShowStatus string `json:"show_status,omitempty"` RatingIMDB *float64 `json:"rating_imdb,omitempty"` RatingTMDB *float64 `json:"rating_tmdb,omitempty"` RatingRTCritic *int `json:"rating_rt_critic,omitempty"` RatingRTAudience *int `json:"rating_rt_audience,omitempty"` OriginalLanguage string `json:"original_language,omitempty"` Overview string `json:"overview,omitempty"` PosterURL string `json:"poster_url,omitempty"` PosterThumbhash string `json:"poster_thumbhash,omitempty"` BackdropURL string `json:"backdrop_url,omitempty"` BackdropThumbhash string `json:"backdrop_thumbhash,omitempty"` ReleaseDate *string `json:"release_date,omitempty"` LastAirDate *string `json:"last_air_date,omitempty"` AddedAt *time.Time `json:"added_at,omitempty"` MangaChapterCount *int `json:"manga_chapter_count,omitempty"` MangaVolumeCount *int `json:"manga_volume_count,omitempty"` OverlaySummary *models.OverlaySummary `json:"overlay_summary,omitempty"` SortMetrics *sortMetricsResponse `json:"sort_metrics,omitempty"` UserState *itemUserStateResponse `json:"user_state,omitempty"` WorkID string `json:"work_id,omitempty"` WorkTitle string `json:"work_title,omitempty"` WorkFormats []catalog.WorkFormatSummary `json:"work_formats,omitempty"` } type sortMetricsResponse struct { ReleaseDate *string `json:"release_date,omitempty"` RuntimeMinutes *int `json:"runtime_minutes,omitempty"` Resolution string `json:"resolution,omitempty"` BitrateKbps *int `json:"bitrate_kbps,omitempty"` ProgressRatio *float64 `json:"progress_ratio,omitempty"` ViewedAt string `json:"viewed_at,omitempty"` PlayCount *int `json:"play_count,omitempty"` Author string `json:"author,omitempty"` Narrator string `json:"narrator,omitempty"` SeriesName string `json:"series_name,omitempty"` } type itemListImageURLs struct { posterURL string backdropURL string } // browseResponse is the paginated response for the /items endpoint. type browseResponse struct { Total int `json:"total"` TotalExact bool `json:"total_exact"` HasMore bool `json:"has_more"` Items []itemListResponse `json:"items"` } type itemFiltersResponse struct { Genres []string `json:"genres"` Studios []string `json:"studios"` Networks []string `json:"networks"` Countries []string `json:"countries"` ContentRatings []string `json:"content_ratings"` } // seasonResponse is the shape of a season in API responses. type seasonResponse struct { ContentID string `json:"content_id"` SeasonNumber int `json:"season_number"` IsSpecials bool `json:"is_specials,omitempty"` Title string `json:"title"` Overview string `json:"overview,omitempty"` AirDate string `json:"air_date,omitempty"` EpisodeCount int `json:"episode_count"` PosterURL string `json:"poster_url,omitempty"` PosterThumbhash string `json:"poster_thumbhash,omitempty"` UserData *catalog.SeasonUserData `json:"user_data,omitempty"` } // seasonsResponse wraps the seasons list for JSON serialization. type seasonsResponse struct { Seasons []seasonResponse `json:"seasons"` } // seasonDetailResponse wraps a single season for JSON serialization. type seasonDetailResponse struct { Season seasonResponse `json:"season"` } // episodesListResponse wraps the episodes list for JSON serialization. type episodesListResponse struct { Episodes []episodeResponse `json:"episodes"` } // episodeFileResponse represents a file version available for an episode. type episodeFileResponse struct { FileID int `json:"file_id"` Resolution string `json:"resolution,omitempty"` CodecVideo string `json:"codec_video,omitempty"` HDR bool `json:"hdr"` AudioChannels int `json:"audio_channels,omitempty"` Container string `json:"container,omitempty"` FileSize int64 `json:"file_size"` } // episodeResponse is the shape of an episode in API responses. type episodeResponse struct { ContentID string `json:"content_id"` SeasonNumber int `json:"season_number"` EpisodeNumber int `json:"episode_number"` Title string `json:"title"` Overview string `json:"overview,omitempty"` AirDate string `json:"air_date,omitempty"` Runtime int `json:"runtime"` ImdbID string `json:"imdb_id,omitempty"` TmdbID string `json:"tmdb_id,omitempty"` TvdbID string `json:"tvdb_id,omitempty"` StillURL string `json:"still_url,omitempty"` StillThumbhash string `json:"still_thumbhash,omitempty"` UserData *catalog.SeasonUserData `json:"user_data,omitempty"` Files []episodeFileResponse `json:"files,omitempty"` OverlaySummary *models.OverlaySummary `json:"overlay_summary,omitempty"` } type episodeImageFallback struct { Path string Thumbhash string } type watchedStateResponse struct { ContentID string `json:"content_id"` Type string `json:"type"` AffectedCount int `json:"affected_count"` Played bool `json:"played"` } // --- Handler methods --- // HandleGetItems handles GET /items with filtering, sorting, and pagination. func (h *ItemsHandler) HandleGetItems(w http.ResponseWriter, r *http.Request) { writeDeprecatedReadHeaders(w, "/api/v1/catalog") if values, ok := buildLegacyItemsCatalogValues(r.URL.Query()); ok && h.catalogResolver != nil { h.writeCatalogBrowseResponse(w, r, values) return } writeError(w, http.StatusInternalServerError, "internal_error", "Catalog is not configured") } // HandleGetLatestItems handles GET /items/latest — shortcut for sort=created_at&order=desc. func (h *ItemsHandler) HandleGetLatestItems(w http.ResponseWriter, r *http.Request) { writeDeprecatedReadHeaders(w, "/api/v1/catalog") if h.catalogResolver != nil { h.writeCatalogBrowseResponse(w, r, buildLegacyLatestCatalogValues(r.URL.Query())) return } writeError(w, http.StatusInternalServerError, "internal_error", "Catalog is not configured") } // HandleGetItemFilters handles GET /items/filters for distinct browse values. func (h *ItemsHandler) HandleGetItemFilters(w http.ResponseWriter, r *http.Request) { writeDeprecatedReadHeaders(w, "/api/v1/catalog/filters") if values, ok := buildLegacyItemsCatalogValues(r.URL.Query()); ok && h.catalogResolver != nil { h.writeCatalogFiltersResponse(w, r, values) return } writeError(w, http.StatusInternalServerError, "internal_error", "Catalog is not configured") } // HandleGetItemDetail handles GET /items/{id}. func (h *ItemsHandler) HandleGetItemDetail(w http.ResponseWriter, r *http.Request) { writeDeprecatedReadHeaders(w, "/api/v1/catalog/items/{id}") h.catalogResourceHandler().HandleGetItemDetail(w, r) } // HandleGetWatchDetail handles GET /watch/{id}. func (h *ItemsHandler) HandleGetWatchDetail(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") if id == "" { writeError(w, http.StatusBadRequest, "bad_request", "Watch target ID is required") return } detail, err := h.detailSvc.GetWatchDetail(r.Context(), id, h.accessFilter(r)) if err != nil { switch { case catalog.IsWatchTargetNotPlayable(err): writeError(w, http.StatusBadRequest, "invalid_watch_target", "Content is not directly playable") return case isNotFound(err): writeError(w, http.StatusNotFound, "not_found", "Watch target not found") return default: writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get watch detail") return } } if detail.Type == "movie" || detail.Type == "episode" || detail.Type == "ebook" || detail.Type == "audiobook" { detail.UserData = h.getLeafUserData(r, detail.ContentID, detail.Type) applyEffectiveEditionPreference(detail.UserData, &detail.EffectiveVersionEditionKey) } writeJSON(w, http.StatusOK, detail) } // trailerRefreshRate bounds how often one user may trigger trailer fetches // across all items. The per-item cooldown enforced by the metadata service is // the real budget; this only keeps a misbehaving client from hammering the // endpoint (same shape as personRefreshRate). var trailerRefreshRate = ratelimit.Rate{ RequestsPerSecond: 10, RequestsPerMinute: 10, Burst: 10, } // trailerRefreshLimiterKey namespaces this action's per-user counter. The // limiter behind it is normally the process-wide one shared with the rate-limit // middleware, whose keys are namespaced the same way ("ip:", "key:"). func trailerRefreshLimiterKey(userID int) string { return "trailers:" + strconv.Itoa(userID) } // trailerRefreshResponse is the body of the trailer refresh endpoint. // NextAllowedAt is present only for the cooldown status. type trailerRefreshResponse struct { Status string `json:"status"` NextAllowedAt string `json:"next_allowed_at,omitempty"` } // trailerRefreshCapabilityResponse tells a client whether this server offers // the viewer-facing trailer fetch, following the per-subsystem convention // (/events/capability, /playback/capability, /ebooks/capability). // // Without it the only signal is a 404 from the POST, which a client cannot // tell apart from a missing item — and the route is registered conditionally // (it needs the metadata service to implement the optional interface), so // "this build has the feature" is not the same question as "this deployment // serves it". A client that finds refresh false should hide the action rather // than offer a button that cannot work. type trailerRefreshCapabilityResponse struct { SchemaVersion int `json:"schema_version"` // Refresh reports that POST /items/{id}/trailers/refresh is served here. Refresh bool `json:"refresh"` // CooldownSeconds is the per-item window between viewer-triggered // refreshes, so a client can explain the wait without having received a // cooldown response first. CooldownSeconds int `json:"cooldown_seconds"` // Statuses is every value the refresh endpoint's status field may take. Statuses []string `json:"statuses"` // SupportedTypes is the item types the action applies to; nothing else // carries remote videos, so clients should not show the action elsewhere. SupportedTypes []string `json:"supported_types"` } // HandleTrailerRefreshCapability reports whether the trailer refresh action is // available. GET /api/v1/items/trailers/capability. // // It answers even when the feature is unwired, because "refresh": false is the // answer in that case; the router registers it unconditionally so a client // never has to interpret a 404 on the probe itself. func (h *ItemsHandler) HandleTrailerRefreshCapability(w http.ResponseWriter, _ *http.Request) { enabled := h != nil && h.trailerRefreshRequester != nil && h.trailerItemAccess != nil resp := trailerRefreshCapabilityResponse{ SchemaVersion: 1, Refresh: enabled, Statuses: []string{}, SupportedTypes: []string{}, } if enabled { resp.CooldownSeconds = int(metadata.TrailerRefreshCooldown / time.Second) resp.Statuses = []string{ metadata.TrailerRefreshStatusQueued, metadata.TrailerRefreshStatusCooldown, metadata.TrailerRefreshStatusDisabled, } resp.SupportedTypes = []string{"movie", "series"} } writeJSON(w, http.StatusOK, resp) } // HandleRequestTrailersRefresh handles POST /api/v1/items/{id}/trailers/refresh: // any authenticated viewer with access to a movie or series may ask the server // to fetch its remote trailers, at most once per item per cooldown window. // // "cooldown" and "disabled" are expected client-rendered states, not errors, // so they answer 200; 429 stays reserved for the per-user limiter. The access // check runs before the metadata service is called so a caller who cannot see // the item can never consume its cooldown slot. A season or episode ID resolves // through its own table to 400 unsupported-type; only genuinely unknown content // answers 404. func (h *ItemsHandler) HandleRequestTrailersRefresh(w http.ResponseWriter, r *http.Request) { if h == nil || h.trailerRefreshRequester == nil || h.trailerItemAccess == nil { writeError(w, http.StatusServiceUnavailable, "unavailable", "Trailer refresh is not configured") return } contentID := strings.TrimSpace(chi.URLParam(r, "id")) if contentID == "" { writeError(w, http.StatusBadRequest, "bad_request", "Item ID is required") return } userID := apimw.GetUserID(r.Context()) if userID == 0 { writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required") return } if h.trailerRefreshLimiter != nil { // The limiter may be the process-wide one the middleware uses, so the // key is namespaced: an unprefixed user id would share a counter with // whatever else keys on the same string. result := h.trailerRefreshLimiter.Allow(r.Context(), trailerRefreshLimiterKey(userID), trailerRefreshRate) if !result.Allowed { if result.RetryAfter > 0 { w.Header().Set("Retry-After", strconv.Itoa(max(1, int(result.RetryAfter.Seconds())))) } writeError(w, http.StatusTooManyRequests, "rate_limited", "Too many trailer refresh requests") return } } target, err := h.resolveTrailerRefreshTarget(r.Context(), contentID) if err != nil { if errors.Is(err, catalog.ErrItemNotFound) { writeError(w, http.StatusNotFound, "not_found", "Item not found") return } slog.ErrorContext(r.Context(), "trailers: failed to look up item", "component", "api", "content_id", contentID, "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to authorize item") return } // Authorize against the series for a season or episode ID, exactly as the // on-view translation route does, so an unsupported-type answer never // leaks the existence of content the caller cannot see. if err := h.trailerItemAccess.EnsureAccessible(r.Context(), target.accessContentID, h.accessFilter(r)); err != nil { if errors.Is(err, catalog.ErrItemNotFound) { writeError(w, http.StatusNotFound, "not_found", "Item not found") return } slog.ErrorContext(r.Context(), "trailers: failed to authorize item", "component", "api", "content_id", contentID, "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to authorize item") return } // Only movie and series detail responses carry videos/extras, so anything // else — another media_items type, or a season/episode ID, which is not a // media_items row at all — is a client bug rather than an empty result. if !target.supportsTrailers { writeError(w, http.StatusBadRequest, "unsupported_type", "Trailers are only available for movies and series") return } outcome, err := h.trailerRefreshRequester.RequestTrailersRefresh(r.Context(), contentID) if err != nil { if errors.Is(err, catalog.ErrItemNotFound) { writeError(w, http.StatusNotFound, "not_found", "Item not found") return } slog.ErrorContext(r.Context(), "trailers: failed to request refresh", "component", "api", "content_id", contentID, "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to request trailers") return } switch outcome.Status { case metadata.TrailerRefreshStatusQueued: writeJSON(w, http.StatusAccepted, trailerRefreshResponse{Status: outcome.Status}) case metadata.TrailerRefreshStatusCooldown: resp := trailerRefreshResponse{Status: outcome.Status} if outcome.NextAllowedAt != nil { resp.NextAllowedAt = outcome.NextAllowedAt.UTC().Format(time.RFC3339) } writeJSON(w, http.StatusOK, resp) case metadata.TrailerRefreshStatusDisabled: writeJSON(w, http.StatusOK, trailerRefreshResponse{Status: outcome.Status}) default: slog.ErrorContext(r.Context(), "trailers: unexpected refresh outcome", "component", "api", "content_id", contentID, "status", outcome.Status) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to request trailers") } } // trailerRefreshTarget is what a content ID on the trailer refresh route turned // out to be: whether trailers apply to it at all, and which item ID authorizes // it (a season or episode is authorized through its series). type trailerRefreshTarget struct { supportsTrailers bool accessContentID string } // resolveTrailerRefreshTarget identifies the content behind an ID the same way // the on-view translation route does. Seasons and episodes live in their own // tables, so a media_items miss is not proof the content is absent — falling // through to those lookups is what lets a real episode ID answer 400 // unsupported-type instead of a misleading 404. func (h *ItemsHandler) resolveTrailerRefreshTarget(ctx context.Context, contentID string) (trailerRefreshTarget, error) { item, err := h.trailerItemAccess.GetByID(ctx, contentID) switch { case err == nil && item != nil: return trailerRefreshTarget{ supportsTrailers: item.Type == "movie" || item.Type == "series", accessContentID: contentID, }, nil case err == nil, errors.Is(err, catalog.ErrItemNotFound): // Fall through to the season and episode lookups. default: return trailerRefreshTarget{}, err } if h.trailerSeasonLookup != nil { season, err := h.trailerSeasonLookup.GetByID(ctx, contentID) switch { case err == nil && season != nil: return trailerRefreshTarget{accessContentID: season.SeriesID}, nil case err == nil, errors.Is(err, catalog.ErrSeasonNotFound): default: return trailerRefreshTarget{}, err } } if h.trailerEpisodeLookup != nil { episode, err := h.trailerEpisodeLookup.GetByID(ctx, contentID) switch { case err == nil && episode != nil: return trailerRefreshTarget{accessContentID: episode.SeriesID}, nil case err == nil, errors.Is(err, catalog.ErrEpisodeNotFound): default: return trailerRefreshTarget{}, err } } return trailerRefreshTarget{}, catalog.ErrItemNotFound } // HandleMarkWatched handles POST /watched/{id}. func (h *ItemsHandler) HandleMarkWatched(w http.ResponseWriter, r *http.Request) { h.handleSetWatchedState(w, r, true) } // HandleMarkUnwatched handles DELETE /watched/{id}. func (h *ItemsHandler) HandleMarkUnwatched(w http.ResponseWriter, r *http.Request) { h.handleSetWatchedState(w, r, false) } func (h *ItemsHandler) dispatchLocalWatchEvent( ctx context.Context, kind watchsync.LocalWatchEventKind, userID int, profileID string, result watchstate.ManualMarkResult, ) { if h == nil || h.localWatchDispatcher == nil { return } plays := watchsync.LocalPlaysFromHistory(result.Entries) if len(plays) == 0 { return } if err := h.localWatchDispatcher.HandleLocalWatchEvent(ctx, watchsync.LocalWatchEvent{ Kind: kind, UserID: userID, ProfileID: profileID, Plays: plays, }); err != nil { slog.WarnContext(ctx, "failed to queue local watch provider event", "component", "api", "kind", kind, "user_id", userID, "profile_id", profileID, "error", err) } } func (h *ItemsHandler) handleSetWatchedState(w http.ResponseWriter, r *http.Request, played bool) { userID := apimw.GetUserID(r.Context()) profileID := apimw.GetProfileID(r.Context()) id := chi.URLParam(r, "id") if id == "" { writeError(w, http.StatusBadRequest, "bad_request", "Item ID is required") return } if profileID == "" { writeError(w, http.StatusBadRequest, "bad_request", "Profile ID is required") return } filter := h.accessFilter(r) targetType, targets, err := h.resolveWatchedTargets(r.Context(), id, filter) if err != nil { switch { case isNotFound(err): writeError(w, http.StatusNotFound, "not_found", "Item not found") default: writeError(w, http.StatusInternalServerError, "internal_error", "Failed to update watched state") } return } switch { case targetType == "ebook": // Ebook read state lives in ebook_reader_progress, not in // user_watch_progress/user_watch_history; watch providers do not sync // books, so no local watch event is dispatched. err = h.setEbookReadState(r.Context(), userID, profileID, id, played, filter) case h.watchState == nil: writeError(w, http.StatusInternalServerError, "internal_error", "Failed to access user store") return case played: leafTargets := make([]watchstate.LeafWatchTarget, 0, len(targets)) for _, target := range targets { leafTargets = append(leafTargets, watchstate.LeafWatchTarget{ MediaItemID: target.ContentID, DurationSeconds: target.DurationSeconds, }) } updatedAt := time.Now().UTC() var result watchstate.ManualMarkResult result, err = h.watchState.RecordManualMarkWatchedWithResult(r.Context(), userID, profileID, leafTargets, updatedAt) if err == nil { h.dispatchLocalWatchEvent(r.Context(), watchsync.LocalWatchEventMarkedWatched, userID, profileID, result) } default: targetIDs := make([]string, 0, len(targets)) for _, target := range targets { targetIDs = append(targetIDs, target.ContentID) } var result watchstate.ManualMarkResult result, err = h.watchState.RecordManualMarkUnwatchedWithResult(r.Context(), userID, profileID, targetIDs) if err == nil { h.dispatchLocalWatchEvent(r.Context(), watchsync.LocalWatchEventMarkedUnwatched, userID, profileID, result) } } if err != nil { if isNotFound(err) { writeError(w, http.StatusNotFound, "not_found", "Item not found") return } writeError(w, http.StatusInternalServerError, "internal_error", "Failed to update watched state") return } triggerProfileRefresh(r.Context(), h.profileStaler, h.profileRefreshRequester, userID, profileID) publishUserStateEvent(r.Context(), h.EventsHub, userID, profileID, id, "", "watched", userStateEventState{ Played: boolPtr(played), }) writeJSON(w, http.StatusOK, watchedStateResponse{ ContentID: id, Type: targetType, AffectedCount: len(targets), Played: played, }) } // HandleGetItemVersions handles GET /items/{id}/versions. func (h *ItemsHandler) HandleGetItemVersions(w http.ResponseWriter, r *http.Request) { writeDeprecatedReadHeaders(w, "/api/v1/catalog/items/{id}/versions") h.catalogResourceHandler().HandleGetItemVersions(w, r) } // HandleGetItemEpisodes handles GET /items/{id}/episodes for season IDs. func (h *ItemsHandler) HandleGetItemEpisodes(w http.ResponseWriter, r *http.Request) { writeDeprecatedReadHeaders(w, "/api/v1/catalog/items/{id}/episodes") h.catalogResourceHandler().HandleGetItemEpisodes(w, r) } // HandleGetSeasons handles GET /series/{id}/seasons. func (h *ItemsHandler) HandleGetSeasons(w http.ResponseWriter, r *http.Request) { writeDeprecatedReadHeaders(w, "/api/v1/catalog/series/{id}/seasons") h.catalogResourceHandler().HandleGetSeasons(w, r) } // HandleGetSeason handles GET /series/{id}/seasons/{num}. func (h *ItemsHandler) HandleGetSeason(w http.ResponseWriter, r *http.Request) { writeDeprecatedReadHeaders(w, "/api/v1/catalog/series/{id}/seasons/{num}") h.catalogResourceHandler().HandleGetSeason(w, r) } // HandleGetEpisodes handles GET /series/{id}/seasons/{num}/episodes. func (h *ItemsHandler) HandleGetEpisodes(w http.ResponseWriter, r *http.Request) { writeDeprecatedReadHeaders(w, "/api/v1/catalog/series/{id}/seasons/{num}/episodes") h.catalogResourceHandler().HandleGetEpisodes(w, r) } func (h *ItemsHandler) catalogResourceHandler() *CatalogResourceHandler { return NewCatalogResourceHandler(h) } func (h *ItemsHandler) catalogHandler() *CatalogHandler { return NewCatalogHandler(h.catalogResolver, h) } func (h *ItemsHandler) writeCatalogBrowseResponse(w http.ResponseWriter, r *http.Request, values map[string][]string) bool { req, err := catalog.ParseCatalogRequest(values) if err != nil { writeError(w, http.StatusBadRequest, "bad_request", err.Error()) return true } result, err := h.catalogResolver.Resolve(r.Context(), req, h.accessFilter(r)) if err != nil { if errors.Is(err, catalog.ErrInvalidCatalogRequest) { writeError(w, http.StatusBadRequest, "bad_request", err.Error()) return true } if errors.Is(err, catalog.ErrCatalogSourceNotFound) { writeError(w, http.StatusNotFound, "not_found", "Catalog source not found") return true } writeError(w, http.StatusInternalServerError, "internal_error", "Failed to browse items") return true } overlaySummaries := h.listOverlaySummaries(r.Context(), result.Items, h.accessFilter(r)) userStates := h.listItemUserStates(r, result.Items) items := make([]itemListResponse, 0, len(result.Items)) for _, item := range result.Items { items = append(items, h.toItemListResponseWithOverlay(r, item, overlaySummaries[item.ContentID], userStates[item.ContentID])) } writeJSON(w, http.StatusOK, browseResponse{ Total: result.Total, TotalExact: result.TotalExact, HasMore: result.HasMore, Items: items, }) return true } func (h *ItemsHandler) writeCatalogFiltersResponse(w http.ResponseWriter, r *http.Request, values map[string][]string) bool { req, err := catalog.ParseCatalogRequest(values) if err != nil { writeError(w, http.StatusBadRequest, "bad_request", err.Error()) return true } filters, err := h.catalogResolver.ListFiltersWithOptions( r.Context(), req, h.accessFilter(r), catalog.CatalogFilterOptions{IncludeTechnical: false}, ) if err != nil { if errors.Is(err, catalog.ErrInvalidCatalogRequest) { writeError(w, http.StatusBadRequest, "bad_request", err.Error()) return true } writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list item filters") return true } writeJSON(w, http.StatusOK, itemFiltersResponse{ Genres: filters.Genres, Studios: filters.Studios, Networks: filters.Networks, Countries: filters.Countries, ContentRatings: filters.ContentRatings, }) return true } // toItemListResponse converts a MediaItem to an itemListResponse with presigned URLs. func (h *ItemsHandler) toItemListResponse(r *http.Request, item *models.MediaItem) itemListResponse { return h.toItemListResponseWithOverlay(r, item, nil, nil) } func (h *ItemsHandler) toItemListResponseWithOverlay(r *http.Request, item *models.MediaItem, overlaySummary *models.OverlaySummary, userState *itemUserStateResponse) itemListResponse { if h.detailSvc != nil { if localized, err := h.detailSvc.LocalizeItemModel(r.Context(), item, h.accessFilter(r)); err == nil && localized != nil { item = localized } } resp := itemListResponseShell(item, overlaySummary, userState) resp.PosterURL = h.presignURL(r, cardThumbnailPath(item.PosterPath), "card") resp.BackdropURL = h.presignURL(r, cardThumbnailPath(item.BackdropPath), "card") return resp } func itemListResponseShell(item *models.MediaItem, overlaySummary *models.OverlaySummary, userState *itemUserStateResponse) itemListResponse { resp := itemListResponse{ ContentID: item.ContentID, Type: item.Type, Title: item.Title, Year: item.Year, Runtime: item.Runtime, Genres: item.Genres, Keywords: item.Keywords, Studios: item.Studios, Networks: item.Networks, ContentRating: item.ContentRating, Status: item.Status, ShowStatus: item.ShowStatus, RatingIMDB: item.RatingIMDB, RatingTMDB: item.RatingTMDB, RatingRTCritic: item.RatingRTCritic, RatingRTAudience: item.RatingRTAudience, OriginalLanguage: item.OriginalLanguage, Overview: item.Overview, PosterThumbhash: item.PosterThumbhash, BackdropThumbhash: item.BackdropThumbhash, OverlaySummary: overlaySummary, UserState: userState, } resp.AddedAt = item.AddedAt resp.MangaChapterCount = item.MangaChapterCount resp.MangaVolumeCount = item.MangaVolumeCount resp.ReleaseDate = item.ReleaseDate resp.LastAirDate = item.LastAirDate if resp.Genres == nil { resp.Genres = []string{} } if resp.Keywords == nil { resp.Keywords = []string{} } return resp } func (h *ItemsHandler) localizeItemListModels(ctx context.Context, items []*models.MediaItem, filter catalog.AccessFilter) []*models.MediaItem { if h == nil || h.detailSvc == nil || len(items) == 0 { return items } localized, err := h.detailSvc.LocalizeItemModels(ctx, items, filter) if err != nil || len(localized) != len(items) { return items } return localized } func (h *ItemsHandler) itemListCardImageURLs(ctx context.Context, items []*models.MediaItem) map[string]itemListImageURLs { urls := make(map[string]itemListImageURLs, len(items)) if h == nil || h.detailSvc == nil || len(items) == 0 { return urls } type pendingImages struct { contentID string posterPath string backdropPath string } pending := make([]pendingImages, 0, len(items)) paths := make([]string, 0, len(items)*2) seenPaths := make(map[string]struct{}, len(items)*2) addPath := func(path string) { if path == "" || path == "-" { return } if _, ok := seenPaths[path]; ok { return } seenPaths[path] = struct{}{} paths = append(paths, path) } for _, item := range items { if item == nil || item.ContentID == "" { continue } images := pendingImages{ contentID: item.ContentID, posterPath: cardThumbnailPath(item.PosterPath), backdropPath: cardThumbnailPath(item.BackdropPath), } pending = append(pending, images) addPath(images.posterPath) addPath(images.backdropPath) } resolved := h.detailSvc.PresignURLsWithExpiry(ctx, paths, "card") for _, images := range pending { urls[images.contentID] = itemListImageURLs{ posterURL: resolved[images.posterPath].URL, backdropURL: resolved[images.backdropPath].URL, } } return urls } func (h *ItemsHandler) listEpisodeBrowseMetadata( ctx context.Context, items []*models.MediaItem, ) map[string]struct { SeriesTitle string SeasonNumber *int EpisodeNumber *int } { if h == nil || h.episodeRepo == nil { return map[string]struct { SeriesTitle string SeasonNumber *int EpisodeNumber *int }{} } episodeIDs := make([]string, 0) for _, item := range items { if item == nil || item.Type != "episode" || strings.TrimSpace(item.ContentID) == "" { continue } episodeIDs = append(episodeIDs, item.ContentID) } if len(episodeIDs) == 0 { return map[string]struct { SeriesTitle string SeasonNumber *int EpisodeNumber *int }{} } episodes, err := h.episodeRepo.GetByIDs(ctx, episodeIDs) if err != nil || len(episodes) == 0 { return map[string]struct { SeriesTitle string SeasonNumber *int EpisodeNumber *int }{} } seriesIDs := make([]string, 0, len(episodes)) seenSeriesIDs := make(map[string]struct{}, len(episodes)) for _, episode := range episodes { if episode == nil || strings.TrimSpace(episode.SeriesID) == "" { continue } if _, ok := seenSeriesIDs[episode.SeriesID]; ok { continue } seenSeriesIDs[episode.SeriesID] = struct{}{} seriesIDs = append(seriesIDs, episode.SeriesID) } seriesTitles := make(map[string]string, len(seriesIDs)) if len(seriesIDs) > 0 { if seriesItems, err := h.itemRepo.GetByIDs(ctx, seriesIDs); err == nil { for _, seriesItem := range seriesItems { if seriesItem == nil { continue } seriesTitles[seriesItem.ContentID] = seriesItem.Title } } } result := make(map[string]struct { SeriesTitle string SeasonNumber *int EpisodeNumber *int }, len(episodes)) for _, episode := range episodes { if episode == nil { continue } seasonNumber := episode.SeasonNumber episodeNumber := episode.EpisodeNumber result[episode.ContentID] = struct { SeriesTitle string SeasonNumber *int EpisodeNumber *int }{ SeriesTitle: seriesTitles[episode.SeriesID], SeasonNumber: &seasonNumber, EpisodeNumber: &episodeNumber, } } return result } func (h *ItemsHandler) listItemUserStates(r *http.Request, items []*models.MediaItem) map[string]*itemUserStateResponse { store, profileID, ok := h.userStoreForRequest(r) if !ok { return map[string]*itemUserStateResponse{} } states, err := resolveItemUserStatesWithOptions(r.Context(), store, profileID, h.episodeRepo, items, itemUserStateOptions{ UserID: apimw.GetUserID(r.Context()), EbookProgressStore: h.ebookProgressStore, }) if err != nil { return map[string]*itemUserStateResponse{} } return states } // toEpisodeResponse converts an Episode model to an API response. func (h *ItemsHandler) toEpisodeResponse(r *http.Request, ep *models.Episode) episodeResponse { return h.toEpisodeResponseWithFallback(r, ep, episodeImageFallback{}) } func (h *ItemsHandler) toEpisodeResponseWithFallback(r *http.Request, ep *models.Episode, fallback episodeImageFallback) episodeResponse { if h.detailSvc != nil { if localized, err := h.detailSvc.LocalizeEpisodeModel(r.Context(), ep, h.accessFilter(r)); err == nil && localized != nil { ep = localized } } resp, stillPath := episodeResponseShell(ep, fallback) resp.StillURL = h.presignURL(r, stillPath, "card") return resp } // episodeResponseShell maps an already-localized episode onto the response // shape, returning the card-variant still path for the caller to presign. func episodeResponseShell(ep *models.Episode, fallback episodeImageFallback) (episodeResponse, string) { stillPath := ep.StillPath stillThumbhash := ep.StillThumbhash if strings.TrimSpace(stillPath) == "" && strings.TrimSpace(fallback.Path) != "" { stillPath = fallback.Path stillThumbhash = fallback.Thumbhash } resp := episodeResponse{ ContentID: ep.ContentID, SeasonNumber: ep.SeasonNumber, EpisodeNumber: ep.EpisodeNumber, Title: ep.Title, Overview: ep.Overview, Runtime: ep.Runtime, ImdbID: ep.ImdbID, TmdbID: ep.TmdbID, TvdbID: ep.TvdbID, StillThumbhash: stillThumbhash, } if ep.AirDate != nil { resp.AirDate = ep.AirDate.Format("2006-01-02") } return resp, cardThumbnailPath(stillPath) } // buildEpisodeResponses converts episodes to API responses using batched // lookups — localization, media files, watch progress, and image presigning // each resolve in one round-trip for the whole list instead of per episode. func (h *ItemsHandler) buildEpisodeResponses(r *http.Request, episodes []*models.Episode) []episodeResponse { ctx := r.Context() filter := h.accessFilter(r) if h.detailSvc != nil { if localized, err := h.detailSvc.LocalizeEpisodeModels(ctx, episodes, filter); err == nil && len(localized) == len(episodes) { episodes = localized } } fallbacks := h.episodeImageFallbacks(ctx, episodes) episodeIDs := make([]string, 0, len(episodes)) for _, ep := range episodes { if ep != nil && ep.ContentID != "" { episodeIDs = append(episodeIDs, ep.ContentID) } } filesByEpisode := h.listEpisodeFiles(ctx, episodeIDs) userData := h.listLeafUserData(r, episodeIDs) resp := make([]episodeResponse, 0, len(episodes)) stillPaths := make([]string, 0, len(episodes)) for _, ep := range episodes { if ep == nil { continue } shell, stillPath := episodeResponseShell(ep, fallbacks[ep.SeriesID]) resp = append(resp, shell) stillPaths = append(stillPaths, stillPath) } stillURLs := map[string]catalog.ResolvedImageURL{} if h.detailSvc != nil { stillURLs = h.detailSvc.PresignURLsWithExpiry(ctx, stillPaths, "card") } for i := range resp { resp[i].StillURL = stillURLs[stillPaths[i]].URL files := catalog.FilterMediaFilesByAccess(filesByEpisode[resp[i].ContentID], filter) resp[i].Files = episodeFileResponses(files, filter) resp[i].UserData = userData[resp[i].ContentID] resp[i].OverlaySummary = overlays.BuildSummary(files) } return resp } // listEpisodeFiles batch-fetches media files keyed by episode ID, falling back // to per-episode lookups when the repository lacks batch support. func (h *ItemsHandler) listEpisodeFiles(ctx context.Context, episodeIDs []string) map[string][]*models.MediaFile { if h.fileRepo == nil || len(episodeIDs) == 0 { return nil } if batchProvider, ok := h.fileRepo.(batchEpisodeFileProvider); ok { files, err := batchProvider.ListByEpisodeIDs(ctx, episodeIDs) if err != nil { return nil } return files } files := make(map[string][]*models.MediaFile, len(episodeIDs)) for _, id := range episodeIDs { if list, err := h.fileRepo.GetByEpisodeID(ctx, id); err == nil { files[id] = list } } return files } func episodeFileResponses(files []*models.MediaFile, filter catalog.AccessFilter) []episodeFileResponse { var resp []episodeFileResponse for _, f := range files { if !catalog.FileAllowedByAccess(f, filter) { continue } resp = append(resp, episodeFileResponse{ FileID: f.ID, Resolution: f.Resolution, CodecVideo: f.CodecVideo, HDR: f.HDR, AudioChannels: f.AudioChannels, Container: f.Container, FileSize: f.FileSize, }) } return resp } func (h *ItemsHandler) episodeImageFallbacks(ctx context.Context, episodes []*models.Episode) map[string]episodeImageFallback { if h.itemRepo == nil || len(episodes) == 0 { return map[string]episodeImageFallback{} } seriesIDs := make([]string, 0, 1) seen := make(map[string]struct{}) for _, ep := range episodes { if ep == nil || strings.TrimSpace(ep.SeriesID) == "" { continue } if _, ok := seen[ep.SeriesID]; ok { continue } seen[ep.SeriesID] = struct{}{} seriesIDs = append(seriesIDs, ep.SeriesID) } if len(seriesIDs) == 0 { return map[string]episodeImageFallback{} } seriesItems, err := h.itemRepo.GetByIDs(ctx, seriesIDs) if err != nil { return map[string]episodeImageFallback{} } fallbacks := make(map[string]episodeImageFallback, len(seriesItems)) for _, series := range seriesItems { if series == nil { continue } path := strings.TrimSpace(series.BackdropPath) thumbhash := series.BackdropThumbhash if path == "" { path = strings.TrimSpace(series.PosterPath) thumbhash = series.PosterThumbhash } if path == "" { continue } fallbacks[series.ContentID] = episodeImageFallback{Path: path, Thumbhash: thumbhash} } return fallbacks } func (h *ItemsHandler) listOverlaySummaries(ctx context.Context, items []*models.MediaItem, filter catalog.AccessFilter) map[string]*models.OverlaySummary { summaries := make(map[string]*models.OverlaySummary, len(items)) if h.fileRepo == nil || len(items) == 0 { return summaries } groupedFiles := h.listBrowseItemOverlayFiles(ctx, items, filter) for contentID, files := range groupedFiles { if summary := overlays.BuildSummary(files); summary != nil { summaries[contentID] = summary } } return summaries } func (h *ItemsHandler) listSortMetrics( ctx context.Context, items []*models.MediaItem, sortField string, filter catalog.AccessFilter, overlaySummaries map[string]*models.OverlaySummary, store userstore.UserStore, userID int, profileID string, ) map[string]*sortMetricsResponse { metrics := make(map[string]*sortMetricsResponse, len(items)) switch sortField { case "release_date": for _, item := range items { if item == nil { continue } releaseDate := firstNonBlankPtr(item.ReleaseDate, item.FirstAirDate) if releaseDate != nil { metrics[item.ContentID] = &sortMetricsResponse{ReleaseDate: releaseDate} } } case "runtime": for _, item := range items { if item == nil || item.Runtime <= 0 { continue } runtimeMinutes := item.Runtime metrics[item.ContentID] = &sortMetricsResponse{RuntimeMinutes: &runtimeMinutes} } case "resolution": for _, item := range items { if item == nil { continue } if summary := overlaySummaries[item.ContentID]; summary != nil && strings.TrimSpace(summary.Resolution) != "" { metrics[item.ContentID] = &sortMetricsResponse{Resolution: summary.Resolution} } } case "bitrate": groupedFiles := h.listBrowseItemFiles(ctx, items, filter) for contentID, files := range groupedFiles { if bitrate := maxFileBitrate(files); bitrate > 0 { value := bitrate metrics[contentID] = &sortMetricsResponse{BitrateKbps: &value} } } case "progress", "date_viewed", "plays": h.listUserSortMetrics(ctx, items, sortField, store, userID, profileID, metrics) case "author", "narrator", "series": h.listAudiobookSortMetrics(ctx, items, sortField, metrics) } return metrics } func (h *ItemsHandler) listAudiobookSortMetrics( ctx context.Context, items []*models.MediaItem, sortField string, metrics map[string]*sortMetricsResponse, ) { if h == nil || h.browseRepo == nil || h.browseRepo.Pool() == nil || len(items) == 0 { return } contentIDs := uniqueItemContentIDs(items) if len(contentIDs) == 0 { return } var query string switch sortField { case "author": query = audiobookPersonSortMetricQuery(int(models.PersonKindAuthor)) case "narrator": query = audiobookPersonSortMetricQuery(int(models.PersonKindNarrator)) case "series": query = ` SELECT target.content_id, BTRIM(s.series_name) AS value FROM unnest($1::text[]) WITH ORDINALITY AS target(content_id, ord) JOIN audiobook_series s ON s.content_id = target.content_id WHERE BTRIM(s.series_name) <> ''` default: return } rows, err := h.browseRepo.Pool().Query(ctx, query, contentIDs) if err != nil { return } defer rows.Close() for rows.Next() { var contentID, value string if err := rows.Scan(&contentID, &value); err != nil { return } value = strings.TrimSpace(value) if contentID == "" || value == "" { continue } resp := metrics[contentID] if resp == nil { resp = &sortMetricsResponse{} metrics[contentID] = resp } switch sortField { case "author": resp.Author = value case "narrator": resp.Narrator = value case "series": resp.SeriesName = value } } } func audiobookPersonSortMetricQuery(kind int) string { return fmt.Sprintf(` SELECT target.content_id, person.name AS value FROM unnest($1::text[]) WITH ORDINALITY AS target(content_id, ord) JOIN LATERAL ( SELECT BTRIM(p.name) AS name FROM item_people ip JOIN people p ON p.id = ip.person_id WHERE ip.content_id = target.content_id AND ip.kind = %d AND p.name IS NOT NULL AND BTRIM(p.name) <> '' ORDER BY p.name ASC LIMIT 1 ) person ON TRUE`, kind) } func (h *ItemsHandler) listUserSortMetrics( ctx context.Context, items []*models.MediaItem, sortField string, store userstore.UserStore, userID int, profileID string, metrics map[string]*sortMetricsResponse, ) { if store == nil || profileID == "" || len(items) == 0 { return } contentIDs := uniqueItemContentIDs(items) if len(contentIDs) == 0 { return } progressMap, err := store.ListProgressByMediaItems(ctx, profileID, contentIDs) if err != nil { return } ebookProgressMap, err := h.listEbookReaderProgressByContentIDs(ctx, userID, profileID, contentIDs) if err != nil { return } switch sortField { case "progress": for _, item := range items { if item == nil || item.ContentID == "" { continue } progress, ok := progressMap[item.ContentID] if ok && !progress.Completed && progress.PositionSeconds > 0 && progress.DurationSeconds > 0 { ratio := progress.PositionSeconds / progress.DurationSeconds metrics[item.ContentID] = &sortMetricsResponse{ProgressRatio: &ratio} continue } ebookProgress, ok := ebookProgressMap[item.ContentID] if !ok || ebookProgress.Progress <= 0 || ebookProgress.Progress >= models.EbookFinishedProgressThreshold { continue } ratio := ebookProgress.Progress metrics[item.ContentID] = &sortMetricsResponse{ProgressRatio: &ratio} } case "date_viewed", "plays": history, err := listCompletedHistoryForItems(ctx, store, profileID, contentIDs) if err != nil { return } historyCounts := make(map[string]int, len(contentIDs)) historyViewedAt := make(map[string]string, len(contentIDs)) for _, entry := range history { if entry.MediaItemID == "" { continue } historyCounts[entry.MediaItemID]++ if entry.WatchedAt > historyViewedAt[entry.MediaItemID] { historyViewedAt[entry.MediaItemID] = entry.WatchedAt } } for _, item := range items { if item == nil || item.ContentID == "" { continue } resp := &sortMetricsResponse{} if sortField == "date_viewed" { viewedAt := historyViewedAt[item.ContentID] if progress, ok := progressMap[item.ContentID]; ok && progress.Completed && progress.UpdatedAt > viewedAt { viewedAt = progress.UpdatedAt } if progress, ok := ebookProgressMap[item.ContentID]; ok && progress.Progress >= models.EbookFinishedProgressThreshold { ebookViewedAt := progress.UpdatedAt.UTC().Format(time.RFC3339) if ebookViewedAt > viewedAt { viewedAt = ebookViewedAt } } if viewedAt == "" { continue } resp.ViewedAt = viewedAt } else { playCount := historyCounts[item.ContentID] if progress, ok := progressMap[item.ContentID]; ok && progress.Completed && playCount < 1 { playCount = 1 } if progress, ok := ebookProgressMap[item.ContentID]; ok && progress.Progress >= models.EbookFinishedProgressThreshold && playCount < 1 { playCount = 1 } if playCount <= 0 { continue } resp.PlayCount = &playCount } metrics[item.ContentID] = resp } } } func (h *ItemsHandler) listEbookReaderProgressByContentIDs( ctx context.Context, userID int, profileID string, contentIDs []string, ) (map[string]EbookReaderProgress, error) { if h == nil || h.ebookProgressStore == nil || userID <= 0 || profileID == "" || len(contentIDs) == 0 { return nil, nil } return h.ebookProgressStore.ListByContentIDs(ctx, userID, profileID, contentIDs) } func uniqueItemContentIDs(items []*models.MediaItem) []string { contentIDs := make([]string, 0, len(items)) seen := make(map[string]struct{}, len(items)) for _, item := range items { if item == nil || item.ContentID == "" { continue } if _, ok := seen[item.ContentID]; ok { continue } seen[item.ContentID] = struct{}{} contentIDs = append(contentIDs, item.ContentID) } return contentIDs } func listCompletedHistoryForItems( ctx context.Context, store userstore.UserStore, profileID string, contentIDs []string, ) ([]userstore.WatchHistoryEntry, error) { const pageSize = 500 var all []userstore.WatchHistoryEntry for offset := 0; ; offset += pageSize { page, err := store.ListCompletedHistory(ctx, userstore.CompletedHistoryQuery{ ProfileID: profileID, MediaItemIDs: contentIDs, Limit: pageSize, Offset: offset, }) if err != nil { return nil, err } all = append(all, page...) if len(page) < pageSize { return all, nil } } } func (h *ItemsHandler) listBrowseItemFiles(ctx context.Context, items []*models.MediaItem, filter catalog.AccessFilter) map[string][]*models.MediaFile { grouped := make(map[string][]*models.MediaFile, len(items)) if h.fileRepo == nil || len(items) == 0 { return grouped } contentIDs := make([]string, 0, len(items)) episodeIDs := make([]string, 0) collectBrowseFileIDs(items, &contentIDs, &episodeIDs) if len(contentIDs) > 0 { contentFiles, err := h.fileRepo.ListByContentIDs(ctx, contentIDs) if err == nil { for contentID, files := range contentFiles { grouped[contentID] = catalog.FilterMediaFilesByAccess(files, filter) } } } if len(episodeIDs) > 0 { if batchProvider, ok := h.fileRepo.(batchEpisodeFileProvider); ok { episodeFiles, err := batchProvider.ListByEpisodeIDs(ctx, episodeIDs) if err == nil { for episodeID, files := range episodeFiles { grouped[episodeID] = catalog.FilterMediaFilesByAccess(files, filter) } } } } return grouped } func (h *ItemsHandler) listBrowseItemOverlayFiles(ctx context.Context, items []*models.MediaItem, filter catalog.AccessFilter) map[string][]*models.MediaFile { if h.fileRepo == nil || len(items) == 0 { return map[string][]*models.MediaFile{} } overlayProvider, ok := h.fileRepo.(overlayFileProvider) if !ok { return h.listBrowseItemFiles(ctx, items, filter) } contentIDs := make([]string, 0, len(items)) episodeIDs := make([]string, 0) collectBrowseFileIDs(items, &contentIDs, &episodeIDs) grouped := make(map[string][]*models.MediaFile, len(items)) if len(contentIDs) > 0 { contentFiles, err := overlayProvider.ListOverlayFilesByContentIDs(ctx, contentIDs) if err != nil { return h.listBrowseItemFiles(ctx, items, filter) } for contentID, files := range contentFiles { grouped[contentID] = catalog.FilterMediaFilesByAccess(files, filter) } } if len(episodeIDs) > 0 { episodeFiles, err := overlayProvider.ListOverlayFilesByEpisodeIDs(ctx, episodeIDs) if err != nil { return h.listBrowseItemFiles(ctx, items, filter) } for episodeID, files := range episodeFiles { grouped[episodeID] = catalog.FilterMediaFilesByAccess(files, filter) } } return grouped } func collectBrowseFileIDs(items []*models.MediaItem, contentIDs *[]string, episodeIDs *[]string) { seen := make(map[string]struct{}, len(items)) for _, item := range items { if item == nil || item.ContentID == "" { continue } if _, ok := seen[item.ContentID]; ok { continue } seen[item.ContentID] = struct{}{} if item.Type == "episode" { *episodeIDs = append(*episodeIDs, item.ContentID) } else { *contentIDs = append(*contentIDs, item.ContentID) } } } func firstNonBlankPtr(values ...*string) *string { for _, value := range values { if value == nil || strings.TrimSpace(*value) == "" { continue } copyValue := *value return ©Value } return nil } func maxFileBitrate(files []*models.MediaFile) int { maxBitrate := 0 for _, file := range files { if file != nil && file.Bitrate > maxBitrate { maxBitrate = file.Bitrate } } return maxBitrate } // toSeasonResponse converts a Season model to an API response. func (h *ItemsHandler) toSeasonResponse(r *http.Request, seriesID string, s *models.Season) seasonResponse { episodes, _ := h.episodeRepo.ListBySeason(r.Context(), seriesID, s.SeasonNumber) return h.toSeasonResponseFromEpisodes(r, seriesID, s, episodes, h.getAggregateUserData(r, episodes)) } func (h *ItemsHandler) toSeasonResponseFromEpisodes( r *http.Request, seriesID string, s *models.Season, episodes []*models.Episode, userData *catalog.SeasonUserData, ) seasonResponse { if h.detailSvc != nil { if localized, err := h.detailSvc.LocalizeSeasonModel(r.Context(), s, h.accessFilter(r)); err == nil && localized != nil { s = localized } } return h.seasonResponseFromEpisodes(r, s, episodes, userData) } // seasonResponseFromEpisodes maps a season that has already been localized. // List endpoints use this after LocalizeSeasonModels so they do not repeat the // localization query for every row. func (h *ItemsHandler) seasonResponseFromEpisodes( r *http.Request, s *models.Season, episodes []*models.Episode, userData *catalog.SeasonUserData, ) seasonResponse { resp := seasonResponse{ ContentID: s.ContentID, SeasonNumber: s.SeasonNumber, IsSpecials: s.SeasonNumber == 0, Title: s.Title, Overview: s.Overview, EpisodeCount: len(episodes), PosterThumbhash: s.PosterThumbhash, } if s.AirDate != nil { resp.AirDate = s.AirDate.Format("2006-01-02") } resp.PosterURL = h.presignURL(r, featuredPosterPath(s.PosterPath), "featured") resp.UserData = userData return resp } func (h *ItemsHandler) getLeafUserData(r *http.Request, contentID string, itemType ...string) *catalog.SeasonUserData { if len(itemType) > 0 && itemType[0] == "ebook" { return h.getEbookLeafUserData(r, contentID) } store, profileID, ok := h.userStoreForRequest(r) if !ok { return nil } progress, err := userstore.GetProgressWithCompletedHistory(r.Context(), store, profileID, contentID) if err != nil { return nil } return leafUserDataFromProgress(progress) } // listLeafUserData batch-fetches watch progress for the given content IDs in a // single query; the result only holds entries for items with progress rows. func (h *ItemsHandler) listLeafUserData(r *http.Request, contentIDs []string) map[string]*catalog.SeasonUserData { store, profileID, ok := h.userStoreForRequest(r) if !ok || len(contentIDs) == 0 { return nil } progressMap, err := userstore.ListProgressWithCompletedHistory(r.Context(), store, profileID, contentIDs) if err != nil { return nil } result := make(map[string]*catalog.SeasonUserData, len(progressMap)) for contentID, progress := range progressMap { progressCopy := progress result[contentID] = leafUserDataFromProgress(&progressCopy) } return result } func leafUserDataFromProgress(progress *userstore.WatchProgress) *catalog.SeasonUserData { if progress == nil { return nil } return &catalog.SeasonUserData{ PositionSeconds: progress.PositionSeconds, DurationSeconds: progress.DurationSeconds, IsInProgress: progress.PositionSeconds > 0, Played: progress.Completed, LastFileID: progress.LastFileID, LastResolution: progress.LastResolution, LastHDR: progress.LastHDR, LastCodecVideo: progress.LastCodecVideo, LastEditionKey: progress.LastEditionKey, } } func (h *ItemsHandler) getEbookLeafUserData(r *http.Request, contentID string) *catalog.SeasonUserData { if h == nil || h.ebookProgressStore == nil { return nil } userID := apimw.GetUserID(r.Context()) profileID := requestProfileID(r) if userID <= 0 || profileID == "" || contentID == "" { return nil } progress, err := h.ebookProgressStore.ListByContentIDs(r.Context(), userID, profileID, []string{contentID}) if err != nil { return nil } row, ok := progress[contentID] if !ok || row.Progress <= 0 { return nil } // Ebooks have no playback duration, so PositionSeconds/DurationSeconds // encode the 0..1 reading ratio (position=ratio, duration=1). Clients can // derive percent-complete from the pair as usual, but rendering them as // absolute times is meaningless for ebooks. Changing the wire shape needs // coordinated Android/Apple client updates. return &catalog.SeasonUserData{ PositionSeconds: row.Progress, DurationSeconds: 1, IsInProgress: row.Progress < models.EbookFinishedProgressThreshold, Played: row.Progress >= models.EbookFinishedProgressThreshold, } } func applyEffectiveEditionPreference(userData *catalog.SeasonUserData, target **string) { if userData == nil || userData.LastEditionKey == nil || *userData.LastEditionKey == "" { return } if target != nil && *target == nil { *target = userData.LastEditionKey } } func (h *ItemsHandler) getAggregateUserData(r *http.Request, episodes []*models.Episode) *catalog.SeasonUserData { if len(episodes) == 0 { return nil } store, profileID, ok := h.userStoreForRequest(r) if !ok { return nil } progressMap, err := h.listProgressForEpisodeIDs(r.Context(), store, profileID, episodeContentIDs(episodes)) if err != nil { return nil } return catalog.EpisodeRollupUserData(episodes, progressMap) } func (h *ItemsHandler) progressMapForEpisodes(r *http.Request, episodes []*models.Episode) (map[string]userstore.WatchProgress, bool) { store, profileID, ok := h.userStoreForRequest(r) if !ok { return nil, false } episodeIDs := episodeContentIDs(episodes) progressMap, err := h.listProgressForEpisodeIDs(r.Context(), store, profileID, episodeIDs) if err != nil { return nil, false } return progressMap, true } func (h *ItemsHandler) listProgressForEpisodeIDs(ctx context.Context, store userstore.UserStore, profileID string, episodeIDs []string) (map[string]userstore.WatchProgress, error) { const chunkSize = 500 progressMap := make(map[string]userstore.WatchProgress, len(episodeIDs)) for start := 0; start < len(episodeIDs); start += chunkSize { end := start + chunkSize if end > len(episodeIDs) { end = len(episodeIDs) } chunk, err := userstore.ListProgressWithCompletedHistory(ctx, store, profileID, episodeIDs[start:end]) if err != nil { return nil, err } for id, progress := range chunk { progressMap[id] = progress } } return progressMap, nil } func episodeContentIDs(episodes []*models.Episode) []string { ids := make([]string, 0, len(episodes)) seen := make(map[string]struct{}, len(episodes)) for _, ep := range episodes { if ep == nil || ep.ContentID == "" { continue } if _, ok := seen[ep.ContentID]; ok { continue } seen[ep.ContentID] = struct{}{} ids = append(ids, ep.ContentID) } return ids } func flattenEpisodeGroups(groups map[int][]*models.Episode) []*models.Episode { var total int for _, episodes := range groups { total += len(episodes) } flattened := make([]*models.Episode, 0, total) for _, episodes := range groups { flattened = append(flattened, episodes...) } return flattened } // requestProfileID resolves the active profile for a request, falling back to // the X-Profile-Id header when the auth context does not carry one. func requestProfileID(r *http.Request) string { profileID := apimw.GetProfileID(r.Context()) if profileID == "" { profileID = r.Header.Get("X-Profile-Id") } return profileID } func (h *ItemsHandler) userStoreForRequest(r *http.Request) (userstore.UserStore, string, bool) { if h.storeProvider == nil { return nil, "", false } userID := apimw.GetUserID(r.Context()) if userID == 0 { return nil, "", false } profileID := requestProfileID(r) if profileID == "" { return nil, "", false } store, err := h.storeProvider.ForUser(r.Context(), userID) if err != nil || store == nil { return nil, "", false } return store, profileID, true } // cardThumbnailPath converts an S3 image path from original to w300 for use in // browse/card views (posters, backdrops, and stills at card size). // Full URLs (TMDB/TVDB) and plugin-prefixed paths are returned as-is — // variant selection is handled at resolution time for plugin paths. func cardThumbnailPath(path string) string { if strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://") { return path } // Plugin-prefixed paths pass through — variant handled at resolution time. if strings.Contains(path, "://") { return path } return strings.Replace(path, "/original.", "/w300.", 1) } // featuredPosterPath converts an S3 poster path from original to w500 for // featured/hero contexts (displayed at ~220px CSS / 440px retina). // Full URLs (TMDB/TVDB) and plugin-prefixed paths are returned as-is. func featuredPosterPath(path string) string { if strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://") { return path } if strings.Contains(path, "://") { return path } return strings.Replace(path, "/original.", "/w500.", 1) } // featuredBackdropPath converts an S3 backdrop path from original to w1920 for // featured/hero contexts (displayed at full viewport width). Episode stills // used as backdrops lack a w1920 variant and clamp to their largest cached // size. Full URLs (TMDB/TVDB) and plugin-prefixed paths are returned as-is. func featuredBackdropPath(path string) string { return catalog.BackdropVariantPath(path, "w1920") } // presignURL resolves an image path to a usable URL, delegating to the // DetailService which handles plugin-prefixed paths, HTTP pass-through, // and legacy S3 presigning. func (h *ItemsHandler) presignURL(r *http.Request, path string, variant string) string { if h.detailSvc != nil { return h.detailSvc.PresignURL(r.Context(), path, variant) } return "" } // HandleFilterItems handles POST /items/filter with a full rule-group filter body. func (h *ItemsHandler) HandleFilterItems(w http.ResponseWriter, r *http.Request) { writeDeprecatedReadHeaders(w, "/api/v1/catalog/query") h.catalogHandler().HandlePostCatalogQuery(w, r) } type filterItemsRequest struct { sections.FilterConfig LibraryID int `json:"library_id"` Limit int `json:"limit"` Offset int `json:"offset"` } func filterItemColumns(alias string) string { cols := []string{ "content_id", "type", "title", "sort_title", "original_title", "year", "genres", "content_rating", "runtime", "overview", "tagline", "rating_imdb", "rating_tmdb", "rating_rt_critic", "rating_rt_audience", "imdb_id", "tmdb_id", "tvdb_id", "poster_path", "poster_thumbhash", "backdrop_path", "backdrop_thumbhash", "logo_path", "metadata_s3_path", "metadata_etag", "season_count", "studios", "networks", "countries", "first_air_date", "last_air_date", "matched_at", "status", "created_at", "updated_at", } prefixed := make([]string, len(cols)) for i, c := range cols { prefixed[i] = alias + "." + c } return strings.Join(prefixed, ", ") } func filterSortClause(sort, order string) string { dir := "DESC" if order == "asc" { dir = "ASC" } switch sort { case "rating": return fmt.Sprintf("ORDER BY mi.rating_imdb %s NULLS LAST", dir) case "year": return fmt.Sprintf("ORDER BY mi.year %s", dir) case "title": return fmt.Sprintf("ORDER BY LOWER(COALESCE(NULLIF(BTRIM(mi.sort_title), ''), mi.title)) %s, LOWER(mi.title) %s, mi.content_id ASC", dir, dir) default: return fmt.Sprintf("ORDER BY mi.created_at %s", dir) } } func (h *ItemsHandler) accessFilter(r *http.Request) catalog.AccessFilter { selectedFileID := 0 if fileIDRaw := strings.TrimSpace(r.URL.Query().Get("fileId")); fileIDRaw != "" { if fileID, err := strconv.Atoi(fileIDRaw); err == nil && fileID > 0 { selectedFileID = fileID } } var presentationLibraryID *int if libraryIDRaw := strings.TrimSpace(r.URL.Query().Get("library_id")); libraryIDRaw != "" { if libraryID, err := strconv.Atoi(libraryIDRaw); err == nil && libraryID > 0 { presentationLibraryID = &libraryID } } if scope, ok := access.GetScope(r.Context()); ok { return catalog.AccessFilter{ AllowedLibraryIDs: scope.AllowedLibraryIDs, DisabledLibraryIDs: scope.DisabledLibraryIDs, MaxContentRating: scope.MaxContentRating, MaxPlaybackQuality: scope.MaxPlaybackQuality, PresentationLibraryID: presentationLibraryID, ProfilePreferredLanguage: scope.PreferredMetadataLanguage, MetadataLanguageOverrides: scope.MetadataLanguageOverrides, SelectedFileID: selectedFileID, UserID: apimw.GetUserID(r.Context()), ProfileID: apimw.GetProfileID(r.Context()), } } var libraryIDs []int var maxPlaybackQuality string if h.UserRepo != nil { userID := apimw.GetUserID(r.Context()) if userID != 0 { user, userErr := h.UserRepo.GetByID(r.Context(), userID) if userErr != nil { slog.ErrorContext(r.Context(), "looking up user for library access", "component", "api", "error", userErr) } else { if user.LibraryIDs != nil { libraryIDs = user.LibraryIDs } maxPlaybackQuality = access.NormalizePlaybackQuality(user.MaxPlaybackQuality) } } } return catalog.AccessFilter{ AllowedLibraryIDs: libraryIDs, MaxPlaybackQuality: maxPlaybackQuality, PresentationLibraryID: presentationLibraryID, SelectedFileID: selectedFileID, UserID: apimw.GetUserID(r.Context()), ProfileID: apimw.GetProfileID(r.Context()), } } func (h *ItemsHandler) ensurePresentationLibraryAccess(ctx context.Context, contentID string, filter catalog.AccessFilter) error { if filter.PresentationLibraryID == nil { return nil } membership, err := h.itemRepo.GetItemsInLibrary(ctx, []string{contentID}, *filter.PresentationLibraryID) if err != nil { return err } if !membership[contentID] { return catalog.ErrItemNotFound } return nil } type watchedLeafTarget struct { ContentID string DurationSeconds float64 } func (h *ItemsHandler) resolveWatchedTargets(ctx context.Context, contentID string, filter catalog.AccessFilter) (string, []watchedLeafTarget, error) { item, err := h.itemRepo.GetByID(ctx, contentID) switch { case err == nil: if err := h.itemRepo.EnsureAccessible(ctx, contentID, filter); err != nil { return "", nil, err } switch item.Type { case "movie": return "movie", []watchedLeafTarget{{ ContentID: item.ContentID, DurationSeconds: h.contentDurationSeconds(ctx, item.ContentID, "", item.Runtime), }}, nil case "ebook": // Ebooks have no playback duration; read state is keyed off // ebook_reader_progress, so the leaf target only carries the ID. return "ebook", []watchedLeafTarget{{ContentID: item.ContentID}}, nil case "series": if h.episodeRepo == nil { return "", nil, catalog.ErrItemNotFound } episodes, err := h.episodeRepo.ListBySeries(ctx, item.ContentID) if err != nil { return "", nil, err } return "series", h.episodeTargets(ctx, episodes), nil default: return "", nil, catalog.ErrItemNotFound } case !errors.Is(err, catalog.ErrItemNotFound): return "", nil, err } if h.seasonRepo != nil { season, err := h.seasonRepo.GetByID(ctx, contentID) switch { case err == nil: if err := h.itemRepo.EnsureAccessible(ctx, season.SeriesID, filter); err != nil { return "", nil, err } if h.episodeRepo == nil { return "", nil, catalog.ErrItemNotFound } episodes, err := h.episodeRepo.ListBySeasonID(ctx, season.ContentID) if err != nil { return "", nil, err } return "season", h.episodeTargets(ctx, episodes), nil case !errors.Is(err, catalog.ErrSeasonNotFound): return "", nil, err } } if h.episodeRepo == nil { return "", nil, catalog.ErrItemNotFound } episode, err := h.episodeRepo.GetByID(ctx, contentID) if err != nil { return "", nil, err } if err := h.itemRepo.EnsureAccessible(ctx, episode.SeriesID, filter); err != nil { return "", nil, err } return "episode", []watchedLeafTarget{{ ContentID: episode.ContentID, DurationSeconds: h.contentDurationSeconds(ctx, "", episode.ContentID, episode.Runtime), }}, nil } func (h *ItemsHandler) episodeTargets(ctx context.Context, episodes []*models.Episode) []watchedLeafTarget { targets := make([]watchedLeafTarget, 0, len(episodes)) for _, episode := range episodes { targets = append(targets, watchedLeafTarget{ ContentID: episode.ContentID, DurationSeconds: h.contentDurationSeconds(ctx, "", episode.ContentID, episode.Runtime), }) } return targets } func (h *ItemsHandler) contentDurationSeconds(ctx context.Context, contentID, episodeID string, fallbackRuntimeMinutes int) float64 { if h.fileRepo == nil { if fallbackRuntimeMinutes > 0 { return float64(fallbackRuntimeMinutes * 60) } return 0 } var ( files []*models.MediaFile err error ) switch { case episodeID != "": files, err = h.fileRepo.GetByEpisodeID(ctx, episodeID) case contentID != "": files, err = h.fileRepo.GetByContentID(ctx, contentID) } if err == nil { for _, file := range files { if file != nil && file.Duration > 0 { return float64(file.Duration) } } } if fallbackRuntimeMinutes > 0 { return float64(fallbackRuntimeMinutes * 60) } return 0 } // isNotFound checks if an error is a "not found" sentinel. func isNotFound(err error) bool { return errors.Is(err, catalog.ErrItemNotFound) || errors.Is(err, catalog.ErrEpisodeNotFound) || errors.Is(err, catalog.ErrSeasonNotFound) } func (h *ItemsHandler) requestCanViewFilePaths(r *http.Request) bool { claims := apimw.GetClaims(r.Context()) if claims == nil { return false } if claims.Role == "admin" { return true } if h == nil || h.UserRepo == nil { return false } user, err := h.UserRepo.GetByID(r.Context(), claims.UserID) if err != nil { slog.WarnContext(r.Context(), "checking file path visibility permissions", "component", "api", "user_id", claims.UserID, "error", err) return false } return auth.HasEffectivePermission(user, auth.PermissionMetadataCuration) }