Marking a large series as watched only marked some episodes when the user
navigated away or closed the tab. POST /watched/{id} expanded a series to
every episode and then did four sequential DB round-trips per episode — a
duration lookup, a progress upsert, a stable-identity resolution (itself an
episode lookup plus a series provider-ID lookup), and a history insert — all
on the request context with no transaction. A 200-episode series was ~800
sequential queries, each committing on its own, so a disconnect mid-loop left
everything already committed in place.
Server:
- episodeTargets now resolves durations through the existing batched
listEpisodeFiles helper instead of one file query per episode. The shared
mediaFileDurationSeconds keeps the batched and single-item paths in step.
- ResolveHistoryIdentities resolves a whole series in one episode query plus
one provider-ID query per distinct series, behind optional-capability
interfaces with per-ID fallbacks.
- New userstore.WatchedBatchWriter capability, implemented transactionally in
both Postgres and SQLite, with a per-target fallback for stores that lack
it. recordMarkWatched and jellycompat's recordMarkWatchedBatch share it.
A cancelled request now rolls back to "nothing marked" rather than stranding
a half-watched series, which is the correct all-or-nothing semantic and needs
no detached context.
Client: the watched mutation sends keepalive so the request survives
navigation and tab close, plus an optimistic played flip with rollback,
following the existing favorites.ts pattern.
Routing jellycompat through the shared path would have clobbered known
durations with 0, since that caller supplies none; the batch upsert only ever
advances a duration, matching what MarkProgressBatch did before.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2353 lines
80 KiB
Go
2353 lines
80 KiB
Go
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 {
|
|
deviceID := deviceMetadataFromRequest(r).DeviceID
|
|
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()),
|
|
DeviceID: deviceID,
|
|
}
|
|
}
|
|
|
|
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()),
|
|
DeviceID: deviceID,
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// episodeTargets resolves the watched leaf targets for a season or series.
|
|
// Durations come from one batched file lookup rather than a query per episode:
|
|
// marking a long series used to issue one GetByEpisodeID per episode before it
|
|
// wrote anything.
|
|
func (h *ItemsHandler) episodeTargets(ctx context.Context, episodes []*models.Episode) []watchedLeafTarget {
|
|
episodeIDs := make([]string, 0, len(episodes))
|
|
for _, episode := range episodes {
|
|
episodeIDs = append(episodeIDs, episode.ContentID)
|
|
}
|
|
filesByEpisode := h.listEpisodeFiles(ctx, episodeIDs)
|
|
|
|
targets := make([]watchedLeafTarget, 0, len(episodes))
|
|
for _, episode := range episodes {
|
|
targets = append(targets, watchedLeafTarget{
|
|
ContentID: episode.ContentID,
|
|
DurationSeconds: mediaFileDurationSeconds(filesByEpisode[episode.ContentID], episode.Runtime),
|
|
})
|
|
}
|
|
return targets
|
|
}
|
|
|
|
// mediaFileDurationSeconds picks a playable duration from an item's files,
|
|
// falling back to the catalog runtime. Mirrors contentDurationSeconds's
|
|
// preference order so the batched and single-item paths agree.
|
|
func mediaFileDurationSeconds(files []*models.MediaFile, fallbackRuntimeMinutes int) float64 {
|
|
for _, file := range files {
|
|
if file != nil && file.Duration > 0 {
|
|
return float64(file.Duration)
|
|
}
|
|
}
|
|
if fallbackRuntimeMinutes > 0 {
|
|
return float64(fallbackRuntimeMinutes * 60)
|
|
}
|
|
return 0
|
|
}
|
|
|
|
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 {
|
|
files = nil
|
|
}
|
|
return mediaFileDurationSeconds(files, fallbackRuntimeMinutes)
|
|
}
|
|
|
|
// 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)
|
|
}
|