From 840526b1b8bebecac8085072fff3dc963a9ba5d1 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sun, 30 Aug 2026 04:45:11 +0200 Subject: [PATCH] feat(plex): filter the feed by playback class (Direct Play / Direct Stream / Transcode HW/SW) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- .../versions/0077_plex_playback_class.py | 54 ++++ backend/app/models.py | 16 + backend/app/plex/cards.py | 2 + backend/app/plex/query.py | 30 ++ backend/app/plex/stream.py | 10 +- backend/app/plex/sync.py | 119 +++++++- backend/app/routes/plex/browse.py | 42 +++ backend/app/routes/plex/detail.py | 4 + backend/tests/test_plex_playback_class.py | 276 ++++++++++++++++++ frontend/src/components/PlexInfo.tsx | 29 ++ frontend/src/components/PlexSidebar.tsx | 22 ++ frontend/src/components/plexCards.tsx | 42 ++- .../src/components/usePlexActiveFilters.ts | 7 + frontend/src/i18n/locales/en/plex.json | 13 +- frontend/src/i18n/locales/hu/plex.json | 13 +- frontend/src/lib/api/plex.dom.test.ts | 29 ++ frontend/src/lib/api/plex.ts | 11 + frontend/src/lib/plexUrlState.dom.test.ts | 1 + 18 files changed, 697 insertions(+), 23 deletions(-) create mode 100644 backend/alembic/versions/0077_plex_playback_class.py create mode 100644 backend/tests/test_plex_playback_class.py diff --git a/backend/alembic/versions/0077_plex_playback_class.py b/backend/alembic/versions/0077_plex_playback_class.py new file mode 100644 index 00000000..b0d3351d --- /dev/null +++ b/backend/alembic/versions/0077_plex_playback_class.py @@ -0,0 +1,54 @@ +"""plex_items playback-class facet (E7: filter the feed by Direct Play / Direct Stream / Transcode) + +Adds the columns the playback-class filter needs: + * transcode_hw — for a `playable="transcode"` item, whether the prod iGPU can hardware-transcode it + (codec HW-decodable AND 8-bit SDR) vs software-only. NULL for non-transcode items. + * video_bit_depth — the source video bit depth (from the Plex media Stream), NULL until probed. + * media_probed_at — watermark for the bit-depth backfill (NULL = not yet probed). + +Backfill for existing rows: `transcode_hw` is derived from the ALREADY-stored `codec_video` alone (the +same HW-decodable allow-list `plex.sync.HW_DECODABLE_CODECS` / `plex.stream` use), so the filter works +immediately after migration. Bit depth is unknown for existing rows (the cheap Plex listing doesn't +carry it), so a 10-bit/HDR HEVC is provisionally 'hw' here and later refined to 'sw' by the bit-depth +backfill (`plex.sync._probe_media_batch`) once its Stream is probed. `video_bit_depth`/`media_probed_at` +start NULL. +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "0077_plex_playback_class" +down_revision: Union[str, None] = "0076_media_asset_kind" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column("plex_items", sa.Column("transcode_hw", sa.Boolean(), nullable=True)) + op.add_column("plex_items", sa.Column("video_bit_depth", sa.Integer(), nullable=True)) + op.add_column( + "plex_items", sa.Column("media_probed_at", sa.DateTime(timezone=True), nullable=True) + ) + op.create_index("ix_plex_items_transcode_hw", "plex_items", ["transcode_hw"]) + # Codec-only derivation for existing rows — keep this codec set identical to + # plex.sync.HW_DECODABLE_CODECS. Bit depth is unknown here, so provisionally 8-bit (→ hw for a + # decodable codec); the bit-depth backfill downgrades 10-bit/HDR HEVC to sw later. + op.execute( + """ + UPDATE plex_items + SET transcode_hw = CASE + WHEN playable = 'transcode' + THEN lower(codec_video) IN ('h264','hevc','h265','vp9','vc1','mpeg2video') + ELSE NULL + END + """ + ) + + +def downgrade() -> None: + op.drop_index("ix_plex_items_transcode_hw", table_name="plex_items") + op.drop_column("plex_items", "media_probed_at") + op.drop_column("plex_items", "video_bit_depth") + op.drop_column("plex_items", "transcode_hw") diff --git a/backend/app/models.py b/backend/app/models.py index d6b81ac9..315ca060 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -1127,6 +1127,22 @@ class PlexItem(Base, TimestampMixin, UpdatedAtMixin): # Browser-playability class from ffprobe: "direct" | "remux" | "transcode" (None = unprobed). playable: Mapped[str | None] = mapped_column(String(12), index=True) probed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + # Transcode HW-capability facet (E7 playback-class filter). NULL for non-transcode items; for a + # `playable="transcode"` item: True = the prod iGPU can hardware-transcode it (codec is HW-decodable + # AND the source is 8-bit SDR), False = software libx264 only (AV1/XviD, or a 10-bit/HDR source that + # breaks the VAAPI nv12 pipeline — see plex/stream.py). Derived by `plex.sync.transcode_hw_capable`: + # set codec-only at sync (bit depth unknown from the cheap listing), then refined by the bit-depth + # backfill (`_probe_media_batch`) which downgrades a 10-bit/HDR HEVC to False. The one filterable + # home of the invariant `_want_hw`'s source gate computes at play time — kept in sync via the shared + # classifier so the catalog facet and the runtime encoder choice never disagree by codec. + transcode_hw: Mapped[bool | None] = mapped_column(Boolean, index=True) + # Video bit depth (8/10/12…) from the Plex media Stream, filled by the bit-depth backfill. NULL = + # not yet probed. Surfaced on the info page (e.g. "HEVC · 10-bit") and feeds `transcode_hw`. + video_bit_depth: Mapped[int | None] = mapped_column(Integer) + # Watermark for the bit-depth backfill: when this item's media Stream was last probed for + # bit-depth/HDR. NULL = never (the backfill's work-list). Only HW-decodable transcode items are + # probed (the rest are SW-only by codec and need no bit-depth); see `_probe_media_batch`. + media_probed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) # Intro/credit markers: [{"type": "intro"|"credits", "start_s": int, "end_s": int}, ...] markers: Mapped[list | None] = mapped_column(JSON) added_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), index=True) diff --git a/backend/app/plex/cards.py b/backend/app/plex/cards.py index da24097e..ae0344e7 100644 --- a/backend/app/plex/cards.py +++ b/backend/app/plex/cards.py @@ -33,6 +33,7 @@ def movie_card(it: PlexItem, st: PlexState | None) -> dict: "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, } @@ -65,6 +66,7 @@ def episode_card(e: PlexItem, st: PlexState | None) -> dict: "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, } diff --git a/backend/app/plex/query.py b/backend/app/plex/query.py index 0bb6f604..f48c4d8d 100644 --- a/backend/app/plex/query.py +++ b/backend/app/plex/query.py @@ -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, } diff --git a/backend/app/plex/stream.py b/backend/app/plex/stream.py index 1f75d071..0e00731f 100644 --- a/backend/app/plex/stream.py +++ b/backend/app/plex/stream.py @@ -34,6 +34,7 @@ from app.config import settings from app.downloads.edit import encode_thread_cap from app.models import PlexItem from app.plex import paths +from app.plex.sync import HW_DECODABLE_CODECS log = logging.getLogger("siftlode.plex") @@ -153,10 +154,11 @@ _HLS_TAIL = [ # Everything else — the `-copyts`/`_HLS_TAIL`/segment/seek-restart model and the AAC audio path — is # unchanged, so hls.js, subtitles, multi-audio and seeking keep working exactly as for remux. _VAAPI_DEVICE = "/dev/dri/renderD128" -# Codecs the prod iGPU (UHD 630 / Quick Sync Gen 9.5) can hardware-DECODE — the precondition for the -# full-GPU VAAPI pipeline. A source outside this set (AV1, MPEG-4 ASP/XviD…) falls back to software even -# when the render node is present, because the GPU can't decode it to feed its own encoder. -_HW_DECODABLE = {"h264", "hevc", "h265", "vp9", "vc1", "mpeg2video"} +# Codecs the prod iGPU can hardware-DECODE (the precondition for the full-GPU VAAPI pipeline; a source +# outside it — AV1, MPEG-4 ASP/XviD… — falls back to software even with the render node present). THE +# single source is plex.sync, so the runtime encoder choice here and the catalog `transcode_hw` facet +# can't disagree by codec; aliased to the old name to keep the call site below unchanged. +_HW_DECODABLE = HW_DECODABLE_CODECS def _hwaccel_in_opts(hw: bool) -> list[str]: diff --git a/backend/app/plex/sync.py b/backend/app/plex/sync.py index e01e05b1..38a6a92c 100644 --- a/backend/app/plex/sync.py +++ b/backend/app/plex/sync.py @@ -18,7 +18,7 @@ item-detail/player path (P2), not here. import logging from datetime import datetime, timezone -from sqlalchemy import select +from sqlalchemy import func, select from sqlalchemy.orm import Session from app import sysconfig @@ -58,6 +58,57 @@ def classify_playable(vcodec: str | None, acodec: str | None, container: str | N return "direct" +# Video codecs the prod iGPU (UHD 630 / Quick Sync Gen 9.5) can hardware-DECODE — the precondition for +# the full-GPU VAAPI transcode pipeline. THE single source of this set: plex.stream imports it here so +# the runtime encoder choice (`_want_hw`) and this catalog facet can never disagree by codec. A source +# outside it (AV1, MPEG-4 ASP/XviD…) is software-only even on the iGPU (it can't decode it to feed its +# own encoder). +HW_DECODABLE_CODECS = frozenset({"h264", "hevc", "h265", "vp9", "vc1", "mpeg2video"}) +# Color-transfer values marking an HDR source (PQ / HLG). A 10-bit/HDR source breaks the VAAPI +# scale=nv12 path on Gen 9.5 (see plex.stream) → software transcode, so it counts as NOT HW-capable. +_HDR_TRANSFERS = frozenset({"smpte2084", "arib-std-b67"}) + + +def transcode_hw_capable( + playable: str | None, + vcodec: str | None, + bit_depth: int | None = None, + hdr: bool = False, +) -> bool | None: + """The stored `PlexItem.transcode_hw` facet: for a `playable="transcode"` item, whether the prod + iGPU can HARDWARE-transcode it (else software libx264 only); None for a non-transcode item. HW needs + a HW-decodable codec AND an 8-bit SDR source — a 10-bit/HDR source (bit_depth>8 or an HDR transfer) + breaks the VAAPI nv12 pipeline and falls back to software. `bit_depth`/`hdr` are unknown at sync + time (the cheap listing carries no Stream), so they default to 8-bit SDR (codec-only) and the + bit-depth backfill refines a 10-bit/HDR HEVC to False later.""" + if playable != "transcode": + return None + if (vcodec or "").lower() not in HW_DECODABLE_CODECS: + return False + if bit_depth is not None and bit_depth > 8: + return False + return not hdr + + +def _video_stream_props(meta: dict) -> tuple[int | None, bool]: + """(bit_depth, is_hdr) from a FULL Plex metadata dict's video Stream (Media→Part→Stream, streamType + 1). Only the rich metadata call carries Stream; the cheap listing does not — so this is read by the + bit-depth backfill, not the listing sync. (None, False) when absent/unparseable.""" + for m in meta.get("Media") or []: + for part in m.get("Part") or []: + for s in part.get("Stream") or []: + if s.get("streamType") != 1: # 1 = video + continue + bd = s.get("bitDepth") + try: + depth = int(bd) if bd else None + except (TypeError, ValueError): + depth = None + hdr = str(s.get("colorTrc") or "").lower() in _HDR_TRANSFERS + return depth, hdr + return None, False + + def _epoch(v) -> datetime | None: try: return datetime.fromtimestamp(int(v), tz=timezone.utc) if v else None @@ -217,6 +268,13 @@ def sync(db: Session) -> dict: stats["people_enriched"] = _enrich_people_batch(db, plex) except Exception as e: # best-effort — never fail the sync over the backfill log.warning("Plex people enrich batch failed: %s", e) + # Probe a bounded batch of HW-decodable transcode items for bit-depth/HDR, so the + # playback-class filter's HW/SW split is precise (a 10-bit/HDR HEVC → software) over + # successive runs. Only HW-decodable-codec transcodes are probed — the rest are SW by codec. + try: + stats["media_probed"] = _probe_media_batch(db, plex) + except Exception as e: # best-effort — never fail the sync over the backfill + log.warning("Plex media probe batch failed: %s", e) except PlexNotConfigured as e: return {"skipped": str(e)} except PlexError as e: @@ -236,6 +294,49 @@ def sync(db: Session) -> dict: # How many titles to rich-fetch per sync run for the cast backfill — bounds the extra Plex load; the # whole library is covered over successive runs (each fetched item is stamped people_enriched_at once). _ENRICH_BATCH = 60 +# Same bounded-per-run pattern for the bit-depth probe. Only HW-decodable-codec transcode items are +# probed (typically a small minority — the bulk of transcodes are old codecs that are SW by codec), so +# a modest batch covers the work-list in a run or two; a stuck item retries next run (watermark unset). +_PROBE_BATCH = 60 + + +def _probe_media_batch(db: Session, plex: PlexClient, limit: int = _PROBE_BATCH) -> int: + """Rich-fetch up to `limit` not-yet-probed HW-decodable transcode items, read the video Stream's + bit-depth + HDR, and store `video_bit_depth` + the refined `transcode_hw` (a 10-bit/HDR HEVC → + False/software). Stamps `media_probed_at` on every attempt (success OR a Plex miss with no Stream), + so an item is probed once and not re-fetched every sync; a transient fetch FAILURE leaves the + watermark unset to retry. Oldest-id first. Returns how many items were probed this run.""" + rows = ( + db.execute( + select(PlexItem) + .where( + PlexItem.playable == "transcode", + func.lower(PlexItem.codec_video).in_(HW_DECODABLE_CODECS), + PlexItem.media_probed_at.is_(None), + ) + .order_by(PlexItem.id) + .limit(limit) + ) + .scalars() + .all() + ) + now = datetime.now(timezone.utc) + n = 0 + for row in rows: + try: + meta = plex.metadata(row.rating_key) or {} + except Exception as e: # transient — leave media_probed_at NULL so it retries next run + log.debug("Plex media probe fetch failed for %s: %s", row.rating_key, e) + continue + bit_depth, hdr = _video_stream_props(meta) + row.video_bit_depth = bit_depth + row.transcode_hw = transcode_hw_capable("transcode", row.codec_video, bit_depth, hdr) + row.media_probed_at = ( + now # probed (even if Plex carried no Stream — don't re-fetch forever) + ) + n += 1 + db.commit() + return n def _enrich_people_batch(db: Session, plex: PlexClient, limit: int = _ENRICH_BATCH) -> int: @@ -369,15 +470,29 @@ def _apply_item(row: PlexItem, lib_id: int, kind: str, meta: dict) -> None: row.added_at = _epoch(meta.get("addedAt")) _apply_facet_fields(row, meta) # filterable/orderable extras (shared with shows) mf = _media_facts(meta) + new_vcodec = mf.get("codec_video") + # A changed video codec means the physical file was replaced — any probed bit-depth is stale, so + # reset the probe watermark and re-derive transcode_hw from codec alone until the backfill re-probes. + if (row.codec_video or "").lower() != (new_vcodec or "").lower(): + row.video_bit_depth = None + row.media_probed_at = None row.file_path = mf.get("file_path") row.part_key = mf.get("part_key") row.container = mf.get("container") - row.codec_video = mf.get("codec_video") + row.codec_video = new_vcodec row.codec_audio = mf.get("codec_audio") row.size_bytes = mf.get("size_bytes") row.playable = classify_playable( mf.get("codec_video"), mf.get("codec_audio"), mf.get("container") ) + # transcode_hw: keep the bit-depth backfill's authoritative value (it folds in bit-depth + HDR) once + # probed; otherwise (re)derive the provisional codec-only class. A non-transcode item is always None, + # even if it had been probed as a transcode before (an audio/container flip to remux without a codec + # change wouldn't reset the watermark, so pin it here). + if row.playable != "transcode": + row.transcode_hw = None + elif row.media_probed_at is None: + row.transcode_hw = transcode_hw_capable("transcode", row.codec_video, row.video_bit_depth) def _prune_stale( diff --git a/backend/app/routes/plex/browse.py b/backend/app/routes/plex/browse.py index 163088a9..3ddacc0b 100644 --- a/backend/app/routes/plex/browse.py +++ b/backend/app/routes/plex/browse.py @@ -2,6 +2,7 @@ from fastapi import APIRouter, Depends, Query from sqlalchemy import ( + Boolean, Integer, String, and_, @@ -26,6 +27,7 @@ from app.models import ( from app.plex import rollup from app.plex.cards import episode_card from app.plex.query import ( + _PLAYBACK_CLASSES, FIELD_COLUMNS, TS_CONFIG, LibraryFilters, @@ -36,6 +38,8 @@ from app.plex.query import ( genres_col, meta_filter_conds, movie_status_case, + playable_conds, + playback_class_expr, show_status_case, split_csv, summary_col, @@ -135,6 +139,7 @@ def unified_library( PlexItem.originally_available_at.label("rel"), PlexItem.duration_s.label("duration_s"), PlexItem.playable.label("playable"), + PlexItem.transcode_hw.label("transcode_hw"), cast(null(), Integer).label("season_count"), func.coalesce(st.position_seconds, 0).label("position_seconds"), m_status.label("status"), @@ -156,6 +161,8 @@ def unified_library( mq = mq.where(PlexItem.duration_s >= f.duration_min) if f.duration_max is not None: mq = mq.where(PlexItem.duration_s <= f.duration_max) + # Playback-class filter (movie-only — a show is an aggregate with no single class). + mq = mq.where(*playable_conds(split_csv(f.playable))) if tsq is not None: mq = mq.where(vector_match(PlexItem, lang, tsq)) if recheck is not None: @@ -179,6 +186,7 @@ def unified_library( PlexShow.originally_available_at.label("rel"), cast(null(), Integer).label("duration_s"), cast(null(), String).label("playable"), + cast(null(), Boolean).label("transcode_hw"), PlexShow.child_count.label("season_count"), literal(0).label("position_seconds"), s_status.label("status"), @@ -237,6 +245,7 @@ def unified_library( "duration_seconds": r.duration_s, "thumb": f"/api/plex/image/{r.rk}", "playable": r.playable, + "transcode_hw": r.transcode_hw, "status": r.status, "position_seconds": r.position_seconds, } @@ -312,6 +321,7 @@ def facets( "genres": [], "content_ratings": [], "studios": [], + "playable": [], "year_min": None, "year_max": None, "rating_max": None, @@ -338,6 +348,9 @@ def facets( query = query.where(model.duration_s >= pp["duration_min"]) if with_dur and pp.get("duration_max") is not None: query = query.where(model.duration_s <= pp["duration_max"]) + # Playback-class filter (movie-only) — applied for the OTHER groups so an active class + # narrows them; the playable facet itself passes pp with playable=None (self-exclusion). + query = query.where(*playable_conds(split_csv(pp.get("playable")))) # Join watch-state and reuse the grid's exact movie state filter (unified_library) — # hidden movies never appear in the grid, so they must not inflate facet counts/bounds either. st = aliased(PlexState) @@ -356,6 +369,7 @@ def facets( genre_counts: dict[str, int] = {} cr_counts: dict[str, int] = {} studio_counts: dict[str, int] = {} + playable_counts: dict[str, int] = {} years: list[int] = [] ratings: list[float] = [] durs: list[int] = [] @@ -420,6 +434,23 @@ def facets( ) for s, c in db.execute(stq).all(): studio_counts[s] = studio_counts.get(s, 0) + c + # Playback class (movie-only) — exclude its own so each class stays an addable OR option. + if kind_movie: + pcls = playback_class_expr() + pcq = ( + wsq( + select(pcls.label("cls"), func.count()), + model, + ids, + {**p, "playable": None}, + kind_movie, + True, + ) + .where(pcls.isnot(None)) + .group_by(pcls) + ) + for cls, c in db.execute(pcq).all(): + playable_counts[cls] = playable_counts.get(cls, 0) + c # Year bounds — exclude the year filter. by = db.execute( wsq( @@ -477,6 +508,11 @@ def facets( # Same for the actively-selected studios, so a selected studio stays removable even at zero matches. for s in split_csv(f.studios): studio_counts.setdefault(s, 0) + # Keep an actively-selected playback class visible even at zero matches (the sidebar hides the whole + # section when its facet list is empty, which would trap a zero-result selection with no way to undo). + for c in split_csv(f.playable): + if c in _PLAYBACK_CLASSES: + playable_counts.setdefault(c, 0) return { "genres": [ {"value": g, "count": c} for g, c in sorted(genre_counts.items(), key=lambda kv: kv[0]) @@ -488,6 +524,12 @@ def facets( {"value": s, "count": c} for s, c in sorted(studio_counts.items(), key=lambda kv: (-kv[1], kv[0])) ], + # Fixed good→heavy order (not by count), so the class list reads as a severity scale. + "playable": [ + {"value": c, "count": playable_counts[c]} + for c in _PLAYBACK_CLASSES + if c in playable_counts + ], "year_min": min(years) if years else None, "year_max": max(years) if years else None, "rating_max": max(ratings) if ratings else None, diff --git a/backend/app/routes/plex/detail.py b/backend/app/routes/plex/detail.py index a747c142..f2bcfc7e 100644 --- a/backend/app/routes/plex/detail.py +++ b/backend/app/routes/plex/detail.py @@ -411,6 +411,10 @@ def item_detail( "year": it.year, "duration_seconds": it.duration_s, "playable": it.playable, + # Playback-class detail: HW/SW transcode split + the codec/bit-depth the info-page chip shows. + "transcode_hw": it.transcode_hw, + "codec_video": it.codec_video, + "video_bit_depth": it.video_bit_depth, "thumb": f"/api/plex/image/{it.rating_key}", "art": f"/api/plex/image/{it.rating_key}?variant=art", "library": _lib_key(db, it.library_id), diff --git a/backend/tests/test_plex_playback_class.py b/backend/tests/test_plex_playback_class.py new file mode 100644 index 00000000..9df9af2c --- /dev/null +++ b/backend/tests/test_plex_playback_class.py @@ -0,0 +1,276 @@ +"""The playback-class filter (E7): filter the Plex feed by Direct Play / Direct Stream / Transcode·HW / +Transcode·SW. Covers the shared classifier + Stream parsing (unit), the sync derivation + codec-change +reset, the bit-depth backfill, and the /library filter + /facets counts (DB lane). + +The HW/SW split is derived from the stored `transcode_hw` facet (codec at sync, refined by the bit-depth +backfill). These tests pin that a 10-bit/HDR HEVC lands in SW even though its codec is HW-decodable — +the exact case the runtime VAAPI path (plex.stream) also special-cases. +""" + +import app.state as state +from app.auth import current_user +from app.db import get_db +from app.main import app +from app.models import PlexItem, PlexLibrary, User +from app.plex import sync +from fastapi.testclient import TestClient + + +class TestTranscodeHwCapable: + """The single classifier both the catalog facet and (by codec) the runtime encoder choice key off.""" + + def test_non_transcode_items_are_none(self): + assert sync.transcode_hw_capable("direct", "h264") is None + assert sync.transcode_hw_capable("remux", "h264") is None + assert sync.transcode_hw_capable(None, "hevc") is None + + def test_hw_decodable_8bit_sdr_is_hw(self): + assert sync.transcode_hw_capable("transcode", "hevc") is True + assert sync.transcode_hw_capable("transcode", "vp9", bit_depth=8) is True + + def test_non_decodable_codec_is_sw(self): + # AV1 / MPEG-4 ASP (XviD) can't be HW-decoded on Gen 9.5 → software regardless of bit depth. + assert sync.transcode_hw_capable("transcode", "av1") is False + assert sync.transcode_hw_capable("transcode", "mpeg4", bit_depth=8) is False + + def test_10bit_or_hdr_hevc_is_sw(self): + # A HW-decodable codec that is 10-bit or HDR still breaks the VAAPI nv12 pipeline → software. + assert sync.transcode_hw_capable("transcode", "hevc", bit_depth=10) is False + assert sync.transcode_hw_capable("transcode", "hevc", bit_depth=8, hdr=True) is False + + +class TestVideoStreamProps: + def test_reads_bit_depth_and_hdr_from_the_video_stream(self): + meta = { + "Media": [ + { + "Part": [ + { + "Stream": [ + {"streamType": 2, "codec": "eac3"}, # audio — skipped + {"streamType": 1, "bitDepth": 10, "colorTrc": "smpte2084"}, + ] + } + ] + } + ] + } + assert sync._video_stream_props(meta) == (10, True) + + def test_8bit_sdr_stream(self): + meta = {"Media": [{"Part": [{"Stream": [{"streamType": 1, "bitDepth": 8}]}]}]} + assert sync._video_stream_props(meta) == (8, False) + + def test_absent_stream_is_none_false(self): + assert sync._video_stream_props({}) == (None, False) + assert sync._video_stream_props({"Media": [{"Part": [{}]}]}) == (None, False) + + +class TestSyncDerivation: + """`_apply_item` sets transcode_hw codec-only at sync (bit depth unknown from the listing), and a + changed video codec resets the probe watermark so a stale bit-depth can't linger.""" + + def _meta(self, vcodec): + return {"title": "X", "Media": [{"videoCodec": vcodec, "Part": [{"file": "/m.mkv"}]}]} + + def test_codec_only_derivation_at_sync(self): + row = PlexItem(rating_key="x", kind="movie") + sync._apply_item(row, lib_id=1, kind="movie", meta=self._meta("hevc")) + assert row.playable == "transcode" and row.transcode_hw is True # decodable, 8-bit assumed + row2 = PlexItem(rating_key="y", kind="movie") + sync._apply_item(row2, lib_id=1, kind="movie", meta=self._meta("av1")) + assert row2.transcode_hw is False # av1 not HW-decodable + + def test_probed_value_survives_resync_when_codec_unchanged(self): + row = PlexItem(rating_key="x", kind="movie") + sync._apply_item(row, lib_id=1, kind="movie", meta=self._meta("hevc")) + # Simulate the backfill downgrading this 10-bit HEVC to software. + row.transcode_hw = False + row.video_bit_depth = 10 + row.media_probed_at = sync.datetime.now(sync.timezone.utc) + sync._apply_item(row, lib_id=1, kind="movie", meta=self._meta("hevc")) + assert row.transcode_hw is False # NOT re-derived back to True + assert row.video_bit_depth == 10 and row.media_probed_at is not None + + def test_codec_change_resets_the_probe(self): + row = PlexItem(rating_key="x", kind="movie") + sync._apply_item(row, lib_id=1, kind="movie", meta=self._meta("hevc")) + row.transcode_hw = False + row.video_bit_depth = 10 + row.media_probed_at = sync.datetime.now(sync.timezone.utc) + # The file was replaced with an h264 remux — the stale 10-bit probe must be cleared. + sync._apply_item(row, lib_id=1, kind="movie", meta=self._meta("h264")) + assert row.video_bit_depth is None and row.media_probed_at is None + + +class _FakePlex: + """Stands in for PlexClient.metadata — returns a canned full-metadata dict per rating_key.""" + + def __init__(self, by_key): + self.by_key = by_key + + def metadata(self, rating_key, markers=False): + return self.by_key.get(rating_key) + + +def test_probe_media_batch_refines_hw_to_sw_for_10bit(db): + lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True) + db.add(lib) + db.flush() + hevc8 = PlexItem( + rating_key="h8", + library_id=lib.id, + kind="movie", + title="8bit", + playable="transcode", + codec_video="hevc", + transcode_hw=True, + ) + hevc10 = PlexItem( + rating_key="h10", + library_id=lib.id, + kind="movie", + title="10bit", + playable="transcode", + codec_video="hevc", + transcode_hw=True, + ) + xvid = PlexItem( + rating_key="xv", + library_id=lib.id, + kind="movie", + title="xvid", + playable="transcode", + codec_video="mpeg4", + transcode_hw=False, + ) + db.add_all([hevc8, hevc10, xvid]) + db.commit() + plex = _FakePlex( + { + "h8": {"Media": [{"Part": [{"Stream": [{"streamType": 1, "bitDepth": 8}]}]}]}, + "h10": {"Media": [{"Part": [{"Stream": [{"streamType": 1, "bitDepth": 10}]}]}]}, + } + ) + probed = sync._probe_media_batch(db, plex) + db.refresh(hevc8) + db.refresh(hevc10) + db.refresh(xvid) + # Only the two HW-decodable HEVC items are probed; the XviD is SW-by-codec and never fetched. + assert probed == 2 + assert hevc8.video_bit_depth == 8 and hevc8.transcode_hw is True and hevc8.media_probed_at + assert hevc10.video_bit_depth == 10 and hevc10.transcode_hw is False # downgraded to software + assert xvid.media_probed_at is None # not in the work-list + # A second run has nothing left to do (watermarks set). + assert sync._probe_media_batch(db, plex) == 0 + + +def test_probe_stamps_even_when_plex_has_no_stream(db): + lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True) + db.add(lib) + db.flush() + item = PlexItem( + rating_key="ns", + library_id=lib.id, + kind="movie", + title="nostream", + playable="transcode", + codec_video="hevc", + transcode_hw=True, + ) + db.add(item) + db.commit() + plex = _FakePlex({"ns": {"Media": [{"Part": [{}]}]}}) # no Stream array + assert sync._probe_media_batch(db, plex) == 1 + db.refresh(item) + # Probed (watermark set so it isn't re-fetched forever); bit depth unknown → stays codec-HW. + assert item.media_probed_at is not None and item.video_bit_depth is None + assert item.transcode_hw is True + + +def _seed_classes(db): + lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True) + db.add(lib) + db.flush() + db.add_all( + [ + PlexItem( + rating_key="d1", library_id=lib.id, kind="movie", title="Direct", playable="direct" + ), + PlexItem( + rating_key="r1", library_id=lib.id, kind="movie", title="Remux", playable="remux" + ), + PlexItem( + rating_key="thw", + library_id=lib.id, + kind="movie", + title="HW", + playable="transcode", + codec_video="hevc", + transcode_hw=True, + ), + PlexItem( + rating_key="tsw", + library_id=lib.id, + kind="movie", + title="SW", + playable="transcode", + codec_video="mpeg4", + transcode_hw=False, + ), + ] + ) + return lib + + +def _client(db, user, monkeypatch): + monkeypatch.setattr(state, "_configured_cache", True) + app.dependency_overrides[get_db] = lambda: db + app.dependency_overrides[current_user] = lambda: user + return TestClient(app) + + +def test_library_filters_by_playback_class(db, monkeypatch): + _seed_classes(db) + user = User(email="pclass@example.com") + db.add(user) + db.commit() + client = _client(db, user, monkeypatch) + try: + # Software-only transcodes. + sw = client.get("/api/plex/library?scope=movie&playable=transcode_sw") + assert sw.status_code == 200, sw.text + assert {i["id"] for i in sw.json()["items"]} == {"tsw"} + # HW transcode + Direct Play, OR-combined. + both = client.get("/api/plex/library?scope=movie&playable=transcode_hw,direct") + assert {i["id"] for i in both.json()["items"]} == {"thw", "d1"} + # The HW card carries the transcode_hw flag for the 4-way badge. + card = next(i for i in both.json()["items"] if i["id"] == "thw") + assert card["transcode_hw"] is True + # No filter → everything. + allm = client.get("/api/plex/library?scope=movie") + assert {i["id"] for i in allm.json()["items"]} == {"d1", "r1", "thw", "tsw"} + finally: + app.dependency_overrides.clear() + + +def test_facets_count_playback_classes_in_severity_order(db, monkeypatch): + _seed_classes(db) + user = User(email="pclassfacet@example.com") + db.add(user) + db.commit() + client = _client(db, user, monkeypatch) + try: + resp = client.get("/api/plex/facets?scope=movie") + finally: + app.dependency_overrides.clear() + assert resp.status_code == 200, resp.text + playable = resp.json()["playable"] + # Fixed good→heavy order (not by count), one per seeded class. + assert [p["value"] for p in playable] == ["direct", "remux", "transcode_hw", "transcode_sw"] + assert {p["value"]: p["count"] for p in playable} == { + "direct": 1, + "remux": 1, + "transcode_hw": 1, + "transcode_sw": 1, + } diff --git a/frontend/src/components/PlexInfo.tsx b/frontend/src/components/PlexInfo.tsx index 30712d86..c4b07416 100644 --- a/frontend/src/components/PlexInfo.tsx +++ b/frontend/src/components/PlexInfo.tsx @@ -19,6 +19,7 @@ import i18n from "../i18n"; import { Carousel } from "./Carousel"; import PlexCollectionEditor from "./PlexCollectionEditor"; import PlexPlaylistAdd from "./PlexPlaylistAdd"; +import { PLAYABLE_TINT, playableClass } from "./plexCards"; // The rich "media info" surface for a Plex item — poster/art, IMDb score + link, content rating, // genres, director, cast (with photos), summary. Reused in TWO places: a full-page view opened from @@ -253,6 +254,33 @@ function CrewPanel({ ); } +// The playback-class chip (dot + Plex-native label), with a muted codec · bit-depth subtitle when +// known. Class + colour are derived through the SAME shared helper the browse-card corner dot uses, +// so the two surfaces can't drift. `overlay` tints the label for the dark player background. +function PlaybackChip({ detail, overlay }: { detail: PlexItemDetail; overlay: boolean }) { + const { t } = useTranslation(); + if (!detail.playable) return null; + const cls = playableClass(detail.playable, detail.transcode_hw); + const sub = [ + detail.codec_video ? detail.codec_video.toUpperCase() : null, + detail.video_bit_depth != null ? `${detail.video_bit_depth}-bit` : null, + ] + .filter(Boolean) + .join(" · "); + return ( + + + {t(`plex.filter.playableOpt.${cls}`)} + {sub && {sub}} + + ); +} + type Props = { detail: PlexItemDetail; variant: "page" | "overlay"; @@ -479,6 +507,7 @@ export default function PlexInfo({ {detail.studio} )} + {/* Multi-source ratings row (E5 S5): IMDb / TMDb / audience, whichever are present. */} diff --git a/frontend/src/components/PlexSidebar.tsx b/frontend/src/components/PlexSidebar.tsx index 64d665cd..61e64dfd 100644 --- a/frontend/src/components/PlexSidebar.tsx +++ b/frontend/src/components/PlexSidebar.tsx @@ -296,6 +296,28 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl }, ] : []), + // Playback class (Direct Play / Direct Stream / Transcode HW/SW) — movie-only (shows have no + // class), and dropped when the facet is empty. Labels are localized (the facet ships class keys). + ...(scope === "movie" && facets && facets.playable.length > 0 + ? [ + { + id: "playable", + title: t("plex.filter.playable"), + body: ( + ({ + value: p.value, + label: t(`plex.filter.playableOpt.${p.value}`), + count: p.count, + }))} + selected={filters.playable} + onChange={(next) => patch({ playable: next })} + /> + ), + }, + ] + : []), ]; return ( diff --git a/frontend/src/components/plexCards.tsx b/frontend/src/components/plexCards.tsx index 5fe5438e..c7350f0c 100644 --- a/frontend/src/components/plexCards.tsx +++ b/frontend/src/components/plexCards.tsx @@ -8,14 +8,23 @@ import type { PlexCard, PlexCastMember, PlexFilters, PlexSeasonDetail } from ".. // Presentational Plex cards + strips, split out of PlexBrowse (all purely prop-driven, no shared // state). Rendered by the browse grid, the person page, and the series show/season views. -// Playback cost, escalating good→heavy: direct (green) → remux, a light repackage -// (amber) → transcode, a full re-encode (red). Reuses the status trio as a gradient; -// all three stay visually distinct. -const PLAYABLE_TINT: Record = { +// Playback cost, escalating good→heavy: Direct Play (green) → Direct Stream / remux, a light +// repackage (blue) → Transcode · HW, a hardware re-encode (amber) → Transcode · SW, a software +// re-encode (red). transcode splits HW vs SW by card.transcode_hw. Reuses the semantic tokens as a +// gradient; all four stay visually distinct in light + dark. +export const PLAYABLE_TINT: Record = { direct: "bg-success", - remux: "bg-warn", - transcode: "bg-danger", + remux: "bg-info", + transcode_hw: "bg-warn", + transcode_sw: "bg-danger", }; +// Collapse the wire `playable` (direct|remux|transcode) + transcode_hw flag into one of the 4 display +// classes, so the corner dot's colour + tooltip can't drift from the sidebar filter's option keys. +// Shared with the info-page playback chip (PlexInfo) so both surfaces derive the class identically. +export function playableClass(playable: string, transcodeHw?: boolean | null): string { + if (playable === "transcode") return transcodeHw ? "transcode_hw" : "transcode_sw"; + return playable; // direct | remux +} // The reveal class for a status pill (watched / in-progress) that shares a corner with a hover-reveal // control (the watched-toggle). When that control is present it takes over on touch, so the pill yields @@ -82,13 +91,20 @@ export function PlexPosterCard({ decoding="async" className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200" /> - {/* Playable-cost tint (always shown — a quick "will it direct-play" cue). */} - {card.playable && ( - - )} + {/* Playable-cost tint (always shown — a quick "will it direct-play" cue). z-20 keeps the dot + ABOVE the full-card hover-reveal overlay below (absolute inset-0, no pointer-events-none): + without it the overlay sits on top and swallows the cursor, so the dot's native `title` + tooltip never fired on hover (a regression once the reveal overlay was added). */} + {card.playable && + (() => { + const cls = playableClass(card.playable, card.transcode_hw); + return ( + + ); + })()} {/* Rest-state watched badge (top-right). Yields to the hover quick-actions on a playable card (reveal-idle: gone on touch where the toggle is always shown); a watched show keeps it. */} {card.status === "watched" && ( diff --git a/frontend/src/components/usePlexActiveFilters.ts b/frontend/src/components/usePlexActiveFilters.ts index fb3ac03d..7127fb52 100644 --- a/frontend/src/components/usePlexActiveFilters.ts +++ b/frontend/src/components/usePlexActiveFilters.ts @@ -110,6 +110,13 @@ export function buildPlexActiveFilters( remove: () => patch({ studios: filters.studios.filter((x) => x !== s) }), }); } + for (const p of filters.playable) { + out.push({ + key: `playable:${p}`, + label: t(`plex.filter.playableOpt.${p}`), + remove: () => patch({ playable: filters.playable.filter((x) => x !== p) }), + }); + } for (const k of filters.keywords) { out.push({ key: `keyword:${k}`, diff --git a/frontend/src/i18n/locales/en/plex.json b/frontend/src/i18n/locales/en/plex.json index 63af5bef..3d398c1b 100644 --- a/frontend/src/i18n/locales/en/plex.json +++ b/frontend/src/i18n/locales/en/plex.json @@ -104,6 +104,13 @@ "collectionEmpty": "No collections", "matchLabel": "Match", "studio": "Studio", + "playable": "Playback", + "playableOpt": { + "direct": "Direct Play", + "remux": "Direct Stream", + "transcode_hw": "Transcode · HW", + "transcode_sw": "Transcode · SW" + }, "moreRefine": "{{count}} more — refine with search" }, "count": "{{count}} titles", @@ -285,8 +292,10 @@ }, "playable": { "direct": "Plays directly in the browser", - "remux": "Needs a light remux (no video re-encode)", - "transcode": "Needs transcoding (heavier)" + "remux": "Direct Stream — a light remux (no video re-encode)", + "transcode": "Needs transcoding (heavier)", + "transcode_hw": "Transcoded on the hardware encoder", + "transcode_sw": "Transcoded in software (heaviest)" }, "playlistAdd": { "manage": "Add to playlist", diff --git a/frontend/src/i18n/locales/hu/plex.json b/frontend/src/i18n/locales/hu/plex.json index beda4989..e629cc3b 100644 --- a/frontend/src/i18n/locales/hu/plex.json +++ b/frontend/src/i18n/locales/hu/plex.json @@ -104,6 +104,13 @@ "collectionEmpty": "Nincs kollekció", "matchLabel": "Egyezés", "studio": "Stúdió", + "playable": "Lejátszás", + "playableOpt": { + "direct": "Közvetlen lejátszás", + "remux": "Direct Stream", + "transcode_hw": "Transcode · HW", + "transcode_sw": "Transcode · SW" + }, "moreRefine": "még {{count}} — finomíts kereséssel" }, "count": "{{count}} cím", @@ -285,8 +292,10 @@ }, "playable": { "direct": "Közvetlenül játszható a böngészőben", - "remux": "Könnyű remux kell (nincs videó-újrakódolás)", - "transcode": "Transcode kell (nehezebb)" + "remux": "Direct Stream — könnyű remux (nincs videó-újrakódolás)", + "transcode": "Transcode kell (nehezebb)", + "transcode_hw": "Hardveres kódolóval transcode-olva", + "transcode_sw": "Szoftveresen transcode-olva (legnehezebb)" }, "playlistAdd": { "manage": "Listához adás", diff --git a/frontend/src/lib/api/plex.dom.test.ts b/frontend/src/lib/api/plex.dom.test.ts index d54ea341..df51f20c 100644 --- a/frontend/src/lib/api/plex.dom.test.ts +++ b/frontend/src/lib/api/plex.dom.test.ts @@ -3,6 +3,9 @@ import { describe, it, expect } from "vitest"; import { coercePlexFilters, plexFiltersSignature, + plexFiltersToParams, + parsePlexFilters, + plexFilterCount, EMPTY_PLEX_FILTERS, type PlexFilters, } from "./plex"; @@ -54,12 +57,14 @@ describe("plexFiltersSignature", () => { directors: [], actors: [], studios: [], + playable: [], keywords: [], collections: ["A"], }; const b: PlexFilters = { collections: ["A"], keywords: [], + playable: [], studios: [], actors: [], directors: [], @@ -81,4 +86,28 @@ describe("plexFiltersSignature", () => { const b: PlexFilters = { ...base(), genres: ["Comedy"] }; expect(plexFiltersSignature(a)).not.toBe(plexFiltersSignature(b)); }); + + it("differs when the playback-class filter differs", () => { + const a = base(); + const b: PlexFilters = { ...base(), playable: ["direct", "transcode_hw"] }; + expect(plexFiltersSignature(a)).not.toBe(plexFiltersSignature(b)); + }); +}); + +describe("playable filter round-trip + count", () => { + it("serializes playable as a CSV param and parses it back", () => { + const f: PlexFilters = { ...EMPTY_PLEX_FILTERS, playable: ["direct", "transcode_sw"] }; + const params = plexFiltersToParams(f); + expect(params.get("playable")).toBe("direct,transcode_sw"); + expect(parsePlexFilters(params).playable).toEqual(["direct", "transcode_sw"]); + }); + + it("omits the playable param when empty", () => { + expect(plexFiltersToParams(EMPTY_PLEX_FILTERS).has("playable")).toBe(false); + }); + + it("counts each selected playback class like studios", () => { + const f: PlexFilters = { ...EMPTY_PLEX_FILTERS, playable: ["remux", "transcode_hw"] }; + expect(plexFilterCount(f)).toBe(2); + }); }); diff --git a/frontend/src/lib/api/plex.ts b/frontend/src/lib/api/plex.ts index ad72de4f..a85b90da 100644 --- a/frontend/src/lib/api/plex.ts +++ b/frontend/src/lib/api/plex.ts @@ -45,6 +45,7 @@ export interface PlexCard { art?: string | null; // wide backdrop (movies/shows) for rails + crossfading hero; null when no art_key rating?: number | null; // Plex audience score (~IMDb), shown on the card hover-overview playable?: string; // direct | remux | transcode + transcode_hw?: boolean | null; // transcode items only: true = HW-capable, false = SW-only, null = n/a status?: string; // new | watched | hidden position_seconds?: number; season_count?: number | null; // show @@ -139,6 +140,9 @@ export interface PlexItemDetail { year?: number | null; duration_seconds: number | null; playable: string; // direct | remux | transcode + transcode_hw?: boolean | null; // transcode items only: true = HW-capable, false = SW-only, null = n/a + codec_video?: string | null; // e.g. "hevc" / "h264" (info-chip subtitle) + video_bit_depth?: number | null; // e.g. 8 / 10 (info-chip subtitle) thumb: string; art: string; library?: string | null; // Plex section key (for the admin collection editor) @@ -253,6 +257,7 @@ export interface PlexFilters { actors: string[]; // OR (any) by default, or AND (all) via actorMode actorMode?: "any" | "all"; // how multiple actors combine (default any = OR) studios: string[]; // OR — from any selected studio + playable: string[]; // OR — playback class (direct | remux | transcode_hw | transcode_sw); movie-only keywords: string[]; // AND — TMDB keyword chips from the detail page (E5 S5) collections: string[]; // Plex collection rating_keys; a title can be in several collectionMode?: "any" | "all"; // how multiple collections combine (default any = OR) @@ -264,6 +269,7 @@ export const EMPTY_PLEX_FILTERS: PlexFilters = { directors: [], actors: [], studios: [], + playable: [], keywords: [], collections: [], }; @@ -309,6 +315,7 @@ export function plexFilterCount(f: PlexFilters): number { f.directors.length + f.actors.length + f.studios.length + + f.playable.length + f.keywords.length + f.collections.length ); @@ -330,6 +337,7 @@ function appendPlexFilters(u: URLSearchParams, f: PlexFilters): void { if (f.actors?.length) u.set("actors", f.actors.join(",")); if (f.actorMode === "all") u.set("actor_mode", "all"); if (f.studios?.length) u.set("studios", f.studios.join(",")); + if (f.playable?.length) u.set("playable", f.playable.join(",")); if (f.keywords?.length) u.set("keywords", f.keywords.join(",")); if (f.collections?.length) u.set("collections", f.collections.join(",")); if (f.collectionMode === "all") u.set("collection_mode", "all"); @@ -371,6 +379,7 @@ export function parsePlexFilters(p: URLSearchParams): PlexFilters { f.actors = csv("actors"); if (p.get("actor_mode") === "all") f.actorMode = "all"; f.studios = csv("studios"); + f.playable = csv("playable"); f.keywords = csv("keywords"); f.collections = csv("collections"); if (p.get("collection_mode") === "all") f.collectionMode = "all"; @@ -380,6 +389,8 @@ export interface PlexFacets { genres: { value: string; count: number }[]; content_ratings: { value: string; count: number }[]; studios: { value: string; count: number }[]; // studio vocabulary for the sidebar picker (E5 S7) + // Playback-class facet (movie-only): value ∈ direct|remux|transcode_hw|transcode_sw. Empty on shows. + playable: { value: string; count: number }[]; year_min: number | null; year_max: number | null; rating_max: number | null; diff --git a/frontend/src/lib/plexUrlState.dom.test.ts b/frontend/src/lib/plexUrlState.dom.test.ts index 3641d1a0..082d5c73 100644 --- a/frontend/src/lib/plexUrlState.dom.test.ts +++ b/frontend/src/lib/plexUrlState.dom.test.ts @@ -24,6 +24,7 @@ const RICH_FILTERS: PlexFilters = { actors: ["Bale", "Caine"], actorMode: "all", studios: ["Warner"], + playable: ["direct", "transcode_hw"], keywords: ["heist"], collections: ["1234"], collectionMode: "all",