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). # 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,
} }
+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 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,32 @@ 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.
_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 +256,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 +276,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 -4
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")
@@ -153,10 +154,11 @@ _HLS_TAIL = [
# Everything else — the `-copyts`/`_HLS_TAIL`/segment/seek-restart model and the AAC audio path — is # 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. # unchanged, so hls.js, subtitles, multi-audio and seeking keep working exactly as for remux.
_VAAPI_DEVICE = "/dev/dri/renderD128" _VAAPI_DEVICE = "/dev/dri/renderD128"
# Codecs the prod iGPU (UHD 630 / Quick Sync Gen 9.5) can hardware-DECODE — the precondition for the # Codecs the prod iGPU can hardware-DECODE (the precondition for the full-GPU VAAPI pipeline; a source
# full-GPU VAAPI pipeline. A source outside this set (AV1, MPEG-4 ASP/XviD…) falls back to software even # outside it — AV1, MPEG-4 ASP/XviD… — falls back to software even with the render node present). THE
# when the render node is present, because the GPU can't decode it to feed its own encoder. # single source is plex.sync, so the runtime encoder choice here and the catalog `transcode_hw` facet
_HW_DECODABLE = {"h264", "hevc", "h265", "vp9", "vc1", "mpeg2video"} # 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]: 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 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,29 @@ 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")
# 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.file_path = mf.get("file_path")
row.part_key = mf.get("part_key") row.part_key = mf.get("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_,
@@ -26,6 +27,7 @@ from app.models import (
from app.plex import rollup 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 (
_PLAYBACK_CLASSES,
FIELD_COLUMNS, FIELD_COLUMNS,
TS_CONFIG, TS_CONFIG,
LibraryFilters, LibraryFilters,
@@ -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),
+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 { 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 (
+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 // 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,20 @@ 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
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}`,
+11 -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,10 @@
}, },
"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": "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",
+11 -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": "Direct Stream",
"transcode_hw": "Transcode · HW",
"transcode_sw": "Transcode · 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,10 @@
}, },
"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": "Direct Stream — könnyű remux (nincs videó-újrakódolás)",
"transcode": "Transcode kell (nehezebb)" "transcode": "Transcode kell (nehezebb)",
"transcode_hw": "Hardveres kódolóval transcode-olva",
"transcode_sw": "Szoftveresen transcode-olva (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",