* 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>
1260 lines
36 KiB
Go
1260 lines
36 KiB
Go
package handlers
|
|
|
|
import (
|
|
"context"
|
|
"log/slog"
|
|
"net/http"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
|
|
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
|
|
"github.com/Silo-Server/silo-server/internal/catalog"
|
|
"github.com/Silo-Server/silo-server/internal/models"
|
|
"github.com/Silo-Server/silo-server/internal/recommendations"
|
|
"github.com/Silo-Server/silo-server/internal/sections"
|
|
"github.com/Silo-Server/silo-server/internal/userstore"
|
|
)
|
|
|
|
type recommendationsEngine interface {
|
|
SimilarItems(ctx context.Context, itemID string, limit int) ([]recommendations.ScoredItem, error)
|
|
BecauseYouWatched(ctx context.Context, userID int, profileID string, sourceItemID string, limit int) ([]recommendations.ScoredItem, error)
|
|
GetTasteProfileSummary(ctx context.Context, userID int, profileID string) (*recommendations.TasteProfileSummary, error)
|
|
}
|
|
|
|
type recommendationsReader interface {
|
|
GetForYouMain(ctx context.Context, userID int, profileID string, limit int, filter catalog.AccessFilter) (*recommendations.ForYouRow, error)
|
|
GetForYouRows(ctx context.Context, userID int, profileID string, limit int, filter catalog.AccessFilter) ([]recommendations.ForYouRow, error)
|
|
GetSimilarUsersLiked(ctx context.Context, userID int, profileID string, limit int, filter catalog.AccessFilter) ([]recommendations.ScoredItem, error)
|
|
GetDiscoverRows(ctx context.Context, userID int, profileID string, limit int, filter catalog.AccessFilter) ([]recommendations.ForYouRow, error)
|
|
GetSection(ctx context.Context, userID int, profileID, kind, key string, limit int, filter catalog.AccessFilter) (*recommendations.ForYouRow, error)
|
|
GetWatchTonight(ctx context.Context, userID int, profileID string, limit int, filter catalog.AccessFilter) (recommendations.WatchTonightResult, error)
|
|
}
|
|
|
|
// RecommendationsHandler handles recommendation API endpoints.
|
|
type RecommendationsHandler struct {
|
|
engine recommendationsEngine
|
|
reader recommendationsReader
|
|
storeProvider userstore.UserStoreProvider
|
|
ratingsRepo *catalog.RatingsRepo
|
|
recsRepo *recommendations.Repo
|
|
enabled bool
|
|
Fetcher discoverFetcher
|
|
DetailSvc discoverPresigner
|
|
CalendarRepo calendarRepository
|
|
EpisodeRepo *catalog.EpisodeRepository
|
|
WatchTonightFetcher watchTonightSectionFetcher
|
|
CastFetcher cardsCastFetcher
|
|
EbookProgress EbookReaderProgressLister
|
|
// RecWorker enqueues asynchronous taste-profile refreshes after writes
|
|
// (taste seeding). Optional — when nil, refresh is simply skipped.
|
|
RecWorker ProfileRefreshRequester
|
|
nowFn func() time.Time
|
|
}
|
|
|
|
type discoverFetcher interface {
|
|
FetchItemsByContentIDs(ctx context.Context, contentIDs []string, filter catalog.AccessFilter) ([]*models.MediaItem, error)
|
|
FetchEpisodesByContentIDs(ctx context.Context, contentIDs []string, filter catalog.AccessFilter) ([]*models.MediaItem, map[string]sections.SectionItemMeta, error)
|
|
ListOverlaySummaries(ctx context.Context, contentIDs []string, filter catalog.AccessFilter) (map[string]*models.OverlaySummary, error)
|
|
}
|
|
|
|
type discoverPresigner interface {
|
|
PresignURL(ctx context.Context, path string, variant string) string
|
|
}
|
|
|
|
// NewRecommendationsHandler creates a new RecommendationsHandler.
|
|
func NewRecommendationsHandler(engine recommendationsEngine, reader recommendationsReader, storeProvider userstore.UserStoreProvider, ratingsRepo *catalog.RatingsRepo, recsRepo *recommendations.Repo, enabled bool) *RecommendationsHandler {
|
|
return &RecommendationsHandler{
|
|
engine: engine,
|
|
reader: reader,
|
|
storeProvider: storeProvider,
|
|
ratingsRepo: ratingsRepo,
|
|
recsRepo: recsRepo,
|
|
enabled: enabled,
|
|
nowFn: time.Now,
|
|
}
|
|
}
|
|
|
|
// --- Response types ---
|
|
|
|
type scoredItemsResponse struct {
|
|
Items []recommendations.ScoredItem `json:"items"`
|
|
}
|
|
|
|
type forYouMainResponse struct {
|
|
Row *recommendations.ForYouRow `json:"row"`
|
|
}
|
|
|
|
// HandleSimilar handles GET /recommendations/similar/{item_id}.
|
|
func (h *RecommendationsHandler) HandleSimilar(w http.ResponseWriter, r *http.Request) {
|
|
if !h.enabled {
|
|
writeJSON(w, http.StatusOK, scoredItemsResponse{Items: []recommendations.ScoredItem{}})
|
|
return
|
|
}
|
|
|
|
itemID := chi.URLParam(r, "item_id")
|
|
if itemID == "" {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "Item ID is required")
|
|
return
|
|
}
|
|
|
|
limit, _ := parsePagination(r)
|
|
if limit > 50 {
|
|
limit = 50
|
|
}
|
|
if limit <= 0 {
|
|
limit = 20
|
|
}
|
|
|
|
items, err := h.engine.SimilarItems(r.Context(), itemID, limit)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to fetch similar items")
|
|
return
|
|
}
|
|
|
|
if items == nil {
|
|
items = []recommendations.ScoredItem{}
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, scoredItemsResponse{Items: items})
|
|
}
|
|
|
|
// HandleForYouMain handles GET /recommendations/for-you/main.
|
|
func (h *RecommendationsHandler) HandleForYouMain(w http.ResponseWriter, r *http.Request) {
|
|
if h.reader == nil {
|
|
writeJSON(w, http.StatusOK, forYouMainResponse{Row: nil})
|
|
return
|
|
}
|
|
|
|
userID := apimw.GetUserID(r.Context())
|
|
profileID := apimw.GetProfileID(r.Context())
|
|
filter := requestAccessFilter(r)
|
|
|
|
limit, _ := parsePagination(r)
|
|
if limit <= 0 {
|
|
limit = 20
|
|
}
|
|
|
|
row, err := h.reader.GetForYouMain(r.Context(), userID, profileID, limit, filter)
|
|
if err != nil {
|
|
slog.Error("ForYouMain failed", "user_id", userID, "profile_id", profileID, "error", err)
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to fetch recommendations")
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, forYouMainResponse{Row: row})
|
|
}
|
|
|
|
// HandleForYouRows handles GET /recommendations/for-you/rows.
|
|
func (h *RecommendationsHandler) HandleForYouRows(w http.ResponseWriter, r *http.Request) {
|
|
if h.reader == nil {
|
|
writeJSON(w, http.StatusOK, recommendations.ForYouResponse{Rows: []recommendations.ForYouRow{}})
|
|
return
|
|
}
|
|
|
|
userID := apimw.GetUserID(r.Context())
|
|
profileID := apimw.GetProfileID(r.Context())
|
|
filter := requestAccessFilter(r)
|
|
|
|
limit, _ := parsePagination(r)
|
|
if limit <= 0 {
|
|
limit = 20
|
|
}
|
|
|
|
rows, err := h.reader.GetForYouRows(r.Context(), userID, profileID, limit, filter)
|
|
if err != nil {
|
|
slog.Error("ForYouRows failed", "user_id", userID, "profile_id", profileID, "error", err)
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to fetch recommendations")
|
|
return
|
|
}
|
|
|
|
if rows == nil {
|
|
rows = []recommendations.ForYouRow{}
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, recommendations.ForYouResponse{Rows: rows})
|
|
}
|
|
|
|
// HandleBecauseWatched handles GET /recommendations/because-watched/{item_id}.
|
|
func (h *RecommendationsHandler) HandleBecauseWatched(w http.ResponseWriter, r *http.Request) {
|
|
userID := apimw.GetUserID(r.Context())
|
|
profileID := apimw.GetProfileID(r.Context())
|
|
|
|
itemID := chi.URLParam(r, "item_id")
|
|
if itemID == "" {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "Item ID is required")
|
|
return
|
|
}
|
|
|
|
limit, _ := parsePagination(r)
|
|
if limit <= 0 {
|
|
limit = 20
|
|
}
|
|
|
|
items, err := h.engine.BecauseYouWatched(r.Context(), userID, profileID, itemID, limit)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to fetch recommendations")
|
|
return
|
|
}
|
|
|
|
items = h.filterRecommendations(r, userID, profileID, items)
|
|
|
|
if items == nil {
|
|
items = []recommendations.ScoredItem{}
|
|
}
|
|
|
|
items = h.excludeWatchedRecommendations(r, userID, profileID, items)
|
|
|
|
writeJSON(w, http.StatusOK, scoredItemsResponse{Items: items})
|
|
}
|
|
|
|
// HandleSimilarUsers handles GET /recommendations/similar-users.
|
|
func (h *RecommendationsHandler) HandleSimilarUsers(w http.ResponseWriter, r *http.Request) {
|
|
if h.reader == nil {
|
|
writeJSON(w, http.StatusOK, scoredItemsResponse{Items: []recommendations.ScoredItem{}})
|
|
return
|
|
}
|
|
|
|
userID := apimw.GetUserID(r.Context())
|
|
profileID := apimw.GetProfileID(r.Context())
|
|
filter := requestAccessFilter(r)
|
|
|
|
limit, _ := parsePagination(r)
|
|
if limit <= 0 {
|
|
limit = 20
|
|
}
|
|
|
|
items, err := h.reader.GetSimilarUsersLiked(r.Context(), userID, profileID, limit, filter)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to fetch recommendations")
|
|
return
|
|
}
|
|
|
|
if items == nil {
|
|
items = []recommendations.ScoredItem{}
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, scoredItemsResponse{Items: items})
|
|
}
|
|
|
|
// HandleTasteProfile handles GET /recommendations/taste-profile.
|
|
func (h *RecommendationsHandler) HandleTasteProfile(w http.ResponseWriter, r *http.Request) {
|
|
userID := apimw.GetUserID(r.Context())
|
|
profileID := apimw.GetProfileID(r.Context())
|
|
|
|
summary, err := h.engine.GetTasteProfileSummary(r.Context(), userID, profileID)
|
|
if err != nil || summary == nil {
|
|
writeJSON(w, http.StatusOK, recommendations.TasteProfileSummary{
|
|
TopGenres: []string{},
|
|
FavoriteDirectors: []string{},
|
|
SignalCounts: map[string]int{},
|
|
})
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, summary)
|
|
}
|
|
|
|
// HandlePopular handles GET /recommendations/popular?days=30&limit=20.
|
|
func (h *RecommendationsHandler) HandlePopular(w http.ResponseWriter, r *http.Request) {
|
|
days := 30
|
|
if d := r.URL.Query().Get("days"); d != "" {
|
|
if parsed, err := strconv.Atoi(d); err == nil && parsed > 0 {
|
|
days = parsed
|
|
}
|
|
}
|
|
|
|
limit := 20
|
|
if l := r.URL.Query().Get("limit"); l != "" {
|
|
if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 && parsed <= 50 {
|
|
limit = parsed
|
|
}
|
|
}
|
|
|
|
items, err := h.recsRepo.GetPopularItems(r.Context(), days, limit)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to fetch popular items")
|
|
return
|
|
}
|
|
if items == nil {
|
|
items = []recommendations.ScoredItem{}
|
|
}
|
|
|
|
items = h.excludeWatchedRecommendations(r, apimw.GetUserID(r.Context()), apimw.GetProfileID(r.Context()), items)
|
|
|
|
writeJSON(w, http.StatusOK, scoredItemsResponse{Items: items})
|
|
}
|
|
|
|
// HandleRecentlyAdded handles GET /recommendations/recently-added?days=14&limit=20.
|
|
func (h *RecommendationsHandler) HandleRecentlyAdded(w http.ResponseWriter, r *http.Request) {
|
|
days := 14
|
|
if d := r.URL.Query().Get("days"); d != "" {
|
|
if parsed, err := strconv.Atoi(d); err == nil && parsed > 0 {
|
|
days = parsed
|
|
}
|
|
}
|
|
|
|
limit := 20
|
|
if l := r.URL.Query().Get("limit"); l != "" {
|
|
if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 && parsed <= 50 {
|
|
limit = parsed
|
|
}
|
|
}
|
|
|
|
items, err := h.recsRepo.GetRecentlyAddedItems(r.Context(), days, limit)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to fetch recently added items")
|
|
return
|
|
}
|
|
if items == nil {
|
|
items = []recommendations.ScoredItem{}
|
|
}
|
|
|
|
items = h.excludeWatchedRecommendations(r, apimw.GetUserID(r.Context()), apimw.GetProfileID(r.Context()), items)
|
|
|
|
writeJSON(w, http.StatusOK, scoredItemsResponse{Items: items})
|
|
}
|
|
|
|
// filterRecommendations removes watched items and low-rated items from the list.
|
|
func (h *RecommendationsHandler) filterRecommendations(r *http.Request, userID int, profileID string, items []recommendations.ScoredItem) []recommendations.ScoredItem {
|
|
items = h.excludeWatchedRecommendations(r, userID, profileID, items)
|
|
return h.excludeLowRatedRecommendations(r, userID, profileID, items)
|
|
}
|
|
|
|
func (h *RecommendationsHandler) excludeWatchedRecommendations(r *http.Request, userID int, profileID string, items []recommendations.ScoredItem) []recommendations.ScoredItem {
|
|
if len(items) == 0 {
|
|
return items
|
|
}
|
|
watchedSet, err := h.watchedItemIDSet(r.Context(), userID, profileID)
|
|
if err != nil || len(watchedSet) == 0 {
|
|
return items
|
|
}
|
|
|
|
filtered := make([]recommendations.ScoredItem, 0, len(items))
|
|
for _, item := range items {
|
|
if _, ok := watchedSet[item.MediaItemID]; ok {
|
|
continue
|
|
}
|
|
filtered = append(filtered, item)
|
|
}
|
|
return filtered
|
|
}
|
|
|
|
func (h *RecommendationsHandler) watchedItemIDSet(ctx context.Context, userID int, profileID string) (map[string]struct{}, error) {
|
|
if h.recsRepo == nil {
|
|
return map[string]struct{}{}, nil
|
|
}
|
|
|
|
if h.storeProvider != nil {
|
|
store, err := h.storeProvider.ForUser(ctx, userID)
|
|
if err == nil && store != nil {
|
|
set, err := h.recsRepo.GetWatchedItemIDSetFromStore(ctx, store, profileID)
|
|
if err == nil {
|
|
return set, nil
|
|
}
|
|
}
|
|
}
|
|
|
|
return h.recsRepo.GetWatchedItemIDSet(ctx, userID, profileID)
|
|
}
|
|
|
|
func (h *RecommendationsHandler) excludeLowRatedRecommendations(r *http.Request, userID int, profileID string, items []recommendations.ScoredItem) []recommendations.ScoredItem {
|
|
if len(items) == 0 || h.ratingsRepo == nil {
|
|
return items
|
|
}
|
|
|
|
itemIDs := make([]string, len(items))
|
|
for i, item := range items {
|
|
itemIDs[i] = item.MediaItemID
|
|
}
|
|
|
|
ratings, err := h.ratingsRepo.ListForItems(r.Context(), userID, profileID, itemIDs)
|
|
if err != nil {
|
|
return items
|
|
}
|
|
|
|
filtered := make([]recommendations.ScoredItem, 0, len(items))
|
|
for _, item := range items {
|
|
if rating, ok := ratings[item.MediaItemID]; ok && rating <= 2 {
|
|
continue
|
|
}
|
|
filtered = append(filtered, item)
|
|
}
|
|
return filtered
|
|
}
|
|
|
|
// --- Discover endpoint ---
|
|
|
|
type discoverRowResponse struct {
|
|
Type string `json:"type"`
|
|
Label string `json:"label"`
|
|
SectionKind string `json:"section_kind,omitempty"`
|
|
SectionKey string `json:"section_key,omitempty"`
|
|
Items []sectionItemResponse `json:"items"`
|
|
}
|
|
|
|
type discoverResponse struct {
|
|
Rows []discoverRowResponse `json:"rows"`
|
|
}
|
|
|
|
type sectionDetailResponse struct {
|
|
Kind string `json:"kind"`
|
|
Key string `json:"key,omitempty"`
|
|
Type string `json:"type"`
|
|
Label string `json:"label"`
|
|
Items []sectionItemResponse `json:"items"`
|
|
}
|
|
|
|
const (
|
|
discoverForYouMaxItems = 28
|
|
discoverUpcomingWindowDays = 14
|
|
discoverForYouLabel = "For You"
|
|
sectionDetailDefaultLimit = recommendations.CacheCandidateLimit
|
|
)
|
|
|
|
type discoverRowModel struct {
|
|
Type string
|
|
Label string
|
|
ClusterIndex int
|
|
Items []recommendations.ScoredItem
|
|
UpcomingEvents map[string]upcomingEventResponse
|
|
}
|
|
|
|
type upcomingDiscoverCandidate struct {
|
|
DisplayID string
|
|
Event upcomingEventResponse
|
|
AirDateTime time.Time
|
|
BaseIndex int
|
|
EffectiveIndex int
|
|
InWatchlist bool
|
|
IsFavorite bool
|
|
}
|
|
|
|
type combinedForYouItem struct {
|
|
Item recommendations.ScoredItem
|
|
BaseIndex int
|
|
EffectiveIndex int
|
|
HasUpcoming bool
|
|
UpcomingEvent *upcomingEventResponse
|
|
AirDateTime time.Time
|
|
}
|
|
|
|
// HandleDiscover handles GET /recommendations/discover.
|
|
// Returns all recommendation rows with fully enriched item metadata for the
|
|
// carousel-based discover page.
|
|
func (h *RecommendationsHandler) HandleDiscover(w http.ResponseWriter, r *http.Request) {
|
|
if h.reader == nil || h.Fetcher == nil {
|
|
writeJSON(w, http.StatusOK, discoverResponse{Rows: []discoverRowResponse{}})
|
|
return
|
|
}
|
|
|
|
userID := apimw.GetUserID(r.Context())
|
|
profileID := apimw.GetProfileID(r.Context())
|
|
filter := requestAccessFilter(r)
|
|
|
|
rows, err := h.reader.GetDiscoverRows(r.Context(), userID, profileID, 20, filter)
|
|
if err != nil {
|
|
slog.Error("Discover failed", "user_id", userID, "profile_id", profileID, "error", err)
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to fetch recommendations")
|
|
return
|
|
}
|
|
|
|
discoverRows := discoverRowModelsFromRecommendations(rows)
|
|
|
|
discoverRows, upcomingErr := h.blendUpcomingIntoDiscoverRows(
|
|
r.Context(),
|
|
userID,
|
|
profileID,
|
|
filter,
|
|
discoverRows,
|
|
rows,
|
|
)
|
|
if upcomingErr != nil {
|
|
slog.Warn(
|
|
"Discover: schedule-aware blending unavailable; falling back to legacy rows",
|
|
"user_id",
|
|
userID,
|
|
"profile_id",
|
|
profileID,
|
|
"error",
|
|
upcomingErr,
|
|
)
|
|
}
|
|
|
|
if len(discoverRows) == 0 {
|
|
writeJSON(w, http.StatusOK, discoverResponse{Rows: []discoverRowResponse{}})
|
|
return
|
|
}
|
|
|
|
// Collect unique item IDs across all rows for batched enrichment.
|
|
seen := make(map[string]struct{})
|
|
var allIDs []string
|
|
for _, row := range discoverRows {
|
|
for _, item := range row.Items {
|
|
if _, ok := seen[item.MediaItemID]; ok {
|
|
continue
|
|
}
|
|
seen[item.MediaItemID] = struct{}{}
|
|
allIDs = append(allIDs, item.MediaItemID)
|
|
}
|
|
}
|
|
|
|
enrichment, err := h.loadItemEnrichment(r.Context(), userID, profileID, filter, allIDs)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to fetch item details")
|
|
return
|
|
}
|
|
|
|
resp := discoverResponse{Rows: make([]discoverRowResponse, 0, len(discoverRows))}
|
|
for _, row := range discoverRows {
|
|
kind, key := discoverRowSectionKey(row.Type, row.Label, row.ClusterIndex)
|
|
respRow := discoverRowResponse{
|
|
Type: row.Type,
|
|
Label: row.Label,
|
|
SectionKind: kind,
|
|
SectionKey: key,
|
|
Items: h.buildSectionItems(r.Context(), row.Items, enrichment, row.UpcomingEvents),
|
|
}
|
|
if len(respRow.Items) > 0 {
|
|
resp.Rows = append(resp.Rows, respRow)
|
|
}
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, resp)
|
|
}
|
|
|
|
// HandleSection handles GET /recommendations/section/{kind} and
|
|
// /recommendations/section/{kind}/{key}. It returns the full contents of a
|
|
// single recommendation row so the UI can render a dedicated "see all" page.
|
|
func (h *RecommendationsHandler) HandleSection(w http.ResponseWriter, r *http.Request) {
|
|
if h.reader == nil || h.Fetcher == nil {
|
|
writeError(w, http.StatusNotFound, "not_found", "Section not found")
|
|
return
|
|
}
|
|
|
|
kind := chi.URLParam(r, "kind")
|
|
key := chi.URLParam(r, "key")
|
|
if kind == "" {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "Section kind is required")
|
|
return
|
|
}
|
|
|
|
limit := sectionDetailDefaultLimit
|
|
if l := r.URL.Query().Get("limit"); l != "" {
|
|
if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 {
|
|
limit = parsed
|
|
}
|
|
}
|
|
if limit > recommendations.CacheCandidateLimit {
|
|
limit = recommendations.CacheCandidateLimit
|
|
}
|
|
|
|
userID := apimw.GetUserID(r.Context())
|
|
profileID := apimw.GetProfileID(r.Context())
|
|
filter := requestAccessFilter(r)
|
|
|
|
row, err := h.reader.GetSection(r.Context(), userID, profileID, kind, key, limit, filter)
|
|
if err != nil {
|
|
slog.Error(
|
|
"Section failed",
|
|
"user_id", userID,
|
|
"profile_id", profileID,
|
|
"kind", kind,
|
|
"key", key,
|
|
"error", err,
|
|
)
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to fetch section")
|
|
return
|
|
}
|
|
if row == nil {
|
|
writeJSON(w, http.StatusOK, sectionDetailResponse{
|
|
Kind: kind,
|
|
Key: key,
|
|
Items: []sectionItemResponse{},
|
|
})
|
|
return
|
|
}
|
|
|
|
ids := make([]string, 0, len(row.Items))
|
|
seen := make(map[string]struct{}, len(row.Items))
|
|
for _, item := range row.Items {
|
|
if _, ok := seen[item.MediaItemID]; ok {
|
|
continue
|
|
}
|
|
seen[item.MediaItemID] = struct{}{}
|
|
ids = append(ids, item.MediaItemID)
|
|
}
|
|
|
|
enrichment, err := h.loadItemEnrichment(r.Context(), userID, profileID, filter, ids)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to fetch item details")
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, sectionDetailResponse{
|
|
Kind: kind,
|
|
Key: key,
|
|
Type: row.Type,
|
|
Label: row.Label,
|
|
Items: h.buildSectionItems(r.Context(), row.Items, enrichment, nil),
|
|
})
|
|
}
|
|
|
|
// itemEnrichment caches the data needed to convert ScoredItem records into
|
|
// sectionItemResponse for a single request.
|
|
type itemEnrichment struct {
|
|
items map[string]*models.MediaItem
|
|
overlays map[string]*models.OverlaySummary
|
|
states map[string]*itemUserStateResponse
|
|
}
|
|
|
|
func (h *RecommendationsHandler) loadItemEnrichment(
|
|
ctx context.Context,
|
|
userID int,
|
|
profileID string,
|
|
filter catalog.AccessFilter,
|
|
ids []string,
|
|
) (*itemEnrichment, error) {
|
|
out := &itemEnrichment{
|
|
items: map[string]*models.MediaItem{},
|
|
}
|
|
if h.Fetcher == nil || len(ids) == 0 {
|
|
return out, nil
|
|
}
|
|
|
|
mediaItems, err := h.Fetcher.FetchItemsByContentIDs(ctx, ids, filter)
|
|
if err != nil {
|
|
slog.Error("Recommendations: fetch items failed", "error", err)
|
|
return nil, err
|
|
}
|
|
for _, mi := range mediaItems {
|
|
out.items[mi.ContentID] = mi
|
|
}
|
|
|
|
overlays, overlayErr := h.Fetcher.ListOverlaySummaries(ctx, ids, filter)
|
|
if overlayErr != nil {
|
|
slog.Error("Recommendations: overlay summaries failed", "error", overlayErr)
|
|
} else {
|
|
out.overlays = overlays
|
|
}
|
|
|
|
if h.storeProvider != nil {
|
|
store, storeErr := h.storeProvider.ForUser(ctx, userID)
|
|
if storeErr == nil && store != nil {
|
|
states, stateErr := resolveItemUserStatesWithOptions(ctx, store, profileID, h.EpisodeRepo, mediaItems, itemUserStateOptions{
|
|
UserID: userID,
|
|
EbookProgressStore: h.EbookProgress,
|
|
})
|
|
if stateErr == nil {
|
|
out.states = states
|
|
}
|
|
}
|
|
}
|
|
|
|
return out, nil
|
|
}
|
|
|
|
func (h *RecommendationsHandler) buildSectionItems(
|
|
ctx context.Context,
|
|
scoredItems []recommendations.ScoredItem,
|
|
enrichment *itemEnrichment,
|
|
upcomingEvents map[string]upcomingEventResponse,
|
|
) []sectionItemResponse {
|
|
out := make([]sectionItemResponse, 0, len(scoredItems))
|
|
for _, scored := range scoredItems {
|
|
mi, ok := enrichment.items[scored.MediaItemID]
|
|
if !ok || mi == nil {
|
|
continue
|
|
}
|
|
item := sectionItemResponse{
|
|
ContentID: mi.ContentID,
|
|
Type: mi.Type,
|
|
Title: mi.Title,
|
|
Year: mi.Year,
|
|
Genres: mi.Genres,
|
|
Keywords: mi.Keywords,
|
|
Status: mi.Status,
|
|
RatingIMDB: mi.RatingIMDB,
|
|
RatingTMDB: mi.RatingTMDB,
|
|
RatingRTCritic: mi.RatingRTCritic,
|
|
RatingRTAudience: mi.RatingRTAudience,
|
|
OriginalLanguage: mi.OriginalLanguage,
|
|
Overview: mi.Overview,
|
|
PosterThumbhash: mi.PosterThumbhash,
|
|
BackdropThumbhash: mi.BackdropThumbhash,
|
|
}
|
|
if item.Genres == nil {
|
|
item.Genres = []string{}
|
|
}
|
|
if item.Keywords == nil {
|
|
item.Keywords = []string{}
|
|
}
|
|
if h.DetailSvc != nil {
|
|
item.PosterURL = h.DetailSvc.PresignURL(ctx, featuredPosterPath(mi.PosterPath), "featured")
|
|
item.BackdropURL = h.DetailSvc.PresignURL(ctx, featuredBackdropPath(mi.BackdropPath), "featured")
|
|
item.LogoURL = h.DetailSvc.PresignURL(ctx, mi.LogoPath, "featured")
|
|
}
|
|
if enrichment.overlays != nil {
|
|
item.OverlaySummary = enrichment.overlays[mi.ContentID]
|
|
}
|
|
if enrichment.states != nil {
|
|
item.UserState = enrichment.states[mi.ContentID]
|
|
}
|
|
if upcomingEvents != nil {
|
|
if upcomingEvent, ok := upcomingEvents[mi.ContentID]; ok {
|
|
upcomingEventCopy := upcomingEvent
|
|
item.UpcomingEvent = &upcomingEventCopy
|
|
}
|
|
}
|
|
out = append(out, item)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func discoverRowModelsFromRecommendations(rows []recommendations.ForYouRow) []discoverRowModel {
|
|
if len(rows) == 0 {
|
|
return nil
|
|
}
|
|
|
|
models := make([]discoverRowModel, 0, len(rows))
|
|
for _, row := range rows {
|
|
models = append(models, discoverRowModel{
|
|
Type: row.Type,
|
|
Label: row.Label,
|
|
ClusterIndex: row.ClusterIndex,
|
|
Items: append([]recommendations.ScoredItem(nil), row.Items...),
|
|
})
|
|
}
|
|
return models
|
|
}
|
|
|
|
// discoverRowSectionKey derives the URL kind/key pair used by the recommendation
|
|
// section detail page from a discover row's type/label/cluster_index. Returns
|
|
// empty strings when the row has no dedicated detail page (so the frontend
|
|
// renders the title without a link).
|
|
func discoverRowSectionKey(rowType, label string, clusterIndex int) (string, string) {
|
|
switch rowType {
|
|
case "cluster":
|
|
// "For You" main row uses Type="cluster" with Label="For You" and no
|
|
// cluster index assigned. Specific cluster rows have label "Because
|
|
// you enjoy ...".
|
|
if label == discoverForYouLabel {
|
|
return recommendations.SectionKindForYouMain, ""
|
|
}
|
|
return recommendations.SectionKindCluster, strconv.Itoa(clusterIndex)
|
|
case "similar_users_liked":
|
|
return recommendations.SectionKindSimilarUsers, ""
|
|
case recommendations.RecTypePopular:
|
|
return recommendations.SectionKindPopular, ""
|
|
case recommendations.RecTypeRecentlyAdded:
|
|
return recommendations.SectionKindRecentlyAdded, ""
|
|
case recommendations.RecTypeTopRated:
|
|
return recommendations.SectionKindTopRated, ""
|
|
case "genre_sampler":
|
|
// Cold-start labels rows "Top X"; the warm-discover path labels them
|
|
// "Popular in X". The genre name is always the label suffix.
|
|
for _, prefix := range []string{"Popular in ", "Top "} {
|
|
if name, ok := strings.CutPrefix(label, prefix); ok && name != "" {
|
|
return recommendations.SectionKindGenre, name
|
|
}
|
|
}
|
|
}
|
|
return "", ""
|
|
}
|
|
|
|
func (h *RecommendationsHandler) blendUpcomingIntoDiscoverRows(
|
|
ctx context.Context,
|
|
userID int,
|
|
profileID string,
|
|
filter catalog.AccessFilter,
|
|
discoverRows []discoverRowModel,
|
|
baseRows []recommendations.ForYouRow,
|
|
) ([]discoverRowModel, error) {
|
|
if h.CalendarRepo == nil {
|
|
return discoverRows, nil
|
|
}
|
|
|
|
mainRowIndex := indexOfDiscoverForYouRow(discoverRows)
|
|
if mainRowIndex < 0 {
|
|
return discoverRows, nil
|
|
}
|
|
|
|
now := time.Now
|
|
if h.nowFn != nil {
|
|
now = h.nowFn
|
|
}
|
|
nowUTC := now().UTC()
|
|
start := discoverUTCDate(nowUTC)
|
|
end := start.AddDate(0, 0, discoverUpcomingWindowDays-1)
|
|
|
|
events, err := h.CalendarRepo.ListEvents(ctx, catalog.CalendarFilter{
|
|
Start: start,
|
|
End: end,
|
|
AllowedLibraryIDs: filter.AllowedLibraryIDs,
|
|
DisabledLibraryIDs: filter.DisabledLibraryIDs,
|
|
MaxContentRating: filter.MaxContentRating,
|
|
})
|
|
if err != nil {
|
|
return discoverRows, err
|
|
}
|
|
if len(events) == 0 {
|
|
return discoverRows, nil
|
|
}
|
|
|
|
premiereCandidates := categorizeUpcomingCandidates(events)
|
|
if len(premiereCandidates) == 0 {
|
|
return discoverRows, nil
|
|
}
|
|
|
|
displayIDs := collectUpcomingDisplayIDsFromCandidates(premiereCandidates)
|
|
rankMap, firstSeenItems := buildDiscoverRankingData(baseRows)
|
|
|
|
watchlistMap := map[string]bool{}
|
|
favoritesMap := map[string]bool{}
|
|
if h.storeProvider != nil && len(displayIDs) > 0 {
|
|
store, err := h.storeProvider.ForUser(ctx, userID)
|
|
if err != nil {
|
|
return discoverRows, err
|
|
}
|
|
if store != nil {
|
|
favoritesMap, err = store.ListFavoritesByMediaItems(ctx, profileID, displayIDs)
|
|
if err != nil {
|
|
return discoverRows, err
|
|
}
|
|
watchlistMap, err = store.ListWatchlistByMediaItems(ctx, profileID, displayIDs)
|
|
if err != nil {
|
|
return discoverRows, err
|
|
}
|
|
}
|
|
}
|
|
|
|
watchedSet, err := h.watchedItemIDSet(ctx, userID, profileID)
|
|
if err != nil {
|
|
return discoverRows, err
|
|
}
|
|
|
|
lowRatings := map[string]int{}
|
|
if h.ratingsRepo != nil && len(displayIDs) > 0 {
|
|
lowRatings, err = h.ratingsRepo.ListForItems(ctx, userID, profileID, displayIDs)
|
|
if err != nil {
|
|
return discoverRows, err
|
|
}
|
|
}
|
|
|
|
discoverRows[mainRowIndex] = mergePremieresIntoDiscoverRow(
|
|
discoverRows[mainRowIndex],
|
|
rankMap,
|
|
firstSeenItems,
|
|
premiereCandidates,
|
|
watchedSet,
|
|
lowRatings,
|
|
watchlistMap,
|
|
favoritesMap,
|
|
nowUTC,
|
|
)
|
|
|
|
return discoverRows, nil
|
|
}
|
|
|
|
func mergePremieresIntoDiscoverRow(
|
|
row discoverRowModel,
|
|
rankMap map[string]int,
|
|
firstSeenItems map[string]recommendations.ScoredItem,
|
|
candidates []upcomingDiscoverCandidate,
|
|
watchedSet map[string]struct{},
|
|
lowRatings map[string]int,
|
|
watchlistMap map[string]bool,
|
|
favoritesMap map[string]bool,
|
|
now time.Time,
|
|
) discoverRowModel {
|
|
if len(candidates) == 0 {
|
|
return row
|
|
}
|
|
|
|
rankedPremieres := rankUpcomingCandidates(
|
|
candidates,
|
|
rankMap,
|
|
watchedSet,
|
|
lowRatings,
|
|
watchlistMap,
|
|
favoritesMap,
|
|
now,
|
|
)
|
|
if len(rankedPremieres) == 0 {
|
|
return row
|
|
}
|
|
|
|
combined := buildCombinedForYouItems(row.Items, rankMap)
|
|
for _, candidate := range rankedPremieres {
|
|
scoredItem, ok := firstSeenItems[candidate.DisplayID]
|
|
if !ok {
|
|
scoredItem = recommendations.ScoredItem{MediaItemID: candidate.DisplayID}
|
|
}
|
|
|
|
if existing, ok := combined[candidate.DisplayID]; ok {
|
|
existing.Item = mergeScoredItem(scoredItem, existing.Item)
|
|
existing.BaseIndex = candidate.BaseIndex
|
|
existing.EffectiveIndex = candidate.EffectiveIndex
|
|
existing.HasUpcoming = true
|
|
existing.UpcomingEvent = &candidate.Event
|
|
existing.AirDateTime = candidate.AirDateTime
|
|
continue
|
|
}
|
|
|
|
combined[candidate.DisplayID] = &combinedForYouItem{
|
|
Item: scoredItem,
|
|
BaseIndex: candidate.BaseIndex,
|
|
EffectiveIndex: candidate.EffectiveIndex,
|
|
HasUpcoming: true,
|
|
UpcomingEvent: &candidate.Event,
|
|
AirDateTime: candidate.AirDateTime,
|
|
}
|
|
}
|
|
|
|
sorted := sortCombinedForYouItems(combined)
|
|
if len(sorted) > discoverForYouMaxItems {
|
|
sorted = sorted[:discoverForYouMaxItems]
|
|
}
|
|
|
|
row.Items = make([]recommendations.ScoredItem, 0, len(sorted))
|
|
row.UpcomingEvents = make(map[string]upcomingEventResponse)
|
|
for _, item := range sorted {
|
|
row.Items = append(row.Items, item.Item)
|
|
if item.HasUpcoming && item.UpcomingEvent != nil {
|
|
row.UpcomingEvents[item.Item.MediaItemID] = *item.UpcomingEvent
|
|
}
|
|
}
|
|
if len(row.UpcomingEvents) == 0 {
|
|
row.UpcomingEvents = nil
|
|
}
|
|
|
|
return row
|
|
}
|
|
|
|
func categorizeUpcomingCandidates(
|
|
events []catalog.CalendarEvent,
|
|
) []upcomingDiscoverCandidate {
|
|
premieres := make(map[string]upcomingDiscoverCandidate)
|
|
|
|
for _, event := range events {
|
|
candidate, ok := buildUpcomingCandidate(event)
|
|
if !ok {
|
|
continue
|
|
}
|
|
if isUpcomingPremiere(event, candidate.Event.Badges) {
|
|
insertUpcomingCandidate(premieres, candidate)
|
|
}
|
|
}
|
|
|
|
return upcomingCandidatesFromMap(premieres)
|
|
}
|
|
|
|
func buildUpcomingCandidate(event catalog.CalendarEvent) (upcomingDiscoverCandidate, bool) {
|
|
displayID := event.ContentID
|
|
if event.Type != "movie" {
|
|
if event.SeriesID == nil || *event.SeriesID == "" {
|
|
return upcomingDiscoverCandidate{}, false
|
|
}
|
|
displayID = *event.SeriesID
|
|
}
|
|
|
|
return upcomingDiscoverCandidate{
|
|
DisplayID: displayID,
|
|
AirDateTime: calendarEventAirDateTime(event),
|
|
Event: upcomingEventResponse{
|
|
Type: event.Type,
|
|
AirDate: event.AirDate.Format("2006-01-02"),
|
|
AirTime: event.AirTime,
|
|
EpisodeTitle: event.EpisodeTitle,
|
|
SeasonNumber: event.SeasonNumber,
|
|
EpisodeNumber: event.EpisodeNumber,
|
|
Badges: buildBadges(event),
|
|
},
|
|
}, true
|
|
}
|
|
|
|
func insertUpcomingCandidate(
|
|
target map[string]upcomingDiscoverCandidate,
|
|
candidate upcomingDiscoverCandidate,
|
|
) {
|
|
existing, ok := target[candidate.DisplayID]
|
|
if !ok || candidate.AirDateTime.Before(existing.AirDateTime) {
|
|
target[candidate.DisplayID] = candidate
|
|
}
|
|
}
|
|
|
|
func upcomingCandidatesFromMap(
|
|
items map[string]upcomingDiscoverCandidate,
|
|
) []upcomingDiscoverCandidate {
|
|
if len(items) == 0 {
|
|
return nil
|
|
}
|
|
|
|
candidates := make([]upcomingDiscoverCandidate, 0, len(items))
|
|
for _, candidate := range items {
|
|
candidates = append(candidates, candidate)
|
|
}
|
|
sort.SliceStable(candidates, func(i, j int) bool {
|
|
if !candidates[i].AirDateTime.Equal(candidates[j].AirDateTime) {
|
|
return candidates[i].AirDateTime.Before(candidates[j].AirDateTime)
|
|
}
|
|
return candidates[i].DisplayID < candidates[j].DisplayID
|
|
})
|
|
return candidates
|
|
}
|
|
|
|
func rankUpcomingCandidates(
|
|
candidates []upcomingDiscoverCandidate,
|
|
rankMap map[string]int,
|
|
watchedSet map[string]struct{},
|
|
lowRatings map[string]int,
|
|
watchlistMap map[string]bool,
|
|
favoritesMap map[string]bool,
|
|
now time.Time,
|
|
) []upcomingDiscoverCandidate {
|
|
if len(candidates) == 0 {
|
|
return nil
|
|
}
|
|
|
|
ranked := make([]upcomingDiscoverCandidate, 0, len(candidates))
|
|
savedOnly := make([]upcomingDiscoverCandidate, 0, len(candidates))
|
|
|
|
for _, candidate := range candidates {
|
|
if _, watched := watchedSet[candidate.DisplayID]; watched {
|
|
continue
|
|
}
|
|
if rating, rated := lowRatings[candidate.DisplayID]; rated && rating <= 2 {
|
|
continue
|
|
}
|
|
|
|
candidate.InWatchlist = watchlistMap[candidate.DisplayID]
|
|
candidate.IsFavorite = favoritesMap[candidate.DisplayID]
|
|
|
|
if baseIndex, ok := rankMap[candidate.DisplayID]; ok {
|
|
candidate.BaseIndex = baseIndex
|
|
ranked = append(ranked, candidate)
|
|
continue
|
|
}
|
|
if candidate.InWatchlist || candidate.IsFavorite {
|
|
savedOnly = append(savedOnly, candidate)
|
|
}
|
|
}
|
|
|
|
sort.SliceStable(savedOnly, func(i, j int) bool {
|
|
if !savedOnly[i].AirDateTime.Equal(savedOnly[j].AirDateTime) {
|
|
return savedOnly[i].AirDateTime.Before(savedOnly[j].AirDateTime)
|
|
}
|
|
return savedOnly[i].DisplayID < savedOnly[j].DisplayID
|
|
})
|
|
for i := range savedOnly {
|
|
savedOnly[i].BaseIndex = len(rankMap) + i
|
|
ranked = append(ranked, savedOnly[i])
|
|
}
|
|
|
|
for i := range ranked {
|
|
ranked[i].EffectiveIndex = effectiveUpcomingIndex(
|
|
ranked[i].BaseIndex,
|
|
ranked[i].InWatchlist,
|
|
ranked[i].IsFavorite,
|
|
ranked[i].AirDateTime,
|
|
now,
|
|
)
|
|
}
|
|
|
|
sort.SliceStable(ranked, func(i, j int) bool {
|
|
if ranked[i].EffectiveIndex != ranked[j].EffectiveIndex {
|
|
return ranked[i].EffectiveIndex < ranked[j].EffectiveIndex
|
|
}
|
|
if !ranked[i].AirDateTime.Equal(ranked[j].AirDateTime) {
|
|
return ranked[i].AirDateTime.Before(ranked[j].AirDateTime)
|
|
}
|
|
if ranked[i].BaseIndex != ranked[j].BaseIndex {
|
|
return ranked[i].BaseIndex < ranked[j].BaseIndex
|
|
}
|
|
return ranked[i].DisplayID < ranked[j].DisplayID
|
|
})
|
|
|
|
return ranked
|
|
}
|
|
|
|
func buildDiscoverRankingData(rows []recommendations.ForYouRow) (map[string]int, map[string]recommendations.ScoredItem) {
|
|
rankMap := make(map[string]int)
|
|
firstSeenItems := make(map[string]recommendations.ScoredItem)
|
|
nextIndex := 0
|
|
for _, row := range rows {
|
|
for _, item := range row.Items {
|
|
if _, ok := rankMap[item.MediaItemID]; ok {
|
|
continue
|
|
}
|
|
rankMap[item.MediaItemID] = nextIndex
|
|
firstSeenItems[item.MediaItemID] = item
|
|
nextIndex++
|
|
}
|
|
}
|
|
return rankMap, firstSeenItems
|
|
}
|
|
|
|
func effectiveUpcomingIndex(
|
|
baseIndex int,
|
|
inWatchlist bool,
|
|
isFavorite bool,
|
|
airDateTime time.Time,
|
|
now time.Time,
|
|
) int {
|
|
effectiveIndex := baseIndex
|
|
if inWatchlist {
|
|
effectiveIndex -= 6
|
|
}
|
|
if isFavorite {
|
|
effectiveIndex -= 3
|
|
}
|
|
|
|
timeUntilAiring := airDateTime.Sub(now)
|
|
if timeUntilAiring <= 48*time.Hour {
|
|
effectiveIndex -= 2
|
|
} else if timeUntilAiring <= 7*24*time.Hour {
|
|
effectiveIndex -= 1
|
|
}
|
|
|
|
return effectiveIndex
|
|
}
|
|
|
|
func collectUpcomingDisplayIDsFromCandidates(candidates []upcomingDiscoverCandidate) []string {
|
|
if len(candidates) == 0 {
|
|
return nil
|
|
}
|
|
|
|
displayIDs := make([]string, 0, len(candidates))
|
|
seen := make(map[string]struct{}, len(candidates))
|
|
for _, candidate := range candidates {
|
|
if _, ok := seen[candidate.DisplayID]; ok {
|
|
continue
|
|
}
|
|
seen[candidate.DisplayID] = struct{}{}
|
|
displayIDs = append(displayIDs, candidate.DisplayID)
|
|
}
|
|
|
|
return displayIDs
|
|
}
|
|
|
|
func isUpcomingPremiere(event catalog.CalendarEvent, badges []string) bool {
|
|
if event.Type == "movie" {
|
|
return true
|
|
}
|
|
return hasUpcomingBadge(badges, "series_premiere") || hasUpcomingBadge(badges, "season_premiere")
|
|
}
|
|
|
|
func indexOfDiscoverForYouRow(rows []discoverRowModel) int {
|
|
for i := range rows {
|
|
if rows[i].Label == discoverForYouLabel {
|
|
return i
|
|
}
|
|
}
|
|
return -1
|
|
}
|
|
|
|
func buildCombinedForYouItems(
|
|
items []recommendations.ScoredItem,
|
|
rankMap map[string]int,
|
|
) map[string]*combinedForYouItem {
|
|
combined := make(map[string]*combinedForYouItem, len(items))
|
|
nextIndex := len(rankMap)
|
|
for _, item := range items {
|
|
baseIndex, ok := rankMap[item.MediaItemID]
|
|
if !ok {
|
|
baseIndex = nextIndex
|
|
nextIndex++
|
|
}
|
|
combined[item.MediaItemID] = &combinedForYouItem{
|
|
Item: item,
|
|
BaseIndex: baseIndex,
|
|
EffectiveIndex: baseIndex,
|
|
}
|
|
}
|
|
return combined
|
|
}
|
|
|
|
func mergeScoredItem(
|
|
primary recommendations.ScoredItem,
|
|
fallback recommendations.ScoredItem,
|
|
) recommendations.ScoredItem {
|
|
if primary.Score == 0 {
|
|
primary.Score = fallback.Score
|
|
}
|
|
if primary.Reason == "" {
|
|
primary.Reason = fallback.Reason
|
|
}
|
|
if primary.ReasonDetail == "" {
|
|
primary.ReasonDetail = fallback.ReasonDetail
|
|
}
|
|
return primary
|
|
}
|
|
|
|
func sortCombinedForYouItems(combined map[string]*combinedForYouItem) []combinedForYouItem {
|
|
items := make([]combinedForYouItem, 0, len(combined))
|
|
for _, item := range combined {
|
|
items = append(items, *item)
|
|
}
|
|
|
|
sort.SliceStable(items, func(i, j int) bool {
|
|
if items[i].EffectiveIndex != items[j].EffectiveIndex {
|
|
return items[i].EffectiveIndex < items[j].EffectiveIndex
|
|
}
|
|
if items[i].HasUpcoming != items[j].HasUpcoming {
|
|
return items[i].HasUpcoming
|
|
}
|
|
if items[i].HasUpcoming && items[j].HasUpcoming && !items[i].AirDateTime.Equal(items[j].AirDateTime) {
|
|
return items[i].AirDateTime.Before(items[j].AirDateTime)
|
|
}
|
|
if items[i].BaseIndex != items[j].BaseIndex {
|
|
return items[i].BaseIndex < items[j].BaseIndex
|
|
}
|
|
return items[i].Item.MediaItemID < items[j].Item.MediaItemID
|
|
})
|
|
|
|
return items
|
|
}
|
|
|
|
func hasUpcomingBadge(badges []string, target string) bool {
|
|
for _, badge := range badges {
|
|
if badge == target {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func calendarEventAirDateTime(event catalog.CalendarEvent) time.Time {
|
|
base := discoverUTCDate(event.AirDate.UTC())
|
|
if event.AirTime == nil || *event.AirTime == "" {
|
|
return base
|
|
}
|
|
|
|
layouts := []string{"15:04:05", "15:04"}
|
|
for _, layout := range layouts {
|
|
parsed, err := time.Parse(layout, *event.AirTime)
|
|
if err == nil {
|
|
return time.Date(
|
|
base.Year(),
|
|
base.Month(),
|
|
base.Day(),
|
|
parsed.Hour(),
|
|
parsed.Minute(),
|
|
parsed.Second(),
|
|
0,
|
|
time.UTC,
|
|
)
|
|
}
|
|
}
|
|
|
|
return base
|
|
}
|
|
|
|
func discoverUTCDate(t time.Time) time.Time {
|
|
t = t.UTC()
|
|
return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC)
|
|
}
|