Files
siftlode/backend/app/discover/cards.py
T
peter f3c0b7be23 feat(e24): the Home surface — one question, one answer, both sources
E24 S2. A new `home` module in layout C+ (the mockup the owner picked): the
time-budget chips on top, one large hero that answers them, then the comfort
shelf and a cross-source resume rail. Home is the landing — deliberately OVER
the remembered last module, since landing on whatever you happened to close the
app on is the habit this epic exists to replace; `LS.startOnHome = "0"` opts an
account back out (Settings toggle lands in S4).

No third card component: each unified card carries the native feed / Plex
payload, so the rails render through the existing VideoCard (`cardsSmall`) and
PlexPosterCard, and `aspect` decides the tile shape without branching on source.
Plex playback hands off to the Plex module via a new `jumpTo` type — the player
lives there with its lazy hls.js and its provider tree, and mounting a second one
on the landing page to save one hand-off would be the expensive way round.

Four defects found by driving it in a real browser against the live account, each
now pinned by a mutation-checked test:

- **The reroll gave the same card back.** A new seed only re-draws the SURPRISE
  tier; the resume tier deterministically returns the most recently progressed
  thing that fits, so anyone with something half-finished pressed a dead button.
  `/pick` now takes an `exclude` list of `<source>:<id>` keys and the client keeps
  a rolling window of 8. Excluding a SHOW also excludes the episode it resolves
  to — otherwise the same suggestion comes back wearing a hat.
- **"Nothing to suggest yet" flashed over a full library** whenever a reroll was
  in flight: the new query key has no data for a beat. An empty state standing in
  for a loading one is the dishonesty the house rules name outright. The hero now
  falls back to the loaded payload while a pick is pending, and only trusts a null
  once the pick has RESOLVED.
- **A budget change replaced the page — chips included — with a spinner**, so the
  control you just clicked vanished under your cursor. `keepPreviousData`, the
  same reason the feed uses it.
- **The hero printed twice**, once as itself and once as the first resume tile.

Two smaller UI calls made from the same session: the budget caption sits NEXT to
its chips rather than in SegmentedToggle's justify-between mode, which on an 80rem
page throws the answers to the far edge; and the feed takes the YouTube mark its
own toolbar already uses, since Home now owns the house icon.

Gate green: ruff, ruff format, 3x tsc, eslint, prettier, knip, vitest 780,
pytest 989. Verified end to end on localdev against the real account: budget
chips re-answer (matched on REMAINING time — a 51-minute video with 2:46 left is
a ten-minute answer), the reroll walks three distinct heroes, and a Plex show
tile opens its drill-down in the Plex module.
2026-09-06 04:14:14 +02:00

142 lines
5.3 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 key(card: dict) -> str:
"""The identity of a card ACROSS sources — `<source>:<id>`. A Plex rating_key and a YouTube video
id could in principle collide as bare strings, and the reroll's exclusion list has to be able to
say "not that one" about either."""
return f"{card['source']}:{card['id']}"
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