Files
siftlode/backend/app/plex/cards.py
T
peter 5b5e76b73d E5 S3: unified Plex card — scrim-reveal hover + rest polish
The poster card (grid + person page) gets the Seerr-style hover: on hover a
bottom scrim slides up with the title, a "year · runtime · ★rating" meta line,
and a 3-line synopsis — the info you'd otherwise click through for — plus a
less-flat play ring (radial-gradient + ring shadow) and a top-right quick-action
cluster (Info + watched-toggle, revealed on hover / always on touch). Rest-state
badges (season / type tag) yield to the scrim on hover; the playable-cost tint
stays. (§1 A + C from the enrichment study.)

Backend (the data the scrim needs, mirroring S1's `art`): the grid
(browse.unified_library) now emits a lang-coalesced, truncated `summary` (new
query.summary_col) + the Plex `rating`; movie_card/show_card do the same (EN) for
the detail/related/person cards. PlexCard.rating type added (summary already
existed for episodes).

Gate: tsc x3 / eslint / prettier / knip / vitest 326 green; ruff green; backend
lane full suite green. Self-E2E on the live dev server (rebuilt on this branch,
S2-enriched): the grid serves rating+summary (Shawshank 9.3, Godfather 9.2, real
synopses) and the card renders the play ring + scrim (The Expanse: ring, synopsis,
★8.1) — verified via the DOM (the pane wouldn't composite for a screenshot).
2026-08-20 20:30:30 +02:00

87 lines
3.4 KiB
Python

"""Plex list/leaf card serializers: the small `model -> dict` projections the library grid,
playlists, and detail views render. Extracted from routes/plex/browse.py so the row-shaping
lives beside the other Plex read-model modules (detail.py, query.py) rather than inside the
router. Pure functions — no DB, no request state."""
from app.models import PlexItem, PlexShow, PlexState
# A card's wide backdrop, for a rail's landscape tile or a crossfading hero — but ONLY when the item
# actually has art. `thumb` is always emitted (its endpoint placeholders a missing poster), but an
# `art` URL for an item with no `art_key` would resolve to a broken/blank image on every card that
# tried to use it, so gate it on the stored key and let the client fall back to the poster.
def _art_url(rating_key: str, art_key: str | None) -> str | None:
return f"/api/plex/image/{rating_key}?variant=art" if art_key else None
# Card hover-overview: a short synopsis for the reveal panel, trimmed so a strip/related row of cards
# stays a few hundred bytes each rather than carrying full synopses. (These non-grid card paths serve
# the EN summary/rating; the main grid coalesces the HU twin in browse.unified_library.)
def _card_summary(summary: str | None) -> str | None:
return (summary or "")[:300] or None
def movie_card(it: PlexItem, st: PlexState | None) -> dict:
return {
"id": it.rating_key,
"type": "movie",
"title": it.title,
"year": it.year,
"rating": it.rating,
"summary": _card_summary(it.summary),
"duration_seconds": it.duration_s,
"thumb": f"/api/plex/image/{it.rating_key}",
"art": _art_url(it.rating_key, it.art_key),
"playable": it.playable,
"status": st.status if st else "new",
"position_seconds": st.position_seconds if st else 0,
}
def show_card(sh: PlexShow, status: str = "new") -> dict:
return {
"id": sh.rating_key,
"type": "show",
"title": sh.title,
"year": sh.year,
"rating": sh.rating,
"summary": _card_summary(sh.summary),
"thumb": f"/api/plex/image/{sh.rating_key}",
"art": _art_url(sh.rating_key, sh.art_key),
"season_count": sh.child_count,
# Aggregate watch-state across the show's episodes (new | in_progress | watched) → grid badge.
"status": status,
}
def episode_card(e: PlexItem, st: PlexState | None) -> dict:
return {
"id": e.rating_key,
"type": "episode",
"title": e.title,
"summary": e.summary,
"season_number": e.season_number,
"episode_number": e.episode_number,
"duration_seconds": e.duration_s,
"thumb": f"/api/plex/image/{e.rating_key}",
"playable": e.playable,
"status": st.status if st else "new",
"position_seconds": st.position_seconds if st else 0,
}
def leaf_card(
it: PlexItem,
st: PlexState | None,
show_title: str | None = None,
show_rk: str | None = None,
) -> dict:
"""A playable-leaf card (movie or episode). Used by playlists, which can mix both kinds.
`show_rk` (the show's rating_key) lets the client group a playlist's episodes by show/season."""
if it.kind == "episode":
card = episode_card(it, st)
card["show_title"] = show_title
card["show_id"] = show_rk
return card
return movie_card(it, st)