// TV library landing page data store. // Powers the focused TV landing: hero + horizontal sliders. // TRACES: UR-007, UR-023, UR-034 | DR-007, DR-038, DR-039 import { writable, derived } from "svelte/store"; import type { MediaItem } from "$lib/api/types"; import { auth } from "./auth"; /** A single "by genre" row: the genre name plus the series in it. */ export interface GenreRow { id: string; name: string; items: MediaItem[]; } interface TvState { // Episodes the user can resume (continue watching). continueWatching: MediaItem[]; // Next unwatched episode per in-progress series. nextUp: MediaItem[]; // Recently added items in the TV library (series/seasons/episodes). recentlyAdded: MediaItem[]; // One slider per genre (top genres by show count). genreRows: GenreRow[]; // Mix used for the hero banner. heroItems: MediaItem[]; isLoading: boolean; error: string | null; } const SECTION_LIMIT = 16; // How many genre sliders to show, and how many genres to probe to find them. const MAX_GENRE_ROWS = 8; const MAX_GENRES_PROBED = 20; function createTvStore() { const initialState: TvState = { continueWatching: [], nextUp: [], recentlyAdded: [], genreRows: [], heroItems: [], isLoading: false, error: null, }; const { subscribe, set, update } = writable(initialState); /** * Build the hero rotation. Prefer in-progress episodes (most personal), * then fall back to next-up, then recently added. De-duplicates by id and * prefers items that carry backdrop/primary artwork for a good banner. */ function buildHero( continueWatching: MediaItem[], nextUp: MediaItem[], recentlyAdded: MediaItem[] ): MediaItem[] { const hasArt = (i: MediaItem) => !!(i.backdropImageTags && i.backdropImageTags.length > 0) || !!(i.parentBackdropImageTags && i.parentBackdropImageTags.length > 0) || !!i.primaryImageTag; const result: MediaItem[] = []; const seen = new Set(); for (const pool of [continueWatching, nextUp, recentlyAdded]) { for (const item of pool) { if (result.length >= 6) break; if (hasArt(item) && !seen.has(item.id)) { seen.add(item.id); result.push(item); } } } return result.slice(0, 6); } async function loadSections(libraryId: string) { update(s => ({ ...s, isLoading: s.continueWatching.length === 0 && s.recentlyAdded.length === 0, error: null, })); try { const repo = auth.getRepository(); const [resume, nextUp, latest] = await Promise.all([ repo.getResumeItems(libraryId, SECTION_LIMIT), repo.getNextUpEpisodes(undefined, SECTION_LIMIT), repo.getLatestItems(libraryId, SECTION_LIMIT), ]); // Resume items are already video-only from the server, but keep episodes // (and the occasional movie that lives in a mixed library) defensively. const continueWatching = resume.filter(i => i.type === "Episode" || i.type === "Movie"); const heroItems = buildHero(continueWatching, nextUp, latest); update(s => ({ ...s, continueWatching, nextUp, recentlyAdded: latest, heroItems, isLoading: false, })); // Genre rows are secondary — load them after the main sections paint so // the page isn't blocked on N per-genre queries. loadGenreRows(libraryId); } catch (error) { const message = error instanceof Error ? error.message : "Failed to load TV sections"; update(s => ({ ...s, isLoading: false, error: message })); console.error("Failed to load TV sections:", error); } } /** * Build one slider per genre, showing the top-rated series in each. We probe * a bounded set of genres in parallel, drop empty ones, then keep the genres * with the most shows (so niche/near-empty genres don't crowd the page). */ async function loadGenreRows(libraryId: string) { try { const repo = auth.getRepository(); const genres = await repo.getGenres(libraryId); if (genres.length === 0) return; const probed = genres.slice(0, MAX_GENRES_PROBED); const rows = await Promise.all( probed.map(async (genre): Promise => { try { const result = await repo.getItems(libraryId, { includeItemTypes: ["Series"], genres: [genre.name], sortBy: "CommunityRating", sortOrder: "Descending", recursive: true, limit: SECTION_LIMIT, }); return { id: genre.id, name: genre.name, items: result.items }; } catch (e) { console.warn(`Failed to load genre row "${genre.name}":`, e); return { id: genre.id, name: genre.name, items: [] }; } }) ); const genreRows = rows .filter(row => row.items.length > 0) .sort((a, b) => b.items.length - a.items.length) .slice(0, MAX_GENRE_ROWS); update(s => ({ ...s, genreRows })); } catch (e) { console.warn("Failed to load TV genre rows:", e); } } function reset() { set(initialState); } return { subscribe, loadSections, reset, }; } export const tv = createTvStore(); export const tvHeroItems = derived(tv, $t => $t.heroItems); export const isTvLoading = derived(tv, $t => $t.isLoading);