feat(plex): filter the feed by playback class (Direct Play / Direct Stream / Transcode HW/SW)
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).
This commit is contained in:
@@ -9,6 +9,8 @@ from datetime import datetime, timedelta, timezone
|
||||
from sqlalchemy import and_, case, func, or_, select, type_coerce
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
|
||||
from app.models import PlexItem
|
||||
|
||||
TS_CONFIG = "public.unaccent_simple"
|
||||
|
||||
# --- Language-aware column pickers (E5 S6) ----------------------------------
|
||||
@@ -206,6 +208,32 @@ def apply_meta_filters(q, model, p: dict, lang: str = "en"):
|
||||
return q.where(*conds) if conds else q
|
||||
|
||||
|
||||
# --- Playback-class filter (E7) — MOVIE-ONLY (a show is an aggregate with no single class) ---------
|
||||
# The 4 classes the feed filter + facet expose, derived from the stored `playable` + `transcode_hw`.
|
||||
# ONE definition (this SQL CASE) so the filter WHERE and the facet counts can't drift: `playable_conds`
|
||||
# filters on it, `facets` groups by it. transcode_hw IS NOT True groups NULL (unprobed) with software.
|
||||
_PLAYBACK_CLASSES = ("direct", "remux", "transcode_hw", "transcode_sw")
|
||||
|
||||
|
||||
def playback_class_expr():
|
||||
"""SQL CASE mapping a PlexItem (movie) to its playback-class string, or NULL. The HW branch must
|
||||
precede the generic transcode branch (ordered evaluation)."""
|
||||
return case(
|
||||
(PlexItem.playable == "direct", "direct"),
|
||||
(PlexItem.playable == "remux", "remux"),
|
||||
(and_(PlexItem.playable == "transcode", PlexItem.transcode_hw.is_(True)), "transcode_hw"),
|
||||
(PlexItem.playable == "transcode", "transcode_sw"),
|
||||
else_=None,
|
||||
)
|
||||
|
||||
|
||||
def playable_conds(sel: list[str]) -> list:
|
||||
"""A one-element condition list (or empty) selecting the given playback classes — an OR via
|
||||
`class IN (...)` over the shared CASE. Unknown keys are dropped."""
|
||||
keep = [c for c in sel if c in _PLAYBACK_CLASSES]
|
||||
return [playback_class_expr().in_(keep)] if keep else []
|
||||
|
||||
|
||||
@dataclass
|
||||
class LibraryFilters:
|
||||
"""The shared sidebar filter query-params for /library + /facets, declared once and injected via
|
||||
@@ -228,6 +256,7 @@ class LibraryFilters:
|
||||
keywords: str | None = None # TMDB keyword chips (E5 S5), CSV; language-neutral
|
||||
collections: str | None = None # CSV of collection rating_keys
|
||||
collection_mode: str = "any" # how multiple collections combine: any (OR, default) | all (AND)
|
||||
playable: str | None = None # CSV of playback classes: direct|remux|transcode_hw|transcode_sw
|
||||
|
||||
def as_dict(self) -> dict:
|
||||
return {
|
||||
@@ -247,6 +276,7 @@ class LibraryFilters:
|
||||
"keywords": self.keywords,
|
||||
"collections": self.collections,
|
||||
"collection_mode": self.collection_mode,
|
||||
"playable": self.playable,
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user