2026-05-22 20:26:11 -04:00
|
|
|
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"
|
2026-08-02 20:53:21 -04:00
|
|
|
"github.com/Silo-Server/silo-server/internal/ratelimit"
|
2026-05-22 20:26:11 -04:00
|
|
|
"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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-29 01:22:45 +00:00
|
|
|
type batchEpisodeFileProvider interface {
|
|
|
|
|
ListByEpisodeIDs(ctx context.Context, episodeIDs []string) (map[string][]*models.MediaFile, error)
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-25 20:46:08 -04:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-22 20:26:11 -04:00
|
|
|
type MetadataRefreshRequester interface {
|
|
|
|
|
RequestStaleMetadataRefresh(ctx context.Context, targetType, contentID string) error
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-02 20:53:21 -04:00
|
|
|
// 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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-22 20:26:11 -04:00
|
|
|
type LocalWatchEventDispatcher interface {
|
|
|
|
|
HandleLocalWatchEvent(ctx context.Context, event watchsync.LocalWatchEvent) error
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-10 08:18:35 -04:00
|
|
|
type EbookReaderProgressLister interface {
|
|
|
|
|
ListByContentIDs(ctx context.Context, userID int, profileID string, contentIDs []string) (map[string]EbookReaderProgress, error)
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-22 20:26:11 -04:00
|
|
|
// 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
|
2026-08-02 20:53:21 -04:00
|
|
|
trailerRefreshRequester TrailerRefreshRequester
|
|
|
|
|
trailerItemAccess trailerItemAccess
|
|
|
|
|
trailerSeasonLookup trailerSeasonLookup
|
|
|
|
|
trailerEpisodeLookup trailerEpisodeLookup
|
|
|
|
|
trailerRefreshLimiter ratelimit.RateLimiter
|
2026-05-22 20:26:11 -04:00
|
|
|
localWatchDispatcher LocalWatchEventDispatcher
|
2026-06-10 08:18:35 -04:00
|
|
|
ebookProgressStore EbookReaderProgressLister
|
|
|
|
|
ebookReadStateStore EbookReadStateStore
|
2026-05-22 20:26:11 -04:00
|
|
|
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,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-26 16:05:53 -04:00
|
|
|
// 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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-22 20:26:11 -04:00
|
|
|
// 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
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-02 20:53:21 -04:00
|
|
|
// 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
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-25 16:20:14 -04:00
|
|
|
func (h *ItemsHandler) SetCatalogSearchProvider(provider catalog.CatalogSearchProvider) {
|
|
|
|
|
if h == nil || h.catalogResolver == nil || provider == nil {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
h.catalogResolver.WithSearchProvider(provider)
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-22 20:26:11 -04:00
|
|
|
func (h *ItemsHandler) SetLocalWatchEventDispatcher(dispatcher LocalWatchEventDispatcher) {
|
|
|
|
|
h.localWatchDispatcher = dispatcher
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-10 08:18:35 -04:00
|
|
|
func (h *ItemsHandler) SetEbookReaderProgressStore(store EbookReaderProgressReadWriter) {
|
|
|
|
|
h.ebookProgressStore = store
|
|
|
|
|
h.ebookReadStateStore = store
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-22 20:26:11 -04:00
|
|
|
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
|
|
|
|
|
}
|
2026-06-06 22:43:35 -04:00
|
|
|
if metadata.EpisodeHasActionableMetadataDebt(episode, time.Now()) {
|
2026-05-22 20:26:11 -04:00
|
|
|
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 {
|
2026-06-06 22:43:35 -04:00
|
|
|
if metadata.EpisodeHasActionableMetadataDebt(episode, now) {
|
2026-05-22 20:26:11 -04:00
|
|
|
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 {
|
2026-07-09 20:53:52 +08:00
|
|
|
slog.WarnContext(ctx, "catalog: failed to request stale metadata refresh", "component", "api",
|
2026-05-22 20:26:11 -04:00
|
|
|
"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 {
|
2026-06-17 00:17:08 +02:00
|
|
|
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"`
|
2026-06-16 20:13:10 -04:00
|
|
|
MangaChapterCount *int `json:"manga_chapter_count,omitempty"`
|
|
|
|
|
MangaVolumeCount *int `json:"manga_volume_count,omitempty"`
|
2026-06-17 00:17:08 +02:00
|
|
|
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"`
|
2026-05-22 20:26:11 -04:00
|
|
|
}
|
|
|
|
|
|
2026-05-29 01:22:45 +00:00
|
|
|
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"`
|
2026-06-09 03:55:35 +02:00
|
|
|
Author string `json:"author,omitempty"`
|
|
|
|
|
Narrator string `json:"narrator,omitempty"`
|
|
|
|
|
SeriesName string `json:"series_name,omitempty"`
|
2026-05-29 01:22:45 +00:00
|
|
|
}
|
|
|
|
|
|
2026-06-25 20:46:08 -04:00
|
|
|
type itemListImageURLs struct {
|
|
|
|
|
posterURL string
|
|
|
|
|
backdropURL string
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-22 20:26:11 -04:00
|
|
|
// browseResponse is the paginated response for the /items endpoint.
|
|
|
|
|
type browseResponse struct {
|
2026-06-26 08:34:04 -04:00
|
|
|
Total int `json:"total"`
|
|
|
|
|
TotalExact bool `json:"total_exact"`
|
|
|
|
|
HasMore bool `json:"has_more"`
|
|
|
|
|
Items []itemListResponse `json:"items"`
|
2026-05-22 20:26:11 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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"`
|
2026-07-16 16:37:34 +02:00
|
|
|
OverlaySummary *models.OverlaySummary `json:"overlay_summary,omitempty"`
|
2026-05-22 20:26:11 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-17 02:32:48 +02:00
|
|
|
if detail.Type == "movie" || detail.Type == "episode" || detail.Type == "ebook" || detail.Type == "audiobook" {
|
2026-06-10 08:18:35 -04:00
|
|
|
detail.UserData = h.getLeafUserData(r, detail.ContentID, detail.Type)
|
2026-05-22 20:26:11 -04:00
|
|
|
applyEffectiveEditionPreference(detail.UserData, &detail.EffectiveVersionEditionKey)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
writeJSON(w, http.StatusOK, detail)
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-02 20:53:21 -04:00
|
|
|
// 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
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-22 20:26:11 -04:00
|
|
|
// 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 {
|
2026-07-09 20:53:52 +08:00
|
|
|
slog.WarnContext(ctx, "failed to queue local watch provider event", "component", "api", "kind", kind, "user_id", userID, "profile_id", profileID, "error", err)
|
2026-05-22 20:26:11 -04:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-10 08:18:35 -04:00
|
|
|
filter := h.accessFilter(r)
|
|
|
|
|
targetType, targets, err := h.resolveWatchedTargets(r.Context(), id, filter)
|
2026-05-22 20:26:11 -04:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-10 08:18:35 -04:00
|
|
|
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:
|
2026-05-22 20:26:11 -04:00
|
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to access user store")
|
|
|
|
|
return
|
2026-06-10 08:18:35 -04:00
|
|
|
case played:
|
2026-05-22 20:26:11 -04:00
|
|
|
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)
|
|
|
|
|
}
|
2026-06-10 08:18:35 -04:00
|
|
|
default:
|
2026-05-22 20:26:11 -04:00
|
|
|
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 {
|
2026-06-10 08:18:35 -04:00
|
|
|
if isNotFound(err) {
|
|
|
|
|
writeError(w, http.StatusNotFound, "not_found", "Item not found")
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-05-22 20:26:11 -04:00
|
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to update watched state")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
triggerProfileRefresh(r.Context(), h.profileStaler, h.profileRefreshRequester, userID, profileID)
|
2026-06-05 19:43:20 -07:00
|
|
|
publishUserStateEvent(r.Context(), h.EventsHub, userID, profileID, id, "", "watched", userStateEventState{
|
|
|
|
|
Played: boolPtr(played),
|
|
|
|
|
})
|
2026-05-22 20:26:11 -04:00
|
|
|
|
|
|
|
|
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{
|
2026-06-26 08:34:04 -04:00
|
|
|
Total: result.Total,
|
|
|
|
|
TotalExact: result.TotalExact,
|
|
|
|
|
HasMore: result.HasMore,
|
|
|
|
|
Items: items,
|
2026-05-22 20:26:11 -04:00
|
|
|
})
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-06-25 20:46:08 -04:00
|
|
|
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 {
|
2026-05-22 20:26:11 -04:00
|
|
|
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
|
2026-06-16 20:13:10 -04:00
|
|
|
resp.MangaChapterCount = item.MangaChapterCount
|
|
|
|
|
resp.MangaVolumeCount = item.MangaVolumeCount
|
2026-05-22 20:26:11 -04:00
|
|
|
resp.ReleaseDate = item.ReleaseDate
|
|
|
|
|
resp.LastAirDate = item.LastAirDate
|
|
|
|
|
if resp.Genres == nil {
|
|
|
|
|
resp.Genres = []string{}
|
|
|
|
|
}
|
|
|
|
|
if resp.Keywords == nil {
|
|
|
|
|
resp.Keywords = []string{}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return resp
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-25 20:46:08 -04:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-22 20:26:11 -04:00
|
|
|
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{}
|
|
|
|
|
}
|
2026-06-10 08:18:35 -04:00
|
|
|
states, err := resolveItemUserStatesWithOptions(r.Context(), store, profileID, h.episodeRepo, items, itemUserStateOptions{
|
|
|
|
|
UserID: apimw.GetUserID(r.Context()),
|
|
|
|
|
EbookProgressStore: h.ebookProgressStore,
|
|
|
|
|
})
|
2026-05-22 20:26:11 -04:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-06-10 16:05:49 -04:00
|
|
|
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) {
|
2026-05-22 20:26:11 -04:00
|
|
|
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")
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-10 16:05:49 -04:00
|
|
|
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)
|
2026-05-22 20:26:11 -04:00
|
|
|
|
2026-06-10 16:05:49 -04:00
|
|
|
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
|
2026-07-16 16:37:34 +02:00
|
|
|
files := catalog.FilterMediaFilesByAccess(filesByEpisode[resp[i].ContentID], filter)
|
|
|
|
|
resp[i].Files = episodeFileResponses(files, filter)
|
2026-06-10 16:05:49 -04:00
|
|
|
resp[i].UserData = userData[resp[i].ContentID]
|
2026-07-16 16:37:34 +02:00
|
|
|
resp[i].OverlaySummary = overlays.BuildSummary(files)
|
2026-06-10 16:05:49 -04:00
|
|
|
}
|
|
|
|
|
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,
|
|
|
|
|
})
|
|
|
|
|
}
|
2026-05-22 20:26:11 -04:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-25 20:46:08 -04:00
|
|
|
groupedFiles := h.listBrowseItemOverlayFiles(ctx, items, filter)
|
2026-05-29 01:22:45 +00:00
|
|
|
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,
|
2026-05-30 02:55:05 +00:00
|
|
|
store userstore.UserStore,
|
2026-06-10 08:18:35 -04:00
|
|
|
userID int,
|
2026-05-30 02:55:05 +00:00
|
|
|
profileID string,
|
2026-05-29 01:22:45 +00:00
|
|
|
) 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}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-05-30 02:55:05 +00:00
|
|
|
case "progress", "date_viewed", "plays":
|
2026-06-10 08:18:35 -04:00
|
|
|
h.listUserSortMetrics(ctx, items, sortField, store, userID, profileID, metrics)
|
2026-06-09 03:55:35 +02:00
|
|
|
case "author", "narrator", "series":
|
|
|
|
|
h.listAudiobookSortMetrics(ctx, items, sortField, metrics)
|
2026-05-29 01:22:45 +00:00
|
|
|
}
|
|
|
|
|
return metrics
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-09 03:55:35 +02:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-30 02:55:05 +00:00
|
|
|
func (h *ItemsHandler) listUserSortMetrics(
|
|
|
|
|
ctx context.Context,
|
|
|
|
|
items []*models.MediaItem,
|
|
|
|
|
sortField string,
|
|
|
|
|
store userstore.UserStore,
|
2026-06-10 08:18:35 -04:00
|
|
|
userID int,
|
2026-05-30 02:55:05 +00:00
|
|
|
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
|
|
|
|
|
}
|
2026-06-10 08:18:35 -04:00
|
|
|
ebookProgressMap, err := h.listEbookReaderProgressByContentIDs(ctx, userID, profileID, contentIDs)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-05-30 02:55:05 +00:00
|
|
|
|
|
|
|
|
switch sortField {
|
|
|
|
|
case "progress":
|
|
|
|
|
for _, item := range items {
|
|
|
|
|
if item == nil || item.ContentID == "" {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
progress, ok := progressMap[item.ContentID]
|
2026-06-10 08:18:35 -04:00
|
|
|
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 {
|
2026-05-30 02:55:05 +00:00
|
|
|
continue
|
|
|
|
|
}
|
2026-06-10 08:18:35 -04:00
|
|
|
ratio := ebookProgress.Progress
|
2026-05-30 02:55:05 +00:00
|
|
|
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
|
|
|
|
|
}
|
2026-06-10 08:18:35 -04:00
|
|
|
if progress, ok := ebookProgressMap[item.ContentID]; ok && progress.Progress >= models.EbookFinishedProgressThreshold {
|
|
|
|
|
ebookViewedAt := progress.UpdatedAt.UTC().Format(time.RFC3339)
|
|
|
|
|
if ebookViewedAt > viewedAt {
|
|
|
|
|
viewedAt = ebookViewedAt
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-05-30 02:55:05 +00:00
|
|
|
if viewedAt == "" {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
resp.ViewedAt = viewedAt
|
|
|
|
|
} else {
|
|
|
|
|
playCount := historyCounts[item.ContentID]
|
|
|
|
|
if progress, ok := progressMap[item.ContentID]; ok && progress.Completed && playCount < 1 {
|
|
|
|
|
playCount = 1
|
|
|
|
|
}
|
2026-06-10 08:18:35 -04:00
|
|
|
if progress, ok := ebookProgressMap[item.ContentID]; ok && progress.Progress >= models.EbookFinishedProgressThreshold && playCount < 1 {
|
|
|
|
|
playCount = 1
|
|
|
|
|
}
|
2026-05-30 02:55:05 +00:00
|
|
|
if playCount <= 0 {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
resp.PlayCount = &playCount
|
|
|
|
|
}
|
|
|
|
|
metrics[item.ContentID] = resp
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-10 08:18:35 -04:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-30 02:55:05 +00:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-29 01:22:45 +00:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-22 20:26:11 -04:00
|
|
|
contentIDs := make([]string, 0, len(items))
|
2026-05-29 01:22:45 +00:00
|
|
|
episodeIDs := make([]string, 0)
|
2026-06-25 20:46:08 -04:00
|
|
|
collectBrowseFileIDs(items, &contentIDs, &episodeIDs)
|
2026-05-29 01:22:45 +00:00
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-05-22 20:26:11 -04:00
|
|
|
}
|
|
|
|
|
|
2026-05-29 01:22:45 +00:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-05-22 20:26:11 -04:00
|
|
|
}
|
2026-05-29 01:22:45 +00:00
|
|
|
|
|
|
|
|
return grouped
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-25 20:46:08 -04:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-29 01:22:45 +00:00
|
|
|
func firstNonBlankPtr(values ...*string) *string {
|
|
|
|
|
for _, value := range values {
|
|
|
|
|
if value == nil || strings.TrimSpace(*value) == "" {
|
|
|
|
|
continue
|
2026-05-22 20:26:11 -04:00
|
|
|
}
|
2026-05-29 01:22:45 +00:00
|
|
|
copyValue := *value
|
|
|
|
|
return ©Value
|
2026-05-22 20:26:11 -04:00
|
|
|
}
|
2026-05-29 01:22:45 +00:00
|
|
|
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
|
2026-05-22 20:26:11 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// toSeasonResponse converts a Season model to an API response.
|
|
|
|
|
func (h *ItemsHandler) toSeasonResponse(r *http.Request, seriesID string, s *models.Season) seasonResponse {
|
2026-06-18 14:18:35 -04:00
|
|
|
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 {
|
2026-05-22 20:26:11 -04:00
|
|
|
if h.detailSvc != nil {
|
|
|
|
|
if localized, err := h.detailSvc.LocalizeSeasonModel(r.Context(), s, h.accessFilter(r)); err == nil && localized != nil {
|
|
|
|
|
s = localized
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-07-31 15:23:30 -04:00
|
|
|
return h.seasonResponseFromEpisodes(r, s, episodes, userData)
|
|
|
|
|
}
|
2026-05-22 20:26:11 -04:00
|
|
|
|
2026-07-31 15:23:30 -04:00
|
|
|
// 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 {
|
2026-05-22 20:26:11 -04:00
|
|
|
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")
|
2026-06-18 14:18:35 -04:00
|
|
|
resp.UserData = userData
|
2026-05-22 20:26:11 -04:00
|
|
|
|
|
|
|
|
return resp
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-10 08:18:35 -04:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-22 20:26:11 -04:00
|
|
|
store, profileID, ok := h.userStoreForRequest(r)
|
|
|
|
|
if !ok {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-18 19:17:22 -04:00
|
|
|
progress, err := userstore.GetProgressWithCompletedHistory(r.Context(), store, profileID, contentID)
|
|
|
|
|
if err != nil {
|
2026-05-22 20:26:11 -04:00
|
|
|
return nil
|
|
|
|
|
}
|
2026-06-18 19:17:22 -04:00
|
|
|
return leafUserDataFromProgress(progress)
|
2026-06-10 16:05:49 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-18 19:17:22 -04:00
|
|
|
progressMap, err := userstore.ListProgressWithCompletedHistory(r.Context(), store, profileID, contentIDs)
|
2026-06-10 16:05:49 -04:00
|
|
|
if err != nil {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
result := make(map[string]*catalog.SeasonUserData, len(progressMap))
|
|
|
|
|
for contentID, progress := range progressMap {
|
2026-06-18 19:17:22 -04:00
|
|
|
progressCopy := progress
|
|
|
|
|
result[contentID] = leafUserDataFromProgress(&progressCopy)
|
2026-06-10 16:05:49 -04:00
|
|
|
}
|
|
|
|
|
return result
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-18 19:17:22 -04:00
|
|
|
func leafUserDataFromProgress(progress *userstore.WatchProgress) *catalog.SeasonUserData {
|
|
|
|
|
if progress == nil {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
2026-05-22 20:26:11 -04:00
|
|
|
return &catalog.SeasonUserData{
|
|
|
|
|
PositionSeconds: progress.PositionSeconds,
|
|
|
|
|
DurationSeconds: progress.DurationSeconds,
|
2026-06-09 19:44:40 -04:00
|
|
|
IsInProgress: progress.PositionSeconds > 0,
|
2026-05-22 20:26:11 -04:00
|
|
|
Played: progress.Completed,
|
|
|
|
|
LastFileID: progress.LastFileID,
|
|
|
|
|
LastResolution: progress.LastResolution,
|
|
|
|
|
LastHDR: progress.LastHDR,
|
|
|
|
|
LastCodecVideo: progress.LastCodecVideo,
|
|
|
|
|
LastEditionKey: progress.LastEditionKey,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-10 08:18:35 -04:00
|
|
|
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,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-22 20:26:11 -04:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-18 14:18:35 -04:00
|
|
|
progressMap, err := h.listProgressForEpisodeIDs(r.Context(), store, profileID, episodeContentIDs(episodes))
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
2026-06-18 19:17:22 -04:00
|
|
|
return catalog.EpisodeRollupUserData(episodes, progressMap)
|
2026-06-18 14:18:35 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
}
|
2026-06-18 19:17:22 -04:00
|
|
|
episodeIDs := episodeContentIDs(episodes)
|
|
|
|
|
progressMap, err := h.listProgressForEpisodeIDs(r.Context(), store, profileID, episodeIDs)
|
2026-06-18 14:18:35 -04:00
|
|
|
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)
|
|
|
|
|
}
|
2026-06-18 19:17:22 -04:00
|
|
|
chunk, err := userstore.ListProgressWithCompletedHistory(ctx, store, profileID, episodeIDs[start:end])
|
2026-06-18 14:18:35 -04:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-10 08:18:35 -04:00
|
|
|
// 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
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-22 20:26:11 -04:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-10 08:18:35 -04:00
|
|
|
profileID := requestProfileID(r)
|
2026-05-22 20:26:11 -04:00
|
|
|
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
|
2026-05-28 17:11:11 -04:00
|
|
|
// 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.
|
2026-05-22 20:26:11 -04:00
|
|
|
func featuredBackdropPath(path string) string {
|
2026-05-28 17:11:11 -04:00
|
|
|
return catalog.BackdropVariantPath(path, "w1920")
|
2026-05-22 20:26:11 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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{
|
2026-07-31 15:23:30 -04:00
|
|
|
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()),
|
2026-05-22 20:26:11 -04:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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 {
|
2026-07-09 20:53:52 +08:00
|
|
|
slog.ErrorContext(r.Context(), "looking up user for library access", "component", "api", "error", userErr)
|
2026-05-22 20:26:11 -04:00
|
|
|
} 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
|
2026-06-10 08:18:35 -04:00
|
|
|
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
|
2026-05-22 20:26:11 -04:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-26 20:04:54 -04:00
|
|
|
func (h *ItemsHandler) requestCanViewFilePaths(r *http.Request) bool {
|
2026-05-22 20:26:11 -04:00
|
|
|
claims := apimw.GetClaims(r.Context())
|
2026-05-26 20:04:54 -04:00
|
|
|
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 {
|
2026-07-09 20:53:52 +08:00
|
|
|
slog.WarnContext(r.Context(), "checking file path visibility permissions", "component", "api", "user_id", claims.UserID, "error", err)
|
2026-05-26 20:04:54 -04:00
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
return auth.HasEffectivePermission(user, auth.PermissionMetadataCuration)
|
2026-05-22 20:26:11 -04:00
|
|
|
}
|