fix(plex,channels): deep-review fixes — durable watch-sync + no shared-link clobber
Deep-review of the quickfix batch surfaced two data/preference-loss bugs plus smaller items; all fixed with tests. - watch_sync _pull_apply: don't let the Plex-authoritative pull overwrite a DIRTY local row (a change whose immediate push failed) — that stranded the value forever since _repush_dirty only retries dirty rows. Leave it dirty for re-push. - PlexProvider / ChannelsProvider: a partial shared link opened in a fresh tab persisted mount-time DEFAULTS over the account's stored scope/sort/filters (base read before the account was pinned). Persist/keep only URL-present dims; re-hydrate the omitted ones from storage. - playback resume write-back: guard against out-of-order delivery (a superseded older push now drops) and evict the throttle map so it can't grow unbounded. - PlexPlayer: announce mid-playback buffering to assistive tech (role=status + sr-only label); new plex.player.buffering (HU+EN). - columnSort: hoist the shared key:dir sort codec used by both URL-state modules. - Tests: dirty-guard + flag-settle + per-key throttle scoping + superseded-push (backend); hold-repeat/suppress-click/unmount + partial-link no-clobber + refetch-doesn't-clobber-undo (frontend). Backend pytest 475, FE vitest 519.
This commit is contained in:
@@ -5,7 +5,7 @@
|
||||
// feed's URL-stripping can't delete them out from under the manager. Only non-default values are
|
||||
// emitted, so URLs stay clean. The manager's name search is deliberately EPHEMERAL (like the feed/plex
|
||||
// rail search) and stays OUT of the shared contract.
|
||||
import { parseSortState, type SortState } from "./columnSort";
|
||||
import { decodeSortParam, encodeSortParam, type SortState } from "./columnSort";
|
||||
import { type FilterMap } from "../components/tableHeader";
|
||||
|
||||
// The content state S8 mirrors. `view` is the raw tab string (the provider clamps it); `sort` is the
|
||||
@@ -20,18 +20,8 @@ export interface ChannelsUrlState {
|
||||
const KEYS = ["ctab", "csort", "cf"] as const;
|
||||
const VIEWS = ["subscribed", "discovery", "hidden", "blocked"];
|
||||
|
||||
// Sort ⇄ `key:dir` (e.g. `subscribers:desc`). Column sort keys are simple words (no colon), so a plain
|
||||
// split is safe. There is no default sort (fallback null), so any sort emits.
|
||||
function encodeSort(s: SortState): string | null {
|
||||
return s ? `${s.key}:${s.dir}` : null;
|
||||
}
|
||||
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 });
|
||||
}
|
||||
|
||||
// Sort ⇄ `key:dir` (e.g. `subscribers:desc`) via the shared codec in columnSort. There is no default
|
||||
// sort here (fallback null), so any sort emits — no default short-circuit needed.
|
||||
// The FilterMap mixes scalar column filters (priority=select, name=text, the sync chip) with array ones
|
||||
// (tags=multi), so it's carried as a compact JSON blob rather than a nested query string — JSON round-
|
||||
// trips both shapes exactly (and future multi columns for free), and the values are opaque enum/id
|
||||
@@ -72,7 +62,7 @@ export function channelsParamUpdates(
|
||||
if (!state) return { ctab: null, csort: null, cf: null };
|
||||
return {
|
||||
ctab: state.view && state.view !== "subscribed" ? state.view : null,
|
||||
csort: encodeSort(state.sort),
|
||||
csort: encodeSortParam(state.sort),
|
||||
cf: encodeFilters(state.filters),
|
||||
};
|
||||
}
|
||||
@@ -92,7 +82,7 @@ export function channelsParamsToState(
|
||||
const csort = params.get("csort");
|
||||
return {
|
||||
view: tab && VIEWS.includes(tab) ? tab : base.view,
|
||||
sort: csort != null ? (decodeSort(csort) ?? base.sort) : base.sort,
|
||||
sort: csort != null ? (decodeSortParam(csort) ?? base.sort) : base.sort,
|
||||
filters: params.has("cf") ? decodeFilters(params.get("cf") ?? "") : base.filters,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -34,6 +34,22 @@ export function parseSortState(raw: unknown): SortState {
|
||||
return typeof key === "string" && (dir === "asc" || dir === "desc") ? { key, dir } : null;
|
||||
}
|
||||
|
||||
/** Encode a SortState to the compact `key:dir` wire form the shareable-URL modules (plex/channels)
|
||||
* put in the address bar. Sort keys are simple words (no colon), so the plain join round-trips. Returns
|
||||
* null for no-sort. A module with a default sort short-circuits that to null at its own call site. */
|
||||
export function encodeSortParam(s: SortState): string | null {
|
||||
return s ? `${s.key}:${s.dir}` : null;
|
||||
}
|
||||
|
||||
/** Inverse of `encodeSortParam`: parse `key:dir` (or a bare key → asc) from the URL back into a
|
||||
* SortState, healing anything malformed to null. One codec for both URL-state modules — no drift. */
|
||||
export function decodeSortParam(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 header-click cycle: unsorted → ascending → descending → unsorted. */
|
||||
export function cycleSort(current: SortState, key: string): SortState {
|
||||
if (!current || current.key !== key) return { key, dir: "asc" };
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
// 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 { decodeSortParam, encodeSortParam, 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
|
||||
@@ -22,17 +22,11 @@ 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.
|
||||
// Sort ⇄ `key:dir` (e.g. `year:desc`) via the shared codec in columnSort; the Plex 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 });
|
||||
return encodeSortParam(s);
|
||||
}
|
||||
|
||||
/** The address-bar delta for the Plex content state: each namespaced key mapped to its emitted value,
|
||||
@@ -61,7 +55,7 @@ export function plexParamsToState(params: URLSearchParams, base: 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;
|
||||
const sort = psort != null ? (decodeSortParam(psort) ?? base.sort) : base.sort;
|
||||
return {
|
||||
scope: scope && SCOPES.includes(scope) ? scope : base.scope,
|
||||
sort,
|
||||
|
||||
Reference in New Issue
Block a user