// The Home surface ("Mit nézzek most?", E24): one hero suggestion, a cross-source resume rail, and // the comfort shelf. Types mirror `backend/app/discover/` — the card carries the NATIVE feed / Plex // payload under `video` / `plex`, so the page renders through the existing VideoCard / // PlexPosterCard instead of a third card component. import { req } from "./core"; import type { Video } from "./feed"; import type { PlexCard } from "./plex"; // How much time the user says they have. The deterministic replacement for a mood question: the band // also decides the source (ten minutes is a video, an evening is a film). Bands live server-side // (`app/discover/budget.py`) — these are just the names on the wire. export const DISCOVER_BUDGETS = ["short", "medium", "long", "any"] as const; export type DiscoverBudget = (typeof DISCOVER_BUDGETS)[number]; // Where the hero came from. Travels with the card so the page can label it honestly instead of // inferring "where you left off" over something the user has never opened. export type HeroTier = "resume" | "comfort" | "surprise"; /** The server's tier order (`app/discover/hero.py::HERO_TIERS`): a reroll's press count `start` * leads with `HERO_TIERS[start % 3]`, which is how a press can ASK for a tier. */ export const HERO_TIERS: readonly HeroTier[] = ["resume", "comfort", "surprise"]; export type DiscoverKind = "video" | "movie" | "show" | "episode" | "channel" | "playlist"; // What can go on the comfort shelf. `plex_item` covers a movie or a single episode; a show is pinned // as `plex_show` because "put that show on" resolves to whichever episode is next. export const PIN_KINDS = ["video", "channel", "plex_item", "plex_show", "playlist"] as const; export type PinKind = (typeof PIN_KINDS)[number]; export interface DiscoverCard { source: "youtube" | "plex"; kind: DiscoverKind; id: string; title: string; subtitle: string | null; thumb: string | null; // The ARTWORK's shape, not the module's: a Plex episode ships a landscape still exactly like a // YouTube video, while movies and shows ship posters. Lets a rail size its tiles without // branching on `source`. aspect: "wide" | "poster"; duration_seconds: number | null; position_seconds: number; remaining_seconds: number | null; status: string; // Exactly one is set (both null for a channel / playlist tile, which are not themselves playable). video: Video | null; plex: PlexCard | null; // Shelf entries only. pin_id?: number; /** A comfort-tier hero only: the shelf entry it was resolved from — for a pinned SHOW, the show * whose next episode this is. The hero's pin row acts on it rather than on the episode, which is * not itself pinned (`app/discover/pins.py::playables`). */ pinned_from?: Pick; /** What actually plays when the tile is clicked: a show's next episode, a channel's newest * unwatched video, or a copy of the tile itself when it plays directly. Null when the pin has * nothing to offer right now (a fully-watched channel, an empty playlist). */ next?: DiscoverCard | null; unwatched_count?: number; item_count?: number; } export interface DiscoverHero { tier: HeroTier; card: DiscoverCard; } export interface DiscoverPayload { budget: DiscoverBudget; hero: DiscoverHero | null; continue_watching: DiscoverCard[]; comfort: DiscoverCard[]; } export const discoverApi = { discover: (budget: DiscoverBudget, seed: number): Promise => req(`/api/discover?budget=${budget}&seed=${seed}`), // The "Mást kérek" reroll: a new hero alone, so pressing it does not re-fetch the rails under the // user's cursor. // `exclude` is the rolling list of `:` keys already shown. Without it a reroll only // moves the surprise tier, so a user with something half-finished gets the same hero back. // `press` is which press this is; it rotates the tier the server answers from, so consecutive // presses offer a different KIND of answer instead of walking down the resume rail. discoverPick: ( budget: DiscoverBudget, seed: number, exclude: string[] = [], press = 0, ): Promise<{ budget: DiscoverBudget; hero: DiscoverHero | null }> => req( `/api/discover/pick?budget=${budget}&seed=${seed}&start=${press}` + (exclude.length ? `&exclude=${encodeURIComponent(exclude.join(","))}` : ""), ), comfortPins: (): Promise<{ items: DiscoverCard[] }> => req("/api/discover/pins"), addComfortPin: ( kind: PinKind, ref: string, ): Promise<{ ok: boolean; id: number; created: boolean }> => req("/api/discover/pins", { method: "POST", body: JSON.stringify({ kind, ref }) }), removeComfortPin: (pinId: number): Promise<{ deleted: number }> => req(`/api/discover/pins/${pinId}`, { method: "DELETE" }), reorderComfortPins: (ids: number[]): Promise<{ ok: boolean }> => req("/api/discover/pins/reorder", { method: "POST", body: JSON.stringify({ ids }) }), }; /** A card's identity across sources — mirrors `app/discover/cards.py::key`. The reroll's exclusion * list and the "is this tile the hero?" check both speak it, so the two cannot drift. */ export function discoverCardKey(card: Pick): string { return `${card.source}:${card.id}`; }