`add_pin`'s IntegrityError handler assumed the unique constraint was the only way in and re-raised otherwise, so a target row pruned between `resolve_ref` confirming it and the INSERT (a Plex sync dropping a removed item) tripped the FK and handed the user a 500. Semantically that is the same "no such thing" the pre-check already returns. Untested: the race needs two concurrent transactions. The five per-kind unique constraints had no test at all — the route's own dedupe returns before reaching the DB, and the migration drift check filters constraint diffs out as noise, so they could go missing with every lane green. Frontend, all from the same review: the shelf's unpin row is now `danger` like every other destructive row in the app; the hero's artwork button says what it will actually do and names the item instead of always announcing "Play"; the empty-budget message offers a reroll rather than `StateMessage`'s "Retry", which promised a re-run of something that had not failed; `setState` and the watch-later toggle catch their rejections instead of leaving an unhandled promise and a click that silently does nothing. `StateMessage` gained a `compact` rung, which retires the frontend's only two `!important` overrides — they existed to beat the primitive's own padding from the call site, and were the copyable idiom for the next surface that wanted a compact empty state. Dead vocabulary removed or made load-bearing: `DISCOVER_BUDGETS` was exported, re-exported and consumed by nothing while `Home.tsx` re-typed the same four bands (a `readonly DiscoverBudget[]` accepts any subset, so a fifth band would have typechecked green and rendered no chip); `PIN_KINDS`' value export had no consumer at all; `useComfortPin` no longer publishes `pinIdOf`/`toggle`, which nothing outside the module calls. Three comments corrected against the code they describe: a pointer to a `resolve.py` that was never written, the file header's claim that Home defines no card component of its own, and the `seen` window still quoting the 8 that was measured and abandoned for 32. The rest of the review's survivors are logged on ROADMAP.md under "Deferred — E24 epic-end deep-review (2026-09-08)" — the ones whose dissent was substantive, and the ones whose fix is wider than the finding.
142 lines
5.3 KiB
Python
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 `pins._shelf_card`) — 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
|