E24 S1. The anti-paralysis answer layer, served from our own Postgres with no upstream call: one hero suggestion, one CROSS-SOURCE resume rail, and the user's own comfort shelf. Survey first, because the roadmap line was wrong about the starting point: Plex already ships the whole trio (E5's Discover rails, including a reshuffling Surprise me), the YouTube side ships none of it, and nothing anywhere answers the question across both. The data already existed on both sides — VideoState and PlexState are structurally identical, the feed has an `in_progress` filter and a seeded shuffle, and the YT player already resumes position. - app/discover/budget.py — the time bands. "How much time do you have" (10 perc / 30 perc / egy este / mindegy) replaces 15/D's mood questionnaire: deterministic, answerable from a column we have, and it picks the source for free. Matched against REMAINING time, so a film with 8 minutes left is a ten-minute answer. An unknown runtime fits only the unbounded band. - app/discover/cards.py — a thin unified card that carries the NATIVE feed / Plex payload, so the page renders with the existing card components instead of a third one. `aspect` is the one new field: a rail needs to know poster from 16:9, and that is a property of the artwork, not of the module. - app/discover/queries.py — the SQL, reusing feedquery and plex.rollup rather than the Plex Discover ROUTE, whose LibraryFilters narrowing belongs to a surface that has a sidebar. One seed drives both halves so a reroll moves them together. - app/discover/hero.py — resume -> comfort -> surprise, with the chosen tier in the payload so the client labels it honestly instead of guessing. - comfort_pins (0083): five nullable CASCADE-ing FKs under num_nonnulls(...) = 1 rather than a flag or a polymorphic string ref. A flag cannot order a shelf or hold a channel; a string ref would let a deleted library item leave a dead tile on the one surface whose whole job is to be reliable. Two honest-state rules the tests pin: the surprise draw excludes anything already started (the resume tier may have skipped it for not fitting, and offering it back as "Meglepetés" would contradict the question), and which source leads the draw follows the seed, so the Plex library can actually answer "egy este". Gate green: ruff, ruff format, 3x tsc, eslint, prettier, knip, vitest 770, pytest 987.
135 lines
5.0 KiB
Python
135 lines
5.0 KiB
Python
"""The unified discover card: one row shape the Home surface can render without knowing which
|
|
module it came from.
|
|
|
|
This is deliberately a THIN mapping, not a new serializer layer. The per-source projections already
|
|
exist and are already the shapes the frontend's `VideoCard` and `PlexPosterCard` consume —
|
|
`feedquery.serialize` for YouTube, `plex.cards.movie_card`/`show_card`/`leaf_card` for Plex — so each
|
|
unified card carries the native payload under `video` / `plex` alongside the handful of fields the
|
|
rail chrome itself needs (title, thumbnail, progress, runtime). The Home page therefore renders with
|
|
the existing card components rather than a third one, and a field added to a feed row or a Plex card
|
|
still gets added in exactly one place. CLAUDE.md's "not wanted" list names a base serializer for
|
|
precisely this reason.
|
|
|
|
`aspect` is the one genuinely new field. A rail has to know whether a tile is a 16:9 still or a 2:3
|
|
poster, and that is a property of the ARTWORK, not of the module: a Plex episode ships a landscape
|
|
still exactly like a YouTube video does, while a movie and a show ship posters. Emitting it lets the
|
|
rail layer stay domain-free instead of branching on `source`.
|
|
|
|
`subtitle` only ever carries DATA — a channel name, a show title, a year. Nothing here composes an
|
|
English phrase: this repo is HU+EN with zero key drift, and a sentence assembled in the backend is a
|
|
string no translation file can reach. Anything that needs wording (season counts, "N videó") is left
|
|
to the client, which has both the native payload and the translations.
|
|
"""
|
|
|
|
from app.discover.budget import remaining_seconds
|
|
from app.models import Channel, Playlist
|
|
|
|
# Plex artwork is a poster for the things you browse and a landscape still for the things you play.
|
|
_POSTER_KINDS = ("movie", "show")
|
|
|
|
|
|
def _base(
|
|
*,
|
|
source: str, # "youtube" | "plex"
|
|
kind: str, # "video" | "movie" | "show" | "episode" | "channel" | "playlist"
|
|
id: str,
|
|
title: str | None,
|
|
aspect: str, # "wide" (16:9) | "poster" (2:3)
|
|
subtitle: str | None = None,
|
|
thumb: str | None = None,
|
|
duration_seconds: int | None = None,
|
|
position_seconds: int = 0,
|
|
status: str = "new",
|
|
) -> dict:
|
|
return {
|
|
"source": source,
|
|
"kind": kind,
|
|
"id": id,
|
|
"title": title or "",
|
|
"subtitle": subtitle,
|
|
"thumb": thumb,
|
|
"aspect": aspect,
|
|
"duration_seconds": duration_seconds,
|
|
"position_seconds": position_seconds,
|
|
"remaining_seconds": remaining_seconds(duration_seconds, position_seconds),
|
|
"status": status,
|
|
# Native payloads — at most one is ever set. The client opens the player from these.
|
|
"video": None,
|
|
"plex": None,
|
|
}
|
|
|
|
|
|
def from_video(row: dict) -> dict:
|
|
"""`row` is a `feedquery.serialize` dict — the same object the feed grid renders."""
|
|
card = _base(
|
|
source="youtube",
|
|
kind="video",
|
|
id=row["id"],
|
|
title=row["title"],
|
|
aspect="wide",
|
|
subtitle=row.get("channel_title"),
|
|
thumb=row.get("thumbnail_url"),
|
|
duration_seconds=row.get("duration_seconds"),
|
|
position_seconds=row.get("position_seconds") or 0,
|
|
status=row.get("status") or "new",
|
|
)
|
|
card["video"] = row
|
|
return card
|
|
|
|
|
|
def from_plex(row: dict) -> dict:
|
|
"""`row` is a `plex.cards` dict (movie / show / leaf). Its own `type` is the kind."""
|
|
kind = row.get("type") or "movie"
|
|
if kind == "episode":
|
|
subtitle = row.get("show_title")
|
|
elif kind == "show":
|
|
subtitle = None # season count lives in the native payload; the client words it
|
|
else:
|
|
subtitle = str(row["year"]) if row.get("year") else None
|
|
|
|
card = _base(
|
|
source="plex",
|
|
kind=kind,
|
|
id=row["id"],
|
|
title=row.get("title"),
|
|
aspect="poster" if kind in _POSTER_KINDS else "wide",
|
|
subtitle=subtitle,
|
|
thumb=row.get("thumb"),
|
|
duration_seconds=row.get("duration_seconds"),
|
|
position_seconds=row.get("position_seconds") or 0,
|
|
status=row.get("status") or "new",
|
|
)
|
|
card["plex"] = row
|
|
return card
|
|
|
|
|
|
def from_channel(ch: Channel, unwatched: int) -> dict:
|
|
"""A pinned channel. It is not itself playable — the shelf resolves it to its newest unwatched
|
|
video on click (see `resolve.py`) — so the tile advertises what is waiting there instead of a
|
|
runtime it does not have."""
|
|
card = _base(
|
|
source="youtube",
|
|
kind="channel",
|
|
id=ch.id,
|
|
title=ch.title,
|
|
aspect="wide",
|
|
thumb=ch.thumbnail_url,
|
|
)
|
|
card["unwatched_count"] = unwatched
|
|
return card
|
|
|
|
|
|
def from_playlist(pl: Playlist, item_count: int, thumb: str | None) -> dict:
|
|
"""A pinned playlist. `thumb` is its first item's thumbnail — a shelf of blank tiles would defeat
|
|
the point of a surface you are supposed to be able to scan at a glance."""
|
|
card = _base(
|
|
source="youtube",
|
|
kind="playlist",
|
|
id=str(pl.id),
|
|
title=pl.name,
|
|
aspect="wide",
|
|
thumb=thumb,
|
|
)
|
|
card["item_count"] = item_count
|
|
return card
|