* feat(metadata): add original-language preferences * fix(settings): show metadata exceptions immediately * docs(settings): add metadata language screenshot * fix(settings): make language exceptions responsive * fix(settings): standardize language display names
2027 lines
66 KiB
Go
2027 lines
66 KiB
Go
package handlers
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
|
|
"log/slog"
|
|
|
|
"github.com/Silo-Server/silo-server/internal/access"
|
|
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
|
|
"github.com/Silo-Server/silo-server/internal/auth"
|
|
"github.com/Silo-Server/silo-server/internal/catalog"
|
|
evt "github.com/Silo-Server/silo-server/internal/events"
|
|
"github.com/Silo-Server/silo-server/internal/metadata"
|
|
"github.com/Silo-Server/silo-server/internal/models"
|
|
"github.com/Silo-Server/silo-server/internal/overlays"
|
|
"github.com/Silo-Server/silo-server/internal/sections"
|
|
"github.com/Silo-Server/silo-server/internal/userstore"
|
|
"github.com/Silo-Server/silo-server/internal/watchstate"
|
|
"github.com/Silo-Server/silo-server/internal/watchsync"
|
|
)
|
|
|
|
// EpisodeFileProvider retrieves media files linked to an episode.
|
|
type EpisodeFileProvider interface {
|
|
GetByContentID(ctx context.Context, contentID string) ([]*models.MediaFile, error)
|
|
GetByEpisodeID(ctx context.Context, episodeID string) ([]*models.MediaFile, error)
|
|
ListByContentIDs(ctx context.Context, contentIDs []string) (map[string][]*models.MediaFile, error)
|
|
}
|
|
|
|
type batchEpisodeFileProvider interface {
|
|
ListByEpisodeIDs(ctx context.Context, episodeIDs []string) (map[string][]*models.MediaFile, error)
|
|
}
|
|
|
|
type overlayFileProvider interface {
|
|
ListOverlayFilesByContentIDs(ctx context.Context, contentIDs []string) (map[string][]*models.MediaFile, error)
|
|
ListOverlayFilesByEpisodeIDs(ctx context.Context, episodeIDs []string) (map[string][]*models.MediaFile, error)
|
|
}
|
|
|
|
type MetadataRefreshRequester interface {
|
|
RequestStaleMetadataRefresh(ctx context.Context, targetType, contentID string) error
|
|
}
|
|
|
|
type LocalWatchEventDispatcher interface {
|
|
HandleLocalWatchEvent(ctx context.Context, event watchsync.LocalWatchEvent) error
|
|
}
|
|
|
|
type EbookReaderProgressLister interface {
|
|
ListByContentIDs(ctx context.Context, userID int, profileID string, contentIDs []string) (map[string]EbookReaderProgress, error)
|
|
}
|
|
|
|
// ItemsHandler handles browse, search, item detail, and series endpoints.
|
|
type ItemsHandler struct {
|
|
browseRepo *catalog.BrowseRepository
|
|
itemRepo *catalog.ItemRepository
|
|
episodeRepo *catalog.EpisodeRepository
|
|
seasonRepo *catalog.SeasonRepository
|
|
ratingsRepo ratingsRepository
|
|
catalogResolver *catalog.CatalogResolver
|
|
fileRepo EpisodeFileProvider
|
|
detailSvc *catalog.DetailService
|
|
storeProvider userstore.UserStoreProvider
|
|
watchState *watchstate.Service
|
|
profileStaler ProfileStaler
|
|
profileRefreshRequester ProfileRefreshRequester
|
|
metadataRefreshRequester MetadataRefreshRequester
|
|
localWatchDispatcher LocalWatchEventDispatcher
|
|
ebookProgressStore EbookReaderProgressLister
|
|
ebookReadStateStore EbookReadStateStore
|
|
EventsHub *evt.Hub
|
|
UserRepo *auth.UserRepository
|
|
}
|
|
|
|
// NewItemsHandler creates a new ItemsHandler.
|
|
func NewItemsHandler(
|
|
browseRepo *catalog.BrowseRepository,
|
|
itemRepo *catalog.ItemRepository,
|
|
episodeRepo *catalog.EpisodeRepository,
|
|
seasonRepo *catalog.SeasonRepository,
|
|
ratingsRepo ratingsRepository,
|
|
fileRepo EpisodeFileProvider,
|
|
storeProvider userstore.UserStoreProvider,
|
|
detailSvc *catalog.DetailService,
|
|
providerIDRepo *catalog.ProviderIDRepository,
|
|
) *ItemsHandler {
|
|
return &ItemsHandler{
|
|
browseRepo: browseRepo,
|
|
itemRepo: itemRepo,
|
|
episodeRepo: episodeRepo,
|
|
seasonRepo: seasonRepo,
|
|
ratingsRepo: ratingsRepo,
|
|
catalogResolver: catalog.NewCatalogResolver(browseRepo, itemRepo).
|
|
WithEpisodeRepository(episodeRepo).
|
|
WithUserStoreProvider(storeProvider),
|
|
fileRepo: fileRepo,
|
|
storeProvider: storeProvider,
|
|
watchState: watchstate.NewService(storeProvider).WithStableIdentityResolver(
|
|
watchstate.NewStableIdentityResolver(itemRepo, episodeRepo, providerIDRepo),
|
|
),
|
|
detailSvc: detailSvc,
|
|
}
|
|
}
|
|
|
|
// SetCompletionObserver wires an optional observer notified when a watch
|
|
// completes (used to auto-remove fully-watched items from the watchlist).
|
|
func (h *ItemsHandler) SetCompletionObserver(obs watchstate.CompletionObserver) {
|
|
if h == nil || h.watchState == nil || obs == nil {
|
|
return
|
|
}
|
|
h.watchState.WithCompletionObserver(obs)
|
|
}
|
|
|
|
// SetProfileStaler configures an optional staleness trigger for taste profiles.
|
|
func (h *ItemsHandler) SetProfileStaler(ps ProfileStaler) {
|
|
h.profileStaler = ps
|
|
}
|
|
|
|
// SetProfileRefreshRequester configures an optional background refresh queue for taste profiles.
|
|
func (h *ItemsHandler) SetProfileRefreshRequester(requester ProfileRefreshRequester) {
|
|
h.profileRefreshRequester = requester
|
|
}
|
|
|
|
func (h *ItemsHandler) SetMetadataRefreshRequester(requester MetadataRefreshRequester) {
|
|
h.metadataRefreshRequester = requester
|
|
}
|
|
|
|
func (h *ItemsHandler) SetCatalogSearchProvider(provider catalog.CatalogSearchProvider) {
|
|
if h == nil || h.catalogResolver == nil || provider == nil {
|
|
return
|
|
}
|
|
h.catalogResolver.WithSearchProvider(provider)
|
|
}
|
|
|
|
func (h *ItemsHandler) SetLocalWatchEventDispatcher(dispatcher LocalWatchEventDispatcher) {
|
|
h.localWatchDispatcher = dispatcher
|
|
}
|
|
|
|
func (h *ItemsHandler) SetEbookReaderProgressStore(store EbookReaderProgressReadWriter) {
|
|
h.ebookProgressStore = store
|
|
h.ebookReadStateStore = store
|
|
}
|
|
|
|
func (h *ItemsHandler) maybeRequestStaleDetailMetadataRefresh(ctx context.Context, detail *catalog.ItemDetail) {
|
|
if h == nil || detail == nil || h.metadataRefreshRequester == nil {
|
|
return
|
|
}
|
|
switch detail.Type {
|
|
case "episode":
|
|
if h.episodeRepo == nil {
|
|
return
|
|
}
|
|
episode, err := h.episodeRepo.GetByID(ctx, detail.ContentID)
|
|
if err == nil {
|
|
h.maybeRequestStaleEpisodeMetadataRefresh(ctx, episode)
|
|
}
|
|
case "season":
|
|
if h.episodeRepo == nil {
|
|
return
|
|
}
|
|
episodes, err := h.episodeRepo.ListBySeasonID(ctx, detail.ContentID)
|
|
if err == nil {
|
|
h.maybeRequestStaleSeasonMetadataRefresh(ctx, detail.ContentID, episodes)
|
|
}
|
|
case "series":
|
|
if h.itemRepo == nil {
|
|
return
|
|
}
|
|
item, err := h.itemRepo.GetByID(ctx, detail.ContentID)
|
|
if err == nil && item != nil && item.EpisodeMetadataIncomplete {
|
|
h.requestStaleMetadataRefresh(ctx, "item", item.ContentID)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (h *ItemsHandler) maybeRequestStaleEpisodeMetadataRefresh(ctx context.Context, episode *models.Episode) {
|
|
if h == nil || h.metadataRefreshRequester == nil || episode == nil {
|
|
return
|
|
}
|
|
if metadata.EpisodeHasActionableMetadataDebt(episode, time.Now()) {
|
|
h.requestStaleMetadataRefresh(ctx, "episode", episode.ContentID)
|
|
}
|
|
}
|
|
|
|
func (h *ItemsHandler) maybeRequestStaleSeasonMetadataRefresh(ctx context.Context, seasonID string, episodes []*models.Episode) {
|
|
if h == nil || h.metadataRefreshRequester == nil || strings.TrimSpace(seasonID) == "" {
|
|
return
|
|
}
|
|
now := time.Now()
|
|
for _, episode := range episodes {
|
|
if metadata.EpisodeHasActionableMetadataDebt(episode, now) {
|
|
h.requestStaleMetadataRefresh(ctx, "season", seasonID)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
func (h *ItemsHandler) requestStaleMetadataRefresh(ctx context.Context, targetType, contentID string) {
|
|
if h == nil || h.metadataRefreshRequester == nil || strings.TrimSpace(contentID) == "" {
|
|
return
|
|
}
|
|
if err := h.metadataRefreshRequester.RequestStaleMetadataRefresh(ctx, targetType, contentID); err != nil {
|
|
slog.WarnContext(ctx, "catalog: failed to request stale metadata refresh", "component", "api",
|
|
"target_type", targetType,
|
|
"content_id", contentID,
|
|
"error", err)
|
|
}
|
|
}
|
|
|
|
// --- Response types ---
|
|
|
|
// itemListResponse is the shape of a single item in browse/search list responses.
|
|
type itemListResponse struct {
|
|
ContentID string `json:"content_id"`
|
|
Type string `json:"type"`
|
|
Title string `json:"title"`
|
|
SeriesTitle string `json:"series_title,omitempty"`
|
|
SeasonNumber *int `json:"season_number,omitempty"`
|
|
EpisodeNumber *int `json:"episode_number,omitempty"`
|
|
Year int `json:"year,omitempty"`
|
|
Runtime int `json:"runtime,omitempty"`
|
|
Genres []string `json:"genres"`
|
|
Keywords []string `json:"keywords"`
|
|
Studios []string `json:"studios,omitempty"`
|
|
Networks []string `json:"networks,omitempty"`
|
|
ContentRating string `json:"content_rating,omitempty"`
|
|
Status string `json:"status"`
|
|
ShowStatus string `json:"show_status,omitempty"`
|
|
RatingIMDB *float64 `json:"rating_imdb,omitempty"`
|
|
RatingTMDB *float64 `json:"rating_tmdb,omitempty"`
|
|
RatingRTCritic *int `json:"rating_rt_critic,omitempty"`
|
|
RatingRTAudience *int `json:"rating_rt_audience,omitempty"`
|
|
OriginalLanguage string `json:"original_language,omitempty"`
|
|
Overview string `json:"overview,omitempty"`
|
|
PosterURL string `json:"poster_url,omitempty"`
|
|
PosterThumbhash string `json:"poster_thumbhash,omitempty"`
|
|
BackdropURL string `json:"backdrop_url,omitempty"`
|
|
BackdropThumbhash string `json:"backdrop_thumbhash,omitempty"`
|
|
ReleaseDate *string `json:"release_date,omitempty"`
|
|
LastAirDate *string `json:"last_air_date,omitempty"`
|
|
AddedAt *time.Time `json:"added_at,omitempty"`
|
|
MangaChapterCount *int `json:"manga_chapter_count,omitempty"`
|
|
MangaVolumeCount *int `json:"manga_volume_count,omitempty"`
|
|
OverlaySummary *models.OverlaySummary `json:"overlay_summary,omitempty"`
|
|
SortMetrics *sortMetricsResponse `json:"sort_metrics,omitempty"`
|
|
UserState *itemUserStateResponse `json:"user_state,omitempty"`
|
|
WorkID string `json:"work_id,omitempty"`
|
|
WorkTitle string `json:"work_title,omitempty"`
|
|
WorkFormats []catalog.WorkFormatSummary `json:"work_formats,omitempty"`
|
|
}
|
|
|
|
type sortMetricsResponse struct {
|
|
ReleaseDate *string `json:"release_date,omitempty"`
|
|
RuntimeMinutes *int `json:"runtime_minutes,omitempty"`
|
|
Resolution string `json:"resolution,omitempty"`
|
|
BitrateKbps *int `json:"bitrate_kbps,omitempty"`
|
|
ProgressRatio *float64 `json:"progress_ratio,omitempty"`
|
|
ViewedAt string `json:"viewed_at,omitempty"`
|
|
PlayCount *int `json:"play_count,omitempty"`
|
|
Author string `json:"author,omitempty"`
|
|
Narrator string `json:"narrator,omitempty"`
|
|
SeriesName string `json:"series_name,omitempty"`
|
|
}
|
|
|
|
type itemListImageURLs struct {
|
|
posterURL string
|
|
backdropURL string
|
|
}
|
|
|
|
// browseResponse is the paginated response for the /items endpoint.
|
|
type browseResponse struct {
|
|
Total int `json:"total"`
|
|
TotalExact bool `json:"total_exact"`
|
|
HasMore bool `json:"has_more"`
|
|
Items []itemListResponse `json:"items"`
|
|
}
|
|
|
|
type itemFiltersResponse struct {
|
|
Genres []string `json:"genres"`
|
|
Studios []string `json:"studios"`
|
|
Networks []string `json:"networks"`
|
|
Countries []string `json:"countries"`
|
|
ContentRatings []string `json:"content_ratings"`
|
|
}
|
|
|
|
// seasonResponse is the shape of a season in API responses.
|
|
type seasonResponse struct {
|
|
ContentID string `json:"content_id"`
|
|
SeasonNumber int `json:"season_number"`
|
|
IsSpecials bool `json:"is_specials,omitempty"`
|
|
Title string `json:"title"`
|
|
Overview string `json:"overview,omitempty"`
|
|
AirDate string `json:"air_date,omitempty"`
|
|
EpisodeCount int `json:"episode_count"`
|
|
PosterURL string `json:"poster_url,omitempty"`
|
|
PosterThumbhash string `json:"poster_thumbhash,omitempty"`
|
|
UserData *catalog.SeasonUserData `json:"user_data,omitempty"`
|
|
}
|
|
|
|
// seasonsResponse wraps the seasons list for JSON serialization.
|
|
type seasonsResponse struct {
|
|
Seasons []seasonResponse `json:"seasons"`
|
|
}
|
|
|
|
// seasonDetailResponse wraps a single season for JSON serialization.
|
|
type seasonDetailResponse struct {
|
|
Season seasonResponse `json:"season"`
|
|
}
|
|
|
|
// episodesListResponse wraps the episodes list for JSON serialization.
|
|
type episodesListResponse struct {
|
|
Episodes []episodeResponse `json:"episodes"`
|
|
}
|
|
|
|
// episodeFileResponse represents a file version available for an episode.
|
|
type episodeFileResponse struct {
|
|
FileID int `json:"file_id"`
|
|
Resolution string `json:"resolution,omitempty"`
|
|
CodecVideo string `json:"codec_video,omitempty"`
|
|
HDR bool `json:"hdr"`
|
|
AudioChannels int `json:"audio_channels,omitempty"`
|
|
Container string `json:"container,omitempty"`
|
|
FileSize int64 `json:"file_size"`
|
|
}
|
|
|
|
// episodeResponse is the shape of an episode in API responses.
|
|
type episodeResponse struct {
|
|
ContentID string `json:"content_id"`
|
|
SeasonNumber int `json:"season_number"`
|
|
EpisodeNumber int `json:"episode_number"`
|
|
Title string `json:"title"`
|
|
Overview string `json:"overview,omitempty"`
|
|
AirDate string `json:"air_date,omitempty"`
|
|
Runtime int `json:"runtime"`
|
|
ImdbID string `json:"imdb_id,omitempty"`
|
|
TmdbID string `json:"tmdb_id,omitempty"`
|
|
TvdbID string `json:"tvdb_id,omitempty"`
|
|
StillURL string `json:"still_url,omitempty"`
|
|
StillThumbhash string `json:"still_thumbhash,omitempty"`
|
|
UserData *catalog.SeasonUserData `json:"user_data,omitempty"`
|
|
Files []episodeFileResponse `json:"files,omitempty"`
|
|
OverlaySummary *models.OverlaySummary `json:"overlay_summary,omitempty"`
|
|
}
|
|
|
|
type episodeImageFallback struct {
|
|
Path string
|
|
Thumbhash string
|
|
}
|
|
|
|
type watchedStateResponse struct {
|
|
ContentID string `json:"content_id"`
|
|
Type string `json:"type"`
|
|
AffectedCount int `json:"affected_count"`
|
|
Played bool `json:"played"`
|
|
}
|
|
|
|
// --- Handler methods ---
|
|
|
|
// HandleGetItems handles GET /items with filtering, sorting, and pagination.
|
|
func (h *ItemsHandler) HandleGetItems(w http.ResponseWriter, r *http.Request) {
|
|
writeDeprecatedReadHeaders(w, "/api/v1/catalog")
|
|
if values, ok := buildLegacyItemsCatalogValues(r.URL.Query()); ok && h.catalogResolver != nil {
|
|
h.writeCatalogBrowseResponse(w, r, values)
|
|
return
|
|
}
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Catalog is not configured")
|
|
}
|
|
|
|
// HandleGetLatestItems handles GET /items/latest — shortcut for sort=created_at&order=desc.
|
|
func (h *ItemsHandler) HandleGetLatestItems(w http.ResponseWriter, r *http.Request) {
|
|
writeDeprecatedReadHeaders(w, "/api/v1/catalog")
|
|
if h.catalogResolver != nil {
|
|
h.writeCatalogBrowseResponse(w, r, buildLegacyLatestCatalogValues(r.URL.Query()))
|
|
return
|
|
}
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Catalog is not configured")
|
|
}
|
|
|
|
// HandleGetItemFilters handles GET /items/filters for distinct browse values.
|
|
func (h *ItemsHandler) HandleGetItemFilters(w http.ResponseWriter, r *http.Request) {
|
|
writeDeprecatedReadHeaders(w, "/api/v1/catalog/filters")
|
|
if values, ok := buildLegacyItemsCatalogValues(r.URL.Query()); ok && h.catalogResolver != nil {
|
|
h.writeCatalogFiltersResponse(w, r, values)
|
|
return
|
|
}
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Catalog is not configured")
|
|
}
|
|
|
|
// HandleGetItemDetail handles GET /items/{id}.
|
|
func (h *ItemsHandler) HandleGetItemDetail(w http.ResponseWriter, r *http.Request) {
|
|
writeDeprecatedReadHeaders(w, "/api/v1/catalog/items/{id}")
|
|
h.catalogResourceHandler().HandleGetItemDetail(w, r)
|
|
}
|
|
|
|
// HandleGetWatchDetail handles GET /watch/{id}.
|
|
func (h *ItemsHandler) HandleGetWatchDetail(w http.ResponseWriter, r *http.Request) {
|
|
id := chi.URLParam(r, "id")
|
|
if id == "" {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "Watch target ID is required")
|
|
return
|
|
}
|
|
|
|
detail, err := h.detailSvc.GetWatchDetail(r.Context(), id, h.accessFilter(r))
|
|
if err != nil {
|
|
switch {
|
|
case catalog.IsWatchTargetNotPlayable(err):
|
|
writeError(w, http.StatusBadRequest, "invalid_watch_target", "Content is not directly playable")
|
|
return
|
|
case isNotFound(err):
|
|
writeError(w, http.StatusNotFound, "not_found", "Watch target not found")
|
|
return
|
|
default:
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get watch detail")
|
|
return
|
|
}
|
|
}
|
|
|
|
if detail.Type == "movie" || detail.Type == "episode" || detail.Type == "ebook" || detail.Type == "audiobook" {
|
|
detail.UserData = h.getLeafUserData(r, detail.ContentID, detail.Type)
|
|
applyEffectiveEditionPreference(detail.UserData, &detail.EffectiveVersionEditionKey)
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, detail)
|
|
}
|
|
|
|
// HandleMarkWatched handles POST /watched/{id}.
|
|
func (h *ItemsHandler) HandleMarkWatched(w http.ResponseWriter, r *http.Request) {
|
|
h.handleSetWatchedState(w, r, true)
|
|
}
|
|
|
|
// HandleMarkUnwatched handles DELETE /watched/{id}.
|
|
func (h *ItemsHandler) HandleMarkUnwatched(w http.ResponseWriter, r *http.Request) {
|
|
h.handleSetWatchedState(w, r, false)
|
|
}
|
|
|
|
func (h *ItemsHandler) dispatchLocalWatchEvent(
|
|
ctx context.Context,
|
|
kind watchsync.LocalWatchEventKind,
|
|
userID int,
|
|
profileID string,
|
|
result watchstate.ManualMarkResult,
|
|
) {
|
|
if h == nil || h.localWatchDispatcher == nil {
|
|
return
|
|
}
|
|
plays := watchsync.LocalPlaysFromHistory(result.Entries)
|
|
if len(plays) == 0 {
|
|
return
|
|
}
|
|
if err := h.localWatchDispatcher.HandleLocalWatchEvent(ctx, watchsync.LocalWatchEvent{
|
|
Kind: kind,
|
|
UserID: userID,
|
|
ProfileID: profileID,
|
|
Plays: plays,
|
|
}); err != nil {
|
|
slog.WarnContext(ctx, "failed to queue local watch provider event", "component", "api", "kind", kind, "user_id", userID, "profile_id", profileID, "error", err)
|
|
}
|
|
}
|
|
|
|
func (h *ItemsHandler) handleSetWatchedState(w http.ResponseWriter, r *http.Request, played bool) {
|
|
userID := apimw.GetUserID(r.Context())
|
|
profileID := apimw.GetProfileID(r.Context())
|
|
id := chi.URLParam(r, "id")
|
|
|
|
if id == "" {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "Item ID is required")
|
|
return
|
|
}
|
|
if profileID == "" {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "Profile ID is required")
|
|
return
|
|
}
|
|
|
|
filter := h.accessFilter(r)
|
|
targetType, targets, err := h.resolveWatchedTargets(r.Context(), id, filter)
|
|
if err != nil {
|
|
switch {
|
|
case isNotFound(err):
|
|
writeError(w, http.StatusNotFound, "not_found", "Item not found")
|
|
default:
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to update watched state")
|
|
}
|
|
return
|
|
}
|
|
|
|
switch {
|
|
case targetType == "ebook":
|
|
// Ebook read state lives in ebook_reader_progress, not in
|
|
// user_watch_progress/user_watch_history; watch providers do not sync
|
|
// books, so no local watch event is dispatched.
|
|
err = h.setEbookReadState(r.Context(), userID, profileID, id, played, filter)
|
|
case h.watchState == nil:
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to access user store")
|
|
return
|
|
case played:
|
|
leafTargets := make([]watchstate.LeafWatchTarget, 0, len(targets))
|
|
for _, target := range targets {
|
|
leafTargets = append(leafTargets, watchstate.LeafWatchTarget{
|
|
MediaItemID: target.ContentID,
|
|
DurationSeconds: target.DurationSeconds,
|
|
})
|
|
}
|
|
updatedAt := time.Now().UTC()
|
|
var result watchstate.ManualMarkResult
|
|
result, err = h.watchState.RecordManualMarkWatchedWithResult(r.Context(), userID, profileID, leafTargets, updatedAt)
|
|
if err == nil {
|
|
h.dispatchLocalWatchEvent(r.Context(), watchsync.LocalWatchEventMarkedWatched, userID, profileID, result)
|
|
}
|
|
default:
|
|
targetIDs := make([]string, 0, len(targets))
|
|
for _, target := range targets {
|
|
targetIDs = append(targetIDs, target.ContentID)
|
|
}
|
|
var result watchstate.ManualMarkResult
|
|
result, err = h.watchState.RecordManualMarkUnwatchedWithResult(r.Context(), userID, profileID, targetIDs)
|
|
if err == nil {
|
|
h.dispatchLocalWatchEvent(r.Context(), watchsync.LocalWatchEventMarkedUnwatched, userID, profileID, result)
|
|
}
|
|
}
|
|
if err != nil {
|
|
if isNotFound(err) {
|
|
writeError(w, http.StatusNotFound, "not_found", "Item not found")
|
|
return
|
|
}
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to update watched state")
|
|
return
|
|
}
|
|
|
|
triggerProfileRefresh(r.Context(), h.profileStaler, h.profileRefreshRequester, userID, profileID)
|
|
publishUserStateEvent(r.Context(), h.EventsHub, userID, profileID, id, "", "watched", userStateEventState{
|
|
Played: boolPtr(played),
|
|
})
|
|
|
|
writeJSON(w, http.StatusOK, watchedStateResponse{
|
|
ContentID: id,
|
|
Type: targetType,
|
|
AffectedCount: len(targets),
|
|
Played: played,
|
|
})
|
|
}
|
|
|
|
// HandleGetItemVersions handles GET /items/{id}/versions.
|
|
func (h *ItemsHandler) HandleGetItemVersions(w http.ResponseWriter, r *http.Request) {
|
|
writeDeprecatedReadHeaders(w, "/api/v1/catalog/items/{id}/versions")
|
|
h.catalogResourceHandler().HandleGetItemVersions(w, r)
|
|
}
|
|
|
|
// HandleGetItemEpisodes handles GET /items/{id}/episodes for season IDs.
|
|
func (h *ItemsHandler) HandleGetItemEpisodes(w http.ResponseWriter, r *http.Request) {
|
|
writeDeprecatedReadHeaders(w, "/api/v1/catalog/items/{id}/episodes")
|
|
h.catalogResourceHandler().HandleGetItemEpisodes(w, r)
|
|
}
|
|
|
|
// HandleGetSeasons handles GET /series/{id}/seasons.
|
|
func (h *ItemsHandler) HandleGetSeasons(w http.ResponseWriter, r *http.Request) {
|
|
writeDeprecatedReadHeaders(w, "/api/v1/catalog/series/{id}/seasons")
|
|
h.catalogResourceHandler().HandleGetSeasons(w, r)
|
|
}
|
|
|
|
// HandleGetSeason handles GET /series/{id}/seasons/{num}.
|
|
func (h *ItemsHandler) HandleGetSeason(w http.ResponseWriter, r *http.Request) {
|
|
writeDeprecatedReadHeaders(w, "/api/v1/catalog/series/{id}/seasons/{num}")
|
|
h.catalogResourceHandler().HandleGetSeason(w, r)
|
|
}
|
|
|
|
// HandleGetEpisodes handles GET /series/{id}/seasons/{num}/episodes.
|
|
func (h *ItemsHandler) HandleGetEpisodes(w http.ResponseWriter, r *http.Request) {
|
|
writeDeprecatedReadHeaders(w, "/api/v1/catalog/series/{id}/seasons/{num}/episodes")
|
|
h.catalogResourceHandler().HandleGetEpisodes(w, r)
|
|
}
|
|
|
|
func (h *ItemsHandler) catalogResourceHandler() *CatalogResourceHandler {
|
|
return NewCatalogResourceHandler(h)
|
|
}
|
|
|
|
func (h *ItemsHandler) catalogHandler() *CatalogHandler {
|
|
return NewCatalogHandler(h.catalogResolver, h)
|
|
}
|
|
|
|
func (h *ItemsHandler) writeCatalogBrowseResponse(w http.ResponseWriter, r *http.Request, values map[string][]string) bool {
|
|
req, err := catalog.ParseCatalogRequest(values)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
|
|
return true
|
|
}
|
|
|
|
result, err := h.catalogResolver.Resolve(r.Context(), req, h.accessFilter(r))
|
|
if err != nil {
|
|
if errors.Is(err, catalog.ErrInvalidCatalogRequest) {
|
|
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
|
|
return true
|
|
}
|
|
if errors.Is(err, catalog.ErrCatalogSourceNotFound) {
|
|
writeError(w, http.StatusNotFound, "not_found", "Catalog source not found")
|
|
return true
|
|
}
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to browse items")
|
|
return true
|
|
}
|
|
|
|
overlaySummaries := h.listOverlaySummaries(r.Context(), result.Items, h.accessFilter(r))
|
|
userStates := h.listItemUserStates(r, result.Items)
|
|
items := make([]itemListResponse, 0, len(result.Items))
|
|
for _, item := range result.Items {
|
|
items = append(items, h.toItemListResponseWithOverlay(r, item, overlaySummaries[item.ContentID], userStates[item.ContentID]))
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, browseResponse{
|
|
Total: result.Total,
|
|
TotalExact: result.TotalExact,
|
|
HasMore: result.HasMore,
|
|
Items: items,
|
|
})
|
|
return true
|
|
}
|
|
|
|
func (h *ItemsHandler) writeCatalogFiltersResponse(w http.ResponseWriter, r *http.Request, values map[string][]string) bool {
|
|
req, err := catalog.ParseCatalogRequest(values)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
|
|
return true
|
|
}
|
|
|
|
filters, err := h.catalogResolver.ListFiltersWithOptions(
|
|
r.Context(),
|
|
req,
|
|
h.accessFilter(r),
|
|
catalog.CatalogFilterOptions{IncludeTechnical: false},
|
|
)
|
|
if err != nil {
|
|
if errors.Is(err, catalog.ErrInvalidCatalogRequest) {
|
|
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
|
|
return true
|
|
}
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list item filters")
|
|
return true
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, itemFiltersResponse{
|
|
Genres: filters.Genres,
|
|
Studios: filters.Studios,
|
|
Networks: filters.Networks,
|
|
Countries: filters.Countries,
|
|
ContentRatings: filters.ContentRatings,
|
|
})
|
|
return true
|
|
}
|
|
|
|
// toItemListResponse converts a MediaItem to an itemListResponse with presigned URLs.
|
|
func (h *ItemsHandler) toItemListResponse(r *http.Request, item *models.MediaItem) itemListResponse {
|
|
return h.toItemListResponseWithOverlay(r, item, nil, nil)
|
|
}
|
|
|
|
func (h *ItemsHandler) toItemListResponseWithOverlay(r *http.Request, item *models.MediaItem, overlaySummary *models.OverlaySummary, userState *itemUserStateResponse) itemListResponse {
|
|
if h.detailSvc != nil {
|
|
if localized, err := h.detailSvc.LocalizeItemModel(r.Context(), item, h.accessFilter(r)); err == nil && localized != nil {
|
|
item = localized
|
|
}
|
|
}
|
|
resp := itemListResponseShell(item, overlaySummary, userState)
|
|
resp.PosterURL = h.presignURL(r, cardThumbnailPath(item.PosterPath), "card")
|
|
resp.BackdropURL = h.presignURL(r, cardThumbnailPath(item.BackdropPath), "card")
|
|
return resp
|
|
}
|
|
|
|
func itemListResponseShell(item *models.MediaItem, overlaySummary *models.OverlaySummary, userState *itemUserStateResponse) itemListResponse {
|
|
resp := itemListResponse{
|
|
ContentID: item.ContentID,
|
|
Type: item.Type,
|
|
Title: item.Title,
|
|
Year: item.Year,
|
|
Runtime: item.Runtime,
|
|
Genres: item.Genres,
|
|
Keywords: item.Keywords,
|
|
Studios: item.Studios,
|
|
Networks: item.Networks,
|
|
ContentRating: item.ContentRating,
|
|
Status: item.Status,
|
|
ShowStatus: item.ShowStatus,
|
|
RatingIMDB: item.RatingIMDB,
|
|
RatingTMDB: item.RatingTMDB,
|
|
RatingRTCritic: item.RatingRTCritic,
|
|
RatingRTAudience: item.RatingRTAudience,
|
|
OriginalLanguage: item.OriginalLanguage,
|
|
Overview: item.Overview,
|
|
PosterThumbhash: item.PosterThumbhash,
|
|
BackdropThumbhash: item.BackdropThumbhash,
|
|
OverlaySummary: overlaySummary,
|
|
UserState: userState,
|
|
}
|
|
|
|
resp.AddedAt = item.AddedAt
|
|
resp.MangaChapterCount = item.MangaChapterCount
|
|
resp.MangaVolumeCount = item.MangaVolumeCount
|
|
resp.ReleaseDate = item.ReleaseDate
|
|
resp.LastAirDate = item.LastAirDate
|
|
if resp.Genres == nil {
|
|
resp.Genres = []string{}
|
|
}
|
|
if resp.Keywords == nil {
|
|
resp.Keywords = []string{}
|
|
}
|
|
|
|
return resp
|
|
}
|
|
|
|
func (h *ItemsHandler) localizeItemListModels(ctx context.Context, items []*models.MediaItem, filter catalog.AccessFilter) []*models.MediaItem {
|
|
if h == nil || h.detailSvc == nil || len(items) == 0 {
|
|
return items
|
|
}
|
|
localized, err := h.detailSvc.LocalizeItemModels(ctx, items, filter)
|
|
if err != nil || len(localized) != len(items) {
|
|
return items
|
|
}
|
|
return localized
|
|
}
|
|
|
|
func (h *ItemsHandler) itemListCardImageURLs(ctx context.Context, items []*models.MediaItem) map[string]itemListImageURLs {
|
|
urls := make(map[string]itemListImageURLs, len(items))
|
|
if h == nil || h.detailSvc == nil || len(items) == 0 {
|
|
return urls
|
|
}
|
|
|
|
type pendingImages struct {
|
|
contentID string
|
|
posterPath string
|
|
backdropPath string
|
|
}
|
|
|
|
pending := make([]pendingImages, 0, len(items))
|
|
paths := make([]string, 0, len(items)*2)
|
|
seenPaths := make(map[string]struct{}, len(items)*2)
|
|
addPath := func(path string) {
|
|
if path == "" || path == "-" {
|
|
return
|
|
}
|
|
if _, ok := seenPaths[path]; ok {
|
|
return
|
|
}
|
|
seenPaths[path] = struct{}{}
|
|
paths = append(paths, path)
|
|
}
|
|
|
|
for _, item := range items {
|
|
if item == nil || item.ContentID == "" {
|
|
continue
|
|
}
|
|
images := pendingImages{
|
|
contentID: item.ContentID,
|
|
posterPath: cardThumbnailPath(item.PosterPath),
|
|
backdropPath: cardThumbnailPath(item.BackdropPath),
|
|
}
|
|
pending = append(pending, images)
|
|
addPath(images.posterPath)
|
|
addPath(images.backdropPath)
|
|
}
|
|
|
|
resolved := h.detailSvc.PresignURLsWithExpiry(ctx, paths, "card")
|
|
for _, images := range pending {
|
|
urls[images.contentID] = itemListImageURLs{
|
|
posterURL: resolved[images.posterPath].URL,
|
|
backdropURL: resolved[images.backdropPath].URL,
|
|
}
|
|
}
|
|
return urls
|
|
}
|
|
|
|
func (h *ItemsHandler) listEpisodeBrowseMetadata(
|
|
ctx context.Context,
|
|
items []*models.MediaItem,
|
|
) map[string]struct {
|
|
SeriesTitle string
|
|
SeasonNumber *int
|
|
EpisodeNumber *int
|
|
} {
|
|
if h == nil || h.episodeRepo == nil {
|
|
return map[string]struct {
|
|
SeriesTitle string
|
|
SeasonNumber *int
|
|
EpisodeNumber *int
|
|
}{}
|
|
}
|
|
|
|
episodeIDs := make([]string, 0)
|
|
for _, item := range items {
|
|
if item == nil || item.Type != "episode" || strings.TrimSpace(item.ContentID) == "" {
|
|
continue
|
|
}
|
|
episodeIDs = append(episodeIDs, item.ContentID)
|
|
}
|
|
if len(episodeIDs) == 0 {
|
|
return map[string]struct {
|
|
SeriesTitle string
|
|
SeasonNumber *int
|
|
EpisodeNumber *int
|
|
}{}
|
|
}
|
|
|
|
episodes, err := h.episodeRepo.GetByIDs(ctx, episodeIDs)
|
|
if err != nil || len(episodes) == 0 {
|
|
return map[string]struct {
|
|
SeriesTitle string
|
|
SeasonNumber *int
|
|
EpisodeNumber *int
|
|
}{}
|
|
}
|
|
|
|
seriesIDs := make([]string, 0, len(episodes))
|
|
seenSeriesIDs := make(map[string]struct{}, len(episodes))
|
|
for _, episode := range episodes {
|
|
if episode == nil || strings.TrimSpace(episode.SeriesID) == "" {
|
|
continue
|
|
}
|
|
if _, ok := seenSeriesIDs[episode.SeriesID]; ok {
|
|
continue
|
|
}
|
|
seenSeriesIDs[episode.SeriesID] = struct{}{}
|
|
seriesIDs = append(seriesIDs, episode.SeriesID)
|
|
}
|
|
|
|
seriesTitles := make(map[string]string, len(seriesIDs))
|
|
if len(seriesIDs) > 0 {
|
|
if seriesItems, err := h.itemRepo.GetByIDs(ctx, seriesIDs); err == nil {
|
|
for _, seriesItem := range seriesItems {
|
|
if seriesItem == nil {
|
|
continue
|
|
}
|
|
seriesTitles[seriesItem.ContentID] = seriesItem.Title
|
|
}
|
|
}
|
|
}
|
|
|
|
result := make(map[string]struct {
|
|
SeriesTitle string
|
|
SeasonNumber *int
|
|
EpisodeNumber *int
|
|
}, len(episodes))
|
|
for _, episode := range episodes {
|
|
if episode == nil {
|
|
continue
|
|
}
|
|
seasonNumber := episode.SeasonNumber
|
|
episodeNumber := episode.EpisodeNumber
|
|
result[episode.ContentID] = struct {
|
|
SeriesTitle string
|
|
SeasonNumber *int
|
|
EpisodeNumber *int
|
|
}{
|
|
SeriesTitle: seriesTitles[episode.SeriesID],
|
|
SeasonNumber: &seasonNumber,
|
|
EpisodeNumber: &episodeNumber,
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
func (h *ItemsHandler) listItemUserStates(r *http.Request, items []*models.MediaItem) map[string]*itemUserStateResponse {
|
|
store, profileID, ok := h.userStoreForRequest(r)
|
|
if !ok {
|
|
return map[string]*itemUserStateResponse{}
|
|
}
|
|
states, err := resolveItemUserStatesWithOptions(r.Context(), store, profileID, h.episodeRepo, items, itemUserStateOptions{
|
|
UserID: apimw.GetUserID(r.Context()),
|
|
EbookProgressStore: h.ebookProgressStore,
|
|
})
|
|
if err != nil {
|
|
return map[string]*itemUserStateResponse{}
|
|
}
|
|
return states
|
|
}
|
|
|
|
// toEpisodeResponse converts an Episode model to an API response.
|
|
func (h *ItemsHandler) toEpisodeResponse(r *http.Request, ep *models.Episode) episodeResponse {
|
|
return h.toEpisodeResponseWithFallback(r, ep, episodeImageFallback{})
|
|
}
|
|
|
|
func (h *ItemsHandler) toEpisodeResponseWithFallback(r *http.Request, ep *models.Episode, fallback episodeImageFallback) episodeResponse {
|
|
if h.detailSvc != nil {
|
|
if localized, err := h.detailSvc.LocalizeEpisodeModel(r.Context(), ep, h.accessFilter(r)); err == nil && localized != nil {
|
|
ep = localized
|
|
}
|
|
}
|
|
resp, stillPath := episodeResponseShell(ep, fallback)
|
|
resp.StillURL = h.presignURL(r, stillPath, "card")
|
|
return resp
|
|
}
|
|
|
|
// episodeResponseShell maps an already-localized episode onto the response
|
|
// shape, returning the card-variant still path for the caller to presign.
|
|
func episodeResponseShell(ep *models.Episode, fallback episodeImageFallback) (episodeResponse, string) {
|
|
stillPath := ep.StillPath
|
|
stillThumbhash := ep.StillThumbhash
|
|
if strings.TrimSpace(stillPath) == "" && strings.TrimSpace(fallback.Path) != "" {
|
|
stillPath = fallback.Path
|
|
stillThumbhash = fallback.Thumbhash
|
|
}
|
|
resp := episodeResponse{
|
|
ContentID: ep.ContentID,
|
|
SeasonNumber: ep.SeasonNumber,
|
|
EpisodeNumber: ep.EpisodeNumber,
|
|
Title: ep.Title,
|
|
Overview: ep.Overview,
|
|
Runtime: ep.Runtime,
|
|
ImdbID: ep.ImdbID,
|
|
TmdbID: ep.TmdbID,
|
|
TvdbID: ep.TvdbID,
|
|
StillThumbhash: stillThumbhash,
|
|
}
|
|
|
|
if ep.AirDate != nil {
|
|
resp.AirDate = ep.AirDate.Format("2006-01-02")
|
|
}
|
|
|
|
return resp, cardThumbnailPath(stillPath)
|
|
}
|
|
|
|
// buildEpisodeResponses converts episodes to API responses using batched
|
|
// lookups — localization, media files, watch progress, and image presigning
|
|
// each resolve in one round-trip for the whole list instead of per episode.
|
|
func (h *ItemsHandler) buildEpisodeResponses(r *http.Request, episodes []*models.Episode) []episodeResponse {
|
|
ctx := r.Context()
|
|
filter := h.accessFilter(r)
|
|
|
|
if h.detailSvc != nil {
|
|
if localized, err := h.detailSvc.LocalizeEpisodeModels(ctx, episodes, filter); err == nil && len(localized) == len(episodes) {
|
|
episodes = localized
|
|
}
|
|
}
|
|
|
|
fallbacks := h.episodeImageFallbacks(ctx, episodes)
|
|
|
|
episodeIDs := make([]string, 0, len(episodes))
|
|
for _, ep := range episodes {
|
|
if ep != nil && ep.ContentID != "" {
|
|
episodeIDs = append(episodeIDs, ep.ContentID)
|
|
}
|
|
}
|
|
filesByEpisode := h.listEpisodeFiles(ctx, episodeIDs)
|
|
userData := h.listLeafUserData(r, episodeIDs)
|
|
|
|
resp := make([]episodeResponse, 0, len(episodes))
|
|
stillPaths := make([]string, 0, len(episodes))
|
|
for _, ep := range episodes {
|
|
if ep == nil {
|
|
continue
|
|
}
|
|
shell, stillPath := episodeResponseShell(ep, fallbacks[ep.SeriesID])
|
|
resp = append(resp, shell)
|
|
stillPaths = append(stillPaths, stillPath)
|
|
}
|
|
|
|
stillURLs := map[string]catalog.ResolvedImageURL{}
|
|
if h.detailSvc != nil {
|
|
stillURLs = h.detailSvc.PresignURLsWithExpiry(ctx, stillPaths, "card")
|
|
}
|
|
for i := range resp {
|
|
resp[i].StillURL = stillURLs[stillPaths[i]].URL
|
|
files := catalog.FilterMediaFilesByAccess(filesByEpisode[resp[i].ContentID], filter)
|
|
resp[i].Files = episodeFileResponses(files, filter)
|
|
resp[i].UserData = userData[resp[i].ContentID]
|
|
resp[i].OverlaySummary = overlays.BuildSummary(files)
|
|
}
|
|
return resp
|
|
}
|
|
|
|
// listEpisodeFiles batch-fetches media files keyed by episode ID, falling back
|
|
// to per-episode lookups when the repository lacks batch support.
|
|
func (h *ItemsHandler) listEpisodeFiles(ctx context.Context, episodeIDs []string) map[string][]*models.MediaFile {
|
|
if h.fileRepo == nil || len(episodeIDs) == 0 {
|
|
return nil
|
|
}
|
|
if batchProvider, ok := h.fileRepo.(batchEpisodeFileProvider); ok {
|
|
files, err := batchProvider.ListByEpisodeIDs(ctx, episodeIDs)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
return files
|
|
}
|
|
files := make(map[string][]*models.MediaFile, len(episodeIDs))
|
|
for _, id := range episodeIDs {
|
|
if list, err := h.fileRepo.GetByEpisodeID(ctx, id); err == nil {
|
|
files[id] = list
|
|
}
|
|
}
|
|
return files
|
|
}
|
|
|
|
func episodeFileResponses(files []*models.MediaFile, filter catalog.AccessFilter) []episodeFileResponse {
|
|
var resp []episodeFileResponse
|
|
for _, f := range files {
|
|
if !catalog.FileAllowedByAccess(f, filter) {
|
|
continue
|
|
}
|
|
resp = append(resp, episodeFileResponse{
|
|
FileID: f.ID,
|
|
Resolution: f.Resolution,
|
|
CodecVideo: f.CodecVideo,
|
|
HDR: f.HDR,
|
|
AudioChannels: f.AudioChannels,
|
|
Container: f.Container,
|
|
FileSize: f.FileSize,
|
|
})
|
|
}
|
|
return resp
|
|
}
|
|
|
|
func (h *ItemsHandler) episodeImageFallbacks(ctx context.Context, episodes []*models.Episode) map[string]episodeImageFallback {
|
|
if h.itemRepo == nil || len(episodes) == 0 {
|
|
return map[string]episodeImageFallback{}
|
|
}
|
|
seriesIDs := make([]string, 0, 1)
|
|
seen := make(map[string]struct{})
|
|
for _, ep := range episodes {
|
|
if ep == nil || strings.TrimSpace(ep.SeriesID) == "" {
|
|
continue
|
|
}
|
|
if _, ok := seen[ep.SeriesID]; ok {
|
|
continue
|
|
}
|
|
seen[ep.SeriesID] = struct{}{}
|
|
seriesIDs = append(seriesIDs, ep.SeriesID)
|
|
}
|
|
if len(seriesIDs) == 0 {
|
|
return map[string]episodeImageFallback{}
|
|
}
|
|
|
|
seriesItems, err := h.itemRepo.GetByIDs(ctx, seriesIDs)
|
|
if err != nil {
|
|
return map[string]episodeImageFallback{}
|
|
}
|
|
fallbacks := make(map[string]episodeImageFallback, len(seriesItems))
|
|
for _, series := range seriesItems {
|
|
if series == nil {
|
|
continue
|
|
}
|
|
path := strings.TrimSpace(series.BackdropPath)
|
|
thumbhash := series.BackdropThumbhash
|
|
if path == "" {
|
|
path = strings.TrimSpace(series.PosterPath)
|
|
thumbhash = series.PosterThumbhash
|
|
}
|
|
if path == "" {
|
|
continue
|
|
}
|
|
fallbacks[series.ContentID] = episodeImageFallback{Path: path, Thumbhash: thumbhash}
|
|
}
|
|
return fallbacks
|
|
}
|
|
|
|
func (h *ItemsHandler) listOverlaySummaries(ctx context.Context, items []*models.MediaItem, filter catalog.AccessFilter) map[string]*models.OverlaySummary {
|
|
summaries := make(map[string]*models.OverlaySummary, len(items))
|
|
if h.fileRepo == nil || len(items) == 0 {
|
|
return summaries
|
|
}
|
|
|
|
groupedFiles := h.listBrowseItemOverlayFiles(ctx, items, filter)
|
|
for contentID, files := range groupedFiles {
|
|
if summary := overlays.BuildSummary(files); summary != nil {
|
|
summaries[contentID] = summary
|
|
}
|
|
}
|
|
return summaries
|
|
}
|
|
|
|
func (h *ItemsHandler) listSortMetrics(
|
|
ctx context.Context,
|
|
items []*models.MediaItem,
|
|
sortField string,
|
|
filter catalog.AccessFilter,
|
|
overlaySummaries map[string]*models.OverlaySummary,
|
|
store userstore.UserStore,
|
|
userID int,
|
|
profileID string,
|
|
) map[string]*sortMetricsResponse {
|
|
metrics := make(map[string]*sortMetricsResponse, len(items))
|
|
switch sortField {
|
|
case "release_date":
|
|
for _, item := range items {
|
|
if item == nil {
|
|
continue
|
|
}
|
|
releaseDate := firstNonBlankPtr(item.ReleaseDate, item.FirstAirDate)
|
|
if releaseDate != nil {
|
|
metrics[item.ContentID] = &sortMetricsResponse{ReleaseDate: releaseDate}
|
|
}
|
|
}
|
|
case "runtime":
|
|
for _, item := range items {
|
|
if item == nil || item.Runtime <= 0 {
|
|
continue
|
|
}
|
|
runtimeMinutes := item.Runtime
|
|
metrics[item.ContentID] = &sortMetricsResponse{RuntimeMinutes: &runtimeMinutes}
|
|
}
|
|
case "resolution":
|
|
for _, item := range items {
|
|
if item == nil {
|
|
continue
|
|
}
|
|
if summary := overlaySummaries[item.ContentID]; summary != nil && strings.TrimSpace(summary.Resolution) != "" {
|
|
metrics[item.ContentID] = &sortMetricsResponse{Resolution: summary.Resolution}
|
|
}
|
|
}
|
|
case "bitrate":
|
|
groupedFiles := h.listBrowseItemFiles(ctx, items, filter)
|
|
for contentID, files := range groupedFiles {
|
|
if bitrate := maxFileBitrate(files); bitrate > 0 {
|
|
value := bitrate
|
|
metrics[contentID] = &sortMetricsResponse{BitrateKbps: &value}
|
|
}
|
|
}
|
|
case "progress", "date_viewed", "plays":
|
|
h.listUserSortMetrics(ctx, items, sortField, store, userID, profileID, metrics)
|
|
case "author", "narrator", "series":
|
|
h.listAudiobookSortMetrics(ctx, items, sortField, metrics)
|
|
}
|
|
return metrics
|
|
}
|
|
|
|
func (h *ItemsHandler) listAudiobookSortMetrics(
|
|
ctx context.Context,
|
|
items []*models.MediaItem,
|
|
sortField string,
|
|
metrics map[string]*sortMetricsResponse,
|
|
) {
|
|
if h == nil || h.browseRepo == nil || h.browseRepo.Pool() == nil || len(items) == 0 {
|
|
return
|
|
}
|
|
contentIDs := uniqueItemContentIDs(items)
|
|
if len(contentIDs) == 0 {
|
|
return
|
|
}
|
|
|
|
var query string
|
|
switch sortField {
|
|
case "author":
|
|
query = audiobookPersonSortMetricQuery(int(models.PersonKindAuthor))
|
|
case "narrator":
|
|
query = audiobookPersonSortMetricQuery(int(models.PersonKindNarrator))
|
|
case "series":
|
|
query = `
|
|
SELECT target.content_id, BTRIM(s.series_name) AS value
|
|
FROM unnest($1::text[]) WITH ORDINALITY AS target(content_id, ord)
|
|
JOIN audiobook_series s ON s.content_id = target.content_id
|
|
WHERE BTRIM(s.series_name) <> ''`
|
|
default:
|
|
return
|
|
}
|
|
|
|
rows, err := h.browseRepo.Pool().Query(ctx, query, contentIDs)
|
|
if err != nil {
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
for rows.Next() {
|
|
var contentID, value string
|
|
if err := rows.Scan(&contentID, &value); err != nil {
|
|
return
|
|
}
|
|
value = strings.TrimSpace(value)
|
|
if contentID == "" || value == "" {
|
|
continue
|
|
}
|
|
resp := metrics[contentID]
|
|
if resp == nil {
|
|
resp = &sortMetricsResponse{}
|
|
metrics[contentID] = resp
|
|
}
|
|
switch sortField {
|
|
case "author":
|
|
resp.Author = value
|
|
case "narrator":
|
|
resp.Narrator = value
|
|
case "series":
|
|
resp.SeriesName = value
|
|
}
|
|
}
|
|
}
|
|
|
|
func audiobookPersonSortMetricQuery(kind int) string {
|
|
return fmt.Sprintf(`
|
|
SELECT target.content_id, person.name AS value
|
|
FROM unnest($1::text[]) WITH ORDINALITY AS target(content_id, ord)
|
|
JOIN LATERAL (
|
|
SELECT BTRIM(p.name) AS name
|
|
FROM item_people ip
|
|
JOIN people p ON p.id = ip.person_id
|
|
WHERE ip.content_id = target.content_id
|
|
AND ip.kind = %d
|
|
AND p.name IS NOT NULL
|
|
AND BTRIM(p.name) <> ''
|
|
ORDER BY p.name ASC
|
|
LIMIT 1
|
|
) person ON TRUE`, kind)
|
|
}
|
|
|
|
func (h *ItemsHandler) listUserSortMetrics(
|
|
ctx context.Context,
|
|
items []*models.MediaItem,
|
|
sortField string,
|
|
store userstore.UserStore,
|
|
userID int,
|
|
profileID string,
|
|
metrics map[string]*sortMetricsResponse,
|
|
) {
|
|
if store == nil || profileID == "" || len(items) == 0 {
|
|
return
|
|
}
|
|
contentIDs := uniqueItemContentIDs(items)
|
|
if len(contentIDs) == 0 {
|
|
return
|
|
}
|
|
|
|
progressMap, err := store.ListProgressByMediaItems(ctx, profileID, contentIDs)
|
|
if err != nil {
|
|
return
|
|
}
|
|
ebookProgressMap, err := h.listEbookReaderProgressByContentIDs(ctx, userID, profileID, contentIDs)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
switch sortField {
|
|
case "progress":
|
|
for _, item := range items {
|
|
if item == nil || item.ContentID == "" {
|
|
continue
|
|
}
|
|
progress, ok := progressMap[item.ContentID]
|
|
if ok && !progress.Completed && progress.PositionSeconds > 0 && progress.DurationSeconds > 0 {
|
|
ratio := progress.PositionSeconds / progress.DurationSeconds
|
|
metrics[item.ContentID] = &sortMetricsResponse{ProgressRatio: &ratio}
|
|
continue
|
|
}
|
|
ebookProgress, ok := ebookProgressMap[item.ContentID]
|
|
if !ok || ebookProgress.Progress <= 0 || ebookProgress.Progress >= models.EbookFinishedProgressThreshold {
|
|
continue
|
|
}
|
|
ratio := ebookProgress.Progress
|
|
metrics[item.ContentID] = &sortMetricsResponse{ProgressRatio: &ratio}
|
|
}
|
|
case "date_viewed", "plays":
|
|
history, err := listCompletedHistoryForItems(ctx, store, profileID, contentIDs)
|
|
if err != nil {
|
|
return
|
|
}
|
|
historyCounts := make(map[string]int, len(contentIDs))
|
|
historyViewedAt := make(map[string]string, len(contentIDs))
|
|
for _, entry := range history {
|
|
if entry.MediaItemID == "" {
|
|
continue
|
|
}
|
|
historyCounts[entry.MediaItemID]++
|
|
if entry.WatchedAt > historyViewedAt[entry.MediaItemID] {
|
|
historyViewedAt[entry.MediaItemID] = entry.WatchedAt
|
|
}
|
|
}
|
|
for _, item := range items {
|
|
if item == nil || item.ContentID == "" {
|
|
continue
|
|
}
|
|
resp := &sortMetricsResponse{}
|
|
if sortField == "date_viewed" {
|
|
viewedAt := historyViewedAt[item.ContentID]
|
|
if progress, ok := progressMap[item.ContentID]; ok && progress.Completed && progress.UpdatedAt > viewedAt {
|
|
viewedAt = progress.UpdatedAt
|
|
}
|
|
if progress, ok := ebookProgressMap[item.ContentID]; ok && progress.Progress >= models.EbookFinishedProgressThreshold {
|
|
ebookViewedAt := progress.UpdatedAt.UTC().Format(time.RFC3339)
|
|
if ebookViewedAt > viewedAt {
|
|
viewedAt = ebookViewedAt
|
|
}
|
|
}
|
|
if viewedAt == "" {
|
|
continue
|
|
}
|
|
resp.ViewedAt = viewedAt
|
|
} else {
|
|
playCount := historyCounts[item.ContentID]
|
|
if progress, ok := progressMap[item.ContentID]; ok && progress.Completed && playCount < 1 {
|
|
playCount = 1
|
|
}
|
|
if progress, ok := ebookProgressMap[item.ContentID]; ok && progress.Progress >= models.EbookFinishedProgressThreshold && playCount < 1 {
|
|
playCount = 1
|
|
}
|
|
if playCount <= 0 {
|
|
continue
|
|
}
|
|
resp.PlayCount = &playCount
|
|
}
|
|
metrics[item.ContentID] = resp
|
|
}
|
|
}
|
|
}
|
|
|
|
func (h *ItemsHandler) listEbookReaderProgressByContentIDs(
|
|
ctx context.Context,
|
|
userID int,
|
|
profileID string,
|
|
contentIDs []string,
|
|
) (map[string]EbookReaderProgress, error) {
|
|
if h == nil || h.ebookProgressStore == nil || userID <= 0 || profileID == "" || len(contentIDs) == 0 {
|
|
return nil, nil
|
|
}
|
|
return h.ebookProgressStore.ListByContentIDs(ctx, userID, profileID, contentIDs)
|
|
}
|
|
|
|
func uniqueItemContentIDs(items []*models.MediaItem) []string {
|
|
contentIDs := make([]string, 0, len(items))
|
|
seen := make(map[string]struct{}, len(items))
|
|
for _, item := range items {
|
|
if item == nil || item.ContentID == "" {
|
|
continue
|
|
}
|
|
if _, ok := seen[item.ContentID]; ok {
|
|
continue
|
|
}
|
|
seen[item.ContentID] = struct{}{}
|
|
contentIDs = append(contentIDs, item.ContentID)
|
|
}
|
|
return contentIDs
|
|
}
|
|
|
|
func listCompletedHistoryForItems(
|
|
ctx context.Context,
|
|
store userstore.UserStore,
|
|
profileID string,
|
|
contentIDs []string,
|
|
) ([]userstore.WatchHistoryEntry, error) {
|
|
const pageSize = 500
|
|
var all []userstore.WatchHistoryEntry
|
|
for offset := 0; ; offset += pageSize {
|
|
page, err := store.ListCompletedHistory(ctx, userstore.CompletedHistoryQuery{
|
|
ProfileID: profileID,
|
|
MediaItemIDs: contentIDs,
|
|
Limit: pageSize,
|
|
Offset: offset,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
all = append(all, page...)
|
|
if len(page) < pageSize {
|
|
return all, nil
|
|
}
|
|
}
|
|
}
|
|
|
|
func (h *ItemsHandler) listBrowseItemFiles(ctx context.Context, items []*models.MediaItem, filter catalog.AccessFilter) map[string][]*models.MediaFile {
|
|
grouped := make(map[string][]*models.MediaFile, len(items))
|
|
if h.fileRepo == nil || len(items) == 0 {
|
|
return grouped
|
|
}
|
|
|
|
contentIDs := make([]string, 0, len(items))
|
|
episodeIDs := make([]string, 0)
|
|
collectBrowseFileIDs(items, &contentIDs, &episodeIDs)
|
|
|
|
if len(contentIDs) > 0 {
|
|
contentFiles, err := h.fileRepo.ListByContentIDs(ctx, contentIDs)
|
|
if err == nil {
|
|
for contentID, files := range contentFiles {
|
|
grouped[contentID] = catalog.FilterMediaFilesByAccess(files, filter)
|
|
}
|
|
}
|
|
}
|
|
|
|
if len(episodeIDs) > 0 {
|
|
if batchProvider, ok := h.fileRepo.(batchEpisodeFileProvider); ok {
|
|
episodeFiles, err := batchProvider.ListByEpisodeIDs(ctx, episodeIDs)
|
|
if err == nil {
|
|
for episodeID, files := range episodeFiles {
|
|
grouped[episodeID] = catalog.FilterMediaFilesByAccess(files, filter)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return grouped
|
|
}
|
|
|
|
func (h *ItemsHandler) listBrowseItemOverlayFiles(ctx context.Context, items []*models.MediaItem, filter catalog.AccessFilter) map[string][]*models.MediaFile {
|
|
if h.fileRepo == nil || len(items) == 0 {
|
|
return map[string][]*models.MediaFile{}
|
|
}
|
|
|
|
overlayProvider, ok := h.fileRepo.(overlayFileProvider)
|
|
if !ok {
|
|
return h.listBrowseItemFiles(ctx, items, filter)
|
|
}
|
|
|
|
contentIDs := make([]string, 0, len(items))
|
|
episodeIDs := make([]string, 0)
|
|
collectBrowseFileIDs(items, &contentIDs, &episodeIDs)
|
|
|
|
grouped := make(map[string][]*models.MediaFile, len(items))
|
|
if len(contentIDs) > 0 {
|
|
contentFiles, err := overlayProvider.ListOverlayFilesByContentIDs(ctx, contentIDs)
|
|
if err != nil {
|
|
return h.listBrowseItemFiles(ctx, items, filter)
|
|
}
|
|
for contentID, files := range contentFiles {
|
|
grouped[contentID] = catalog.FilterMediaFilesByAccess(files, filter)
|
|
}
|
|
}
|
|
if len(episodeIDs) > 0 {
|
|
episodeFiles, err := overlayProvider.ListOverlayFilesByEpisodeIDs(ctx, episodeIDs)
|
|
if err != nil {
|
|
return h.listBrowseItemFiles(ctx, items, filter)
|
|
}
|
|
for episodeID, files := range episodeFiles {
|
|
grouped[episodeID] = catalog.FilterMediaFilesByAccess(files, filter)
|
|
}
|
|
}
|
|
return grouped
|
|
}
|
|
|
|
func collectBrowseFileIDs(items []*models.MediaItem, contentIDs *[]string, episodeIDs *[]string) {
|
|
seen := make(map[string]struct{}, len(items))
|
|
for _, item := range items {
|
|
if item == nil || item.ContentID == "" {
|
|
continue
|
|
}
|
|
if _, ok := seen[item.ContentID]; ok {
|
|
continue
|
|
}
|
|
seen[item.ContentID] = struct{}{}
|
|
if item.Type == "episode" {
|
|
*episodeIDs = append(*episodeIDs, item.ContentID)
|
|
} else {
|
|
*contentIDs = append(*contentIDs, item.ContentID)
|
|
}
|
|
}
|
|
}
|
|
|
|
func firstNonBlankPtr(values ...*string) *string {
|
|
for _, value := range values {
|
|
if value == nil || strings.TrimSpace(*value) == "" {
|
|
continue
|
|
}
|
|
copyValue := *value
|
|
return ©Value
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func maxFileBitrate(files []*models.MediaFile) int {
|
|
maxBitrate := 0
|
|
for _, file := range files {
|
|
if file != nil && file.Bitrate > maxBitrate {
|
|
maxBitrate = file.Bitrate
|
|
}
|
|
}
|
|
return maxBitrate
|
|
}
|
|
|
|
// toSeasonResponse converts a Season model to an API response.
|
|
func (h *ItemsHandler) toSeasonResponse(r *http.Request, seriesID string, s *models.Season) seasonResponse {
|
|
episodes, _ := h.episodeRepo.ListBySeason(r.Context(), seriesID, s.SeasonNumber)
|
|
return h.toSeasonResponseFromEpisodes(r, seriesID, s, episodes, h.getAggregateUserData(r, episodes))
|
|
}
|
|
|
|
func (h *ItemsHandler) toSeasonResponseFromEpisodes(
|
|
r *http.Request,
|
|
seriesID string,
|
|
s *models.Season,
|
|
episodes []*models.Episode,
|
|
userData *catalog.SeasonUserData,
|
|
) seasonResponse {
|
|
if h.detailSvc != nil {
|
|
if localized, err := h.detailSvc.LocalizeSeasonModel(r.Context(), s, h.accessFilter(r)); err == nil && localized != nil {
|
|
s = localized
|
|
}
|
|
}
|
|
return h.seasonResponseFromEpisodes(r, s, episodes, userData)
|
|
}
|
|
|
|
// seasonResponseFromEpisodes maps a season that has already been localized.
|
|
// List endpoints use this after LocalizeSeasonModels so they do not repeat the
|
|
// localization query for every row.
|
|
func (h *ItemsHandler) seasonResponseFromEpisodes(
|
|
r *http.Request,
|
|
s *models.Season,
|
|
episodes []*models.Episode,
|
|
userData *catalog.SeasonUserData,
|
|
) seasonResponse {
|
|
resp := seasonResponse{
|
|
ContentID: s.ContentID,
|
|
SeasonNumber: s.SeasonNumber,
|
|
IsSpecials: s.SeasonNumber == 0,
|
|
Title: s.Title,
|
|
Overview: s.Overview,
|
|
EpisodeCount: len(episodes),
|
|
PosterThumbhash: s.PosterThumbhash,
|
|
}
|
|
if s.AirDate != nil {
|
|
resp.AirDate = s.AirDate.Format("2006-01-02")
|
|
}
|
|
resp.PosterURL = h.presignURL(r, featuredPosterPath(s.PosterPath), "featured")
|
|
resp.UserData = userData
|
|
|
|
return resp
|
|
}
|
|
|
|
func (h *ItemsHandler) getLeafUserData(r *http.Request, contentID string, itemType ...string) *catalog.SeasonUserData {
|
|
if len(itemType) > 0 && itemType[0] == "ebook" {
|
|
return h.getEbookLeafUserData(r, contentID)
|
|
}
|
|
|
|
store, profileID, ok := h.userStoreForRequest(r)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
|
|
progress, err := userstore.GetProgressWithCompletedHistory(r.Context(), store, profileID, contentID)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
return leafUserDataFromProgress(progress)
|
|
}
|
|
|
|
// listLeafUserData batch-fetches watch progress for the given content IDs in a
|
|
// single query; the result only holds entries for items with progress rows.
|
|
func (h *ItemsHandler) listLeafUserData(r *http.Request, contentIDs []string) map[string]*catalog.SeasonUserData {
|
|
store, profileID, ok := h.userStoreForRequest(r)
|
|
if !ok || len(contentIDs) == 0 {
|
|
return nil
|
|
}
|
|
|
|
progressMap, err := userstore.ListProgressWithCompletedHistory(r.Context(), store, profileID, contentIDs)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
|
|
result := make(map[string]*catalog.SeasonUserData, len(progressMap))
|
|
for contentID, progress := range progressMap {
|
|
progressCopy := progress
|
|
result[contentID] = leafUserDataFromProgress(&progressCopy)
|
|
}
|
|
return result
|
|
}
|
|
|
|
func leafUserDataFromProgress(progress *userstore.WatchProgress) *catalog.SeasonUserData {
|
|
if progress == nil {
|
|
return nil
|
|
}
|
|
return &catalog.SeasonUserData{
|
|
PositionSeconds: progress.PositionSeconds,
|
|
DurationSeconds: progress.DurationSeconds,
|
|
IsInProgress: progress.PositionSeconds > 0,
|
|
Played: progress.Completed,
|
|
LastFileID: progress.LastFileID,
|
|
LastResolution: progress.LastResolution,
|
|
LastHDR: progress.LastHDR,
|
|
LastCodecVideo: progress.LastCodecVideo,
|
|
LastEditionKey: progress.LastEditionKey,
|
|
}
|
|
}
|
|
|
|
func (h *ItemsHandler) getEbookLeafUserData(r *http.Request, contentID string) *catalog.SeasonUserData {
|
|
if h == nil || h.ebookProgressStore == nil {
|
|
return nil
|
|
}
|
|
userID := apimw.GetUserID(r.Context())
|
|
profileID := requestProfileID(r)
|
|
if userID <= 0 || profileID == "" || contentID == "" {
|
|
return nil
|
|
}
|
|
|
|
progress, err := h.ebookProgressStore.ListByContentIDs(r.Context(), userID, profileID, []string{contentID})
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
row, ok := progress[contentID]
|
|
if !ok || row.Progress <= 0 {
|
|
return nil
|
|
}
|
|
|
|
// Ebooks have no playback duration, so PositionSeconds/DurationSeconds
|
|
// encode the 0..1 reading ratio (position=ratio, duration=1). Clients can
|
|
// derive percent-complete from the pair as usual, but rendering them as
|
|
// absolute times is meaningless for ebooks. Changing the wire shape needs
|
|
// coordinated Android/Apple client updates.
|
|
return &catalog.SeasonUserData{
|
|
PositionSeconds: row.Progress,
|
|
DurationSeconds: 1,
|
|
IsInProgress: row.Progress < models.EbookFinishedProgressThreshold,
|
|
Played: row.Progress >= models.EbookFinishedProgressThreshold,
|
|
}
|
|
}
|
|
|
|
func applyEffectiveEditionPreference(userData *catalog.SeasonUserData, target **string) {
|
|
if userData == nil || userData.LastEditionKey == nil || *userData.LastEditionKey == "" {
|
|
return
|
|
}
|
|
if target != nil && *target == nil {
|
|
*target = userData.LastEditionKey
|
|
}
|
|
}
|
|
|
|
func (h *ItemsHandler) getAggregateUserData(r *http.Request, episodes []*models.Episode) *catalog.SeasonUserData {
|
|
if len(episodes) == 0 {
|
|
return nil
|
|
}
|
|
|
|
store, profileID, ok := h.userStoreForRequest(r)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
|
|
progressMap, err := h.listProgressForEpisodeIDs(r.Context(), store, profileID, episodeContentIDs(episodes))
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
return catalog.EpisodeRollupUserData(episodes, progressMap)
|
|
}
|
|
|
|
func (h *ItemsHandler) progressMapForEpisodes(r *http.Request, episodes []*models.Episode) (map[string]userstore.WatchProgress, bool) {
|
|
store, profileID, ok := h.userStoreForRequest(r)
|
|
if !ok {
|
|
return nil, false
|
|
}
|
|
episodeIDs := episodeContentIDs(episodes)
|
|
progressMap, err := h.listProgressForEpisodeIDs(r.Context(), store, profileID, episodeIDs)
|
|
if err != nil {
|
|
return nil, false
|
|
}
|
|
return progressMap, true
|
|
}
|
|
|
|
func (h *ItemsHandler) listProgressForEpisodeIDs(ctx context.Context, store userstore.UserStore, profileID string, episodeIDs []string) (map[string]userstore.WatchProgress, error) {
|
|
const chunkSize = 500
|
|
progressMap := make(map[string]userstore.WatchProgress, len(episodeIDs))
|
|
for start := 0; start < len(episodeIDs); start += chunkSize {
|
|
end := start + chunkSize
|
|
if end > len(episodeIDs) {
|
|
end = len(episodeIDs)
|
|
}
|
|
chunk, err := userstore.ListProgressWithCompletedHistory(ctx, store, profileID, episodeIDs[start:end])
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for id, progress := range chunk {
|
|
progressMap[id] = progress
|
|
}
|
|
}
|
|
return progressMap, nil
|
|
}
|
|
|
|
func episodeContentIDs(episodes []*models.Episode) []string {
|
|
ids := make([]string, 0, len(episodes))
|
|
seen := make(map[string]struct{}, len(episodes))
|
|
for _, ep := range episodes {
|
|
if ep == nil || ep.ContentID == "" {
|
|
continue
|
|
}
|
|
if _, ok := seen[ep.ContentID]; ok {
|
|
continue
|
|
}
|
|
seen[ep.ContentID] = struct{}{}
|
|
ids = append(ids, ep.ContentID)
|
|
}
|
|
return ids
|
|
}
|
|
|
|
func flattenEpisodeGroups(groups map[int][]*models.Episode) []*models.Episode {
|
|
var total int
|
|
for _, episodes := range groups {
|
|
total += len(episodes)
|
|
}
|
|
flattened := make([]*models.Episode, 0, total)
|
|
for _, episodes := range groups {
|
|
flattened = append(flattened, episodes...)
|
|
}
|
|
return flattened
|
|
}
|
|
|
|
// requestProfileID resolves the active profile for a request, falling back to
|
|
// the X-Profile-Id header when the auth context does not carry one.
|
|
func requestProfileID(r *http.Request) string {
|
|
profileID := apimw.GetProfileID(r.Context())
|
|
if profileID == "" {
|
|
profileID = r.Header.Get("X-Profile-Id")
|
|
}
|
|
return profileID
|
|
}
|
|
|
|
func (h *ItemsHandler) userStoreForRequest(r *http.Request) (userstore.UserStore, string, bool) {
|
|
if h.storeProvider == nil {
|
|
return nil, "", false
|
|
}
|
|
|
|
userID := apimw.GetUserID(r.Context())
|
|
if userID == 0 {
|
|
return nil, "", false
|
|
}
|
|
|
|
profileID := requestProfileID(r)
|
|
if profileID == "" {
|
|
return nil, "", false
|
|
}
|
|
|
|
store, err := h.storeProvider.ForUser(r.Context(), userID)
|
|
if err != nil || store == nil {
|
|
return nil, "", false
|
|
}
|
|
|
|
return store, profileID, true
|
|
}
|
|
|
|
// cardThumbnailPath converts an S3 image path from original to w300 for use in
|
|
// browse/card views (posters, backdrops, and stills at card size).
|
|
// Full URLs (TMDB/TVDB) and plugin-prefixed paths are returned as-is —
|
|
// variant selection is handled at resolution time for plugin paths.
|
|
func cardThumbnailPath(path string) string {
|
|
if strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://") {
|
|
return path
|
|
}
|
|
// Plugin-prefixed paths pass through — variant handled at resolution time.
|
|
if strings.Contains(path, "://") {
|
|
return path
|
|
}
|
|
return strings.Replace(path, "/original.", "/w300.", 1)
|
|
}
|
|
|
|
// featuredPosterPath converts an S3 poster path from original to w500 for
|
|
// featured/hero contexts (displayed at ~220px CSS / 440px retina).
|
|
// Full URLs (TMDB/TVDB) and plugin-prefixed paths are returned as-is.
|
|
func featuredPosterPath(path string) string {
|
|
if strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://") {
|
|
return path
|
|
}
|
|
if strings.Contains(path, "://") {
|
|
return path
|
|
}
|
|
return strings.Replace(path, "/original.", "/w500.", 1)
|
|
}
|
|
|
|
// featuredBackdropPath converts an S3 backdrop path from original to w1920 for
|
|
// featured/hero contexts (displayed at full viewport width). Episode stills
|
|
// used as backdrops lack a w1920 variant and clamp to their largest cached
|
|
// size. Full URLs (TMDB/TVDB) and plugin-prefixed paths are returned as-is.
|
|
func featuredBackdropPath(path string) string {
|
|
return catalog.BackdropVariantPath(path, "w1920")
|
|
}
|
|
|
|
// presignURL resolves an image path to a usable URL, delegating to the
|
|
// DetailService which handles plugin-prefixed paths, HTTP pass-through,
|
|
// and legacy S3 presigning.
|
|
func (h *ItemsHandler) presignURL(r *http.Request, path string, variant string) string {
|
|
if h.detailSvc != nil {
|
|
return h.detailSvc.PresignURL(r.Context(), path, variant)
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// HandleFilterItems handles POST /items/filter with a full rule-group filter body.
|
|
func (h *ItemsHandler) HandleFilterItems(w http.ResponseWriter, r *http.Request) {
|
|
writeDeprecatedReadHeaders(w, "/api/v1/catalog/query")
|
|
h.catalogHandler().HandlePostCatalogQuery(w, r)
|
|
}
|
|
|
|
type filterItemsRequest struct {
|
|
sections.FilterConfig
|
|
LibraryID int `json:"library_id"`
|
|
Limit int `json:"limit"`
|
|
Offset int `json:"offset"`
|
|
}
|
|
|
|
func filterItemColumns(alias string) string {
|
|
cols := []string{
|
|
"content_id", "type", "title", "sort_title", "original_title", "year", "genres",
|
|
"content_rating", "runtime", "overview", "tagline",
|
|
"rating_imdb", "rating_tmdb", "rating_rt_critic", "rating_rt_audience",
|
|
"imdb_id", "tmdb_id", "tvdb_id",
|
|
"poster_path", "poster_thumbhash", "backdrop_path", "backdrop_thumbhash", "logo_path",
|
|
"metadata_s3_path", "metadata_etag", "season_count",
|
|
"studios", "networks", "countries", "first_air_date", "last_air_date",
|
|
"matched_at", "status", "created_at", "updated_at",
|
|
}
|
|
prefixed := make([]string, len(cols))
|
|
for i, c := range cols {
|
|
prefixed[i] = alias + "." + c
|
|
}
|
|
return strings.Join(prefixed, ", ")
|
|
}
|
|
|
|
func filterSortClause(sort, order string) string {
|
|
dir := "DESC"
|
|
if order == "asc" {
|
|
dir = "ASC"
|
|
}
|
|
switch sort {
|
|
case "rating":
|
|
return fmt.Sprintf("ORDER BY mi.rating_imdb %s NULLS LAST", dir)
|
|
case "year":
|
|
return fmt.Sprintf("ORDER BY mi.year %s", dir)
|
|
case "title":
|
|
return fmt.Sprintf("ORDER BY LOWER(COALESCE(NULLIF(BTRIM(mi.sort_title), ''), mi.title)) %s, LOWER(mi.title) %s, mi.content_id ASC", dir, dir)
|
|
default:
|
|
return fmt.Sprintf("ORDER BY mi.created_at %s", dir)
|
|
}
|
|
}
|
|
|
|
func (h *ItemsHandler) accessFilter(r *http.Request) catalog.AccessFilter {
|
|
selectedFileID := 0
|
|
if fileIDRaw := strings.TrimSpace(r.URL.Query().Get("fileId")); fileIDRaw != "" {
|
|
if fileID, err := strconv.Atoi(fileIDRaw); err == nil && fileID > 0 {
|
|
selectedFileID = fileID
|
|
}
|
|
}
|
|
|
|
var presentationLibraryID *int
|
|
if libraryIDRaw := strings.TrimSpace(r.URL.Query().Get("library_id")); libraryIDRaw != "" {
|
|
if libraryID, err := strconv.Atoi(libraryIDRaw); err == nil && libraryID > 0 {
|
|
presentationLibraryID = &libraryID
|
|
}
|
|
}
|
|
|
|
if scope, ok := access.GetScope(r.Context()); ok {
|
|
return catalog.AccessFilter{
|
|
AllowedLibraryIDs: scope.AllowedLibraryIDs,
|
|
DisabledLibraryIDs: scope.DisabledLibraryIDs,
|
|
MaxContentRating: scope.MaxContentRating,
|
|
MaxPlaybackQuality: scope.MaxPlaybackQuality,
|
|
PresentationLibraryID: presentationLibraryID,
|
|
ProfilePreferredLanguage: scope.PreferredMetadataLanguage,
|
|
MetadataLanguageOverrides: scope.MetadataLanguageOverrides,
|
|
SelectedFileID: selectedFileID,
|
|
UserID: apimw.GetUserID(r.Context()),
|
|
ProfileID: apimw.GetProfileID(r.Context()),
|
|
}
|
|
}
|
|
|
|
var libraryIDs []int
|
|
var maxPlaybackQuality string
|
|
if h.UserRepo != nil {
|
|
userID := apimw.GetUserID(r.Context())
|
|
if userID != 0 {
|
|
user, userErr := h.UserRepo.GetByID(r.Context(), userID)
|
|
if userErr != nil {
|
|
slog.ErrorContext(r.Context(), "looking up user for library access", "component", "api", "error", userErr)
|
|
} else {
|
|
if user.LibraryIDs != nil {
|
|
libraryIDs = user.LibraryIDs
|
|
}
|
|
maxPlaybackQuality = access.NormalizePlaybackQuality(user.MaxPlaybackQuality)
|
|
}
|
|
}
|
|
}
|
|
|
|
return catalog.AccessFilter{
|
|
AllowedLibraryIDs: libraryIDs,
|
|
MaxPlaybackQuality: maxPlaybackQuality,
|
|
PresentationLibraryID: presentationLibraryID,
|
|
SelectedFileID: selectedFileID,
|
|
UserID: apimw.GetUserID(r.Context()),
|
|
ProfileID: apimw.GetProfileID(r.Context()),
|
|
}
|
|
}
|
|
|
|
func (h *ItemsHandler) ensurePresentationLibraryAccess(ctx context.Context, contentID string, filter catalog.AccessFilter) error {
|
|
if filter.PresentationLibraryID == nil {
|
|
return nil
|
|
}
|
|
membership, err := h.itemRepo.GetItemsInLibrary(ctx, []string{contentID}, *filter.PresentationLibraryID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !membership[contentID] {
|
|
return catalog.ErrItemNotFound
|
|
}
|
|
return nil
|
|
}
|
|
|
|
type watchedLeafTarget struct {
|
|
ContentID string
|
|
DurationSeconds float64
|
|
}
|
|
|
|
func (h *ItemsHandler) resolveWatchedTargets(ctx context.Context, contentID string, filter catalog.AccessFilter) (string, []watchedLeafTarget, error) {
|
|
item, err := h.itemRepo.GetByID(ctx, contentID)
|
|
switch {
|
|
case err == nil:
|
|
if err := h.itemRepo.EnsureAccessible(ctx, contentID, filter); err != nil {
|
|
return "", nil, err
|
|
}
|
|
switch item.Type {
|
|
case "movie":
|
|
return "movie", []watchedLeafTarget{{
|
|
ContentID: item.ContentID,
|
|
DurationSeconds: h.contentDurationSeconds(ctx, item.ContentID, "", item.Runtime),
|
|
}}, nil
|
|
case "ebook":
|
|
// Ebooks have no playback duration; read state is keyed off
|
|
// ebook_reader_progress, so the leaf target only carries the ID.
|
|
return "ebook", []watchedLeafTarget{{ContentID: item.ContentID}}, nil
|
|
case "series":
|
|
if h.episodeRepo == nil {
|
|
return "", nil, catalog.ErrItemNotFound
|
|
}
|
|
episodes, err := h.episodeRepo.ListBySeries(ctx, item.ContentID)
|
|
if err != nil {
|
|
return "", nil, err
|
|
}
|
|
return "series", h.episodeTargets(ctx, episodes), nil
|
|
default:
|
|
return "", nil, catalog.ErrItemNotFound
|
|
}
|
|
case !errors.Is(err, catalog.ErrItemNotFound):
|
|
return "", nil, err
|
|
}
|
|
|
|
if h.seasonRepo != nil {
|
|
season, err := h.seasonRepo.GetByID(ctx, contentID)
|
|
switch {
|
|
case err == nil:
|
|
if err := h.itemRepo.EnsureAccessible(ctx, season.SeriesID, filter); err != nil {
|
|
return "", nil, err
|
|
}
|
|
if h.episodeRepo == nil {
|
|
return "", nil, catalog.ErrItemNotFound
|
|
}
|
|
episodes, err := h.episodeRepo.ListBySeasonID(ctx, season.ContentID)
|
|
if err != nil {
|
|
return "", nil, err
|
|
}
|
|
return "season", h.episodeTargets(ctx, episodes), nil
|
|
case !errors.Is(err, catalog.ErrSeasonNotFound):
|
|
return "", nil, err
|
|
}
|
|
}
|
|
|
|
if h.episodeRepo == nil {
|
|
return "", nil, catalog.ErrItemNotFound
|
|
}
|
|
|
|
episode, err := h.episodeRepo.GetByID(ctx, contentID)
|
|
if err != nil {
|
|
return "", nil, err
|
|
}
|
|
if err := h.itemRepo.EnsureAccessible(ctx, episode.SeriesID, filter); err != nil {
|
|
return "", nil, err
|
|
}
|
|
|
|
return "episode", []watchedLeafTarget{{
|
|
ContentID: episode.ContentID,
|
|
DurationSeconds: h.contentDurationSeconds(ctx, "", episode.ContentID, episode.Runtime),
|
|
}}, nil
|
|
}
|
|
|
|
func (h *ItemsHandler) episodeTargets(ctx context.Context, episodes []*models.Episode) []watchedLeafTarget {
|
|
targets := make([]watchedLeafTarget, 0, len(episodes))
|
|
for _, episode := range episodes {
|
|
targets = append(targets, watchedLeafTarget{
|
|
ContentID: episode.ContentID,
|
|
DurationSeconds: h.contentDurationSeconds(ctx, "", episode.ContentID, episode.Runtime),
|
|
})
|
|
}
|
|
return targets
|
|
}
|
|
|
|
func (h *ItemsHandler) contentDurationSeconds(ctx context.Context, contentID, episodeID string, fallbackRuntimeMinutes int) float64 {
|
|
if h.fileRepo == nil {
|
|
if fallbackRuntimeMinutes > 0 {
|
|
return float64(fallbackRuntimeMinutes * 60)
|
|
}
|
|
return 0
|
|
}
|
|
|
|
var (
|
|
files []*models.MediaFile
|
|
err error
|
|
)
|
|
|
|
switch {
|
|
case episodeID != "":
|
|
files, err = h.fileRepo.GetByEpisodeID(ctx, episodeID)
|
|
case contentID != "":
|
|
files, err = h.fileRepo.GetByContentID(ctx, contentID)
|
|
}
|
|
if err == nil {
|
|
for _, file := range files {
|
|
if file != nil && file.Duration > 0 {
|
|
return float64(file.Duration)
|
|
}
|
|
}
|
|
}
|
|
|
|
if fallbackRuntimeMinutes > 0 {
|
|
return float64(fallbackRuntimeMinutes * 60)
|
|
}
|
|
return 0
|
|
}
|
|
|
|
// isNotFound checks if an error is a "not found" sentinel.
|
|
func isNotFound(err error) bool {
|
|
return errors.Is(err, catalog.ErrItemNotFound) ||
|
|
errors.Is(err, catalog.ErrEpisodeNotFound) ||
|
|
errors.Is(err, catalog.ErrSeasonNotFound)
|
|
}
|
|
|
|
func (h *ItemsHandler) requestCanViewFilePaths(r *http.Request) bool {
|
|
claims := apimw.GetClaims(r.Context())
|
|
if claims == nil {
|
|
return false
|
|
}
|
|
if claims.Role == "admin" {
|
|
return true
|
|
}
|
|
if h == nil || h.UserRepo == nil {
|
|
return false
|
|
}
|
|
user, err := h.UserRepo.GetByID(r.Context(), claims.UserID)
|
|
if err != nil {
|
|
slog.WarnContext(r.Context(), "checking file path visibility permissions", "component", "api", "user_id", claims.UserID, "error", err)
|
|
return false
|
|
}
|
|
return auth.HasEffectivePermission(user, auth.PermissionMetadataCuration)
|
|
}
|