feat(plex): filter the feed by playback class (Direct Play / Direct Stream / Transcode HW/SW)

Adds a movie-scoped "Playback" filter to the Plex feed so a user can isolate items by how
they play — the groundwork for collecting HW-transcodable (or no-transcode) versions.

Four classes derived from the stored `playable` + a new `transcode_hw` facet:
- Direct Play (direct), Direct Stream (remux)
- Transcode · HW  — playable=transcode AND the iGPU can hardware-transcode it
- Transcode · SW  — playable=transcode, software libx264 only

The HW/SW split is precise, not codec-only: a 10-bit/HDR HEVC is HW-decodable yet breaks the
VAAPI nv12 pipeline (the same case plex/stream special-cases), so it must count as SW.

Backend:
- New PlexItem columns (migration 0077): transcode_hw, video_bit_depth, media_probed_at.
  Migration backfills transcode_hw codec-only so the filter works immediately.
- `plex.sync.transcode_hw_capable` is the single classifier; the HW-decodable codec set moves
  to sync as `HW_DECODABLE_CODECS` and plex.stream imports it, so the catalog facet and the
  runtime encoder choice can't disagree by codec.
- Bit-depth backfill (`_probe_media_batch`, wired into sync): probes only HW-decodable transcode
  items' video Stream for bit-depth + HDR and refines a 10-bit/HDR HEVC to software. Bounded
  per run, watermarked by media_probed_at. A codec change resets the watermark (stale probe).
- Filter + self-excluded facet on a shared `playback_class_expr` CASE (movie-leg only — a show
  is an aggregate with no single class). transcode_hw threaded onto the feed/detail/card JSON.

Frontend:
- Multi-select "Playback" Combobox in the sidebar (movie scope, facet-empty drop-out), active
  chips, 4-way corner-dot badge (green/blue/amber/red) + tooltip, and an info-page playback chip
  with a codec · bit-depth subtitle. New `bg-info` (blue) token, i18n HU/EN parity.
- Fix a regression where the card's playback dot lost its hover tooltip: the full-card reveal
  overlay swallowed the cursor; the dot now sits above it (z-20).

Gate: backend full lane + frontend typecheck/lint/knip/prettier/vitest all green. Browser-UAT'd
on localdev (filter narrows to the SW set, facet counts, badge colour + tooltip, info chip).
This commit is contained in:
2026-08-30 04:45:11 +02:00
parent a84f2db310
commit 840526b1b8
18 changed files with 697 additions and 23 deletions
@@ -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")
+16
View File
@@ -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)
+2
View File
@@ -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,
}
+30
View File
@@ -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,
}
+6 -4
View File
@@ -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]:
+117 -2
View File
@@ -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(
+42
View File
@@ -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,
+4
View File
@@ -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),
+276
View File
@@ -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,
}
+29
View File
@@ -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 (
<span
className={`inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-xs ${
overlay ? "bg-white/10 text-white/90" : "bg-surface text-muted"
}`}
title={t(`plex.playable.${cls}`)}
>
<span className={`h-2 w-2 shrink-0 rounded-full ${PLAYABLE_TINT[cls] ?? "bg-gray-400"}`} />
<span className="font-medium">{t(`plex.filter.playableOpt.${cls}`)}</span>
{sub && <span className="opacity-70">{sub}</span>}
</span>
);
}
type Props = {
detail: PlexItemDetail;
variant: "page" | "overlay";
@@ -479,6 +507,7 @@ export default function PlexInfo({
{detail.studio}
</Filterable>
)}
<PlaybackChip detail={detail} overlay={overlay} />
</div>
{/* Multi-source ratings row (E5 S5): IMDb / TMDb / audience, whichever are present. */}
+22
View File
@@ -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: (
<Combobox
label={t("plex.filter.playable")}
options={facets.playable.map((p) => ({
value: p.value,
label: t(`plex.filter.playableOpt.${p.value}`),
count: p.count,
}))}
selected={filters.playable}
onChange={(next) => patch({ playable: next })}
/>
),
},
]
: []),
];
return (
+29 -13
View File
@@ -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<string, string> = {
// 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<string, string> = {
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 && (
<span
className={`absolute top-1.5 left-1.5 w-2 h-2 rounded-full ring-2 ring-black/40 ${PLAYABLE_TINT[card.playable] ?? "bg-gray-400"}`}
title={t(`plex.playable.${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 (
<span
className={`absolute top-1.5 left-1.5 z-20 w-2 h-2 rounded-full ring-2 ring-black/40 ${PLAYABLE_TINT[cls] ?? "bg-gray-400"}`}
title={t(`plex.playable.${cls}`)}
/>
);
})()}
{/* 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" && (
@@ -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}`,
+11 -2
View File
@@ -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",
+11 -2
View File
@@ -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",
+29
View File
@@ -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);
});
});
+11
View File
@@ -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;
@@ -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",