Channel, Subscription, Video, SearchFind, ExploredChannel and BlockedChannel (and LIVE_OR_UPCOMING) leave app/models.py for app/catalog/models.py; app.tables registers them. The User↔Subscription relationship names its other side as a type only. No schema change.
143 lines
5.4 KiB
Python
143 lines
5.4 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.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 — `<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
|