From 56a2ecdec320dfe9c26debbf10058cb4eaa7ade2 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Thu, 27 Aug 2026 03:14:54 +0200 Subject: [PATCH] =?UTF-8?q?feat(plex):=20shareable=20Plex-browse=20state?= =?UTF-8?q?=20in=20the=20URL=20=E2=80=94=20scope+filters+sort+show=20(S4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- .../src/components/NavigationProvider.tsx | 16 ++- frontend/src/components/PlexProvider.tsx | 62 ++++++++- frontend/src/lib/api.ts | 2 + frontend/src/lib/api/plex.ts | 42 ++++++ frontend/src/lib/plexUrlState.dom.test.ts | 130 ++++++++++++++++++ frontend/src/lib/plexUrlState.ts | 73 ++++++++++ 6 files changed, 318 insertions(+), 7 deletions(-) create mode 100644 frontend/src/lib/plexUrlState.dom.test.ts create mode 100644 frontend/src/lib/plexUrlState.ts diff --git a/frontend/src/components/NavigationProvider.tsx b/frontend/src/components/NavigationProvider.tsx index f3a5e7f7..64008888 100644 --- a/frontend/src/components/NavigationProvider.tsx +++ b/frontend/src/components/NavigationProvider.tsx @@ -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(); }, diff --git a/frontend/src/components/PlexProvider.tsx b/frontend/src/components/PlexProvider.tsx index 2c18da71..ed4b97ed 100644 --- a/frontend/src/components/PlexProvider.tsx +++ b/frontend/src/components/PlexProvider.tsx @@ -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); + } 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(readPlexScope); - const [show, setShowState] = useState(readPlexShow); - const [sort, setSortState] = useState(readPlexSort); + const [scope, setScopeState] = useState(() => urlSeed?.scope ?? readPlexScope()); + const [show, setShowState] = useState(() => urlSeed?.show ?? readPlexShow()); + const [sort, setSortState] = useState(() => urlSeed?.sort ?? readPlexSort()); // The expanded Plex filters live as one JSON blob (the string-only account store serializes it). - const [filtersRaw, setFiltersRaw] = useState(readPlexFiltersRaw); + const [filtersRaw, setFiltersRaw] = useState(() => + urlSeed ? JSON.stringify(urlSeed.filters) : readPlexFiltersRaw(), + ); const filters = useMemo(() => { 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(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 ( {children} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index d83ce77e..b3e91cfd 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -62,6 +62,8 @@ export { plexFilterCount, coercePlexFilters, plexFiltersSignature, + plexFiltersToParams, + parsePlexFilters, isIntrinsicReadOnly, } from "./api/plex"; import { messagingApi } from "./api/messaging"; diff --git a/frontend/src/lib/api/plex.ts b/frontend/src/lib/api/plex.ts index 44156b70..b8950c4e 100644 --- a/frontend/src/lib/api/plex.ts +++ b/frontend/src/lib/api/plex.ts @@ -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 }[]; diff --git a/frontend/src/lib/plexUrlState.dom.test.ts b/frontend/src/lib/plexUrlState.dom.test.ts new file mode 100644 index 00000000..3641d1a0 --- /dev/null +++ b/frontend/src/lib/plexUrlState.dom.test.ts @@ -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): 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); + }); +}); diff --git a/frontend/src/lib/plexUrlState.ts b/frontend/src/lib/plexUrlState.ts new file mode 100644 index 00000000..4bbcb0a3 --- /dev/null +++ b/frontend/src/lib/plexUrlState.ts @@ -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 { + 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, + }; +}