1507 lines
49 KiB
Go
1507 lines
49 KiB
Go
package handlers
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"log/slog"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
|
|
"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"
|
|
"github.com/Silo-Server/silo-server/internal/models"
|
|
"github.com/Silo-Server/silo-server/internal/sections"
|
|
"github.com/Silo-Server/silo-server/internal/sections/recipes"
|
|
"github.com/Silo-Server/silo-server/internal/userstore"
|
|
)
|
|
|
|
// SectionHandler handles section management and batch section endpoints.
|
|
type SectionHandler struct {
|
|
repo *sections.Repository
|
|
fetcher *sections.Fetcher
|
|
previewFetcher sectionPreviewFetcher // set to fetcher at construction; separate for test injection
|
|
FolderRepo *catalog.FolderRepository
|
|
EpisodeRepo *catalog.EpisodeRepository
|
|
StoreProvider userstore.UserStoreProvider
|
|
UserRepo *auth.UserRepository
|
|
DetailSvc *catalog.DetailService
|
|
Settings *catalog.ServerSettingsRepo
|
|
CollectionRepo *catalog.LibraryCollectionRepository
|
|
}
|
|
|
|
// NewSectionHandler creates a new SectionHandler.
|
|
func NewSectionHandler(repo *sections.Repository, fetcher *sections.Fetcher) *SectionHandler {
|
|
return &SectionHandler{repo: repo, fetcher: fetcher, previewFetcher: fetcher}
|
|
}
|
|
|
|
func (h *SectionHandler) defaultHomeSections(ctx context.Context) ([]*sections.PageSection, error) {
|
|
if h.FolderRepo == nil {
|
|
return sections.DefaultHomeSections(nil), nil
|
|
}
|
|
|
|
libraries, err := h.FolderRepo.List(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return sections.DefaultHomeSections(libraries), nil
|
|
}
|
|
|
|
func (h *SectionHandler) defaultLibrarySections(ctx context.Context, libraryID int) ([]*sections.PageSection, error) {
|
|
if h.FolderRepo == nil {
|
|
return libraryDefaultSections(nil, libraryID), nil
|
|
}
|
|
|
|
folder, err := h.FolderRepo.GetByID(ctx, libraryID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return libraryDefaultSections(folder, libraryID), nil
|
|
}
|
|
|
|
func libraryDefaultSections(folder *models.MediaFolder, libraryID int) []*sections.PageSection {
|
|
if folder == nil {
|
|
return sections.DefaultLibrarySections(&libraryID)
|
|
}
|
|
return sections.DefaultLibrarySectionsForType(&libraryID, folder.Type)
|
|
}
|
|
|
|
// --- Request/Response types ---
|
|
|
|
type createSectionRequest struct {
|
|
Scope string `json:"scope"`
|
|
LibraryID *int `json:"library_id"`
|
|
Position int `json:"position"`
|
|
SectionType string `json:"section_type"`
|
|
Title string `json:"title"`
|
|
Featured bool `json:"featured"`
|
|
ItemLimit int `json:"item_limit"`
|
|
Config json.RawMessage `json:"config"`
|
|
Enabled bool `json:"enabled"`
|
|
}
|
|
|
|
type updateSectionRequest struct {
|
|
Position *int `json:"position"`
|
|
SectionType string `json:"section_type,omitempty"`
|
|
Title string `json:"title,omitempty"`
|
|
Featured *bool `json:"featured"`
|
|
ItemLimit *int `json:"item_limit"`
|
|
Config json.RawMessage `json:"config,omitempty"`
|
|
Enabled *bool `json:"enabled"`
|
|
}
|
|
|
|
type reorderSectionsRequest struct {
|
|
Entries []sections.ReorderEntry `json:"entries"`
|
|
}
|
|
|
|
type restoreDefaultsRequest struct {
|
|
Scope string `json:"scope"`
|
|
LibraryID *int `json:"library_id"`
|
|
ResetProfiles bool `json:"reset_profiles"`
|
|
}
|
|
|
|
type sectionResponse struct {
|
|
ID string `json:"id"`
|
|
Scope string `json:"scope"`
|
|
LibraryID *int `json:"library_id"`
|
|
Position int `json:"position"`
|
|
SectionType string `json:"section_type"`
|
|
Title string `json:"title"`
|
|
Featured bool `json:"featured"`
|
|
ItemLimit int `json:"item_limit"`
|
|
Config json.RawMessage `json:"config"`
|
|
Enabled bool `json:"enabled"`
|
|
CreatedAt string `json:"created_at"`
|
|
UpdatedAt string `json:"updated_at"`
|
|
}
|
|
|
|
type sectionListResponse struct {
|
|
Sections []sectionResponse `json:"sections"`
|
|
}
|
|
|
|
func toSectionResponse(s *sections.PageSection) sectionResponse {
|
|
return sectionResponse{
|
|
ID: s.ID,
|
|
Scope: s.Scope,
|
|
LibraryID: s.LibraryID,
|
|
Position: s.Position,
|
|
SectionType: string(s.SectionType),
|
|
Title: s.Title,
|
|
Featured: s.Featured,
|
|
ItemLimit: s.ItemLimit,
|
|
Config: s.Config,
|
|
Enabled: s.Enabled,
|
|
CreatedAt: s.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
|
UpdatedAt: s.UpdatedAt.Format("2006-01-02T15:04:05Z"),
|
|
}
|
|
}
|
|
|
|
// validateSectionConfig checks config requirements for the given section type.
|
|
func validateSectionConfig(sectionType sections.SectionType, config json.RawMessage) (string, bool) {
|
|
if sectionType == sections.SectionCollection {
|
|
collectionConfig := sections.ParseCollectionConfig(config)
|
|
if strings.TrimSpace(collectionConfig.LibraryCollectionID) == "" {
|
|
return "library_collection_id is required for collection sections", false
|
|
}
|
|
}
|
|
|
|
cfg := sections.ParseConfigFilters(config)
|
|
if cfg.FilterType != "" && cfg.FilterType != "movie" && cfg.FilterType != "series" {
|
|
return "filter_type must be 'movie' or 'series'", false
|
|
}
|
|
|
|
var rawLibraryConfig struct {
|
|
FilterLibraryID *int `json:"filter_library_id"`
|
|
FilterLibraryIDs []int `json:"filter_library_ids"`
|
|
}
|
|
if len(config) > 0 {
|
|
_ = json.Unmarshal(config, &rawLibraryConfig)
|
|
}
|
|
if rawLibraryConfig.FilterLibraryID != nil && *rawLibraryConfig.FilterLibraryID <= 0 {
|
|
return "filter_library_id must be a positive library ID", false
|
|
}
|
|
for _, id := range rawLibraryConfig.FilterLibraryIDs {
|
|
if id <= 0 {
|
|
return "filter_library_ids must contain positive library IDs", false
|
|
}
|
|
}
|
|
if sectionType == sections.SectionCustomFilter || sectionType == sections.SectionGenre || sectionType == sections.SectionRandom || len(config) > 0 {
|
|
if _, err := sections.ParseQueryDefinition(config); err != nil {
|
|
return err.Error(), false
|
|
}
|
|
}
|
|
return "", true
|
|
}
|
|
|
|
func validateSectionScope(scope string, libraryID *int) (string, bool) {
|
|
switch scope {
|
|
case "", "home":
|
|
if libraryID != nil {
|
|
return "library_id must not be set for home sections", false
|
|
}
|
|
case "library":
|
|
if libraryID == nil {
|
|
return "library_id is required for library sections", false
|
|
}
|
|
default:
|
|
return "Invalid scope", false
|
|
}
|
|
|
|
return "", true
|
|
}
|
|
|
|
// --- Admin CRUD endpoints ---
|
|
|
|
// HandleListSections handles GET /admin/sections?scope=home&library_id=123
|
|
func (h *SectionHandler) HandleListSections(w http.ResponseWriter, r *http.Request) {
|
|
scope := r.URL.Query().Get("scope")
|
|
if scope == "" {
|
|
scope = "home"
|
|
}
|
|
|
|
var libraryID *int
|
|
if lid := r.URL.Query().Get("library_id"); lid != "" {
|
|
v, err := strconv.Atoi(lid)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "Invalid library_id")
|
|
return
|
|
}
|
|
libraryID = &v
|
|
}
|
|
|
|
list, err := h.repo.ListByScopeAll(r.Context(), scope, libraryID)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list sections")
|
|
return
|
|
}
|
|
|
|
resp := sectionListResponse{Sections: make([]sectionResponse, 0, len(list))}
|
|
for _, s := range list {
|
|
resp.Sections = append(resp.Sections, toSectionResponse(s))
|
|
}
|
|
writeJSON(w, http.StatusOK, resp)
|
|
}
|
|
|
|
// HandleCreateSection handles POST /admin/sections
|
|
func (h *SectionHandler) HandleCreateSection(w http.ResponseWriter, r *http.Request) {
|
|
var req createSectionRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
|
|
return
|
|
}
|
|
|
|
if req.Title == "" || req.SectionType == "" {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "Title and section_type are required")
|
|
return
|
|
}
|
|
|
|
if !sections.ValidSectionTypes[sections.SectionType(req.SectionType)] {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "Invalid section_type")
|
|
return
|
|
}
|
|
|
|
scope := req.Scope
|
|
if scope == "" {
|
|
scope = "home"
|
|
}
|
|
|
|
if msg, ok := validateSectionScope(scope, req.LibraryID); !ok {
|
|
writeError(w, http.StatusBadRequest, "bad_request", msg)
|
|
return
|
|
}
|
|
|
|
if msg, ok := validateSectionConfig(sections.SectionType(req.SectionType), req.Config); !ok {
|
|
writeError(w, http.StatusBadRequest, "bad_request", msg)
|
|
return
|
|
}
|
|
|
|
sec := §ions.PageSection{
|
|
Scope: scope,
|
|
LibraryID: req.LibraryID,
|
|
Position: req.Position,
|
|
SectionType: sections.SectionType(req.SectionType),
|
|
Title: req.Title,
|
|
Featured: req.Featured,
|
|
ItemLimit: req.ItemLimit,
|
|
Config: req.Config,
|
|
Enabled: req.Enabled,
|
|
}
|
|
if sec.Scope == "" {
|
|
sec.Scope = "home"
|
|
}
|
|
if sec.ItemLimit <= 0 {
|
|
sec.ItemLimit = 20
|
|
}
|
|
|
|
created, err := h.repo.Create(r.Context(), sec)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to create section")
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusCreated, toSectionResponse(created))
|
|
}
|
|
|
|
// HandleUpdateSection handles PUT /admin/sections/{id}
|
|
func (h *SectionHandler) HandleUpdateSection(w http.ResponseWriter, r *http.Request) {
|
|
id := chi.URLParam(r, "id")
|
|
if id == "" {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "Section ID is required")
|
|
return
|
|
}
|
|
|
|
existing, err := h.repo.GetByID(r.Context(), id)
|
|
if err != nil {
|
|
writeError(w, http.StatusNotFound, "not_found", "Section not found")
|
|
return
|
|
}
|
|
|
|
var req updateSectionRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
|
|
return
|
|
}
|
|
|
|
if req.Position != nil {
|
|
existing.Position = *req.Position
|
|
}
|
|
if req.SectionType != "" {
|
|
existing.SectionType = sections.SectionType(req.SectionType)
|
|
}
|
|
if req.Title != "" {
|
|
existing.Title = req.Title
|
|
}
|
|
if req.Featured != nil {
|
|
existing.Featured = *req.Featured
|
|
}
|
|
if req.ItemLimit != nil {
|
|
existing.ItemLimit = *req.ItemLimit
|
|
}
|
|
if len(req.Config) > 0 {
|
|
existing.Config = req.Config
|
|
}
|
|
if req.Enabled != nil {
|
|
existing.Enabled = *req.Enabled
|
|
}
|
|
|
|
if msg, ok := validateSectionScope(existing.Scope, existing.LibraryID); !ok {
|
|
writeError(w, http.StatusBadRequest, "bad_request", msg)
|
|
return
|
|
}
|
|
|
|
if msg, ok := validateSectionConfig(existing.SectionType, existing.Config); !ok {
|
|
writeError(w, http.StatusBadRequest, "bad_request", msg)
|
|
return
|
|
}
|
|
|
|
if err := h.repo.Update(r.Context(), existing); err != nil {
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to update section")
|
|
return
|
|
}
|
|
|
|
updated, _ := h.repo.GetByID(r.Context(), id)
|
|
writeJSON(w, http.StatusOK, toSectionResponse(updated))
|
|
}
|
|
|
|
// HandleDeleteSection handles DELETE /admin/sections/{id}
|
|
func (h *SectionHandler) HandleDeleteSection(w http.ResponseWriter, r *http.Request) {
|
|
id := chi.URLParam(r, "id")
|
|
if id == "" {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "Section ID is required")
|
|
return
|
|
}
|
|
|
|
existing, err := h.repo.GetByID(r.Context(), id)
|
|
if err != nil {
|
|
writeError(w, http.StatusNotFound, "not_found", "Section not found")
|
|
return
|
|
}
|
|
collectionID := strings.TrimSpace(sections.ParseCollectionConfig(existing.Config).LibraryCollectionID)
|
|
|
|
if err := h.repo.Delete(r.Context(), id); err != nil {
|
|
writeError(w, http.StatusNotFound, "not_found", "Section not found")
|
|
return
|
|
}
|
|
h.deleteUnreferencedSectionManagedCollection(r.Context(), collectionID)
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
func (h *SectionHandler) deleteUnreferencedSectionManagedCollection(ctx context.Context, collectionID string) {
|
|
if collectionID == "" || h.CollectionRepo == nil {
|
|
return
|
|
}
|
|
collection, err := h.CollectionRepo.GetByID(ctx, collectionID)
|
|
if err != nil {
|
|
if !errors.Is(err, catalog.ErrLibraryCollectionNotFound) {
|
|
slog.Warn("failed to load section-managed collection during section delete", "collection_id", collectionID, "error", err)
|
|
}
|
|
return
|
|
}
|
|
if collection.ManagementMode != "section" {
|
|
return
|
|
}
|
|
refs, err := h.repo.CountLibraryCollectionReferences(ctx, collectionID, "")
|
|
if err != nil {
|
|
slog.Warn("failed to count section-managed collection references", "collection_id", collectionID, "error", err)
|
|
return
|
|
}
|
|
if refs > 0 {
|
|
return
|
|
}
|
|
if err := h.CollectionRepo.Delete(ctx, collectionID); err != nil && !errors.Is(err, catalog.ErrLibraryCollectionNotFound) {
|
|
slog.Warn("failed to delete unreferenced section-managed collection", "collection_id", collectionID, "error", err)
|
|
}
|
|
}
|
|
|
|
// HandleReorderSections handles PUT /admin/sections/reorder
|
|
func (h *SectionHandler) HandleReorderSections(w http.ResponseWriter, r *http.Request) {
|
|
var req reorderSectionsRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
|
|
return
|
|
}
|
|
|
|
if err := h.repo.Reorder(r.Context(), req.Entries); err != nil {
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to reorder sections")
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// --- Batch section endpoints ---
|
|
|
|
type upcomingEventResponse struct {
|
|
Type string `json:"type"`
|
|
AirDate string `json:"air_date"`
|
|
AirTime *string `json:"air_time,omitempty"`
|
|
EpisodeTitle *string `json:"episode_title,omitempty"`
|
|
SeasonNumber *int `json:"season_number,omitempty"`
|
|
EpisodeNumber *int `json:"episode_number,omitempty"`
|
|
Badges []string `json:"badges"`
|
|
}
|
|
|
|
type sectionItemResponse struct {
|
|
ContentID string `json:"content_id"`
|
|
Type string `json:"type"`
|
|
Title string `json:"title"`
|
|
SeriesID string `json:"series_id,omitempty"`
|
|
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"`
|
|
PositionSeconds *float64 `json:"position_seconds,omitempty"`
|
|
DurationSeconds *float64 `json:"duration_seconds,omitempty"`
|
|
ProgressUpdatedAt *string `json:"progress_updated_at,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"`
|
|
LogoURL string `json:"logo_url,omitempty"`
|
|
OverlaySummary *models.OverlaySummary `json:"overlay_summary,omitempty"`
|
|
Badges []string `json:"badges,omitempty"`
|
|
ItemSource string `json:"item_source,omitempty"`
|
|
UserState *itemUserStateResponse `json:"user_state,omitempty"`
|
|
UpcomingEvent *upcomingEventResponse `json:"upcoming_event,omitempty"`
|
|
}
|
|
|
|
type resolvedSectionResponse struct {
|
|
ID string `json:"id"`
|
|
SectionType string `json:"section_type"`
|
|
Title string `json:"title"`
|
|
Featured bool `json:"featured"`
|
|
ItemLimit int `json:"item_limit"`
|
|
TotalCount int `json:"total_count"`
|
|
IsCustom bool `json:"is_custom"`
|
|
Customized bool `json:"customized"`
|
|
Items []sectionItemResponse `json:"items"`
|
|
}
|
|
|
|
type homeSectionsResponse struct {
|
|
Sections []resolvedSectionResponse `json:"sections"`
|
|
}
|
|
|
|
type resolvedSectionLayoutResponse struct {
|
|
ID string `json:"id"`
|
|
SectionType string `json:"section_type"`
|
|
Title string `json:"title"`
|
|
Featured bool `json:"featured"`
|
|
ItemLimit int `json:"item_limit"`
|
|
IsCustom bool `json:"is_custom"`
|
|
Customized bool `json:"customized"`
|
|
}
|
|
|
|
type homeLayoutResponse struct {
|
|
Sections []resolvedSectionLayoutResponse `json:"sections"`
|
|
}
|
|
|
|
type homeSectionItemsResponse struct {
|
|
Section resolvedSectionResponse `json:"section"`
|
|
}
|
|
|
|
// HandleHomeLayout handles GET /home/layout
|
|
func (h *SectionHandler) HandleHomeLayout(w http.ResponseWriter, r *http.Request) {
|
|
resolved, _, _, _, err := h.loadResolvedHomeSections(r)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load sections")
|
|
return
|
|
}
|
|
|
|
userID := apimw.GetUserID(r.Context())
|
|
resolved = h.maybeInjectNextUp(r.Context(), resolved, userID)
|
|
|
|
resp := homeLayoutResponse{
|
|
Sections: make([]resolvedSectionLayoutResponse, 0, len(resolved)),
|
|
}
|
|
for _, s := range resolved {
|
|
resp.Sections = append(resp.Sections, resolvedSectionLayoutResponse{
|
|
ID: s.ID,
|
|
SectionType: string(s.SectionType),
|
|
Title: s.Title,
|
|
Featured: s.Featured,
|
|
ItemLimit: s.ItemLimit,
|
|
IsCustom: s.IsCustom,
|
|
Customized: s.Customized,
|
|
})
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, resp)
|
|
}
|
|
|
|
// HandleLibraryLayout handles GET /library/{id}/layout
|
|
func (h *SectionHandler) HandleLibraryLayout(w http.ResponseWriter, r *http.Request) {
|
|
idStr := chi.URLParam(r, "id")
|
|
libraryID, err := strconv.Atoi(idStr)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "Invalid library ID")
|
|
return
|
|
}
|
|
|
|
resolved, _, _, err := h.loadResolvedLibrarySections(r, libraryID)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load sections")
|
|
return
|
|
}
|
|
|
|
resp := homeLayoutResponse{
|
|
Sections: make([]resolvedSectionLayoutResponse, 0, len(resolved)),
|
|
}
|
|
for _, s := range resolved {
|
|
resp.Sections = append(resp.Sections, resolvedSectionLayoutResponse{
|
|
ID: s.ID,
|
|
SectionType: string(s.SectionType),
|
|
Title: s.Title,
|
|
Featured: s.Featured,
|
|
ItemLimit: s.ItemLimit,
|
|
IsCustom: s.IsCustom,
|
|
Customized: s.Customized,
|
|
})
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, resp)
|
|
}
|
|
|
|
// HandleHomeSections handles GET /home/sections
|
|
func (h *SectionHandler) HandleHomeSections(w http.ResponseWriter, r *http.Request) {
|
|
resolved, libraryIDs, accessFilter, profileID, err := h.loadResolvedHomeSections(r)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load sections")
|
|
return
|
|
}
|
|
|
|
userID := apimw.GetUserID(r.Context())
|
|
resolved = h.maybeInjectNextUp(r.Context(), resolved, userID)
|
|
withItems := h.fetcher.FetchAll(r.Context(), resolved, nil, libraryIDs, userID, profileID, accessFilter)
|
|
withItems = applyDiversityFilter(withItems)
|
|
withItems = dropEmptySeasonalSections(withItems)
|
|
writeJSON(w, http.StatusOK, h.buildSectionsResponse(r, withItems))
|
|
}
|
|
|
|
// HandleHomeSectionItems handles GET /home/sections/{id}/items
|
|
func (h *SectionHandler) HandleHomeSectionItems(w http.ResponseWriter, r *http.Request) {
|
|
sectionID := chi.URLParam(r, "id")
|
|
if sectionID == "" {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "Section ID is required")
|
|
return
|
|
}
|
|
|
|
resolved, libraryIDs, accessFilter, profileID, err := h.loadResolvedHomeSections(r)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load sections")
|
|
return
|
|
}
|
|
userID := apimw.GetUserID(r.Context())
|
|
resolved = h.maybeInjectNextUp(r.Context(), resolved, userID)
|
|
|
|
for _, s := range resolved {
|
|
if s.ID != sectionID {
|
|
continue
|
|
}
|
|
|
|
withItems, fetchErr := h.fetcher.FetchOne(r.Context(), s, nil, libraryIDs, userID, profileID, accessFilter)
|
|
if fetchErr != nil {
|
|
slog.Error("fetching section items", "section_id", s.ID, "type", s.SectionType, "error", fetchErr)
|
|
withItems = sections.SectionWithItems{
|
|
ResolvedSection: s,
|
|
Items: []*models.MediaItem{},
|
|
}
|
|
}
|
|
|
|
resp := h.buildSectionsResponse(r, []sections.SectionWithItems{withItems})
|
|
if len(resp.Sections) == 0 {
|
|
resp.Sections = append(resp.Sections, resolvedSectionResponse{
|
|
ID: withItems.ID,
|
|
SectionType: string(withItems.SectionType),
|
|
Title: withItems.Title,
|
|
Featured: withItems.Featured,
|
|
ItemLimit: withItems.ItemLimit,
|
|
TotalCount: withItems.TotalCount,
|
|
IsCustom: withItems.IsCustom,
|
|
Customized: withItems.Customized,
|
|
Items: []sectionItemResponse{},
|
|
})
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, homeSectionItemsResponse{
|
|
Section: resp.Sections[0],
|
|
})
|
|
return
|
|
}
|
|
|
|
writeError(w, http.StatusNotFound, "not_found", "Section not found")
|
|
}
|
|
|
|
// HandleLibrarySections handles GET /library/{id}/sections
|
|
func (h *SectionHandler) HandleLibrarySections(w http.ResponseWriter, r *http.Request) {
|
|
idStr := chi.URLParam(r, "id")
|
|
libraryID, err := strconv.Atoi(idStr)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "Invalid library ID")
|
|
return
|
|
}
|
|
|
|
resolved, accessFilter, profileID, err := h.loadResolvedLibrarySections(r, libraryID)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load sections")
|
|
return
|
|
}
|
|
|
|
userID := apimw.GetUserID(r.Context())
|
|
withItems := h.fetcher.FetchAll(r.Context(), resolved, &libraryID, nil, userID, profileID, accessFilter)
|
|
withItems = applyDiversityFilter(withItems)
|
|
withItems = dropEmptySeasonalSections(withItems)
|
|
writeJSON(w, http.StatusOK, h.buildSectionsResponse(r, withItems))
|
|
}
|
|
|
|
// HandleLibrarySectionItems handles GET /library/{id}/sections/{sectionId}/items
|
|
func (h *SectionHandler) HandleLibrarySectionItems(w http.ResponseWriter, r *http.Request) {
|
|
idStr := chi.URLParam(r, "id")
|
|
libraryID, err := strconv.Atoi(idStr)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "Invalid library ID")
|
|
return
|
|
}
|
|
|
|
sectionID := chi.URLParam(r, "sectionId")
|
|
if sectionID == "" {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "Section ID is required")
|
|
return
|
|
}
|
|
|
|
resolved, accessFilter, profileID, err := h.loadResolvedLibrarySections(r, libraryID)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load sections")
|
|
return
|
|
}
|
|
|
|
userID := apimw.GetUserID(r.Context())
|
|
for _, s := range resolved {
|
|
if s.ID != sectionID {
|
|
continue
|
|
}
|
|
|
|
withItems, fetchErr := h.fetcher.FetchOne(r.Context(), s, &libraryID, nil, userID, profileID, accessFilter)
|
|
if fetchErr != nil {
|
|
slog.Error("fetching section items", "section_id", s.ID, "type", s.SectionType, "error", fetchErr)
|
|
withItems = sections.SectionWithItems{
|
|
ResolvedSection: s,
|
|
Items: []*models.MediaItem{},
|
|
}
|
|
}
|
|
|
|
resp := h.buildSectionsResponse(r, []sections.SectionWithItems{withItems})
|
|
if len(resp.Sections) == 0 {
|
|
resp.Sections = append(resp.Sections, resolvedSectionResponse{
|
|
ID: withItems.ID,
|
|
SectionType: string(withItems.SectionType),
|
|
Title: withItems.Title,
|
|
Featured: withItems.Featured,
|
|
ItemLimit: withItems.ItemLimit,
|
|
TotalCount: withItems.TotalCount,
|
|
IsCustom: withItems.IsCustom,
|
|
Customized: withItems.Customized,
|
|
Items: []sectionItemResponse{},
|
|
})
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, homeSectionItemsResponse{
|
|
Section: resp.Sections[0],
|
|
})
|
|
return
|
|
}
|
|
|
|
writeError(w, http.StatusNotFound, "not_found", "Section not found")
|
|
}
|
|
|
|
func (h *SectionHandler) loadResolvedHomeSections(r *http.Request) ([]sections.ResolvedSection, []int, catalog.AccessFilter, string, error) {
|
|
profileID := apimw.GetProfileID(r.Context())
|
|
userID := apimw.GetUserID(r.Context())
|
|
|
|
adminSections, err := h.repo.ListByScope(r.Context(), "home", nil)
|
|
if err != nil {
|
|
return nil, nil, catalog.AccessFilter{}, profileID, err
|
|
}
|
|
|
|
// Fall back to default sections when none are admin-configured.
|
|
if len(adminSections) == 0 {
|
|
adminSections, err = h.defaultHomeSections(r.Context())
|
|
if err != nil {
|
|
return nil, nil, catalog.AccessFilter{}, profileID, err
|
|
}
|
|
}
|
|
|
|
var overrides []sections.ProfileSectionOverride
|
|
if h.StoreProvider != nil && profileID != "" {
|
|
store, storeErr := h.StoreProvider.ForUser(r.Context(), userID)
|
|
if storeErr == nil {
|
|
userOverrides, _ := store.ListSectionOverrides(r.Context(), profileID, "home", "")
|
|
overrides = toSectionOverrides(userOverrides)
|
|
}
|
|
}
|
|
|
|
resolved := sections.Resolve(adminSections, overrides)
|
|
|
|
var libraryIDs []int
|
|
accessFilter := catalog.AccessFilter{}
|
|
if scope, ok := access.GetScope(r.Context()); ok {
|
|
libraryIDs = scope.AllowedLibraryIDs
|
|
accessFilter.AllowedLibraryIDs = scope.AllowedLibraryIDs
|
|
accessFilter.DisabledLibraryIDs = scope.DisabledLibraryIDs
|
|
accessFilter.MaxContentRating = scope.MaxContentRating
|
|
} else if h.UserRepo != nil {
|
|
user, _ := h.UserRepo.GetByID(r.Context(), userID)
|
|
if user != nil && user.LibraryIDs != nil {
|
|
libraryIDs = user.LibraryIDs
|
|
accessFilter.AllowedLibraryIDs = user.LibraryIDs
|
|
}
|
|
}
|
|
|
|
return resolved, libraryIDs, accessFilter, profileID, nil
|
|
}
|
|
|
|
func (h *SectionHandler) loadResolvedLibrarySections(r *http.Request, libraryID int) ([]sections.ResolvedSection, catalog.AccessFilter, string, error) {
|
|
profileID := apimw.GetProfileID(r.Context())
|
|
userID := apimw.GetUserID(r.Context())
|
|
|
|
adminSections, err := h.repo.ListByScope(r.Context(), "library", &libraryID)
|
|
if err != nil {
|
|
return nil, catalog.AccessFilter{}, profileID, err
|
|
}
|
|
|
|
// Fall back to default sections when none are admin-configured.
|
|
if len(adminSections) == 0 {
|
|
defaults, defaultsErr := h.defaultLibrarySections(r.Context(), libraryID)
|
|
if defaultsErr != nil {
|
|
slog.Warn("loading typed library section defaults", "library_id", libraryID, "error", defaultsErr)
|
|
adminSections = sections.DefaultLibrarySections(&libraryID)
|
|
} else {
|
|
adminSections = defaults
|
|
}
|
|
}
|
|
|
|
var overrides []sections.ProfileSectionOverride
|
|
if h.StoreProvider != nil && profileID != "" {
|
|
store, storeErr := h.StoreProvider.ForUser(r.Context(), userID)
|
|
if storeErr == nil {
|
|
libStr := strconv.Itoa(libraryID)
|
|
userOverrides, _ := store.ListSectionOverrides(r.Context(), profileID, "library", libStr)
|
|
overrides = toSectionOverrides(userOverrides)
|
|
}
|
|
}
|
|
|
|
resolved := sections.Resolve(adminSections, overrides)
|
|
resolved = h.maybeInjectNextUp(r.Context(), resolved, userID)
|
|
|
|
accessFilter := catalog.AccessFilter{}
|
|
if scope, ok := access.GetScope(r.Context()); ok {
|
|
accessFilter.AllowedLibraryIDs = scope.AllowedLibraryIDs
|
|
accessFilter.DisabledLibraryIDs = scope.DisabledLibraryIDs
|
|
accessFilter.MaxContentRating = scope.MaxContentRating
|
|
}
|
|
|
|
return resolved, accessFilter, profileID, nil
|
|
}
|
|
|
|
// --- Profile override endpoints ---
|
|
|
|
type saveOverridesRequest struct {
|
|
Scope string `json:"scope"`
|
|
LibraryID string `json:"library_id"`
|
|
Overrides []profileOverrideRequest `json:"overrides"`
|
|
}
|
|
|
|
type profileOverrideRequest struct {
|
|
ID string `json:"id"`
|
|
SectionID string `json:"section_id"`
|
|
Position *int `json:"position"`
|
|
Hidden bool `json:"hidden"`
|
|
Removed bool `json:"removed"`
|
|
SectionType string `json:"section_type"`
|
|
Title string `json:"title"`
|
|
Featured *bool `json:"featured"`
|
|
ItemLimit *int `json:"item_limit"`
|
|
Config json.RawMessage `json:"config"`
|
|
IsUserAdded bool `json:"is_user_added,omitempty"`
|
|
UserSectionType string `json:"user_section_type,omitempty"`
|
|
UserConfig json.RawMessage `json:"user_config,omitempty"`
|
|
UserTitle string `json:"user_title,omitempty"`
|
|
}
|
|
|
|
// HandleGetProfileOverrides handles GET /profile/sections?scope=home
|
|
func (h *SectionHandler) HandleGetProfileOverrides(w http.ResponseWriter, r *http.Request) {
|
|
profileID := apimw.GetProfileID(r.Context())
|
|
userID := apimw.GetUserID(r.Context())
|
|
|
|
scope := r.URL.Query().Get("scope")
|
|
if scope == "" {
|
|
scope = "home"
|
|
}
|
|
libraryID := r.URL.Query().Get("library_id")
|
|
|
|
if h.StoreProvider == nil {
|
|
writeJSON(w, http.StatusOK, map[string][]userstore.SectionOverride{"overrides": {}})
|
|
return
|
|
}
|
|
|
|
store, err := h.StoreProvider.ForUser(r.Context(), userID)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to access user store")
|
|
return
|
|
}
|
|
|
|
overrides, err := store.ListSectionOverrides(r.Context(), profileID, scope, libraryID)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load overrides")
|
|
return
|
|
}
|
|
|
|
if overrides == nil {
|
|
overrides = []userstore.SectionOverride{}
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string][]userstore.SectionOverride{"overrides": overrides})
|
|
}
|
|
|
|
// HandleSaveProfileOverrides handles PUT /profile/sections
|
|
func (h *SectionHandler) HandleSaveProfileOverrides(w http.ResponseWriter, r *http.Request) {
|
|
profileID := apimw.GetProfileID(r.Context())
|
|
userID := apimw.GetUserID(r.Context())
|
|
|
|
var req saveOverridesRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
|
|
return
|
|
}
|
|
|
|
if req.Scope == "" {
|
|
req.Scope = "home"
|
|
}
|
|
|
|
// Gate: validate user-added overrides before touching the store.
|
|
allowCustom := false
|
|
if h.Settings != nil {
|
|
v, _ := h.Settings.Get(r.Context(), SectionsAllowProfileCustomSettingKey)
|
|
allowCustom = v == "true"
|
|
}
|
|
isAdmin := false
|
|
if claims := apimw.GetClaims(r.Context()); claims != nil {
|
|
isAdmin = claims.Role == "admin"
|
|
}
|
|
|
|
for _, o := range req.Overrides {
|
|
// The resolver treats any override with empty SectionID as user-added,
|
|
// regardless of the IsUserAdded flag. Match that here so a client cannot
|
|
// bypass the recipe gate by omitting is_user_added and sending the legacy
|
|
// shape (section_id:"", section_type:"admin_curated_list", config:{…}).
|
|
isUserAdded := o.IsUserAdded || o.SectionID == ""
|
|
if !isUserAdded {
|
|
continue
|
|
}
|
|
|
|
// Prefer the explicit user_section_type; fall back to legacy section_type
|
|
// for parity with resolveUserAdded.
|
|
recipeType := o.UserSectionType
|
|
if recipeType == "" {
|
|
recipeType = o.SectionType
|
|
}
|
|
rec, ok := recipes.Get(recipeType)
|
|
if !ok {
|
|
writeError(w, http.StatusBadRequest, "unknown_recipe", "section_type not registered: "+recipeType)
|
|
return
|
|
}
|
|
if rec.Definition().AdminOnly && !isAdmin && !allowCustom {
|
|
writeError(w, http.StatusForbidden, "custom_disabled", "this server does not allow profiles to build custom sections")
|
|
return
|
|
}
|
|
// Validate whichever config the resolver will actually use.
|
|
cfg := o.UserConfig
|
|
if len(cfg) == 0 {
|
|
cfg = o.Config
|
|
}
|
|
if err := rec.Validate(cfg); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid_config", err.Error())
|
|
return
|
|
}
|
|
}
|
|
|
|
if h.StoreProvider == nil {
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "User store not available")
|
|
return
|
|
}
|
|
|
|
store, err := h.StoreProvider.ForUser(r.Context(), userID)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to access user store")
|
|
return
|
|
}
|
|
|
|
overrides := make([]userstore.SectionOverride, len(req.Overrides))
|
|
for i, o := range req.Overrides {
|
|
var configStr string
|
|
if len(o.Config) > 0 {
|
|
configStr = string(o.Config)
|
|
}
|
|
overrides[i] = userstore.SectionOverride{
|
|
ID: o.ID,
|
|
ProfileID: profileID,
|
|
Scope: req.Scope,
|
|
LibraryID: req.LibraryID,
|
|
SectionID: o.SectionID,
|
|
Position: o.Position,
|
|
Hidden: o.Hidden,
|
|
Removed: o.Removed,
|
|
SectionType: o.SectionType,
|
|
Title: o.Title,
|
|
Featured: o.Featured,
|
|
ItemLimit: o.ItemLimit,
|
|
Config: configStr,
|
|
IsUserAdded: o.IsUserAdded,
|
|
UserSectionType: o.UserSectionType,
|
|
UserConfig: string(o.UserConfig),
|
|
UserTitle: o.UserTitle,
|
|
}
|
|
}
|
|
|
|
if err := store.SaveSectionOverrides(r.Context(), profileID, req.Scope, req.LibraryID, overrides); err != nil {
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to save overrides")
|
|
return
|
|
}
|
|
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// HandleResetProfileOverrides handles DELETE /profile/sections/reset?scope=home
|
|
func (h *SectionHandler) HandleResetProfileOverrides(w http.ResponseWriter, r *http.Request) {
|
|
profileID := apimw.GetProfileID(r.Context())
|
|
userID := apimw.GetUserID(r.Context())
|
|
|
|
scope := r.URL.Query().Get("scope")
|
|
if scope == "" {
|
|
scope = "home"
|
|
}
|
|
libraryID := r.URL.Query().Get("library_id")
|
|
|
|
if h.StoreProvider == nil {
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "User store not available")
|
|
return
|
|
}
|
|
|
|
store, err := h.StoreProvider.ForUser(r.Context(), userID)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to access user store")
|
|
return
|
|
}
|
|
|
|
if err := store.ResetSectionOverrides(r.Context(), profileID, scope, libraryID); err != nil {
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to reset overrides")
|
|
return
|
|
}
|
|
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// HandleSectionSettings handles GET /profile/sections/settings?scope=home&library_id=123
|
|
func (h *SectionHandler) HandleSectionSettings(w http.ResponseWriter, r *http.Request) {
|
|
profileID := apimw.GetProfileID(r.Context())
|
|
userID := apimw.GetUserID(r.Context())
|
|
|
|
scope := r.URL.Query().Get("scope")
|
|
if scope == "" {
|
|
scope = "home"
|
|
}
|
|
|
|
var libIDPtr *int
|
|
if lid := r.URL.Query().Get("library_id"); lid != "" {
|
|
v, err := strconv.Atoi(lid)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "Invalid library_id")
|
|
return
|
|
}
|
|
libIDPtr = &v
|
|
}
|
|
|
|
adminSections, err := h.repo.ListByScope(r.Context(), scope, libIDPtr)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load sections")
|
|
return
|
|
}
|
|
|
|
var overrides []sections.ProfileSectionOverride
|
|
if h.StoreProvider != nil && profileID != "" {
|
|
store, storeErr := h.StoreProvider.ForUser(r.Context(), userID)
|
|
if storeErr == nil {
|
|
libStr := ""
|
|
if libIDPtr != nil {
|
|
libStr = strconv.Itoa(*libIDPtr)
|
|
}
|
|
userOverrides, _ := store.ListSectionOverrides(r.Context(), profileID, scope, libStr)
|
|
overrides = toSectionOverrides(userOverrides)
|
|
}
|
|
}
|
|
|
|
resolved := sections.ResolveForSettings(adminSections, overrides)
|
|
|
|
type settingsEntry struct {
|
|
ID string `json:"id"`
|
|
SectionType string `json:"section_type"`
|
|
Title string `json:"title"`
|
|
Featured bool `json:"featured"`
|
|
ItemLimit int `json:"item_limit"`
|
|
Hidden bool `json:"hidden"`
|
|
IsCustom bool `json:"is_custom"`
|
|
Customized bool `json:"customized"`
|
|
Position int `json:"position"`
|
|
Config json.RawMessage `json:"config,omitempty"`
|
|
}
|
|
|
|
entries := make([]settingsEntry, 0, len(resolved))
|
|
for _, s := range resolved {
|
|
entries = append(entries, settingsEntry{
|
|
ID: s.ID,
|
|
SectionType: string(s.SectionType),
|
|
Title: s.Title,
|
|
Featured: s.Featured,
|
|
ItemLimit: s.ItemLimit,
|
|
Hidden: s.Hidden,
|
|
IsCustom: s.IsCustom,
|
|
Customized: s.Customized,
|
|
Position: s.Position,
|
|
Config: s.Config,
|
|
})
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string][]settingsEntry{"sections": entries})
|
|
}
|
|
|
|
// applyDiversityFilter removes items from sections whose recipe has
|
|
// AvoidDuplicates=true if the same content ID was already surfaced by an
|
|
// earlier section in the same render. Operates in-place — preserves order.
|
|
// Sections without an AvoidDuplicates recipe are seen-but-not-filtered:
|
|
// their items still mark content_ids as "seen" for downstream sections.
|
|
func applyDiversityFilter(withItems []sections.SectionWithItems) []sections.SectionWithItems {
|
|
seen := map[string]struct{}{}
|
|
for i := range withItems {
|
|
sectionType := string(withItems[i].SectionType)
|
|
rec, ok := recipes.Get(sectionType)
|
|
avoid := ok && rec.Definition().AvoidDuplicates
|
|
if avoid {
|
|
kept := withItems[i].Items[:0]
|
|
for _, item := range withItems[i].Items {
|
|
if item == nil || item.ContentID == "" {
|
|
kept = append(kept, item)
|
|
continue
|
|
}
|
|
if _, dup := seen[item.ContentID]; dup {
|
|
continue
|
|
}
|
|
kept = append(kept, item)
|
|
}
|
|
withItems[i].Items = kept
|
|
withItems[i].TotalCount = len(kept)
|
|
}
|
|
for _, item := range withItems[i].Items {
|
|
if item == nil || item.ContentID == "" {
|
|
continue
|
|
}
|
|
seen[item.ContentID] = struct{}{}
|
|
}
|
|
}
|
|
return withItems
|
|
}
|
|
|
|
// dropEmptySeasonalSections removes seasonal_themed sections that produced
|
|
// no items so the home page doesn't render an empty row off-season. Called
|
|
// from the aggregate handlers; per-section endpoints still return empty
|
|
// seasonal sections when explicitly requested by ID.
|
|
func dropEmptySeasonalSections(withItems []sections.SectionWithItems) []sections.SectionWithItems {
|
|
out := withItems[:0]
|
|
for _, w := range withItems {
|
|
if w.SectionType == sections.SectionSeasonalThemed && len(w.Items) == 0 {
|
|
continue
|
|
}
|
|
out = append(out, w)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// --- Helper methods ---
|
|
|
|
type sectionItemImageKey struct {
|
|
sectionID string
|
|
contentID string
|
|
}
|
|
|
|
type sectionItemImageURLs struct {
|
|
posterURL string
|
|
backdropURL string
|
|
logoURL string
|
|
}
|
|
|
|
func (h *SectionHandler) buildSectionsResponse(r *http.Request, withItems []sections.SectionWithItems) homeSectionsResponse {
|
|
overlaySummaries := make(map[string]*models.OverlaySummary)
|
|
contentIDs := make([]string, 0)
|
|
seen := make(map[string]struct{})
|
|
for _, section := range withItems {
|
|
for _, item := range section.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 && h.fetcher != nil {
|
|
summaries, err := h.fetcher.ListOverlaySummaries(r.Context(), contentIDs, requestAccessFilter(r))
|
|
if err != nil {
|
|
slog.Error("loading overlay summaries", "error", err)
|
|
} else {
|
|
overlaySummaries = summaries
|
|
}
|
|
}
|
|
|
|
resp := homeSectionsResponse{
|
|
Sections: make([]resolvedSectionResponse, 0, len(withItems)),
|
|
}
|
|
allItems := make([]*models.MediaItem, 0)
|
|
for _, s := range withItems {
|
|
allItems = append(allItems, s.Items...)
|
|
}
|
|
userStates := h.listSectionItemUserStates(r, allItems)
|
|
imageURLs := h.resolveSectionItemImageURLs(r.Context(), withItems)
|
|
for _, s := range withItems {
|
|
items := make([]sectionItemResponse, 0, len(s.Items))
|
|
for _, item := range s.Items {
|
|
var meta *sections.SectionItemMeta
|
|
if s.ItemMeta != nil {
|
|
if value, ok := s.ItemMeta[item.ContentID]; ok {
|
|
meta = &value
|
|
}
|
|
}
|
|
imageKey := sectionItemImageKey{sectionID: s.ID, contentID: item.ContentID}
|
|
items = append(items, h.toSectionItemResponse(s.SectionType, item, meta, overlaySummaries[item.ContentID], userStates[item.ContentID], imageURLs[imageKey]))
|
|
}
|
|
resp.Sections = append(resp.Sections, resolvedSectionResponse{
|
|
ID: s.ID,
|
|
SectionType: string(s.SectionType),
|
|
Title: s.Title,
|
|
Featured: s.Featured,
|
|
ItemLimit: s.ItemLimit,
|
|
TotalCount: s.TotalCount,
|
|
IsCustom: s.IsCustom,
|
|
Customized: s.Customized,
|
|
Items: items,
|
|
})
|
|
}
|
|
return resp
|
|
}
|
|
|
|
func (h *SectionHandler) resolveSectionItemImageURLs(ctx context.Context, withItems []sections.SectionWithItems) map[sectionItemImageKey]sectionItemImageURLs {
|
|
result := make(map[sectionItemImageKey]sectionItemImageURLs)
|
|
if h.DetailSvc == nil {
|
|
return result
|
|
}
|
|
|
|
type pendingImages struct {
|
|
key sectionItemImageKey
|
|
posterPath string
|
|
backdropPath string
|
|
logoPath string
|
|
}
|
|
|
|
pending := make([]pendingImages, 0)
|
|
paths := make([]string, 0)
|
|
seenPaths := make(map[string]struct{})
|
|
addPath := func(path string) {
|
|
if path == "" || path == "-" {
|
|
return
|
|
}
|
|
if _, ok := seenPaths[path]; ok {
|
|
return
|
|
}
|
|
seenPaths[path] = struct{}{}
|
|
paths = append(paths, path)
|
|
}
|
|
|
|
for _, section := range withItems {
|
|
for _, item := range section.Items {
|
|
if item == nil {
|
|
continue
|
|
}
|
|
images := pendingImages{
|
|
key: sectionItemImageKey{
|
|
sectionID: section.ID,
|
|
contentID: item.ContentID,
|
|
},
|
|
posterPath: featuredPosterPath(item.PosterPath),
|
|
backdropPath: sectionBackdropPath(section.SectionType, item.BackdropPath),
|
|
logoPath: item.LogoPath,
|
|
}
|
|
pending = append(pending, images)
|
|
addPath(images.posterPath)
|
|
addPath(images.backdropPath)
|
|
addPath(images.logoPath)
|
|
}
|
|
}
|
|
|
|
resolved := h.DetailSvc.PresignURLsWithExpiry(ctx, paths, "featured")
|
|
for _, images := range pending {
|
|
result[images.key] = sectionItemImageURLs{
|
|
posterURL: resolved[images.posterPath].URL,
|
|
backdropURL: resolved[images.backdropPath].URL,
|
|
logoURL: resolved[images.logoPath].URL,
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
func (h *SectionHandler) toSectionItemResponse(sectionType sections.SectionType, item *models.MediaItem, meta *sections.SectionItemMeta, overlaySummary *models.OverlaySummary, userState *itemUserStateResponse, imageURLs sectionItemImageURLs) sectionItemResponse {
|
|
resp := sectionItemResponse{
|
|
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,
|
|
}
|
|
if meta != nil {
|
|
if meta.SeriesID != nil {
|
|
resp.SeriesID = *meta.SeriesID
|
|
}
|
|
resp.SeriesTitle = meta.SeriesTitle
|
|
resp.SeasonNumber = meta.SeasonNumber
|
|
resp.EpisodeNumber = meta.EpisodeNumber
|
|
resp.Badges = meta.Badges
|
|
resp.PositionSeconds = meta.PositionSeconds
|
|
resp.DurationSeconds = meta.DurationSeconds
|
|
resp.ProgressUpdatedAt = meta.ProgressUpdatedAt
|
|
resp.ItemSource = meta.ItemSource
|
|
}
|
|
|
|
if resp.Genres == nil {
|
|
resp.Genres = []string{}
|
|
}
|
|
if resp.Keywords == nil {
|
|
resp.Keywords = []string{}
|
|
}
|
|
|
|
resp.PosterURL = imageURLs.posterURL
|
|
resp.BackdropURL = imageURLs.backdropURL
|
|
resp.LogoURL = imageURLs.logoURL
|
|
|
|
return resp
|
|
}
|
|
|
|
// sectionBackdropPath keeps featured-style backdrops for most sections, but
|
|
// uses the cached w1280 backdrop for Continue Watching / Next Up rows.
|
|
func sectionBackdropPath(sectionType sections.SectionType, path string) string {
|
|
if sectionType == sections.SectionContinueWatching || sectionType == sections.SectionNextUp {
|
|
if strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://") {
|
|
return path
|
|
}
|
|
// Plugin-prefixed paths pass through; the resolver still receives the
|
|
// existing "featured" semantic variant.
|
|
if strings.Contains(path, "://") {
|
|
return path
|
|
}
|
|
return strings.Replace(path, "/original.", "/w1280.", 1)
|
|
}
|
|
return featuredBackdropPath(path)
|
|
}
|
|
|
|
func (h *SectionHandler) listSectionItemUserStates(r *http.Request, items []*models.MediaItem) map[string]*itemUserStateResponse {
|
|
if h.StoreProvider == nil {
|
|
return map[string]*itemUserStateResponse{}
|
|
}
|
|
userID := apimw.GetUserID(r.Context())
|
|
profileID := apimw.GetProfileID(r.Context())
|
|
if userID == 0 || profileID == "" {
|
|
return map[string]*itemUserStateResponse{}
|
|
}
|
|
store, err := h.StoreProvider.ForUser(r.Context(), userID)
|
|
if err != nil || store == nil {
|
|
return map[string]*itemUserStateResponse{}
|
|
}
|
|
states, err := resolveItemUserStates(r.Context(), store, profileID, h.EpisodeRepo, items)
|
|
if err != nil {
|
|
return map[string]*itemUserStateResponse{}
|
|
}
|
|
return states
|
|
}
|
|
|
|
func (h *SectionHandler) sectionPresignURL(r *http.Request, path string, variant string) string {
|
|
if h.DetailSvc != nil {
|
|
return h.DetailSvc.PresignURL(r.Context(), path, variant)
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// maybeInjectNextUp injects a SectionNextUp entry after SectionContinueWatching
|
|
// if the user's next_up_mode setting is "separate".
|
|
func (h *SectionHandler) maybeInjectNextUp(ctx context.Context, resolved []sections.ResolvedSection, userID int) []sections.ResolvedSection {
|
|
if h.StoreProvider == nil || userID <= 0 {
|
|
return resolved
|
|
}
|
|
store, err := h.StoreProvider.ForUser(ctx, userID)
|
|
if err != nil {
|
|
return resolved
|
|
}
|
|
mode, _ := store.GetSetting(ctx, "next_up_mode")
|
|
if mode == "separate" {
|
|
return injectNextUpSection(resolved)
|
|
}
|
|
return resolved
|
|
}
|
|
|
|
// injectNextUpSection inserts a synthetic SectionNextUp entry immediately after
|
|
// SectionContinueWatching in the resolved sections list.
|
|
func injectNextUpSection(resolved []sections.ResolvedSection) []sections.ResolvedSection {
|
|
nextUp := sections.ResolvedSection{
|
|
ID: "system-next-up",
|
|
SectionType: sections.SectionNextUp,
|
|
Title: "Next Up",
|
|
ItemLimit: 20,
|
|
}
|
|
|
|
for i, s := range resolved {
|
|
if s.SectionType == sections.SectionContinueWatching {
|
|
result := make([]sections.ResolvedSection, 0, len(resolved)+1)
|
|
result = append(result, resolved[:i+1]...)
|
|
result = append(result, nextUp)
|
|
result = append(result, resolved[i+1:]...)
|
|
return result
|
|
}
|
|
}
|
|
|
|
// If no continue_watching found, prepend
|
|
return append([]sections.ResolvedSection{nextUp}, resolved...)
|
|
}
|
|
|
|
func toSectionOverrides(storeOverrides []userstore.SectionOverride) []sections.ProfileSectionOverride {
|
|
result := make([]sections.ProfileSectionOverride, len(storeOverrides))
|
|
for i, o := range storeOverrides {
|
|
var cfg json.RawMessage
|
|
if o.Config != "" {
|
|
cfg = json.RawMessage(o.Config)
|
|
}
|
|
var userCfg json.RawMessage
|
|
if o.UserConfig != "" {
|
|
userCfg = json.RawMessage(o.UserConfig)
|
|
}
|
|
result[i] = sections.ProfileSectionOverride{
|
|
ID: o.ID,
|
|
ProfileID: o.ProfileID,
|
|
Scope: o.Scope,
|
|
LibraryID: o.LibraryID,
|
|
SectionID: o.SectionID,
|
|
Position: o.Position,
|
|
Hidden: o.Hidden,
|
|
Removed: o.Removed,
|
|
SectionType: sections.SectionType(o.SectionType),
|
|
Title: o.Title,
|
|
Featured: o.Featured,
|
|
ItemLimit: o.ItemLimit,
|
|
Config: cfg,
|
|
CreatedAt: o.CreatedAt,
|
|
UpdatedAt: o.UpdatedAt,
|
|
// User-added recipe fields. Without these the resolver sees
|
|
// SectionType="" / IsUserAdded=false on every load and silently
|
|
// drops profile-built sections.
|
|
IsUserAdded: o.IsUserAdded,
|
|
UserSectionType: sections.SectionType(o.UserSectionType),
|
|
UserConfig: userCfg,
|
|
UserTitle: o.UserTitle,
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
// HandleRestoreDefaults handles POST /admin/sections/restore-defaults.
|
|
// It replaces all sections for a scope with the canonical defaults.
|
|
func (h *SectionHandler) HandleRestoreDefaults(w http.ResponseWriter, r *http.Request) {
|
|
var req restoreDefaultsRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
|
|
return
|
|
}
|
|
|
|
if req.Scope == "" {
|
|
req.Scope = "home"
|
|
}
|
|
if req.Scope != "home" && req.Scope != "library" {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "Scope must be 'home' or 'library'")
|
|
return
|
|
}
|
|
if req.Scope == "library" && req.LibraryID == nil {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "library_id is required for library scope")
|
|
return
|
|
}
|
|
if req.Scope == "home" && req.LibraryID != nil {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "library_id must not be set for home scope")
|
|
return
|
|
}
|
|
|
|
var defaults []*sections.PageSection
|
|
var err error
|
|
if req.Scope == "home" {
|
|
defaults, err = h.defaultHomeSections(r.Context())
|
|
if err != nil {
|
|
slog.Error("loading default home sections", "error", err)
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load libraries")
|
|
return
|
|
}
|
|
} else {
|
|
defaults, err = h.defaultLibrarySections(r.Context(), *req.LibraryID)
|
|
if err != nil {
|
|
if errors.Is(err, catalog.ErrFolderNotFound) {
|
|
writeError(w, http.StatusNotFound, "not_found", "Library not found")
|
|
return
|
|
}
|
|
slog.Error("loading default library sections", "library_id", *req.LibraryID, "error", err)
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load library")
|
|
return
|
|
}
|
|
}
|
|
|
|
created, err := h.repo.RestoreDefaults(r.Context(), req.Scope, req.LibraryID, defaults)
|
|
if err != nil {
|
|
slog.Error("restoring default sections", "scope", req.Scope, "error", err)
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to restore defaults")
|
|
return
|
|
}
|
|
|
|
// Optionally clear all profile overrides for this scope.
|
|
if req.ResetProfiles {
|
|
libraryIDStr := ""
|
|
if req.LibraryID != nil {
|
|
libraryIDStr = strconv.Itoa(*req.LibraryID)
|
|
}
|
|
if err := h.repo.ClearAllProfileOverrides(r.Context(), req.Scope, libraryIDStr); err != nil {
|
|
slog.Error("clearing profile overrides", "scope", req.Scope, "error", err)
|
|
// Don't fail the whole request — sections were already restored.
|
|
}
|
|
}
|
|
|
|
resp := sectionListResponse{Sections: make([]sectionResponse, 0, len(created))}
|
|
for _, s := range created {
|
|
resp.Sections = append(resp.Sections, toSectionResponse(s))
|
|
}
|
|
writeJSON(w, http.StatusOK, resp)
|
|
}
|