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:
@@ -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. */}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user