Files
siftlode/frontend/src/lib/api/discover.ts
T
peter 4aaaaa7860 chore(comments): drop epic, sprint and review-round ids from the code
The code cited its plan instead of saying why: `E28 review J4`, `(R6 S3b)`, `S1 justified…`,
`deep-review round 5`. That plan lives in the private ops repo, so for every reader of this one the
citation was noise, and a sentence whose subject was the plan step ("round 2 showed…") hid its
lesson behind an id. Each one is gone and the "why" kept: a bare citation is deleted, a sentence
carrying a lesson is reworded around it ("a later attempt showed…").

Measured (`scripts/audit_metrics.py`, product code): 652 lines in 270 files carried an epic code by
the definition the next commit holds (507 / 241 by the old, two-digit-only one) -> 0. Across all
code, tests, scripts and configs: ~1100 epic-code lines plus ~320 sprint/round ids.

Behaviour unchanged, checked mechanically: every changed .py file has the same AST once docstrings
are blanked; css/sh/toml/yml/json files are identical once comments are stripped; the only
non-comment changes in TS/JS are test titles, one description string in a contract table, and five
ESLint messages, whose text is all the gates snapshot diff holds. Not touched: Markdown (docs and
the changelog keep their history), Alembic migrations, TV episode notation (`S2 · E5`), DESIGN.md
ids (`C1`, `B4`, `S3h`), tracker ids (`SEC-16`, `DX-17`).

One stale claim fixed on the way: test_hls_reaper's docstring said the player checkpoints "every
10 s whether or not it is playing", which plex/stream.py calls wrong; it now says what the code does.
2026-10-05 00:19:39 +02:00

113 lines
5.4 KiB
TypeScript

// The Home surface ("Mit nézzek most?"): 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"];
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.
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<DiscoverCard, "source" | "kind" | "id" | "title">;
/** 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 = {
// `lang` "hu" serves a Plex card's Hungarian title; en is the server default.
discover: (budget: DiscoverBudget, seed: number, lang?: string): Promise<DiscoverPayload> =>
req(`/api/discover?budget=${budget}&seed=${seed}${lang === "hu" ? "&lang=hu" : ""}`),
// 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 `<source>:<id>` 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,
lang?: string,
): Promise<{ budget: DiscoverBudget; hero: DiscoverHero | null }> =>
req(
`/api/discover/pick?budget=${budget}&seed=${seed}&start=${press}` +
(exclude.length ? `&exclude=${encodeURIComponent(exclude.join(","))}` : "") +
(lang === "hu" ? "&lang=hu" : ""),
),
comfortPins: (lang?: string): Promise<{ items: DiscoverCard[] }> =>
req(`/api/discover/pins${lang === "hu" ? "?lang=hu" : ""}`),
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<DiscoverCard, "source" | "id">): string {
return `${card.source}:${card.id}`;
}