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).
132 lines
4.0 KiB
TypeScript
132 lines
4.0 KiB
TypeScript
// @vitest-environment jsdom
|
|
import { describe, expect, it } from "vitest";
|
|
import { EMPTY_PLEX_FILTERS, type PlexFilters } from "./api";
|
|
import { PLEX_SORT_FALLBACK } from "./plexSort";
|
|
import {
|
|
hasPlexParams,
|
|
plexParamsToState,
|
|
plexParamUpdates,
|
|
type PlexUrlState,
|
|
} from "./plexUrlState";
|
|
|
|
// A concrete non-default filter set exercising every wire shape (csv arrays, scalars, modes).
|
|
const RICH_FILTERS: PlexFilters = {
|
|
genres: ["Drama", "Comedy"],
|
|
genreMode: "all",
|
|
contentRatings: ["PG-13"],
|
|
yearMin: 2000,
|
|
yearMax: 2020,
|
|
ratingMin: 7,
|
|
durationMin: 600,
|
|
durationMax: 7200,
|
|
addedWithin: "1m",
|
|
directors: ["Nolan"],
|
|
actors: ["Bale", "Caine"],
|
|
actorMode: "all",
|
|
studios: ["Warner"],
|
|
playable: ["direct", "transcode_hw"],
|
|
keywords: ["heist"],
|
|
collections: ["1234"],
|
|
collectionMode: "all",
|
|
collectionTitles: { "1234": "Marvel" },
|
|
};
|
|
|
|
// Apply an updates delta onto a base URLSearchParams the way urlWithParams would (null deletes).
|
|
function applyUpdates(updates: Record<string, string | null>): URLSearchParams {
|
|
const p = new URLSearchParams();
|
|
for (const [k, v] of Object.entries(updates)) {
|
|
if (v != null) p.set(k, v);
|
|
}
|
|
return p;
|
|
}
|
|
|
|
const BASE: PlexUrlState = {
|
|
scope: "both",
|
|
sort: PLEX_SORT_FALLBACK,
|
|
show: "all",
|
|
filters: EMPTY_PLEX_FILTERS,
|
|
};
|
|
|
|
describe("plexParamUpdates", () => {
|
|
it("emits nothing for the all-default state", () => {
|
|
expect(plexParamUpdates(BASE)).toEqual({
|
|
pscope: null,
|
|
psort: null,
|
|
pshow: null,
|
|
pf: null,
|
|
});
|
|
});
|
|
|
|
it("clears every key when passed null (off-plex page)", () => {
|
|
expect(plexParamUpdates(null)).toEqual({
|
|
pscope: null,
|
|
psort: null,
|
|
pshow: null,
|
|
pf: null,
|
|
});
|
|
});
|
|
|
|
it("emits only non-default keys", () => {
|
|
const u = plexParamUpdates({
|
|
scope: "movie",
|
|
sort: { key: "year", dir: "desc" },
|
|
show: "in_progress",
|
|
filters: EMPTY_PLEX_FILTERS,
|
|
});
|
|
expect(u.pscope).toBe("movie");
|
|
expect(u.psort).toBe("year:desc");
|
|
expect(u.pshow).toBe("in_progress");
|
|
expect(u.pf).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("round-trip", () => {
|
|
it("restores a rich state from the emitted URL", () => {
|
|
const state: PlexUrlState = {
|
|
scope: "show",
|
|
sort: { key: "rating", dir: "desc" },
|
|
show: "watched",
|
|
filters: RICH_FILTERS,
|
|
};
|
|
const params = applyUpdates(plexParamUpdates(state));
|
|
const back = plexParamsToState(params, BASE);
|
|
expect(back.scope).toBe("show");
|
|
expect(back.sort).toEqual({ key: "rating", dir: "desc" });
|
|
expect(back.show).toBe("watched");
|
|
// collectionTitles is display-only (excluded from the wire), so compare the rest.
|
|
const { collectionTitles: _drop, ...wire } = RICH_FILTERS;
|
|
expect(back.filters).toEqual(wire);
|
|
});
|
|
|
|
it("keeps base values for absent keys (partial link)", () => {
|
|
const params = new URLSearchParams("psort=year:asc");
|
|
const back = plexParamsToState(params, {
|
|
scope: "movie",
|
|
sort: PLEX_SORT_FALLBACK,
|
|
show: "unwatched",
|
|
filters: EMPTY_PLEX_FILTERS,
|
|
});
|
|
expect(back.scope).toBe("movie"); // from base, no pscope
|
|
expect(back.show).toBe("unwatched"); // from base, no pshow
|
|
expect(back.sort).toEqual({ key: "year", dir: "asc" });
|
|
});
|
|
|
|
it("ignores an out-of-vocabulary scope/show/sort and falls back to base", () => {
|
|
const params = new URLSearchParams("pscope=bogus&pshow=nope&psort=key:sideways");
|
|
const back = plexParamsToState(params, BASE);
|
|
expect(back.scope).toBe("both");
|
|
expect(back.show).toBe("all");
|
|
expect(back.sort).toEqual(PLEX_SORT_FALLBACK); // bad dir → parseSortState null → base
|
|
});
|
|
});
|
|
|
|
describe("hasPlexParams", () => {
|
|
it("detects any namespaced key", () => {
|
|
expect(hasPlexParams(new URLSearchParams("pf=genres%3DDrama"))).toBe(true);
|
|
expect(hasPlexParams(new URLSearchParams("pscope=movie"))).toBe(true);
|
|
});
|
|
it("ignores the feed's bare keys", () => {
|
|
expect(hasPlexParams(new URLSearchParams("scope=all&sort=oldest&show=watched"))).toBe(false);
|
|
});
|
|
});
|