package handlers import ( "context" "errors" "fmt" "net/http" "strconv" "strings" "time" "github.com/go-chi/chi/v5" "log/slog" "github.com/Silo-Server/silo-server/internal/access" apimw "github.com/Silo-Server/silo-server/internal/api/middleware" "github.com/Silo-Server/silo-server/internal/auth" "github.com/Silo-Server/silo-server/internal/catalog" evt "github.com/Silo-Server/silo-server/internal/events" "github.com/Silo-Server/silo-server/internal/metadata" "github.com/Silo-Server/silo-server/internal/models" "github.com/Silo-Server/silo-server/internal/overlays" "github.com/Silo-Server/silo-server/internal/sections" "github.com/Silo-Server/silo-server/internal/userstore" "github.com/Silo-Server/silo-server/internal/watchstate" "github.com/Silo-Server/silo-server/internal/watchsync" ) // EpisodeFileProvider retrieves media files linked to an episode. type EpisodeFileProvider interface { GetByContentID(ctx context.Context, contentID string) ([]*models.MediaFile, error) GetByEpisodeID(ctx context.Context, episodeID string) ([]*models.MediaFile, error) ListByContentIDs(ctx context.Context, contentIDs []string) (map[string][]*models.MediaFile, error) } type MetadataRefreshRequester interface { RequestStaleMetadataRefresh(ctx context.Context, targetType, contentID string) error } type LocalWatchEventDispatcher interface { HandleLocalWatchEvent(ctx context.Context, event watchsync.LocalWatchEvent) error } // ItemsHandler handles browse, search, item detail, and series endpoints. type ItemsHandler struct { browseRepo *catalog.BrowseRepository itemRepo *catalog.ItemRepository episodeRepo *catalog.EpisodeRepository seasonRepo *catalog.SeasonRepository ratingsRepo ratingsRepository catalogResolver *catalog.CatalogResolver fileRepo EpisodeFileProvider detailSvc *catalog.DetailService storeProvider userstore.UserStoreProvider watchState *watchstate.Service profileStaler ProfileStaler profileRefreshRequester ProfileRefreshRequester metadataRefreshRequester MetadataRefreshRequester localWatchDispatcher LocalWatchEventDispatcher 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, } } // 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 } func (h *ItemsHandler) SetLocalWatchEventDispatcher(dispatcher LocalWatchEventDispatcher) { h.localWatchDispatcher = dispatcher } func (h *ItemsHandler) maybeRequestStaleDetailMetadataRefresh(ctx context.Context, detail *catalog.ItemDetail) { if h == nil || detail == nil || h.metadataRefreshRequester == nil { return } switch detail.Type { case "episode": if h.episodeRepo == nil { return } episode, err := h.episodeRepo.GetByID(ctx, detail.ContentID) if err == nil { h.maybeRequestStaleEpisodeMetadataRefresh(ctx, episode) } case "season": if h.episodeRepo == nil { return } episodes, err := h.episodeRepo.ListBySeasonID(ctx, detail.ContentID) if err == nil { h.maybeRequestStaleSeasonMetadataRefresh(ctx, detail.ContentID, episodes) } case "series": if h.itemRepo == nil { return } item, err := h.itemRepo.GetByID(ctx, detail.ContentID) if err == nil && item != nil && item.EpisodeMetadataIncomplete { h.requestStaleMetadataRefresh(ctx, "item", item.ContentID) } } } func (h *ItemsHandler) maybeRequestStaleEpisodeMetadataRefresh(ctx context.Context, episode *models.Episode) { if h == nil || h.metadataRefreshRequester == nil || episode == nil { return } if metadata.EpisodeHasIncompleteMetadata(episode, time.Now()) { h.requestStaleMetadataRefresh(ctx, "episode", episode.ContentID) } } func (h *ItemsHandler) maybeRequestStaleSeasonMetadataRefresh(ctx context.Context, seasonID string, episodes []*models.Episode) { if h == nil || h.metadataRefreshRequester == nil || strings.TrimSpace(seasonID) == "" { return } now := time.Now() for _, episode := range episodes { if metadata.EpisodeHasIncompleteMetadata(episode, now) { h.requestStaleMetadataRefresh(ctx, "season", seasonID) return } } } func (h *ItemsHandler) requestStaleMetadataRefresh(ctx context.Context, targetType, contentID string) { if h == nil || h.metadataRefreshRequester == nil || strings.TrimSpace(contentID) == "" { return } if err := h.metadataRefreshRequester.RequestStaleMetadataRefresh(ctx, targetType, contentID); err != nil { slog.Warn("catalog: failed to request stale metadata refresh", "target_type", targetType, "content_id", contentID, "error", err) } } // --- Response types --- // itemListResponse is the shape of a single item in browse/search list responses. type itemListResponse struct { ContentID string `json:"content_id"` Type string `json:"type"` Title string `json:"title"` SeriesTitle string `json:"series_title,omitempty"` SeasonNumber *int `json:"season_number,omitempty"` EpisodeNumber *int `json:"episode_number,omitempty"` Year int `json:"year,omitempty"` Runtime int `json:"runtime,omitempty"` Genres []string `json:"genres"` Keywords []string `json:"keywords"` Studios []string `json:"studios,omitempty"` Networks []string `json:"networks,omitempty"` ContentRating string `json:"content_rating,omitempty"` Status string `json:"status"` ShowStatus string `json:"show_status,omitempty"` RatingIMDB *float64 `json:"rating_imdb,omitempty"` RatingTMDB *float64 `json:"rating_tmdb,omitempty"` RatingRTCritic *int `json:"rating_rt_critic,omitempty"` RatingRTAudience *int `json:"rating_rt_audience,omitempty"` OriginalLanguage string `json:"original_language,omitempty"` Overview string `json:"overview,omitempty"` PosterURL string `json:"poster_url,omitempty"` PosterThumbhash string `json:"poster_thumbhash,omitempty"` BackdropURL string `json:"backdrop_url,omitempty"` BackdropThumbhash string `json:"backdrop_thumbhash,omitempty"` ReleaseDate *string `json:"release_date,omitempty"` LastAirDate *string `json:"last_air_date,omitempty"` AddedAt *time.Time `json:"added_at,omitempty"` OverlaySummary *models.OverlaySummary `json:"overlay_summary,omitempty"` UserState *itemUserStateResponse `json:"user_state,omitempty"` } // browseResponse is the paginated response for the /items endpoint. type browseResponse struct { Total int `json:"total"` HasMore bool `json:"has_more"` Items []itemListResponse `json:"items"` } type itemFiltersResponse struct { Genres []string `json:"genres"` Studios []string `json:"studios"` Networks []string `json:"networks"` Countries []string `json:"countries"` ContentRatings []string `json:"content_ratings"` } // seasonResponse is the shape of a season in API responses. type seasonResponse struct { ContentID string `json:"content_id"` SeasonNumber int `json:"season_number"` IsSpecials bool `json:"is_specials,omitempty"` Title string `json:"title"` Overview string `json:"overview,omitempty"` AirDate string `json:"air_date,omitempty"` EpisodeCount int `json:"episode_count"` PosterURL string `json:"poster_url,omitempty"` PosterThumbhash string `json:"poster_thumbhash,omitempty"` UserData *catalog.SeasonUserData `json:"user_data,omitempty"` } // seasonsResponse wraps the seasons list for JSON serialization. type seasonsResponse struct { Seasons []seasonResponse `json:"seasons"` } // seasonDetailResponse wraps a single season for JSON serialization. type seasonDetailResponse struct { Season seasonResponse `json:"season"` } // episodesListResponse wraps the episodes list for JSON serialization. type episodesListResponse struct { Episodes []episodeResponse `json:"episodes"` } // episodeFileResponse represents a file version available for an episode. type episodeFileResponse struct { FileID int `json:"file_id"` Resolution string `json:"resolution,omitempty"` CodecVideo string `json:"codec_video,omitempty"` HDR bool `json:"hdr"` AudioChannels int `json:"audio_channels,omitempty"` Container string `json:"container,omitempty"` FileSize int64 `json:"file_size"` } // episodeResponse is the shape of an episode in API responses. type episodeResponse struct { ContentID string `json:"content_id"` SeasonNumber int `json:"season_number"` EpisodeNumber int `json:"episode_number"` Title string `json:"title"` Overview string `json:"overview,omitempty"` AirDate string `json:"air_date,omitempty"` Runtime int `json:"runtime"` ImdbID string `json:"imdb_id,omitempty"` TmdbID string `json:"tmdb_id,omitempty"` TvdbID string `json:"tvdb_id,omitempty"` StillURL string `json:"still_url,omitempty"` StillThumbhash string `json:"still_thumbhash,omitempty"` UserData *catalog.SeasonUserData `json:"user_data,omitempty"` Files []episodeFileResponse `json:"files,omitempty"` } type episodeImageFallback struct { Path string Thumbhash string } type watchedStateResponse struct { ContentID string `json:"content_id"` Type string `json:"type"` AffectedCount int `json:"affected_count"` Played bool `json:"played"` } // --- Handler methods --- // HandleGetItems handles GET /items with filtering, sorting, and pagination. func (h *ItemsHandler) HandleGetItems(w http.ResponseWriter, r *http.Request) { writeDeprecatedReadHeaders(w, "/api/v1/catalog") if values, ok := buildLegacyItemsCatalogValues(r.URL.Query()); ok && h.catalogResolver != nil { h.writeCatalogBrowseResponse(w, r, values) return } writeError(w, http.StatusInternalServerError, "internal_error", "Catalog is not configured") } // HandleGetLatestItems handles GET /items/latest — shortcut for sort=created_at&order=desc. func (h *ItemsHandler) HandleGetLatestItems(w http.ResponseWriter, r *http.Request) { writeDeprecatedReadHeaders(w, "/api/v1/catalog") if h.catalogResolver != nil { h.writeCatalogBrowseResponse(w, r, buildLegacyLatestCatalogValues(r.URL.Query())) return } writeError(w, http.StatusInternalServerError, "internal_error", "Catalog is not configured") } // HandleGetItemFilters handles GET /items/filters for distinct browse values. func (h *ItemsHandler) HandleGetItemFilters(w http.ResponseWriter, r *http.Request) { writeDeprecatedReadHeaders(w, "/api/v1/catalog/filters") if values, ok := buildLegacyItemsCatalogValues(r.URL.Query()); ok && h.catalogResolver != nil { h.writeCatalogFiltersResponse(w, r, values) return } writeError(w, http.StatusInternalServerError, "internal_error", "Catalog is not configured") } // HandleGetItemDetail handles GET /items/{id}. func (h *ItemsHandler) HandleGetItemDetail(w http.ResponseWriter, r *http.Request) { writeDeprecatedReadHeaders(w, "/api/v1/catalog/items/{id}") h.catalogResourceHandler().HandleGetItemDetail(w, r) } // HandleGetWatchDetail handles GET /watch/{id}. func (h *ItemsHandler) HandleGetWatchDetail(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") if id == "" { writeError(w, http.StatusBadRequest, "bad_request", "Watch target ID is required") return } detail, err := h.detailSvc.GetWatchDetail(r.Context(), id, h.accessFilter(r)) if err != nil { switch { case catalog.IsWatchTargetNotPlayable(err): writeError(w, http.StatusBadRequest, "invalid_watch_target", "Content is not directly playable") return case isNotFound(err): writeError(w, http.StatusNotFound, "not_found", "Watch target not found") return default: writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get watch detail") return } } if detail.Type == "movie" || detail.Type == "episode" { detail.UserData = h.getLeafUserData(r, detail.ContentID) applyEffectiveEditionPreference(detail.UserData, &detail.EffectiveVersionEditionKey) } writeJSON(w, http.StatusOK, detail) } // HandleMarkWatched handles POST /watched/{id}. func (h *ItemsHandler) HandleMarkWatched(w http.ResponseWriter, r *http.Request) { h.handleSetWatchedState(w, r, true) } // HandleMarkUnwatched handles DELETE /watched/{id}. func (h *ItemsHandler) HandleMarkUnwatched(w http.ResponseWriter, r *http.Request) { h.handleSetWatchedState(w, r, false) } func (h *ItemsHandler) dispatchLocalWatchEvent( ctx context.Context, kind watchsync.LocalWatchEventKind, userID int, profileID string, result watchstate.ManualMarkResult, ) { if h == nil || h.localWatchDispatcher == nil { return } plays := watchsync.LocalPlaysFromHistory(result.Entries) if len(plays) == 0 { return } if err := h.localWatchDispatcher.HandleLocalWatchEvent(ctx, watchsync.LocalWatchEvent{ Kind: kind, UserID: userID, ProfileID: profileID, Plays: plays, }); err != nil { slog.Warn("failed to queue local watch provider event", "kind", kind, "user_id", userID, "profile_id", profileID, "error", err) } } func (h *ItemsHandler) handleSetWatchedState(w http.ResponseWriter, r *http.Request, played bool) { userID := apimw.GetUserID(r.Context()) profileID := apimw.GetProfileID(r.Context()) id := chi.URLParam(r, "id") if id == "" { writeError(w, http.StatusBadRequest, "bad_request", "Item ID is required") return } if profileID == "" { writeError(w, http.StatusBadRequest, "bad_request", "Profile ID is required") return } targetType, targets, err := h.resolveWatchedTargets(r.Context(), id, h.accessFilter(r)) 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 } if h.watchState == nil { writeError(w, http.StatusInternalServerError, "internal_error", "Failed to access user store") return } if played { leafTargets := make([]watchstate.LeafWatchTarget, 0, len(targets)) for _, target := range targets { leafTargets = append(leafTargets, watchstate.LeafWatchTarget{ MediaItemID: target.ContentID, DurationSeconds: target.DurationSeconds, }) } updatedAt := time.Now().UTC() var result watchstate.ManualMarkResult result, err = h.watchState.RecordManualMarkWatchedWithResult(r.Context(), userID, profileID, leafTargets, updatedAt) if err == nil { h.dispatchLocalWatchEvent(r.Context(), watchsync.LocalWatchEventMarkedWatched, userID, profileID, result) } } else { 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 { writeError(w, http.StatusInternalServerError, "internal_error", "Failed to update watched state") return } triggerProfileRefresh(r.Context(), h.profileStaler, h.profileRefreshRequester, userID, profileID) publishUserStateEvent(r.Context(), h.EventsHub, userID, profileID, id, "", "watched", "watched.updated") writeJSON(w, http.StatusOK, watchedStateResponse{ ContentID: id, Type: targetType, AffectedCount: len(targets), Played: played, }) } // HandleGetItemVersions handles GET /items/{id}/versions. func (h *ItemsHandler) HandleGetItemVersions(w http.ResponseWriter, r *http.Request) { writeDeprecatedReadHeaders(w, "/api/v1/catalog/items/{id}/versions") h.catalogResourceHandler().HandleGetItemVersions(w, r) } // HandleGetItemEpisodes handles GET /items/{id}/episodes for season IDs. func (h *ItemsHandler) HandleGetItemEpisodes(w http.ResponseWriter, r *http.Request) { writeDeprecatedReadHeaders(w, "/api/v1/catalog/items/{id}/episodes") h.catalogResourceHandler().HandleGetItemEpisodes(w, r) } // HandleGetSeasons handles GET /series/{id}/seasons. func (h *ItemsHandler) HandleGetSeasons(w http.ResponseWriter, r *http.Request) { writeDeprecatedReadHeaders(w, "/api/v1/catalog/series/{id}/seasons") h.catalogResourceHandler().HandleGetSeasons(w, r) } // HandleGetSeason handles GET /series/{id}/seasons/{num}. func (h *ItemsHandler) HandleGetSeason(w http.ResponseWriter, r *http.Request) { writeDeprecatedReadHeaders(w, "/api/v1/catalog/series/{id}/seasons/{num}") h.catalogResourceHandler().HandleGetSeason(w, r) } // HandleGetEpisodes handles GET /series/{id}/seasons/{num}/episodes. func (h *ItemsHandler) HandleGetEpisodes(w http.ResponseWriter, r *http.Request) { writeDeprecatedReadHeaders(w, "/api/v1/catalog/series/{id}/seasons/{num}/episodes") h.catalogResourceHandler().HandleGetEpisodes(w, r) } func (h *ItemsHandler) catalogResourceHandler() *CatalogResourceHandler { return NewCatalogResourceHandler(h) } func (h *ItemsHandler) catalogHandler() *CatalogHandler { return NewCatalogHandler(h.catalogResolver, h) } func (h *ItemsHandler) writeCatalogBrowseResponse(w http.ResponseWriter, r *http.Request, values map[string][]string) bool { req, err := catalog.ParseCatalogRequest(values) if err != nil { writeError(w, http.StatusBadRequest, "bad_request", err.Error()) return true } result, err := h.catalogResolver.Resolve(r.Context(), req, h.accessFilter(r)) if err != nil { if errors.Is(err, catalog.ErrInvalidCatalogRequest) { writeError(w, http.StatusBadRequest, "bad_request", err.Error()) return true } if errors.Is(err, catalog.ErrCatalogSourceNotFound) { writeError(w, http.StatusNotFound, "not_found", "Catalog source not found") return true } writeError(w, http.StatusInternalServerError, "internal_error", "Failed to browse items") return true } overlaySummaries := h.listOverlaySummaries(r.Context(), result.Items, h.accessFilter(r)) userStates := h.listItemUserStates(r, result.Items) items := make([]itemListResponse, 0, len(result.Items)) for _, item := range result.Items { items = append(items, h.toItemListResponseWithOverlay(r, item, overlaySummaries[item.ContentID], userStates[item.ContentID])) } writeJSON(w, http.StatusOK, browseResponse{ Total: result.Total, HasMore: result.HasMore, Items: items, }) return true } func (h *ItemsHandler) writeCatalogFiltersResponse(w http.ResponseWriter, r *http.Request, values map[string][]string) bool { req, err := catalog.ParseCatalogRequest(values) if err != nil { writeError(w, http.StatusBadRequest, "bad_request", err.Error()) return true } filters, err := h.catalogResolver.ListFiltersWithOptions( r.Context(), req, h.accessFilter(r), catalog.CatalogFilterOptions{IncludeTechnical: false}, ) if err != nil { if errors.Is(err, catalog.ErrInvalidCatalogRequest) { writeError(w, http.StatusBadRequest, "bad_request", err.Error()) return true } writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list item filters") return true } writeJSON(w, http.StatusOK, itemFiltersResponse{ Genres: filters.Genres, Studios: filters.Studios, Networks: filters.Networks, Countries: filters.Countries, ContentRatings: filters.ContentRatings, }) return true } // toItemListResponse converts a MediaItem to an itemListResponse with presigned URLs. func (h *ItemsHandler) toItemListResponse(r *http.Request, item *models.MediaItem) itemListResponse { return h.toItemListResponseWithOverlay(r, item, nil, nil) } func (h *ItemsHandler) toItemListResponseWithOverlay(r *http.Request, item *models.MediaItem, overlaySummary *models.OverlaySummary, userState *itemUserStateResponse) itemListResponse { if h.detailSvc != nil { if localized, err := h.detailSvc.LocalizeItemModel(r.Context(), item, h.accessFilter(r)); err == nil && localized != nil { item = localized } } resp := itemListResponse{ ContentID: item.ContentID, Type: item.Type, Title: item.Title, Year: item.Year, Runtime: item.Runtime, Genres: item.Genres, Keywords: item.Keywords, Studios: item.Studios, Networks: item.Networks, ContentRating: item.ContentRating, Status: item.Status, ShowStatus: item.ShowStatus, RatingIMDB: item.RatingIMDB, RatingTMDB: item.RatingTMDB, RatingRTCritic: item.RatingRTCritic, RatingRTAudience: item.RatingRTAudience, OriginalLanguage: item.OriginalLanguage, Overview: item.Overview, PosterThumbhash: item.PosterThumbhash, BackdropThumbhash: item.BackdropThumbhash, OverlaySummary: overlaySummary, UserState: userState, } resp.AddedAt = item.AddedAt resp.ReleaseDate = item.ReleaseDate resp.LastAirDate = item.LastAirDate resp.PosterURL = h.presignURL(r, cardThumbnailPath(item.PosterPath), "card") resp.BackdropURL = h.presignURL(r, cardThumbnailPath(item.BackdropPath), "card") if resp.Genres == nil { resp.Genres = []string{} } if resp.Keywords == nil { resp.Keywords = []string{} } return resp } func (h *ItemsHandler) listEpisodeBrowseMetadata( ctx context.Context, items []*models.MediaItem, ) map[string]struct { SeriesTitle string SeasonNumber *int EpisodeNumber *int } { if h == nil || h.episodeRepo == nil { return map[string]struct { SeriesTitle string SeasonNumber *int EpisodeNumber *int }{} } episodeIDs := make([]string, 0) for _, item := range items { if item == nil || item.Type != "episode" || strings.TrimSpace(item.ContentID) == "" { continue } episodeIDs = append(episodeIDs, item.ContentID) } if len(episodeIDs) == 0 { return map[string]struct { SeriesTitle string SeasonNumber *int EpisodeNumber *int }{} } episodes, err := h.episodeRepo.GetByIDs(ctx, episodeIDs) if err != nil || len(episodes) == 0 { return map[string]struct { SeriesTitle string SeasonNumber *int EpisodeNumber *int }{} } seriesIDs := make([]string, 0, len(episodes)) seenSeriesIDs := make(map[string]struct{}, len(episodes)) for _, episode := range episodes { if episode == nil || strings.TrimSpace(episode.SeriesID) == "" { continue } if _, ok := seenSeriesIDs[episode.SeriesID]; ok { continue } seenSeriesIDs[episode.SeriesID] = struct{}{} seriesIDs = append(seriesIDs, episode.SeriesID) } seriesTitles := make(map[string]string, len(seriesIDs)) if len(seriesIDs) > 0 { if seriesItems, err := h.itemRepo.GetByIDs(ctx, seriesIDs); err == nil { for _, seriesItem := range seriesItems { if seriesItem == nil { continue } seriesTitles[seriesItem.ContentID] = seriesItem.Title } } } result := make(map[string]struct { SeriesTitle string SeasonNumber *int EpisodeNumber *int }, len(episodes)) for _, episode := range episodes { if episode == nil { continue } seasonNumber := episode.SeasonNumber episodeNumber := episode.EpisodeNumber result[episode.ContentID] = struct { SeriesTitle string SeasonNumber *int EpisodeNumber *int }{ SeriesTitle: seriesTitles[episode.SeriesID], SeasonNumber: &seasonNumber, EpisodeNumber: &episodeNumber, } } return result } func (h *ItemsHandler) listItemUserStates(r *http.Request, items []*models.MediaItem) map[string]*itemUserStateResponse { store, profileID, ok := h.userStoreForRequest(r) if !ok { return map[string]*itemUserStateResponse{} } states, err := resolveItemUserStates(r.Context(), store, profileID, h.episodeRepo, items) 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 } } 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") } resp.StillURL = h.presignURL(r, cardThumbnailPath(stillPath), "card") 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 } 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) } if len(contentIDs) == 0 { return summaries } groupedFiles, err := h.fileRepo.ListByContentIDs(ctx, contentIDs) if err != nil { return summaries } for contentID, files := range groupedFiles { files = catalog.FilterMediaFilesByAccess(files, filter) if summary := overlays.BuildSummary(files); summary != nil { summaries[contentID] = summary } } return summaries } // toSeasonResponse converts a Season model to an API response. func (h *ItemsHandler) toSeasonResponse(r *http.Request, seriesID string, s *models.Season) seasonResponse { if h.detailSvc != nil { if localized, err := h.detailSvc.LocalizeSeasonModel(r.Context(), s, h.accessFilter(r)); err == nil && localized != nil { s = localized } } episodes, _ := h.episodeRepo.ListBySeason(r.Context(), seriesID, s.SeasonNumber) resp := seasonResponse{ ContentID: s.ContentID, SeasonNumber: s.SeasonNumber, IsSpecials: s.SeasonNumber == 0, Title: s.Title, Overview: s.Overview, EpisodeCount: len(episodes), PosterThumbhash: s.PosterThumbhash, } if s.AirDate != nil { resp.AirDate = s.AirDate.Format("2006-01-02") } resp.PosterURL = h.presignURL(r, featuredPosterPath(s.PosterPath), "featured") resp.UserData = h.getAggregateUserData(r, episodes) return resp } func (h *ItemsHandler) getLeafUserData(r *http.Request, contentID string) *catalog.SeasonUserData { store, profileID, ok := h.userStoreForRequest(r) if !ok { return nil } progress, err := store.GetProgress(r.Context(), profileID, contentID) if err != nil || progress == nil { return nil } return &catalog.SeasonUserData{ PositionSeconds: progress.PositionSeconds, DurationSeconds: progress.DurationSeconds, IsInProgress: !progress.Completed && progress.PositionSeconds > 0, Played: progress.Completed, LastFileID: progress.LastFileID, LastResolution: progress.LastResolution, LastHDR: progress.LastHDR, LastCodecVideo: progress.LastCodecVideo, LastEditionKey: progress.LastEditionKey, } } 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 } var watchedCount int var inProgressCount int for _, ep := range episodes { progress, progressErr := store.GetProgress(r.Context(), profileID, ep.ContentID) if progressErr != nil || progress == nil { continue } if progress.Completed { watchedCount++ continue } if progress.PositionSeconds > 0 { inProgressCount++ } } unplayedCount := len(episodes) - watchedCount return &catalog.SeasonUserData{ WatchedCount: watchedCount, UnplayedCount: unplayedCount, InProgressCount: inProgressCount, Played: watchedCount == len(episodes), } } func (h *ItemsHandler) userStoreForRequest(r *http.Request) (userstore.UserStore, string, bool) { if h.storeProvider == nil { return nil, "", false } userID := apimw.GetUserID(r.Context()) if userID == 0 { return nil, "", false } profileID := apimw.GetProfileID(r.Context()) if profileID == "" { profileID = r.Header.Get("X-Profile-Id") } if profileID == "" { return nil, "", false } store, err := h.storeProvider.ForUser(r.Context(), userID) if err != nil || store == nil { return nil, "", false } return store, profileID, true } // cardThumbnailPath converts an S3 image path from original to w300 for use in // browse/card views (posters, backdrops, and stills at card size). // Full URLs (TMDB/TVDB) and plugin-prefixed paths are returned as-is — // variant selection is handled at resolution time for plugin paths. func cardThumbnailPath(path string) string { if strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://") { return path } // Plugin-prefixed paths pass through — variant handled at resolution time. if strings.Contains(path, "://") { return path } return strings.Replace(path, "/original.", "/w300.", 1) } // featuredPosterPath converts an S3 poster path from original to w500 for // featured/hero contexts (displayed at ~220px CSS / 440px retina). // Full URLs (TMDB/TVDB) and plugin-prefixed paths are returned as-is. func featuredPosterPath(path string) string { if strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://") { return path } if strings.Contains(path, "://") { return path } return strings.Replace(path, "/original.", "/w500.", 1) } // featuredBackdropPath converts an S3 backdrop path from original to w1920 for // featured/hero contexts (displayed at full viewport width). // Full URLs (TMDB/TVDB) and plugin-prefixed paths are returned as-is. func featuredBackdropPath(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.", "/w1920.", 1) } // presignURL resolves an image path to a usable URL, delegating to the // DetailService which handles plugin-prefixed paths, HTTP pass-through, // and legacy S3 presigning. func (h *ItemsHandler) presignURL(r *http.Request, path string, variant string) string { if h.detailSvc != nil { return h.detailSvc.PresignURL(r.Context(), path, variant) } return "" } // HandleFilterItems handles POST /items/filter with a full rule-group filter body. func (h *ItemsHandler) HandleFilterItems(w http.ResponseWriter, r *http.Request) { writeDeprecatedReadHeaders(w, "/api/v1/catalog/query") h.catalogHandler().HandlePostCatalogQuery(w, r) } type filterItemsRequest struct { sections.FilterConfig LibraryID int `json:"library_id"` Limit int `json:"limit"` Offset int `json:"offset"` } func filterItemColumns(alias string) string { cols := []string{ "content_id", "type", "title", "sort_title", "original_title", "year", "genres", "content_rating", "runtime", "overview", "tagline", "rating_imdb", "rating_tmdb", "rating_rt_critic", "rating_rt_audience", "imdb_id", "tmdb_id", "tvdb_id", "poster_path", "poster_thumbhash", "backdrop_path", "backdrop_thumbhash", "logo_path", "metadata_s3_path", "metadata_etag", "season_count", "studios", "networks", "countries", "first_air_date", "last_air_date", "matched_at", "status", "created_at", "updated_at", } prefixed := make([]string, len(cols)) for i, c := range cols { prefixed[i] = alias + "." + c } return strings.Join(prefixed, ", ") } func filterSortClause(sort, order string) string { dir := "DESC" if order == "asc" { dir = "ASC" } switch sort { case "rating": return fmt.Sprintf("ORDER BY mi.rating_imdb %s NULLS LAST", dir) case "year": return fmt.Sprintf("ORDER BY mi.year %s", dir) case "title": return fmt.Sprintf("ORDER BY LOWER(COALESCE(NULLIF(BTRIM(mi.sort_title), ''), mi.title)) %s, LOWER(mi.title) %s, mi.content_id ASC", dir, dir) default: return fmt.Sprintf("ORDER BY mi.created_at %s", dir) } } func (h *ItemsHandler) accessFilter(r *http.Request) catalog.AccessFilter { selectedFileID := 0 if fileIDRaw := strings.TrimSpace(r.URL.Query().Get("fileId")); fileIDRaw != "" { if fileID, err := strconv.Atoi(fileIDRaw); err == nil && fileID > 0 { selectedFileID = fileID } } var presentationLibraryID *int if libraryIDRaw := strings.TrimSpace(r.URL.Query().Get("library_id")); libraryIDRaw != "" { if libraryID, err := strconv.Atoi(libraryIDRaw); err == nil && libraryID > 0 { presentationLibraryID = &libraryID } } if scope, ok := access.GetScope(r.Context()); ok { return catalog.AccessFilter{ AllowedLibraryIDs: scope.AllowedLibraryIDs, DisabledLibraryIDs: scope.DisabledLibraryIDs, MaxContentRating: scope.MaxContentRating, MaxPlaybackQuality: scope.MaxPlaybackQuality, PresentationLibraryID: presentationLibraryID, SelectedFileID: selectedFileID, UserID: apimw.GetUserID(r.Context()), ProfileID: apimw.GetProfileID(r.Context()), } } var libraryIDs []int var maxPlaybackQuality string if h.UserRepo != nil { userID := apimw.GetUserID(r.Context()) if userID != 0 { user, userErr := h.UserRepo.GetByID(r.Context(), userID) if userErr != nil { slog.Error("looking up user for library access", "error", userErr) } else { if user.LibraryIDs != nil { libraryIDs = user.LibraryIDs } maxPlaybackQuality = access.NormalizePlaybackQuality(user.MaxPlaybackQuality) } } } return catalog.AccessFilter{ AllowedLibraryIDs: libraryIDs, MaxPlaybackQuality: maxPlaybackQuality, PresentationLibraryID: presentationLibraryID, SelectedFileID: selectedFileID, UserID: apimw.GetUserID(r.Context()), ProfileID: apimw.GetProfileID(r.Context()), } } func (h *ItemsHandler) ensurePresentationLibraryAccess(ctx context.Context, contentID string, filter catalog.AccessFilter) error { if filter.PresentationLibraryID == nil { return nil } membership, err := h.itemRepo.GetItemsInLibrary(ctx, []string{contentID}, *filter.PresentationLibraryID) if err != nil { return err } if !membership[contentID] { return catalog.ErrItemNotFound } return nil } type watchedLeafTarget struct { ContentID string DurationSeconds float64 } func (h *ItemsHandler) resolveWatchedTargets(ctx context.Context, contentID string, filter catalog.AccessFilter) (string, []watchedLeafTarget, error) { item, err := h.itemRepo.GetByID(ctx, contentID) switch { case err == nil: if err := h.itemRepo.EnsureAccessible(ctx, contentID, filter); err != nil { return "", nil, err } switch item.Type { case "movie": return "movie", []watchedLeafTarget{{ ContentID: item.ContentID, DurationSeconds: h.contentDurationSeconds(ctx, item.ContentID, "", item.Runtime), }}, nil case "series": if h.episodeRepo == nil { return "", nil, catalog.ErrItemNotFound } episodes, err := h.episodeRepo.ListBySeries(ctx, item.ContentID) if err != nil { return "", nil, err } return "series", h.episodeTargets(ctx, episodes), nil default: return "", nil, catalog.ErrItemNotFound } case !errors.Is(err, catalog.ErrItemNotFound): return "", nil, err } if h.seasonRepo != nil { season, err := h.seasonRepo.GetByID(ctx, contentID) switch { case err == nil: if err := h.itemRepo.EnsureAccessible(ctx, season.SeriesID, filter); err != nil { return "", nil, err } if h.episodeRepo == nil { return "", nil, catalog.ErrItemNotFound } episodes, err := h.episodeRepo.ListBySeasonID(ctx, season.ContentID) if err != nil { return "", nil, err } return "season", h.episodeTargets(ctx, episodes), nil case !errors.Is(err, catalog.ErrSeasonNotFound): return "", nil, err } } if h.episodeRepo == nil { return "", nil, catalog.ErrItemNotFound } episode, err := h.episodeRepo.GetByID(ctx, contentID) if err != nil { return "", nil, err } if err := h.itemRepo.EnsureAccessible(ctx, episode.SeriesID, filter); err != nil { return "", nil, err } return "episode", []watchedLeafTarget{{ ContentID: episode.ContentID, DurationSeconds: h.contentDurationSeconds(ctx, "", episode.ContentID, episode.Runtime), }}, nil } func (h *ItemsHandler) episodeTargets(ctx context.Context, episodes []*models.Episode) []watchedLeafTarget { targets := make([]watchedLeafTarget, 0, len(episodes)) for _, episode := range episodes { targets = append(targets, watchedLeafTarget{ ContentID: episode.ContentID, DurationSeconds: h.contentDurationSeconds(ctx, "", episode.ContentID, episode.Runtime), }) } return targets } func (h *ItemsHandler) contentDurationSeconds(ctx context.Context, contentID, episodeID string, fallbackRuntimeMinutes int) float64 { if h.fileRepo == nil { if fallbackRuntimeMinutes > 0 { return float64(fallbackRuntimeMinutes * 60) } return 0 } var ( files []*models.MediaFile err error ) switch { case episodeID != "": files, err = h.fileRepo.GetByEpisodeID(ctx, episodeID) case contentID != "": files, err = h.fileRepo.GetByContentID(ctx, contentID) } if err == nil { for _, file := range files { if file != nil && file.Duration > 0 { return float64(file.Duration) } } } if fallbackRuntimeMinutes > 0 { return float64(fallbackRuntimeMinutes * 60) } return 0 } // isNotFound checks if an error is a "not found" sentinel. func isNotFound(err error) bool { return errors.Is(err, catalog.ErrItemNotFound) || errors.Is(err, catalog.ErrEpisodeNotFound) || errors.Is(err, catalog.ErrSeasonNotFound) } func (h *ItemsHandler) requestCanViewFilePaths(r *http.Request) bool { claims := apimw.GetClaims(r.Context()) if claims == nil { return false } if claims.Role == "admin" { return true } if h == nil || h.UserRepo == nil { return false } user, err := h.UserRepo.GetByID(r.Context(), claims.UserID) if err != nil { slog.WarnContext(r.Context(), "checking file path visibility permissions", "user_id", claims.UserID, "error", err) return false } return auth.HasEffectivePermission(user, auth.PermissionMetadataCuration) }