feat(plex): shareable Plex-browse state in the URL — scope+filters+sort+show (S4)

Mirror the Plex-browse content state into the address bar so a browse view is
bookmarkable/shareable, the Plex analogue of the feed's urlState. Keys are
NAMESPACED (pscope/psort/pshow/pf) rather than reusing the feed's bare
scope/sort/show: FeedFiltersProvider actively strips its own KEYS from the URL
whenever off the feed page, so a shared key would be deleted out from under the
Plex page. The namespaced keys are Plex-unique (like ?v=/?pl), so no other
provider ever reads or clears them.

- New lib/plexUrlState.ts: plexParamUpdates / plexParamsToState / hasPlexParams.
  The pf filter blob reuses the SAME appendPlexFilters wire serializer (new
  public plexFiltersToParams + inverse parsePlexFilters, kept next to each other
  so a new wire key can't be added on one side only).
- PlexProvider: URL-seed wins over localStorage at mount; the account-pin
  rehydrate persists the seed instead of clobbering it (mirrors feed's
  initialHadUrlFilters); useUrlMirror mirrors/clears the 4 keys, gated on
  page===plex. q/field stay out of the shareable contract.
- NavigationProvider: a page switch now also nulls the 4 plex keys.

Tests: 8 new round-trip cases in plexUrlState.dom.test.ts (FE vitest 483→491).
This commit is contained in:
2026-08-27 03:14:54 +02:00
parent 8299ea72b7
commit 56a2ecdec3
6 changed files with 318 additions and 7 deletions
+2
View File
@@ -62,6 +62,8 @@ export {
plexFilterCount,
coercePlexFilters,
plexFiltersSignature,
plexFiltersToParams,
parsePlexFilters,
isIntrinsicReadOnly,
} from "./api/plex";
import { messagingApi } from "./api/messaging";
+42
View File
@@ -334,6 +334,48 @@ function appendPlexFilters(u: URLSearchParams, f: PlexFilters): void {
if (f.collections?.length) u.set("collections", f.collections.join(","));
if (f.collectionMode === "all") u.set("collection_mode", "all");
}
// Public wrapper over the private appendPlexFilters so the URL mirror (plexUrlState) serializes the
// filter set through the SAME single source the API request uses — the two can never drift. Display-only
// titles (collectionTitles) stay out, exactly like the wire params and the saved-view signature.
export function plexFiltersToParams(f: PlexFilters): URLSearchParams {
const u = new URLSearchParams();
appendPlexFilters(u, f);
return u;
}
// The inverse of appendPlexFilters: parse a filter sub-querystring back into a full PlexFilters (missing
// keys → EMPTY defaults). Kept next to its encoder so a new wire key can't be added on one side only.
export function parsePlexFilters(p: URLSearchParams): PlexFilters {
const csv = (k: string): string[] => {
const v = p.get(k);
return v ? v.split(",").filter(Boolean) : [];
};
const num = (k: string): number | undefined => {
if (!p.has(k)) return undefined;
const n = Number(p.get(k));
return Number.isFinite(n) ? n : undefined;
};
const f: PlexFilters = { ...EMPTY_PLEX_FILTERS };
f.genres = csv("genres");
if (p.get("genre_mode") === "all") f.genreMode = "all";
f.contentRatings = csv("content_ratings");
f.yearMin = num("year_min");
f.yearMax = num("year_max");
f.ratingMin = num("rating_min");
f.durationMin = num("duration_min");
f.durationMax = num("duration_max");
const aw = p.get("added_within");
if (aw) f.addedWithin = aw;
f.directors = csv("directors");
f.actors = csv("actors");
if (p.get("actor_mode") === "all") f.actorMode = "all";
f.studios = csv("studios");
f.keywords = csv("keywords");
f.collections = csv("collections");
if (p.get("collection_mode") === "all") f.collectionMode = "all";
return f;
}
export interface PlexFacets {
genres: { value: string; count: number }[];
content_ratings: { value: string; count: number }[];
+130
View File
@@ -0,0 +1,130 @@
// @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"],
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);
});
});
+73
View File
@@ -0,0 +1,73 @@
// Serialize the Plex-browse content state (library scope + watch-state + sort + metadata filters) to/
// from the address bar so a Plex-browse view is bookmarkable/shareable — the S4 analogue of the feed's
// urlState. Keys are NAMESPACED (`pscope`/`psort`/`pshow`/`pf`) rather than reusing the feed's bare
// `scope`/`sort`/`show`: FeedFiltersProvider actively STRIPS its own KEYS from the URL whenever the app
// is off the feed page, so a shared key would be deleted out from under the Plex page. The namespaced
// keys are Plex-unique (like `?v=`/`?pl`), so no other provider ever reads or clears them. Only non-
// default values are emitted, so URLs stay clean.
import { parsePlexFilters, plexFiltersToParams, type PlexFilters } from "./api";
import { parseSortState, type SortState } from "./columnSort";
import { PLEX_SORT_FALLBACK } from "./plexSort";
// The content state S4 mirrors. `sort` is the RAW provider SortState (scope-healed only at the point of
// use), so the user's real preference round-trips through the URL.
export interface PlexUrlState {
scope: string;
sort: SortState;
show: string;
filters: PlexFilters;
}
const KEYS = ["pscope", "psort", "pshow", "pf"] as const;
const SCOPES = ["movie", "show", "both"];
const SHOWS = ["all", "unwatched", "in_progress", "watched"];
// Sort ⇄ `key:dir` (e.g. `year:desc`). Plex sort keys are simple words (no colon), so a plain split is
// safe. The default (`title:asc`) emits nothing.
function encodeSort(s: SortState): string | null {
if (!s || (s.key === PLEX_SORT_FALLBACK?.key && s.dir === PLEX_SORT_FALLBACK?.dir)) return null;
return `${s.key}:${s.dir}`;
}
function decodeSort(raw: string): SortState {
const i = raw.indexOf(":");
const key = i === -1 ? raw : raw.slice(0, i);
const dir = i === -1 ? "asc" : raw.slice(i + 1);
return parseSortState({ key, dir });
}
/** The address-bar delta for the Plex content state: each namespaced key mapped to its emitted value,
* or null to delete it (so a control back at its default clears the key). Pass `null` to clear ALL Plex
* keys — used when the app is off the Plex page (localStorage still holds the state; it re-mirrors on
* return). Feed the result into urlWithParams(). */
export function plexParamUpdates(state: PlexUrlState | null): Record<string, string | null> {
if (!state) return { pscope: null, psort: null, pshow: null, pf: null };
const pf = plexFiltersToParams(state.filters).toString();
return {
pscope: state.scope && state.scope !== "both" ? state.scope : null,
psort: encodeSort(state.sort),
pshow: state.show && state.show !== "all" ? state.show : null,
pf: pf || null,
};
}
/** True if the query string carries any Plex content key we recognize (a shared Plex-browse link). */
export function hasPlexParams(params: URLSearchParams): boolean {
return KEYS.some((k) => params.has(k));
}
/** Merge the Plex content keys from the URL over a base state (the localStorage-seeded defaults); an
* absent or invalid key keeps the base value, so a partial link (only `?psort=`) is honoured. */
export function plexParamsToState(params: URLSearchParams, base: PlexUrlState): PlexUrlState {
const scope = params.get("pscope");
const show = params.get("pshow");
const psort = params.get("psort");
const sort = psort != null ? (decodeSort(psort) ?? base.sort) : base.sort;
return {
scope: scope && SCOPES.includes(scope) ? scope : base.scope,
sort,
show: show && SHOWS.includes(show) ? show : base.show,
filters: params.has("pf")
? parsePlexFilters(new URLSearchParams(params.get("pf") ?? ""))
: base.filters,
};
}