release: 0.78.3 (feature/plex-playback-class-filter)
gate / frontend (push) Failing after 1m36s
gate / backend (push) Successful in 2m35s
gate / frontend (push) Failing after 1m36s
gate / backend (push) Successful in 2m35s
This commit is contained in:
@@ -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")
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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(
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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. */}
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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}`,
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user