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",