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:
2026-08-27 23:54:44 +02:00
parent 7fbb996297
commit f80ed115ad
14 changed files with 403 additions and 60 deletions
+5 -15
View File
@@ -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,
};
}
+16
View File
@@ -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" };
+5 -11
View File
@@ -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,