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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user