* docs: define ebook architecture matching audiobooks * docs: plan ebook audiobook-parity implementation * feat: add ebook scanner parser foundation * fix: harden ebook scanner foundation * fix: handle ebook isbn labels * fix: guard ebook subtree scans * feat: scan ebook libraries in core * fix: preserve ebook scan people credits * fix: refresh ebook scan metadata safely * feat: persist ebook series membership * test: cover ebook series persistence decisions * fix: address ebook scanner PR review * docs: clarify ebook foundation PR scope * feat: add ebook metadata enricher * fix: harden ebook poster cache * feat: wire ebook metadata sync task * feat: expose ebook library metadata setup * feat: add ebook catalog scope support * feat: add ebook detail view * feat: label ebook file versions by format * feat: use file-size copy for downloads * feat: use file language in download dialog * test: cover ebook detail authors and downloads * fix: drop narrator credits from ebook scanner merges * fix: align ebook collection filters with book media * fix: drop asin provider ids from ebook enrichment * fix: force ebook people refresh for stale narrators * chore: omit ebook planning docs from branch * feat: add ebook detail related content * feat: add ebook reader file entrypoint * feat: render ebooks with foliate reader * feat: persist ebook reader progress * feat: add ebook reader controls * feat: extract ebook pdf metadata * feat: favor scanner isbn during ebook enrichment * feat: extract fbz ebook metadata * feat: count cbz ebook pages * feat: show ebook file page counts * feat: show ebook download summaries * feat: switch ebook reader files * feat: prefer epub for ebook read action * feat: surface ebook reader progress * feat: sync ebook reader progress cache * feat: hide ebook read action for unsupported files * feat: filter ebook reader file selector * fix: serve fbz ebook archives with reader mime type * fix: detect fbz ebooks from compound filename * fix: authorize fbz ebooks from compound filename * fix: scope ebook catalog facets * fix: reject narrator queries for ebooks * fix: build ebook recommendation text from authors * fix: include ebooks in embedding eligibility * fix: include ebooks in recommendation media mix * fix: include ebooks in recently added recommendations * feat: include ebook progress in recommendation signals * feat: include ebooks in continue watching sections * feat: include ebooks in catalog progress metrics * fix: read ebook isbn from epub metadata * fix: filter ebook asin provider aliases * fix: fall back from unsupported ebook reader files * fix: sort ebook catalogs by reader progress * fix: filter ebook catalogs by reader progress * fix: include ebooks in last watched catalog filters * feat: reflect ebook reader progress in item user state * feat: share ebook progress state across item surfaces * feat: report ebook scan progress * fix: include ebook activity in recommendations * fix: expose ebook reader progress on item detail * fix: support ebook subtree scans * fix: honor profile header for ebook item progress * fix: add ebook library default sections * fix: route ebook continue cards to reader * fix: hide watched toggle for ebooks * fix: route ebook watch tonight cards to reader * fix: route ebook hero actions to reader * fix: detect archive ebook reader formats by filename * feat: cache embedded ebook covers during scan * fix: encode ebook hero reader links * fix: persist non-epub ebook reader progress * fix: scope narrator catalog badges to audiobooks * fix: merge ebook reader progress during item repair * fix: label ebook progress filters as read * fix: show ebook related rails as book covers * fix: remove txt ebook reader support * fix: reject txt ebook reader files * fix: label ebook advanced filters as read * fix: label ebook personalized sorts as read * fix: remove plain text reader loader path * test: cover ebook unread catalog rules * fix: preserve ebook reader library context * fix: link ebook genres with library scope * fix: encode related rail item links * fix: encode catalog card item links * fix: encode hero and continue item links * fix: encode watch tonight item links * fix: encode recommendation and search item links * test: cover ebook scan format set * fix: label ebook search results clearly * fix: make global search prompt media neutral * fix: encode catalog read API ids * fix: encode item API ids * fix: include ebook reader vendor in docker build * fix: make ebook reader build clean * fix: clean ebook embedded descriptions * docs: plan ebook reader shell parity * feat: add ebook reader shell controls * fix: widen ebook scrolled reader flow * fix: remove scrolled reader content width cap * docs: plan ebook reader full parity * feat: persist ebook reader config * feat: add ebook annotations and bookmarks * feat: add ebook reader tools and aids * feat: add ebook advanced reader settings * fix: keep ebook reader panel in viewport * fix: use foliate sizing units for ebook scroll flow * fix: keep ebook settings controls readable * fix: simplify ebook reader settings controls * feat(ebooks): extract local covers during scan (#98) * feat(ebooks): extract local covers during scan * fix(ebooks): read nullable poster paths during cover scan * fix(catalog): coalesce nullable media artwork fields * fix(ebooks): group sibling formats by book identity * fix(ebooks): tolerate legacy ebook metadata encodings * fix(ebooks): decode PDF hex metadata strings * fix(ebooks): harden local cover extraction and format grouping Address review findings on the local cover scan: - Restrict generic sidecar covers (cover.jpg, folder.png, ...) to single-book directories, always accept images named after the book file, and apply exactly one cover per reconcile with sidecar taking precedence over the embedded cover. - Replace the read-then-write poster update with an atomic conditional UPDATE (ItemRepository.SetLocalPoster) so provider/admin artwork is never clobbered by concurrent writers, and refresh locally owned posters when the extracted cover bytes change (thumbhash compare). - Preserve UTF-8 PDF Info strings (including a UTF-8 BOM) instead of forcing everything through Windows-1252; the cp1252 fallback now only applies to non-UTF-8 bytes. - Select EPUB covers by manifest media-type with properties="cover-image" outranking the EPUB2 meta name="cover" id, so XHTML cover pages no longer shadow the real image. - Order CBZ pages naturally (2.jpg before 10.jpg, ch2/ before ch10/) when picking the cover page, via a single O(n) min-scan. - Bump the ebook content group key scheme to version 2 and reprocess rows written under older versions so pre-existing libraries gain sibling-format grouping instead of accumulating duplicates. - Group different formats only (a same-format sibling with colliding sparse metadata stays a separate item) and stop a joining sibling's embedded metadata from overwriting a provider-matched item. - Decode any IANA-labelled OPF/FB2 XML charset (windows-1251, koi8-r, shift_jis, ...) via x/net/html/charset, and wire the charset reader into FB2 parsing which previously had none. - Strip the full .fb2.zip double extension from filename-derived titles and group keys. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: rxwatcher <rxwatcher@users.noreply.github.com> Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> * feat(ebooks): add reader profiles and ruler (#99) * feat(ebooks): extract local covers during scan * fix(ebooks): read nullable poster paths during cover scan * fix(catalog): coalesce nullable media artwork fields * fix(ebooks): group sibling formats by book identity * fix(ebooks): tolerate legacy ebook metadata encodings * fix(ebooks): decode PDF hex metadata strings * feat(ebooks): add reader profiles and ruler * fix(ebooks): address reader ruler and profile review findings - skip renderer setStyles/render when computed styles and attributes are unchanged, so ruler position updates no longer re-style the book view - drag the ruler via a local draft that commits on release, with the surface rect cached at pointer-down - migrate font values persisted before the generic stacks (Inter, Georgia, Merriweather, legacy serif) so the font select never renders blank, with a Custom fallback option for unknown values - make the ruler band click-through and move dragging to a dedicated keyboard-accessible slider handle so links and text selection keep working under the band - share font stacks between options and profiles via READER_FONT_STACKS - surface the active reading profile, move presets to the top of the settings panel, and drop the redundant profile button aria-labels Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ebooks): resolve prefer-const lint error in readest document lib `pnpm run lint` failed on the branch because `direction` is never reassigned in getDirection; split the destructure so only the reassigned `writingMode` stays mutable. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: rxwatcher <rxwatcher@users.noreply.github.com> Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> * Merge branch 'main' into work/ebooks-reader-base Brings the ebook integration branch up to date with main (audiobook library redesign, continue-watching rework and card affordances, quic-go bump, jellycompat fixes). Conflict resolutions favor main's generalized mechanisms and register ebooks with them: - media scope validation goes through IsValidMediaScope (now including "ebook" alongside main's "video" group scope), in Go and in the web filter/search types - continue-watching uses main's typed rails; reading-type sections pull resume points from ebook_reader_progress and the ebook library default section is wired to ContinueTypeConfig(ContinueTypeReading) - item_repo keeps main's derived select-list machinery (itemColumnExpr) and both poster accessors (GetPoster/SetLocalPoster for ebook covers, GetPosterPath for audiobook covers) - web cards/hero/watch-tonight adopt main's buildMediaPlayHref helpers, which now route ebooks to /reader/ebook and encode content ids; ebook affordances (BookOpen icon, Read verb, percent-read subtitle) carry over onto main's reworked components - LibraryForm ebook support ported into main's refactored useLibraryForm/libraryTypes modules Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(docker): copy foliate-js vendor into Dockerfile.dev frontend stage foliate-js is a file:vendor/foliate-js dependency, so pnpm install needs the vendor directory before the lockfile install layer. The production Dockerfile already copies it; the dev image was missed, breaking make dev-deploy with ENOENT on /app/web/vendor/foliate-js. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(ebooks): render Continue Reading sections as upright poster cards All-ebook continue sections previously fell through to the horizontal 16:9 wide card; include ebooks in the poster-variant check so book covers render in their natural 2:3 framing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ui): stop related-rail highlight ring clipping on detail pages Move the current-item ring onto the cover artwork with a themed ring-offset color (matching the sidebar profile highlight) and give the scroll container top headroom so the ring is not cut off by overflow-x-auto. Applies to both ebook and audiobook detail rails. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(scanner): harden ebook scanning against data loss and bad metadata - Reconcile missing ebook files like video/audio, with real per-root walk failure tracking (failed/unmounted roots are excluded from deletion), symlinked-root support via the shared logical walker, and the empty-root cleanup allowance before any destructive reconciliation. - Create ebook items as 'pending' so enrichment can promote them to 'matched' (backfill migration included), and protect matched items from re-scan clobbering: title/year skipped, people/series fill-empty only. - PDF metadata: scan head + tail windows (non-linearized PDFs keep the Info dict at the end), require proper key delimiters, head values win. - Cap plain .fb2 reads like .fbz entries; drop .md as an ebook format. - gofmt internal/scanner/audiobook.go (pre-existing drift). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ebooks): make enrichment failures non-terminal with dedicated backoff state - Provider errors now record a failure (capped retries) instead of stamping last_refreshed, which permanently excluded items after transient outages. - Unconfigured metadata chains and the scan-window membership race skip the item without stamping or burning a retry. - Failure tracking moves to a new ebook_enrichment_state table, decoupling it from media_items.refresh_failures (shared with metadata refresh debt). - Preserve non-author people credits when persisting enrichment results. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(catalog): gate ebook progress on hidden history and centralize threshold - Apply user_history_hidden_items gating (video semantics) to the ebook watched/in-progress filters, progress sort plan, and Continue Reading. - Continue Reading pages past dismissed items via the shared collector and dedupes items across pages (also fixes the video path's latent exposure). - Centralize the 0.9 finished threshold as models.EbookFinishedProgressThreshold with a single SQL-interpolated mirror in catalog. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(recommendations): correct watcher counting and wire ebook taste signals - itemWatchersQuery dedupes to distinct (watcher, item) rows so one binge-watcher can no longer satisfy minWatchers; the eligibility floor now counts distinct accounts rather than profiles. - Hidden-history gating on GetEbookReaderProgressForUser (signal reader). - Ebook reading produces canonical implicit taste signals (weighted like the equivalent movie progress ratio); ebooks join taste-seed candidates. - Stale GetRecentlyAddedItems doc comment corrected. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(api): harden ebook reader endpoints and serve a Content-Security-Policy - Serve a CSP on all SPA HTML responses: blob/srcdoc book iframes inherit it, so script-src 'self' 'wasm-unsafe-eval' blocks script execution from malicious book content (sandbox alone is defeated by the WebKit allow-scripts requirement). Threat model documented on the constant. - X-Content-Type-Options: nosniff on frontend, jellycompat, and ebook file responses; MIME resolution can no longer fall through to octet-stream for an admitted ebook file. - Annotation PATCH: presence-aware field semantics (absent keeps, present sets/clears), invariant re-validation on the merged row, and an atomic SELECT ... FOR UPDATE read-merge-write. - Request size caps (413) on progress/config/annotation writes; Content-Disposition via mime.FormatMediaType; hidden-history gating in the shared ebook progress lister; FK-cascade indexes for reader tables. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(api): native read-state endpoints for ebooks - POST/DELETE /watched/{id} accepts ebook content IDs: mark read upserts progress 1.0 preserving the reader's file/location (or picks the preferred reader file for never-opened books); mark unread mirrors video unwatch semantics and deletes the progress row. - /history/remove accepts ebooks: hides via user_history_hidden_items without touching the reading position (hidden != unread; next reading activity resurfaces the book, mirroring video re-watch). - Access-filter checks match the video branch; shared logic lives in ebook_read_state.go. Sort metrics/user-state thresholds use the shared constant; profile-header fallback deduplicated. Clients: response is {type: "ebook", affected_count: 1, played: bool}; the existing watched SSE event fires. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(web): harden the ebook reader UI - Open-flow race: cancellation checked after every await with full stale-run teardown (no wrong-file progress saves, no leaked views/blob URLs); book.destroy() on cleanup. - Progress: monotonic stale-response guard; visibilitychange flush uses the refresh-capable client, pagehide uses keepalive; per-book cross-format progress documented as deliberate. - Settings: side effects out of the setState updater; local edits no longer clobbered by late server config; pending saves flushed on unmount/pagehide. - TTS: generation token so Stop actually stops (Chromium/Firefox synthetic events); Media Session uninstalled on unmount. - External book links: http(s) only, opened with noopener,noreferrer. - apiBlob 512 MiB guard with a user-facing error; fraction bookmarks navigable; search-result key collisions fixed; dead e-ink code removed; getLibrarySortRelevanceScope deduplicated; md format dropped. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(web): mark read/unread affordances for ebooks - Item detail gets a Mark Read/Unread button; card menus drop the ebook gate and share type-aware labels/toasts (also dedupes audiobook wording). - Watched-state invalidation includes the reader progress query key so the Continue button and percent refresh after toggling. - Continue Reading dismiss copy for ebooks; dismissal path now URL-encodes item IDs (ebook content IDs can contain reserved characters). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: record the PR #124 review and hardening pass Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: rxwatcher <rxwatcher@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1567 lines
51 KiB
Go
1567 lines
51 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
|
|
episodeFetcher sectionEpisodeFetcher
|
|
FolderRepo *catalog.FolderRepository
|
|
EpisodeRepo *catalog.EpisodeRepository
|
|
StoreProvider userstore.UserStoreProvider
|
|
UserRepo *auth.UserRepository
|
|
DetailSvc *catalog.DetailService
|
|
Settings catalog.SettingsStore
|
|
CollectionRepo *catalog.LibraryCollectionRepository
|
|
EbookProgress EbookReaderProgressLister
|
|
}
|
|
|
|
// NewSectionHandler creates a new SectionHandler.
|
|
func NewSectionHandler(repo *sections.Repository, fetcher *sections.Fetcher) *SectionHandler {
|
|
return &SectionHandler{repo: repo, fetcher: fetcher, previewFetcher: fetcher, episodeFetcher: fetcher}
|
|
}
|
|
|
|
type sectionEpisodeFetcher interface {
|
|
FetchEpisodesByContentIDs(ctx context.Context, contentIDs []string, filter catalog.AccessFilter) ([]*models.MediaItem, map[string]sections.SectionItemMeta, error)
|
|
}
|
|
|
|
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" && cfg.FilterType != "audiobook" {
|
|
return "filter_type must be 'movie', 'series', or 'audiobook'", false
|
|
}
|
|
if _, err := sections.ParseContinueType(config); err != nil {
|
|
return err.Error(), 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)
|
|
episodeMeta := h.listSectionEpisodeItemMeta(r.Context(), withItems, requestAccessFilter(r))
|
|
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
|
|
}
|
|
}
|
|
if meta == nil {
|
|
if value, ok := episodeMeta[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) listSectionEpisodeItemMeta(ctx context.Context, withItems []sections.SectionWithItems, filter catalog.AccessFilter) map[string]sections.SectionItemMeta {
|
|
if h == nil || h.episodeFetcher == nil {
|
|
return map[string]sections.SectionItemMeta{}
|
|
}
|
|
|
|
ids := make([]string, 0)
|
|
seen := make(map[string]struct{})
|
|
for _, section := range withItems {
|
|
for _, item := range section.Items {
|
|
if item == nil || item.Type != "episode" || strings.TrimSpace(item.ContentID) == "" {
|
|
continue
|
|
}
|
|
if section.ItemMeta != nil {
|
|
if _, ok := section.ItemMeta[item.ContentID]; ok {
|
|
continue
|
|
}
|
|
}
|
|
if _, ok := seen[item.ContentID]; ok {
|
|
continue
|
|
}
|
|
seen[item.ContentID] = struct{}{}
|
|
ids = append(ids, item.ContentID)
|
|
}
|
|
}
|
|
if len(ids) == 0 {
|
|
return map[string]sections.SectionItemMeta{}
|
|
}
|
|
|
|
_, meta, err := h.episodeFetcher.FetchEpisodesByContentIDs(ctx, ids, filter)
|
|
if err != nil {
|
|
slog.Warn("loading section episode metadata", "error", err)
|
|
return map[string]sections.SectionItemMeta{}
|
|
}
|
|
return meta
|
|
}
|
|
|
|
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 {
|
|
return catalog.BackdropVariantPath(path, "w1280")
|
|
}
|
|
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 := resolveItemUserStatesWithOptions(r.Context(), store, profileID, h.EpisodeRepo, items, itemUserStateOptions{
|
|
UserID: userID,
|
|
EbookProgressStore: h.EbookProgress,
|
|
})
|
|
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 after the
|
|
// contiguous continue rows that start with the video Continue Watching row.
|
|
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 && sections.ContinueTypeFromConfig(s.Config) == sections.ContinueTypeWatching {
|
|
insertAt := i + 1
|
|
for insertAt < len(resolved) && resolved[insertAt].SectionType == sections.SectionContinueWatching {
|
|
insertAt++
|
|
}
|
|
result := make([]sections.ResolvedSection, 0, len(resolved)+1)
|
|
result = append(result, resolved[:insertAt]...)
|
|
result = append(result, nextUp)
|
|
result = append(result, resolved[insertAt:]...)
|
|
return result
|
|
}
|
|
}
|
|
|
|
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 resume row is 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)
|
|
}
|