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:
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user