Adds a movie-scoped "Playback" filter to the Plex feed so a user can isolate items by how they play — the groundwork for collecting HW-transcodable (or no-transcode) versions. Four classes derived from the stored `playable` + a new `transcode_hw` facet: - Direct Play (direct), Direct Stream (remux) - Transcode · HW — playable=transcode AND the iGPU can hardware-transcode it - Transcode · SW — playable=transcode, software libx264 only The HW/SW split is precise, not codec-only: a 10-bit/HDR HEVC is HW-decodable yet breaks the VAAPI nv12 pipeline (the same case plex/stream special-cases), so it must count as SW. Backend: - New PlexItem columns (migration 0077): transcode_hw, video_bit_depth, media_probed_at. Migration backfills transcode_hw codec-only so the filter works immediately. - `plex.sync.transcode_hw_capable` is the single classifier; the HW-decodable codec set moves to sync as `HW_DECODABLE_CODECS` and plex.stream imports it, so the catalog facet and the runtime encoder choice can't disagree by codec. - Bit-depth backfill (`_probe_media_batch`, wired into sync): probes only HW-decodable transcode items' video Stream for bit-depth + HDR and refines a 10-bit/HDR HEVC to software. Bounded per run, watermarked by media_probed_at. A codec change resets the watermark (stale probe). - Filter + self-excluded facet on a shared `playback_class_expr` CASE (movie-leg only — a show is an aggregate with no single class). transcode_hw threaded onto the feed/detail/card JSON. Frontend: - Multi-select "Playback" Combobox in the sidebar (movie scope, facet-empty drop-out), active chips, 4-way corner-dot badge (green/blue/amber/red) + tooltip, and an info-page playback chip with a codec · bit-depth subtitle. New `bg-info` (blue) token, i18n HU/EN parity. - Fix a regression where the card's playback dot lost its hover tooltip: the full-card reveal overlay swallowed the cursor; the dot now sits above it (z-20). Gate: backend full lane + frontend typecheck/lint/knip/prettier/vitest all green. Browser-UAT'd on localdev (filter narrows to the SW set, facet counts, badge colour + tooltip, info chip).
89 lines
3.5 KiB
Python
89 lines
3.5 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,
|
|
"transcode_hw": it.transcode_hw, # HW/SW transcode split → the 4-way corner-dot badge
|
|
"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,
|
|
"transcode_hw": e.transcode_hw, # HW/SW transcode split → the 4-way corner-dot badge
|
|
"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)
|