"""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.catalog.models import Channel from app.reader.models import 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 — `:`. 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