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:
@@ -126,12 +126,22 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
|
||||
// Push an in-app history entry so browser/mouse Back steps through pages instead of leaving the
|
||||
// app. A fresh { sfPage } (no carried-over _yt/_chan markers) so each page starts at its root, and
|
||||
// the address bar reflects the page (feed is implicit) while clearing the view-specific params
|
||||
// (chan/v/pl) — a page switch resets to that module's root. Feed filters (owned by
|
||||
// FeedFiltersProvider) are left intact.
|
||||
// (chan/v/pl and the plex content keys pscope/psort/pshow/pf) — a page switch resets to that
|
||||
// module's root. Feed filters (owned by FeedFiltersProvider) are left intact; the plex keys
|
||||
// re-mirror from PlexProvider on return to the plex page.
|
||||
window.history.pushState(
|
||||
{ sfPage: next, sfScroll: nextNavId() },
|
||||
"",
|
||||
urlWithParams({ page: next === "feed" ? null : next, chan: null, v: null, pl: null }),
|
||||
urlWithParams({
|
||||
page: next === "feed" ? null : next,
|
||||
chan: null,
|
||||
v: null,
|
||||
pl: null,
|
||||
pscope: null,
|
||||
psort: null,
|
||||
pshow: null,
|
||||
pf: null,
|
||||
}),
|
||||
);
|
||||
notifyNav();
|
||||
},
|
||||
|
||||
@@ -3,8 +3,16 @@ import { coercePlexFilters, EMPTY_PLEX_FILTERS, type PlexFilters } from "../lib/
|
||||
import { parseSortState, type SortState } from "../lib/columnSort";
|
||||
import { parsePlexField, type PlexSearchField } from "../lib/plexSearch";
|
||||
import { PLEX_SORT_FALLBACK } from "../lib/plexSort";
|
||||
import {
|
||||
hasPlexParams,
|
||||
plexParamsToState,
|
||||
plexParamUpdates,
|
||||
type PlexUrlState,
|
||||
} from "../lib/plexUrlState";
|
||||
import { getAccountRaw, LS, setAccountRaw } from "../lib/storage";
|
||||
import { useAccountRehydrate } from "../lib/useAccountRehydrate";
|
||||
import { useUrlMirror } from "../lib/useUrlMirror";
|
||||
import { useNavigation } from "./NavigationProvider";
|
||||
|
||||
// Owns the Plex module's shared local state — the left-rail library scope / watch-state / sort /
|
||||
// expanded filters, the header search term, and the "open this playlist" hand-off — lifted out of
|
||||
@@ -112,14 +120,41 @@ function readPlexSort(): SortState {
|
||||
return { key: raw, dir };
|
||||
}
|
||||
|
||||
// S4: a shared Plex-browse link carries the content state in namespaced URL keys (pscope/psort/pshow/
|
||||
// pf). When present, the URL wins over this account's stored view (like the feed's URL-over-localStorage
|
||||
// seed) — read once at mount. The keys are Plex-unique, so their mere presence identifies a shared Plex
|
||||
// view; no ?page check is needed (and the mirror below is page-gated so it never pollutes other pages).
|
||||
function readPlexFilters(): PlexFilters {
|
||||
try {
|
||||
return coercePlexFilters(JSON.parse(readPlexFiltersRaw()) as Record<string, unknown>);
|
||||
} catch {
|
||||
return EMPTY_PLEX_FILTERS;
|
||||
}
|
||||
}
|
||||
function readPlexUrlSeed(): PlexUrlState | null {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
if (!hasPlexParams(params)) return null;
|
||||
return plexParamsToState(params, {
|
||||
scope: readPlexScope(),
|
||||
sort: readPlexSort(),
|
||||
show: readPlexShow(),
|
||||
filters: readPlexFilters(),
|
||||
});
|
||||
}
|
||||
|
||||
export function PlexProvider({ children }: { children: ReactNode }) {
|
||||
// Captured once: the shared-link content state (or null). Seeds the slices below and, on account pin,
|
||||
// is persisted to the account instead of being clobbered by the stored view (see the rehydrate).
|
||||
const [urlSeed] = useState(readPlexUrlSeed);
|
||||
// The former per-library picker is now a cross-library SCOPE: movie | show | both (unified library).
|
||||
// (Reuses the old LS.plexLibrary key; an old stored library-id value falls back to "both".)
|
||||
const [scope, setScopeState] = useState<string>(readPlexScope);
|
||||
const [show, setShowState] = useState<string>(readPlexShow);
|
||||
const [sort, setSortState] = useState<SortState>(readPlexSort);
|
||||
const [scope, setScopeState] = useState<string>(() => urlSeed?.scope ?? readPlexScope());
|
||||
const [show, setShowState] = useState<string>(() => urlSeed?.show ?? readPlexShow());
|
||||
const [sort, setSortState] = useState<SortState>(() => urlSeed?.sort ?? readPlexSort());
|
||||
// The expanded Plex filters live as one JSON blob (the string-only account store serializes it).
|
||||
const [filtersRaw, setFiltersRaw] = useState<string>(readPlexFiltersRaw);
|
||||
const [filtersRaw, setFiltersRaw] = useState<string>(() =>
|
||||
urlSeed ? JSON.stringify(urlSeed.filters) : readPlexFiltersRaw(),
|
||||
);
|
||||
const filters = useMemo<PlexFilters>(() => {
|
||||
try {
|
||||
// coercePlexFilters fills defaults, drops the legacy `sortDir` (direction now lives in the sort
|
||||
@@ -140,7 +175,19 @@ export function PlexProvider({ children }: { children: ReactNode }) {
|
||||
const [jumpTo, setJumpTo] = useState<PlexJumpTarget | null>(null);
|
||||
|
||||
// Re-read every per-account slice once the account is pinned after mount (first load on a fresh tab).
|
||||
// A shared-link seed is the exception: instead of reloading the account's stored view, PERSIST the URL
|
||||
// state to this account (so the shared view wins and survives a later plain reload), and adopt only the
|
||||
// non-shared slices (q/field) from storage. Mirrors FeedFiltersProvider's initialHadUrlFilters branch.
|
||||
const markDirty = useAccountRehydrate(() => {
|
||||
if (urlSeed) {
|
||||
setAccountRaw(LS.plexLibrary, urlSeed.scope);
|
||||
setAccountRaw(LS.plexShow, urlSeed.show);
|
||||
setAccountRaw(LS.plexSort, JSON.stringify(urlSeed.sort));
|
||||
setAccountRaw(LS.plexFilters, JSON.stringify(urlSeed.filters));
|
||||
setQState(readPlexQ());
|
||||
setFieldState(readPlexField());
|
||||
return;
|
||||
}
|
||||
setScopeState(readPlexScope());
|
||||
setShowState(readPlexShow());
|
||||
setSortState(readPlexSort());
|
||||
@@ -208,6 +255,13 @@ export function PlexProvider({ children }: { children: ReactNode }) {
|
||||
[setScope, setShow, setSort, setFilters, setQ, setField],
|
||||
);
|
||||
|
||||
// S4: mirror the content state (scope + sort + watch-state + filters) into the address bar under the
|
||||
// namespaced keys so a Plex-browse view is shareable. Gated on page===plex: off the Plex page the keys
|
||||
// are cleared (localStorage still holds the state; it re-mirrors on return). `q`/`field` stay OUT — the
|
||||
// search term is deliberately transient-per-view and not part of the shared four-key contract.
|
||||
const { page } = useNavigation();
|
||||
useUrlMirror(plexParamUpdates(page === "plex" ? { scope, sort, show, filters } : null));
|
||||
|
||||
return (
|
||||
<ActionsContext.Provider value={actions}>
|
||||
<StateContext.Provider value={state}>{children}</StateContext.Provider>
|
||||
|
||||
@@ -62,6 +62,8 @@ export {
|
||||
plexFilterCount,
|
||||
coercePlexFilters,
|
||||
plexFiltersSignature,
|
||||
plexFiltersToParams,
|
||||
parsePlexFilters,
|
||||
isIntrinsicReadOnly,
|
||||
} from "./api/plex";
|
||||
import { messagingApi } from "./api/messaging";
|
||||
|
||||
@@ -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 }[];
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user