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
+29
View File
@@ -19,6 +19,7 @@ import i18n from "../i18n";
import { Carousel } from "./Carousel";
import PlexCollectionEditor from "./PlexCollectionEditor";
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,
// 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 = {
detail: PlexItemDetail;
variant: "page" | "overlay";
@@ -479,6 +507,7 @@ export default function PlexInfo({
{detail.studio}
</Filterable>
)}
<PlaybackChip detail={detail} overlay={overlay} />
</div>
{/* 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 (
+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
// 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
// (amber) → transcode, a full re-encode (red). Reuses the status trio as a gradient;
// all three stay visually distinct.
const PLAYABLE_TINT: Record<string, string> = {
// Playback cost, escalating good→heavy: Direct Play (green) → Direct Stream / remux, a light
// repackage (blue) → Transcode · HW, a hardware re-encode (amber) → Transcode · SW, a software
// re-encode (red). transcode splits HW vs SW by card.transcode_hw. Reuses the semantic tokens as a
// gradient; all four stay visually distinct in light + dark.
export const PLAYABLE_TINT: Record<string, string> = {
direct: "bg-success",
remux: "bg-warn",
transcode: "bg-danger",
remux: "bg-info",
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
// 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"
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). */}
{card.playable && (
<span
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"}`}
title={t(`plex.playable.${card.playable}`)}
/>
)}
{/* Playable-cost tint (always shown — a quick "will it direct-play" cue). z-20 keeps the dot
ABOVE the full-card hover-reveal overlay below (absolute inset-0, no pointer-events-none):
without it the overlay sits on top and swallows the cursor, so the dot's native `title`
tooltip never fired on hover (a regression once the reveal overlay was added). */}
{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
(reveal-idle: gone on touch where the toggle is always shown); a watched show keeps it. */}
{card.status === "watched" && (
@@ -110,6 +110,13 @@ export function buildPlexActiveFilters(
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) {
out.push({
key: `keyword:${k}`,
+11 -2
View File
@@ -104,6 +104,13 @@
"collectionEmpty": "No collections",
"matchLabel": "Match",
"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"
},
"count": "{{count}} titles",
@@ -285,8 +292,10 @@
},
"playable": {
"direct": "Plays directly in the browser",
"remux": "Needs a light remux (no video re-encode)",
"transcode": "Needs transcoding (heavier)"
"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": {
"manage": "Add to playlist",
+11 -2
View File
@@ -104,6 +104,13 @@
"collectionEmpty": "Nincs kollekció",
"matchLabel": "Egyezés",
"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"
},
"count": "{{count}} cím",
@@ -285,8 +292,10 @@
},
"playable": {
"direct": "Közvetlenül játszható a böngészőben",
"remux": "Könnyű remux kell (nincs videó-újrakódolás)",
"transcode": "Transcode kell (nehezebb)"
"remux": "Direct Stream — könnyű remux (nincs videó-újrakódolás)",
"transcode": "Transcode kell (nehezebb)",
"transcode_hw": "Hardveres kódolóval transcode-olva",
"transcode_sw": "Szoftveresen transcode-olva (legnehezebb)"
},
"playlistAdd": {
"manage": "Listához adás",
+29
View File
@@ -3,6 +3,9 @@ import { describe, it, expect } from "vitest";
import {
coercePlexFilters,
plexFiltersSignature,
plexFiltersToParams,
parsePlexFilters,
plexFilterCount,
EMPTY_PLEX_FILTERS,
type PlexFilters,
} from "./plex";
@@ -54,12 +57,14 @@ describe("plexFiltersSignature", () => {
directors: [],
actors: [],
studios: [],
playable: [],
keywords: [],
collections: ["A"],
};
const b: PlexFilters = {
collections: ["A"],
keywords: [],
playable: [],
studios: [],
actors: [],
directors: [],
@@ -81,4 +86,28 @@ describe("plexFiltersSignature", () => {
const b: PlexFilters = { ...base(), genres: ["Comedy"] };
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
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;
@@ -24,6 +24,7 @@ const RICH_FILTERS: PlexFilters = {
actors: ["Bale", "Caine"],
actorMode: "all",
studios: ["Warner"],
playable: ["direct", "transcode_hw"],
keywords: ["heist"],
collections: ["1234"],
collectionMode: "all",