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
+11
View File
@@ -45,6 +45,7 @@ export interface PlexCard {
art?: string | null; // wide backdrop (movies/shows) for rails + crossfading hero; null when no art_key
rating?: number | null; // Plex audience score (~IMDb), shown on the card hover-overview
playable?: string; // direct | remux | transcode
transcode_hw?: boolean | null; // transcode items only: true = HW-capable, false = SW-only, null = n/a
status?: string; // new | watched | hidden
position_seconds?: number;
season_count?: number | null; // show
@@ -139,6 +140,9 @@ export interface PlexItemDetail {
year?: number | null;
duration_seconds: number | null;
playable: string; // direct | remux | transcode
transcode_hw?: boolean | null; // transcode items only: true = HW-capable, false = SW-only, null = n/a
codec_video?: string | null; // e.g. "hevc" / "h264" (info-chip subtitle)
video_bit_depth?: number | null; // e.g. 8 / 10 (info-chip subtitle)
thumb: string;
art: string;
library?: string | null; // Plex section key (for the admin collection editor)
@@ -253,6 +257,7 @@ export interface PlexFilters {
actors: string[]; // OR (any) by default, or AND (all) via actorMode
actorMode?: "any" | "all"; // how multiple actors combine (default any = OR)
studios: string[]; // OR — from any selected studio
playable: string[]; // OR — playback class (direct | remux | transcode_hw | transcode_sw); movie-only
keywords: string[]; // AND — TMDB keyword chips from the detail page (E5 S5)
collections: string[]; // Plex collection rating_keys; a title can be in several
collectionMode?: "any" | "all"; // how multiple collections combine (default any = OR)
@@ -264,6 +269,7 @@ export const EMPTY_PLEX_FILTERS: PlexFilters = {
directors: [],
actors: [],
studios: [],
playable: [],
keywords: [],
collections: [],
};
@@ -309,6 +315,7 @@ export function plexFilterCount(f: PlexFilters): number {
f.directors.length +
f.actors.length +
f.studios.length +
f.playable.length +
f.keywords.length +
f.collections.length
);
@@ -330,6 +337,7 @@ function appendPlexFilters(u: URLSearchParams, f: PlexFilters): void {
if (f.actors?.length) u.set("actors", f.actors.join(","));
if (f.actorMode === "all") u.set("actor_mode", "all");
if (f.studios?.length) u.set("studios", f.studios.join(","));
if (f.playable?.length) u.set("playable", f.playable.join(","));
if (f.keywords?.length) u.set("keywords", f.keywords.join(","));
if (f.collections?.length) u.set("collections", f.collections.join(","));
if (f.collectionMode === "all") u.set("collection_mode", "all");
@@ -371,6 +379,7 @@ export function parsePlexFilters(p: URLSearchParams): PlexFilters {
f.actors = csv("actors");
if (p.get("actor_mode") === "all") f.actorMode = "all";
f.studios = csv("studios");
f.playable = csv("playable");
f.keywords = csv("keywords");
f.collections = csv("collections");
if (p.get("collection_mode") === "all") f.collectionMode = "all";
@@ -380,6 +389,8 @@ export interface PlexFacets {
genres: { value: string; count: number }[];
content_ratings: { value: string; count: number }[];
studios: { value: string; count: number }[]; // studio vocabulary for the sidebar picker (E5 S7)
// Playback-class facet (movie-only): value ∈ direct|remux|transcode_hw|transcode_sw. Empty on shows.
playable: { value: string; count: number }[];
year_min: number | null;
year_max: number | null;
rating_max: number | null;