release: 0.78.3 (feature/plex-playback-class-filter)
gate / frontend (push) Failing after 1m36s
gate / backend (push) Successful in 2m35s

This commit is contained in:
2026-08-30 19:09:22 +02:00
20 changed files with 943 additions and 24 deletions
+1 -1
View File
@@ -1 +1 @@
0.78.2 0.78.3
@@ -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). # Browser-playability class from ffprobe: "direct" | "remux" | "transcode" (None = unprobed).
playable: Mapped[str | None] = mapped_column(String(12), index=True) playable: Mapped[str | None] = mapped_column(String(12), index=True)
probed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=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}, ...] # Intro/credit markers: [{"type": "intro"|"credits", "start_s": int, "end_s": int}, ...]
markers: Mapped[list | None] = mapped_column(JSON) markers: Mapped[list | None] = mapped_column(JSON)
added_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), index=True) 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}", "thumb": f"/api/plex/image/{it.rating_key}",
"art": _art_url(it.rating_key, it.art_key), "art": _art_url(it.rating_key, it.art_key),
"playable": it.playable, "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", "status": st.status if st else "new",
"position_seconds": st.position_seconds if st else 0, "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, "duration_seconds": e.duration_s,
"thumb": f"/api/plex/image/{e.rating_key}", "thumb": f"/api/plex/image/{e.rating_key}",
"playable": e.playable, "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", "status": st.status if st else "new",
"position_seconds": st.position_seconds if st else 0, "position_seconds": st.position_seconds if st else 0,
} }
+31
View File
@@ -9,6 +9,8 @@ from datetime import datetime, timedelta, timezone
from sqlalchemy import and_, case, func, or_, select, type_coerce from sqlalchemy import and_, case, func, or_, select, type_coerce
from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.dialects.postgresql import JSONB
from app.models import PlexItem
TS_CONFIG = "public.unaccent_simple" TS_CONFIG = "public.unaccent_simple"
# --- Language-aware column pickers (E5 S6) ---------------------------------- # --- Language-aware column pickers (E5 S6) ----------------------------------
@@ -206,6 +208,33 @@ def apply_meta_filters(q, model, p: dict, lang: str = "en"):
return q.where(*conds) if conds else q 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.
# Public — it is the shared source of truth imported by routes.plex.browse (facet order + zero-fill).
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 @dataclass
class LibraryFilters: class LibraryFilters:
"""The shared sidebar filter query-params for /library + /facets, declared once and injected via """The shared sidebar filter query-params for /library + /facets, declared once and injected via
@@ -228,6 +257,7 @@ class LibraryFilters:
keywords: str | None = None # TMDB keyword chips (E5 S5), CSV; language-neutral keywords: str | None = None # TMDB keyword chips (E5 S5), CSV; language-neutral
collections: str | None = None # CSV of collection rating_keys collections: str | None = None # CSV of collection rating_keys
collection_mode: str = "any" # how multiple collections combine: any (OR, default) | all (AND) 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: def as_dict(self) -> dict:
return { return {
@@ -247,6 +277,7 @@ class LibraryFilters:
"keywords": self.keywords, "keywords": self.keywords,
"collections": self.collections, "collections": self.collections,
"collection_mode": self.collection_mode, "collection_mode": self.collection_mode,
"playable": self.playable,
} }
+6 -3
View File
@@ -34,6 +34,7 @@ from app.config import settings
from app.downloads.edit import encode_thread_cap from app.downloads.edit import encode_thread_cap
from app.models import PlexItem from app.models import PlexItem
from app.plex import paths from app.plex import paths
from app.plex.sync import HW_DECODABLE_CODECS
log = logging.getLogger("siftlode.plex") log = logging.getLogger("siftlode.plex")
@@ -159,9 +160,11 @@ _VAAPI_DEVICE = "/dev/dri/renderD128"
# here; its presence is the precondition for the HW HDR→SDR tonemap graph (see _opencl_present). # here; its presence is the precondition for the HW HDR→SDR tonemap graph (see _opencl_present).
_OPENCL_ICD_DIR = Path("/etc/OpenCL/vendors") _OPENCL_ICD_DIR = Path("/etc/OpenCL/vendors")
# Codecs the prod iGPU (UHD 630 / Quick Sync Gen 9.5) can hardware-DECODE — the precondition for the # 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 # full-GPU VAAPI pipeline. A source outside it (AV1, MPEG-4 ASP/XviD…) falls back to software even with
# when the render node is present, because the GPU can't decode it to feed its own encoder. # the render node present, because the GPU can't decode it to feed its own encoder. THE single source is
_HW_DECODABLE = {"h264", "hevc", "h265", "vp9", "vc1", "mpeg2video"} # 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 sites below unchanged.
_HW_DECODABLE = HW_DECODABLE_CODECS
# Color-transfer values that mark a true-HDR source (PQ / HLG). A HW-decodable HDR source can't take the # Color-transfer values that mark a true-HDR source (PQ / HLG). A HW-decodable HDR source can't take the
# plain scale_vaapi=format=nv12 path (Gen 9.5 VPP refuses it) — it needs the OpenCL tonemap graph # plain scale_vaapi=format=nv12 path (Gen 9.5 VPP refuses it) — it needs the OpenCL tonemap graph
# (_venc_opts tonemap branch) when the OpenCL runtime is present, else software libx264. A 10-bit source # (_venc_opts tonemap branch) when the OpenCL runtime is present, else software libx264. A 10-bit source
+130 -3
View File
@@ -18,7 +18,7 @@ item-detail/player path (P2), not here.
import logging import logging
from datetime import datetime, timezone from datetime import datetime, timezone
from sqlalchemy import select from sqlalchemy import func, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app import sysconfig from app import sysconfig
@@ -58,6 +58,57 @@ def classify_playable(vcodec: str | None, acodec: str | None, container: str | N
return "direct" 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: def _epoch(v) -> datetime | None:
try: try:
return datetime.fromtimestamp(int(v), tz=timezone.utc) if v else None 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) stats["people_enriched"] = _enrich_people_batch(db, plex)
except Exception as e: # best-effort — never fail the sync over the backfill except Exception as e: # best-effort — never fail the sync over the backfill
log.warning("Plex people enrich batch failed: %s", e) 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: except PlexNotConfigured as e:
return {"skipped": str(e)} return {"skipped": str(e)}
except PlexError as 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 # 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). # whole library is covered over successive runs (each fetched item is stamped people_enriched_at once).
_ENRICH_BATCH = 60 _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: def _enrich_people_batch(db: Session, plex: PlexClient, limit: int = _ENRICH_BATCH) -> int:
@@ -369,15 +470,41 @@ def _apply_item(row: PlexItem, lib_id: int, kind: str, meta: dict) -> None:
row.added_at = _epoch(meta.get("addedAt")) row.added_at = _epoch(meta.get("addedAt"))
_apply_facet_fields(row, meta) # filterable/orderable extras (shared with shows) _apply_facet_fields(row, meta) # filterable/orderable extras (shared with shows)
mf = _media_facts(meta) mf = _media_facts(meta)
new_vcodec = mf.get("codec_video")
new_part_key = mf.get("part_key")
# The physical file was replaced when its video codec changes OR — for a SINGLE-version item — when its
# Plex Part key changes (a re-encode / quality upgrade gets a new part id). Either way any probed
# bit-depth is stale, so reset the probe watermark and re-derive transcode_hw from codec alone until the
# backfill re-probes. Codec alone is NOT enough: a same-codec bit-depth re-encode (8-bit→10-bit HEVC)
# keeps the codec `hevc` but flips the HW class, and only the part-key move catches it.
#
# The part-key check is gated on single-version because `_media_facts` reads media[0]/part[0], and for a
# MULTI-version item (an original + a Plex "Optimized" copy) that order isn't guaranteed stable across
# listings — an unguarded part-key compare would then reset+re-probe every sync and oscillate the
# transcode_hw bucket between the two versions. A multi-version same-codec re-encode not self-healing is
# an acceptable rare edge; a per-sync re-probe loop is not.
single_version = len(meta.get("Media") or []) <= 1
codec_changed = (row.codec_video or "").lower() != (new_vcodec or "").lower()
if codec_changed or (single_version and row.part_key != new_part_key):
row.video_bit_depth = None
row.media_probed_at = None
row.file_path = mf.get("file_path") row.file_path = mf.get("file_path")
row.part_key = mf.get("part_key") row.part_key = new_part_key
row.container = mf.get("container") 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.codec_audio = mf.get("codec_audio")
row.size_bytes = mf.get("size_bytes") row.size_bytes = mf.get("size_bytes")
row.playable = classify_playable( row.playable = classify_playable(
mf.get("codec_video"), mf.get("codec_audio"), mf.get("container") 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( def _prune_stale(
+42
View File
@@ -2,6 +2,7 @@
from fastapi import APIRouter, Depends, Query from fastapi import APIRouter, Depends, Query
from sqlalchemy import ( from sqlalchemy import (
Boolean,
Integer, Integer,
String, String,
and_, and_,
@@ -27,6 +28,7 @@ from app.plex import rollup
from app.plex.cards import episode_card from app.plex.cards import episode_card
from app.plex.query import ( from app.plex.query import (
FIELD_COLUMNS, FIELD_COLUMNS,
PLAYBACK_CLASSES,
TS_CONFIG, TS_CONFIG,
LibraryFilters, LibraryFilters,
apply_meta_filters, apply_meta_filters,
@@ -36,6 +38,8 @@ from app.plex.query import (
genres_col, genres_col,
meta_filter_conds, meta_filter_conds,
movie_status_case, movie_status_case,
playable_conds,
playback_class_expr,
show_status_case, show_status_case,
split_csv, split_csv,
summary_col, summary_col,
@@ -135,6 +139,7 @@ def unified_library(
PlexItem.originally_available_at.label("rel"), PlexItem.originally_available_at.label("rel"),
PlexItem.duration_s.label("duration_s"), PlexItem.duration_s.label("duration_s"),
PlexItem.playable.label("playable"), PlexItem.playable.label("playable"),
PlexItem.transcode_hw.label("transcode_hw"),
cast(null(), Integer).label("season_count"), cast(null(), Integer).label("season_count"),
func.coalesce(st.position_seconds, 0).label("position_seconds"), func.coalesce(st.position_seconds, 0).label("position_seconds"),
m_status.label("status"), m_status.label("status"),
@@ -156,6 +161,8 @@ def unified_library(
mq = mq.where(PlexItem.duration_s >= f.duration_min) mq = mq.where(PlexItem.duration_s >= f.duration_min)
if f.duration_max is not None: if f.duration_max is not None:
mq = mq.where(PlexItem.duration_s <= f.duration_max) 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: if tsq is not None:
mq = mq.where(vector_match(PlexItem, lang, tsq)) mq = mq.where(vector_match(PlexItem, lang, tsq))
if recheck is not None: if recheck is not None:
@@ -179,6 +186,7 @@ def unified_library(
PlexShow.originally_available_at.label("rel"), PlexShow.originally_available_at.label("rel"),
cast(null(), Integer).label("duration_s"), cast(null(), Integer).label("duration_s"),
cast(null(), String).label("playable"), cast(null(), String).label("playable"),
cast(null(), Boolean).label("transcode_hw"),
PlexShow.child_count.label("season_count"), PlexShow.child_count.label("season_count"),
literal(0).label("position_seconds"), literal(0).label("position_seconds"),
s_status.label("status"), s_status.label("status"),
@@ -237,6 +245,7 @@ def unified_library(
"duration_seconds": r.duration_s, "duration_seconds": r.duration_s,
"thumb": f"/api/plex/image/{r.rk}", "thumb": f"/api/plex/image/{r.rk}",
"playable": r.playable, "playable": r.playable,
"transcode_hw": r.transcode_hw,
"status": r.status, "status": r.status,
"position_seconds": r.position_seconds, "position_seconds": r.position_seconds,
} }
@@ -312,6 +321,7 @@ def facets(
"genres": [], "genres": [],
"content_ratings": [], "content_ratings": [],
"studios": [], "studios": [],
"playable": [],
"year_min": None, "year_min": None,
"year_max": None, "year_max": None,
"rating_max": None, "rating_max": None,
@@ -338,6 +348,9 @@ def facets(
query = query.where(model.duration_s >= pp["duration_min"]) query = query.where(model.duration_s >= pp["duration_min"])
if with_dur and pp.get("duration_max") is not None: if with_dur and pp.get("duration_max") is not None:
query = query.where(model.duration_s <= pp["duration_max"]) 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) — # 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. # hidden movies never appear in the grid, so they must not inflate facet counts/bounds either.
st = aliased(PlexState) st = aliased(PlexState)
@@ -356,6 +369,7 @@ def facets(
genre_counts: dict[str, int] = {} genre_counts: dict[str, int] = {}
cr_counts: dict[str, int] = {} cr_counts: dict[str, int] = {}
studio_counts: dict[str, int] = {} studio_counts: dict[str, int] = {}
playable_counts: dict[str, int] = {}
years: list[int] = [] years: list[int] = []
ratings: list[float] = [] ratings: list[float] = []
durs: list[int] = [] durs: list[int] = []
@@ -420,6 +434,23 @@ def facets(
) )
for s, c in db.execute(stq).all(): for s, c in db.execute(stq).all():
studio_counts[s] = studio_counts.get(s, 0) + c 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. # Year bounds — exclude the year filter.
by = db.execute( by = db.execute(
wsq( wsq(
@@ -477,6 +508,11 @@ def facets(
# Same for the actively-selected studios, so a selected studio stays removable even at zero matches. # Same for the actively-selected studios, so a selected studio stays removable even at zero matches.
for s in split_csv(f.studios): for s in split_csv(f.studios):
studio_counts.setdefault(s, 0) 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 { return {
"genres": [ "genres": [
{"value": g, "count": c} for g, c in sorted(genre_counts.items(), key=lambda kv: kv[0]) {"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} {"value": s, "count": c}
for s, c in sorted(studio_counts.items(), key=lambda kv: (-kv[1], kv[0])) 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_min": min(years) if years else None,
"year_max": max(years) if years else None, "year_max": max(years) if years else None,
"rating_max": max(ratings) if ratings else None, "rating_max": max(ratings) if ratings else None,
+4
View File
@@ -411,6 +411,10 @@ def item_detail(
"year": it.year, "year": it.year,
"duration_seconds": it.duration_s, "duration_seconds": it.duration_s,
"playable": it.playable, "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}", "thumb": f"/api/plex/image/{it.rating_key}",
"art": f"/api/plex/image/{it.rating_key}?variant=art", "art": f"/api/plex/image/{it.rating_key}?variant=art",
"library": _lib_key(db, it.library_id), "library": _lib_key(db, it.library_id),
+495
View File
@@ -0,0 +1,495 @@
"""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 re
from pathlib import Path
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, *, key="/parts/1/file.mkv", file="/m.mkv"):
return {
"title": "X",
"Media": [{"videoCodec": vcodec, "Part": [{"file": file, "key": key}]}],
}
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
def test_same_codec_reencode_with_a_new_part_resets_the_probe(self):
# A same-codec bit-depth re-encode (e.g. 8-bit→10-bit HEVC) keeps codec 'hevc' but replaces the
# physical file, so its Plex Part key changes. Codec-only reset would miss this and keep the stale
# 8-bit/HW verdict forever (the backfill only re-probes media_probed_at IS NULL rows) — the
# part-key move must reset the watermark so the new bit depth is re-probed.
row = PlexItem(rating_key="x", kind="movie")
sync._apply_item(row, lib_id=1, kind="movie", meta=self._meta("hevc", key="/parts/1/f.mkv"))
row.transcode_hw = True
row.video_bit_depth = 8
row.media_probed_at = sync.datetime.now(sync.timezone.utc)
# Re-encoded in place: same codec 'hevc', but a new Part key (new file).
sync._apply_item(row, lib_id=1, kind="movie", meta=self._meta("hevc", key="/parts/2/f.mkv"))
assert row.video_bit_depth is None and row.media_probed_at is None # re-probe scheduled
def test_migration_codec_set_matches_hw_decodable_codecs():
# Migration 0077 hardcodes the HW-decodable codec set in raw SQL and its comment mandates it stay
# identical to plex.sync.HW_DECODABLE_CODECS. A comment isn't enforcement (house rule) — pin the
# equality so adding/removing a codec from the frozenset without editing the migration fails here
# (else already-migrated prod rows keep a wrong transcode_hw the runtime derivation wouldn't catch).
mig = (
Path(__file__).resolve().parent.parent
/ "alembic"
/ "versions"
/ "0077_plex_playback_class.py"
)
m = re.search(r"lower\(codec_video\)\s+IN\s+\(([^)]*)\)", mig.read_text())
assert m, "could not find the codec IN(...) list in migration 0077"
codecs = {c.strip().strip("'\"").lower() for c in m.group(1).split(",")}
assert codecs == {c.lower() for c in sync.HW_DECODABLE_CODECS}
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 test_probe_media_batch_is_bounded_and_oldest_first(db):
# The per-run bound (limit) caps Plex load; oldest-id-first means successive runs cover the whole
# backlog without starving any item. Seed 3 eligible items, probe with limit=2.
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
db.add(lib)
db.flush()
items = [
PlexItem(
rating_key=f"h{i}",
library_id=lib.id,
kind="movie",
title=f"m{i}",
playable="transcode",
codec_video="hevc",
transcode_hw=True,
)
for i in range(3)
]
db.add_all(items) # inserted in order → ascending ids
db.commit()
plex = _FakePlex(
{
f"h{i}": {"Media": [{"Part": [{"Stream": [{"streamType": 1, "bitDepth": 8}]}]}]}
for i in range(3)
}
)
assert sync._probe_media_batch(db, plex, limit=2) == 2 # exactly the bound
for it in items:
db.refresh(it)
# The two LOWEST-id rows were probed; the third waits for the next run.
assert items[0].media_probed_at is not None and items[1].media_probed_at is not None
assert items[2].media_probed_at is None
# The next run continues with the remaining item.
assert sync._probe_media_batch(db, plex, limit=2) == 1
db.refresh(items[2])
assert items[2].media_probed_at is not None
class _RaisingPlex:
"""metadata() raises for `bad_key`, returns a canned dict otherwise — to exercise the transient
fetch-failure branch (leave the watermark NULL so the item retries next run)."""
def __init__(self, by_key, bad_key):
self.by_key = by_key
self.bad_key = bad_key
def metadata(self, rating_key, markers=False):
if rating_key == self.bad_key:
raise RuntimeError("plex timeout")
return self.by_key.get(rating_key)
def test_probe_media_batch_leaves_a_transient_failure_unstamped_to_retry(db):
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
db.add(lib)
db.flush()
good = PlexItem(
rating_key="g",
library_id=lib.id,
kind="movie",
title="good",
playable="transcode",
codec_video="hevc",
transcode_hw=True,
)
bad = PlexItem(
rating_key="b",
library_id=lib.id,
kind="movie",
title="bad",
playable="transcode",
codec_video="hevc",
transcode_hw=True,
)
db.add_all([good, bad])
db.commit()
plex = _RaisingPlex(
{"g": {"Media": [{"Part": [{"Stream": [{"streamType": 1, "bitDepth": 10}]}]}]}}, bad_key="b"
)
# The raise on 'b' must not abort the batch: 'g' is still probed and counted; 'b' stays NULL.
assert sync._probe_media_batch(db, plex) == 1
db.refresh(good)
db.refresh(bad)
assert good.media_probed_at is not None and good.transcode_hw is False # 10-bit → SW
assert bad.media_probed_at is None # transient failure → retried next run, not stamped
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,
}
def test_unprobed_transcode_folds_into_sw(db, monkeypatch):
# transcode_hw NULL is the COMMON initial state (before the bit-depth backfill runs). The query's
# CASE has no explicit NULL arm — it relies on the `playable=='transcode'` else folding NULL in with
# software. Pin that fold for BOTH the filter and the facet, so a reorder can't silently drop the
# item from the feed (an honest-state violation).
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
db.add(lib)
db.flush()
db.add(
PlexItem(
rating_key="tnull",
library_id=lib.id,
kind="movie",
title="Unprobed",
playable="transcode",
codec_video="hevc",
transcode_hw=None,
)
)
user = User(email="pnull@example.com")
db.add(user)
db.commit()
client = _client(db, user, monkeypatch)
try:
sw = client.get("/api/plex/library?scope=movie&playable=transcode_sw")
assert {i["id"] for i in sw.json()["items"]} == {"tnull"} # NULL → SW bucket
hw = client.get("/api/plex/library?scope=movie&playable=transcode_hw")
assert hw.json()["items"] == [] # never HW
facets = client.get("/api/plex/facets?scope=movie")
counts = {p["value"]: p["count"] for p in facets.json()["playable"]}
assert counts.get("transcode_sw") == 1 and counts.get("transcode_hw", 0) == 0
finally:
app.dependency_overrides.clear()
def test_facet_playable_self_excludes_the_active_selection(db, monkeypatch):
# A selected playable class must NOT narrow the playable facet's OWN counts (facet self-exclusion),
# otherwise picking one class would collapse the list and you couldn't OR-add a second. Selecting
# 'direct' must still return all four classes at their full, unnarrowed counts.
_seed_classes(db)
user = User(email="pself@example.com")
db.add(user)
db.commit()
client = _client(db, user, monkeypatch)
try:
resp = client.get("/api/plex/facets?scope=movie&playable=direct")
finally:
app.dependency_overrides.clear()
assert resp.status_code == 200, resp.text
counts = {p["value"]: p["count"] for p in resp.json()["playable"]}
assert counts == {"direct": 1, "remux": 1, "transcode_hw": 1, "transcode_sw": 1}
def test_active_playable_narrows_the_other_facets(db, monkeypatch):
# The flip side of self-exclusion: an active playable class DOES narrow the OTHER facet groups (so
# e.g. the year range reflects only the selected class). Seed a Direct movie (2000) and a
# software-transcode movie (2020); selecting playable=direct must collapse the year facet to 2000.
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
db.add(lib)
db.flush()
db.add_all(
[
PlexItem(
rating_key="d2000",
library_id=lib.id,
kind="movie",
title="Direct2000",
playable="direct",
year=2000,
),
PlexItem(
rating_key="s2020",
library_id=lib.id,
kind="movie",
title="SW2020",
playable="transcode",
codec_video="mpeg4",
transcode_hw=False,
year=2020,
),
]
)
user = User(email="pnarrow@example.com")
db.add(user)
db.commit()
client = _client(db, user, monkeypatch)
try:
wide = client.get("/api/plex/facets?scope=movie").json()
narrow = client.get("/api/plex/facets?scope=movie&playable=direct").json()
finally:
app.dependency_overrides.clear()
assert (wide["year_min"], wide["year_max"]) == (2000, 2020) # both classes span the range
assert (narrow["year_min"], narrow["year_max"]) == (2000, 2000) # narrowed to the Direct subset
+29
View File
@@ -19,6 +19,7 @@ import i18n from "../i18n";
import { Carousel } from "./Carousel"; import { Carousel } from "./Carousel";
import PlexCollectionEditor from "./PlexCollectionEditor"; import PlexCollectionEditor from "./PlexCollectionEditor";
import PlexPlaylistAdd from "./PlexPlaylistAdd"; 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, // 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 // 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 = { type Props = {
detail: PlexItemDetail; detail: PlexItemDetail;
variant: "page" | "overlay"; variant: "page" | "overlay";
@@ -479,6 +507,7 @@ export default function PlexInfo({
{detail.studio} {detail.studio}
</Filterable> </Filterable>
)} )}
<PlaybackChip detail={detail} overlay={overlay} />
</div> </div>
{/* Multi-source ratings row (E5 S5): IMDb / TMDb / audience, whichever are present. */} {/* 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 ( return (
+31 -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 // 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. // 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 // Playback cost, escalating good→heavy: Direct Play (green) → Direct Stream / remux, a light
// (amber) → transcode, a full re-encode (red). Reuses the status trio as a gradient; // repackage (blue) → Transcode · HW, a hardware re-encode (amber) → Transcode · SW, a software
// all three stay visually distinct. // re-encode (red). transcode splits HW vs SW by card.transcode_hw. Reuses the semantic tokens as a
const PLAYABLE_TINT: Record<string, string> = { // gradient; all four stay visually distinct in light + dark.
export const PLAYABLE_TINT: Record<string, string> = {
direct: "bg-success", direct: "bg-success",
remux: "bg-warn", remux: "bg-info",
transcode: "bg-danger", 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 // 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 // control (the watched-toggle). When that control is present it takes over on touch, so the pill yields
@@ -82,13 +91,22 @@ export function PlexPosterCard({
decoding="async" decoding="async"
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200" 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). */} {/* Playable-cost tint (always shown — a quick "will it direct-play" cue). z-20 keeps the dot
{card.playable && ( ABOVE the full-card hover-reveal overlay below (absolute inset-0, no pointer-events-none):
<span without it the overlay sits on top and swallows the cursor, so the dot's native `title`
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"}`} tooltip never fired on hover (a regression once the reveal overlay was added). */}
title={t(`plex.playable.${card.playable}`)} {card.playable &&
/> (() => {
)} const cls = playableClass(card.playable, card.transcode_hw);
return (
<span
role="img"
aria-label={t(`plex.playable.${cls}`)}
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 {/* 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. */} (reveal-idle: gone on touch where the toggle is always shown); a watched show keeps it. */}
{card.status === "watched" && ( {card.status === "watched" && (
@@ -110,6 +110,13 @@ export function buildPlexActiveFilters(
remove: () => patch({ studios: filters.studios.filter((x) => x !== s) }), 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) { for (const k of filters.keywords) {
out.push({ out.push({
key: `keyword:${k}`, key: `keyword:${k}`,
+10 -2
View File
@@ -104,6 +104,13 @@
"collectionEmpty": "No collections", "collectionEmpty": "No collections",
"matchLabel": "Match", "matchLabel": "Match",
"studio": "Studio", "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" "moreRefine": "{{count}} more — refine with search"
}, },
"count": "{{count}} titles", "count": "{{count}} titles",
@@ -285,8 +292,9 @@
}, },
"playable": { "playable": {
"direct": "Plays directly in the browser", "direct": "Plays directly in the browser",
"remux": "Needs a light remux (no video re-encode)", "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": { "playlistAdd": {
"manage": "Add to playlist", "manage": "Add to playlist",
+10 -2
View File
@@ -104,6 +104,13 @@
"collectionEmpty": "Nincs kollekció", "collectionEmpty": "Nincs kollekció",
"matchLabel": "Egyezés", "matchLabel": "Egyezés",
"studio": "Stúdió", "studio": "Stúdió",
"playable": "Lejátszás",
"playableOpt": {
"direct": "Közvetlen lejátszás",
"remux": "Közvetlen streamelés",
"transcode_hw": "Átkódolás · HW",
"transcode_sw": "Átkódolás · SW"
},
"moreRefine": "még {{count}} — finomíts kereséssel" "moreRefine": "még {{count}} — finomíts kereséssel"
}, },
"count": "{{count}} cím", "count": "{{count}} cím",
@@ -285,8 +292,9 @@
}, },
"playable": { "playable": {
"direct": "Közvetlenül játszható a böngészőben", "direct": "Közvetlenül játszható a böngészőben",
"remux": "Könnyű remux kell (nincs videó-újrakódolás)", "remux": "Közvetlen streamelés — könnyű remux (nincs videó-újrakódolás)",
"transcode": "Transcode kell (nehezebb)" "transcode_hw": "Hardveres kódolóval átkódolva",
"transcode_sw": "Szoftveresen átkódolva (legnehezebb)"
}, },
"playlistAdd": { "playlistAdd": {
"manage": "Listához adás", "manage": "Listához adás",
+29
View File
@@ -3,6 +3,9 @@ import { describe, it, expect } from "vitest";
import { import {
coercePlexFilters, coercePlexFilters,
plexFiltersSignature, plexFiltersSignature,
plexFiltersToParams,
parsePlexFilters,
plexFilterCount,
EMPTY_PLEX_FILTERS, EMPTY_PLEX_FILTERS,
type PlexFilters, type PlexFilters,
} from "./plex"; } from "./plex";
@@ -54,12 +57,14 @@ describe("plexFiltersSignature", () => {
directors: [], directors: [],
actors: [], actors: [],
studios: [], studios: [],
playable: [],
keywords: [], keywords: [],
collections: ["A"], collections: ["A"],
}; };
const b: PlexFilters = { const b: PlexFilters = {
collections: ["A"], collections: ["A"],
keywords: [], keywords: [],
playable: [],
studios: [], studios: [],
actors: [], actors: [],
directors: [], directors: [],
@@ -81,4 +86,28 @@ describe("plexFiltersSignature", () => {
const b: PlexFilters = { ...base(), genres: ["Comedy"] }; const b: PlexFilters = { ...base(), genres: ["Comedy"] };
expect(plexFiltersSignature(a)).not.toBe(plexFiltersSignature(b)); 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 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 rating?: number | null; // Plex audience score (~IMDb), shown on the card hover-overview
playable?: string; // direct | remux | transcode 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 status?: string; // new | watched | hidden
position_seconds?: number; position_seconds?: number;
season_count?: number | null; // show season_count?: number | null; // show
@@ -139,6 +140,9 @@ export interface PlexItemDetail {
year?: number | null; year?: number | null;
duration_seconds: number | null; duration_seconds: number | null;
playable: string; // direct | remux | transcode 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; thumb: string;
art: string; art: string;
library?: string | null; // Plex section key (for the admin collection editor) 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 actors: string[]; // OR (any) by default, or AND (all) via actorMode
actorMode?: "any" | "all"; // how multiple actors combine (default any = OR) actorMode?: "any" | "all"; // how multiple actors combine (default any = OR)
studios: string[]; // OR — from any selected studio 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) keywords: string[]; // AND — TMDB keyword chips from the detail page (E5 S5)
collections: string[]; // Plex collection rating_keys; a title can be in several collections: string[]; // Plex collection rating_keys; a title can be in several
collectionMode?: "any" | "all"; // how multiple collections combine (default any = OR) collectionMode?: "any" | "all"; // how multiple collections combine (default any = OR)
@@ -264,6 +269,7 @@ export const EMPTY_PLEX_FILTERS: PlexFilters = {
directors: [], directors: [],
actors: [], actors: [],
studios: [], studios: [],
playable: [],
keywords: [], keywords: [],
collections: [], collections: [],
}; };
@@ -309,6 +315,7 @@ export function plexFilterCount(f: PlexFilters): number {
f.directors.length + f.directors.length +
f.actors.length + f.actors.length +
f.studios.length + f.studios.length +
f.playable.length +
f.keywords.length + f.keywords.length +
f.collections.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.actors?.length) u.set("actors", f.actors.join(","));
if (f.actorMode === "all") u.set("actor_mode", "all"); if (f.actorMode === "all") u.set("actor_mode", "all");
if (f.studios?.length) u.set("studios", f.studios.join(",")); 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.keywords?.length) u.set("keywords", f.keywords.join(","));
if (f.collections?.length) u.set("collections", f.collections.join(",")); if (f.collections?.length) u.set("collections", f.collections.join(","));
if (f.collectionMode === "all") u.set("collection_mode", "all"); if (f.collectionMode === "all") u.set("collection_mode", "all");
@@ -371,6 +379,7 @@ export function parsePlexFilters(p: URLSearchParams): PlexFilters {
f.actors = csv("actors"); f.actors = csv("actors");
if (p.get("actor_mode") === "all") f.actorMode = "all"; if (p.get("actor_mode") === "all") f.actorMode = "all";
f.studios = csv("studios"); f.studios = csv("studios");
f.playable = csv("playable");
f.keywords = csv("keywords"); f.keywords = csv("keywords");
f.collections = csv("collections"); f.collections = csv("collections");
if (p.get("collection_mode") === "all") f.collectionMode = "all"; if (p.get("collection_mode") === "all") f.collectionMode = "all";
@@ -380,6 +389,8 @@ export interface PlexFacets {
genres: { value: string; count: number }[]; genres: { value: string; count: number }[];
content_ratings: { value: string; count: number }[]; content_ratings: { value: string; count: number }[];
studios: { value: string; count: number }[]; // studio vocabulary for the sidebar picker (E5 S7) 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_min: number | null;
year_max: number | null; year_max: number | null;
rating_max: number | null; rating_max: number | null;
@@ -24,6 +24,7 @@ const RICH_FILTERS: PlexFilters = {
actors: ["Bale", "Caine"], actors: ["Bale", "Caine"],
actorMode: "all", actorMode: "all",
studios: ["Warner"], studios: ["Warner"],
playable: ["direct", "transcode_hw"],
keywords: ["heist"], keywords: ["heist"],
collections: ["1234"], collections: ["1234"],
collectionMode: "all", collectionMode: "all",
+12
View File
@@ -14,6 +14,18 @@ export interface ReleaseEntry {
} }
export const RELEASE_NOTES: ReleaseEntry[] = [ export const RELEASE_NOTES: ReleaseEntry[] = [
{
version: "0.78.3",
date: "2026-08-30",
summary:
"A new Plex filter: narrow the movie library by how each title plays — Direct Play, Direct Stream, or Transcode (hardware or software). See at a glance which titles are light on the server and which need a re-encode.",
features: [
"Filter movies by playback class in the sidebar: Direct Play (plays as-is), Direct Stream (a light remux), Transcode · HW (re-encoded on the GPU) and Transcode · SW (re-encoded on the CPU — the heaviest). Pick several to combine them. A small coloured dot on each movie card shows its class at a glance.",
],
chores: [
"The class is derived at sync from the codec, then refined by a bounded background probe that reads each transcode title's real bit depth/HDR — so a 10-bit HEVC that needs the heavier software path is labelled correctly. Went through several multi-agent deep-review rounds (probe-freshness on file replacement, facet self-exclusion, HU/EN parity, an accessibility label on the class dot) — re-reviewed until a round came back clean.",
],
},
{ {
version: "0.78.2", version: "0.78.2",
date: "2026-08-30", date: "2026-08-30",