The Plex player names the playlist it plays through (v=player:<id>:playlist:<pid>), so a bookmark rebuilds the queue from the playlist's own order while the item is still in it. Found in the browser and fixed: - after a reload the shell's player opened a second one over Home and the feed: it read the catalog row the page had fetched (a disabled query still reads the shared cache); it now waits for ready. - every reload under an open overlay left a dead history step: the first overlay to open takes over the entry the document was loaded on. - the feed's address was implicit, the same as the start-page landing, so a reloaded or bookmarked feed opened as Home: the feed now names itself (page=feed) like every page. - a linked file or video that is gone was said, but stayed in the address and was said again on every reload: its keys are taken off. The overlay address is written in one place (the entry is named once it is current), and the player keys only the codec reads are no longer exported.
153 lines
4.7 KiB
TypeScript
153 lines
4.7 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("writes the default view in full: this account remembers one a link must not inherit", () => {
|
|
expect(plexParamUpdates(BASE)).toEqual({
|
|
pscope: "both",
|
|
psort: "title:asc",
|
|
pshow: "all",
|
|
pf: "",
|
|
});
|
|
});
|
|
|
|
it("writes an unsorted view as such, so a link to it is not sorted by the remembered sort", () => {
|
|
const unsorted = { ...BASE, sort: null };
|
|
expect(plexParamUpdates(unsorted).psort).toBe("none");
|
|
const back = plexParamsToState(applyUpdates(plexParamUpdates(unsorted)), {
|
|
...BASE,
|
|
sort: { key: "year", dir: "desc" },
|
|
});
|
|
expect(back?.sort).toBeNull();
|
|
});
|
|
|
|
it("a link to the default view opens it over another remembered one", () => {
|
|
const params = applyUpdates(plexParamUpdates(BASE));
|
|
const back = plexParamsToState(params, {
|
|
scope: "movie",
|
|
sort: { key: "year", dir: "desc" },
|
|
show: "watched",
|
|
filters: RICH_FILTERS,
|
|
});
|
|
expect(back).toEqual(BASE);
|
|
});
|
|
|
|
it("clears every key when passed null (off-plex page)", () => {
|
|
expect(plexParamUpdates(null)).toEqual({
|
|
pscope: null,
|
|
psort: null,
|
|
pshow: null,
|
|
pf: null,
|
|
});
|
|
});
|
|
|
|
it("writes the view picked", () => {
|
|
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).toBe("");
|
|
});
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|