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:
@@ -369,6 +369,14 @@ def _pull_apply(
|
||||
# `_same_state`; a row that reaches here really is out of step with the source of truth. The
|
||||
# `_same_state` + `synced_to_plex` short-circuit above still absorbs the freshly-pushed case (Plex
|
||||
# echoes the same shape a beat later) so a just-pushed row never bounces.
|
||||
if st is not None and not st.synced_to_plex:
|
||||
# Durability guard: this row holds a local change that never reached Plex — its immediate push
|
||||
# failed, so it's still dirty. Do NOT let the Plex-authoritative pull overwrite it and clear the
|
||||
# flag: that would strand the local value forever, since `_repush_dirty` only retries dirty rows.
|
||||
# Leave it dirty; `_repush_dirty` (later in this same run) pushes the local value up to Plex. This
|
||||
# closes the "Plex flapped mid-session" lost-update — the one case the Plex-wins comment's
|
||||
# "almost always already pushed" assumption above excludes.
|
||||
return None
|
||||
if st is None:
|
||||
st = PlexState(user_id=user_id, item_id=item_id)
|
||||
db.add(st)
|
||||
|
||||
@@ -4,7 +4,7 @@ import hashlib
|
||||
import os
|
||||
import tempfile
|
||||
import threading
|
||||
from datetime import datetime, timezone
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import httpx
|
||||
from fastapi import (
|
||||
@@ -171,15 +171,33 @@ _push_throttle_lock = threading.Lock()
|
||||
def _should_push_resume(user_id: int, item_id: int, now: datetime, final: bool) -> bool:
|
||||
"""Whether to mirror this resume checkpoint to Plex now. `final` always pushes; a periodic tick
|
||||
pushes only when ≥_PUSH_THROTTLE_S has elapsed since the last push for this (user, item). Records
|
||||
`now` as the last-push time whenever it returns True so the window is measured from the real push."""
|
||||
`now` as the last-push time whenever it returns True so the window is measured from the real push
|
||||
(and so `_resume_push_superseded` can drop an older, still-pending push — see there)."""
|
||||
with _push_throttle_lock:
|
||||
last = _last_push_at.get((user_id, item_id))
|
||||
if final or last is None or (now - last).total_seconds() >= _PUSH_THROTTLE_S:
|
||||
_last_push_at[(user_id, item_id)] = now
|
||||
# Opportunistic eviction so the map can't grow unbounded over a long-lived process (one
|
||||
# entry per item ever checkpointed). Anything older than a few windows is dead — a push
|
||||
# completes in seconds, so a stale entry protects nothing.
|
||||
if len(_last_push_at) > 2048:
|
||||
cutoff = now - timedelta(seconds=_PUSH_THROTTLE_S * 6)
|
||||
for k in [k for k, t in _last_push_at.items() if t < cutoff]:
|
||||
del _last_push_at[k]
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _resume_push_superseded(user_id: int, item_id: int, intent: datetime) -> bool:
|
||||
"""True if a newer resume push for this (user, item) was scheduled after `intent`. Called at the
|
||||
HTTP boundary of a background resume push: because periodic and seek/pause pushes are independent
|
||||
background tasks with no ordering guard, an older still-pending periodic push could otherwise land
|
||||
at Plex AFTER a newer backward-seek push and — with the Plex-authoritative pull — durably revive the
|
||||
stale position. Skipping a superseded push keeps the last-scheduled position the one Plex settles on."""
|
||||
with _push_throttle_lock:
|
||||
return _last_push_at.get((user_id, item_id), intent) > intent
|
||||
|
||||
|
||||
@router.get("/person-image")
|
||||
def person_image(u: str, request: Request) -> Response:
|
||||
"""Proxy a cast/crew photo from Plex's public metadata CDN (host-whitelisted; no token needed).
|
||||
@@ -202,6 +220,26 @@ def person_image(u: str, request: Request) -> Response:
|
||||
return imgcache.response(r.content, ct)
|
||||
|
||||
|
||||
def _push_resume_guarded(
|
||||
user_id: int,
|
||||
item_id: int,
|
||||
rating_key: str,
|
||||
time_ms: int,
|
||||
duration_ms: int,
|
||||
expect_ts: datetime | None,
|
||||
intent: datetime,
|
||||
) -> None:
|
||||
"""Background entrypoint for a resume push that drops itself if a newer resume push for the same
|
||||
(user, item) was scheduled after it (see `_resume_push_superseded`), then delegates to the real
|
||||
push. This narrows the periodic-vs-seek write-ordering race to the (rare) case where the older push
|
||||
has already left the process before the newer one is scheduled."""
|
||||
if _resume_push_superseded(user_id, item_id, intent):
|
||||
return
|
||||
plex_watch.push_state_to_plex(
|
||||
user_id, item_id, rating_key, "resume", time_ms, duration_ms, expect_ts
|
||||
)
|
||||
|
||||
|
||||
def _push_watch(
|
||||
background: BackgroundTasks,
|
||||
db: Session,
|
||||
@@ -211,10 +249,14 @@ def _push_watch(
|
||||
action: str,
|
||||
time_ms: int = 0,
|
||||
duration_ms: int = 0,
|
||||
intent: datetime | None = None,
|
||||
) -> None:
|
||||
"""Schedule a best-effort Siftlode→Plex watch push, but only for a user whose owner link has
|
||||
push enabled — non-owner users never touch Plex. Guarding here (with the request session) avoids
|
||||
spinning up a throwaway background session on every state change for the common no-link case."""
|
||||
spinning up a throwaway background session on every state change for the common no-link case.
|
||||
|
||||
A `resume` push with an `intent` timestamp is routed through `_push_resume_guarded` so a superseded
|
||||
(older) push can't land out of order; `watched`/`unwatched` scrobbles go straight through."""
|
||||
if plex_watch.link_for_push(db, user_id) is None:
|
||||
return
|
||||
# Capture the row's freshest timestamp NOW so the background push can tell if a newer local edit
|
||||
@@ -226,6 +268,12 @@ def _push_watch(
|
||||
.scalars()
|
||||
.first()
|
||||
)
|
||||
marker = plex_watch._state_marker(st)
|
||||
if action == "resume" and intent is not None:
|
||||
background.add_task(
|
||||
_push_resume_guarded, user_id, item_id, rating_key, time_ms, duration_ms, marker, intent
|
||||
)
|
||||
return
|
||||
background.add_task(
|
||||
plex_watch.push_state_to_plex,
|
||||
user_id,
|
||||
@@ -234,7 +282,7 @@ def _push_watch(
|
||||
action,
|
||||
time_ms,
|
||||
duration_ms,
|
||||
plex_watch._state_marker(st),
|
||||
marker,
|
||||
)
|
||||
|
||||
|
||||
@@ -284,6 +332,10 @@ def item_progress(
|
||||
st.position_seconds = 0
|
||||
st.progress_updated_at = now
|
||||
st.synced_to_plex = False
|
||||
# Terminal state for resume write-back: drop the throttle/ordering entry so the map doesn't
|
||||
# retain a dead key for every finished item.
|
||||
with _push_throttle_lock:
|
||||
_last_push_at.pop((user.id, it.id), None)
|
||||
db.commit()
|
||||
# Immediate on the watched transition; skip a repeat scrobble if it was already watched
|
||||
# (a later checkpoint of the same finished item would otherwise bump Plex's viewCount again).
|
||||
@@ -321,6 +373,7 @@ def item_progress(
|
||||
"resume",
|
||||
position * 1000,
|
||||
duration * 1000,
|
||||
intent=now,
|
||||
)
|
||||
return {"position_seconds": position}
|
||||
|
||||
|
||||
@@ -14,7 +14,11 @@ from sqlalchemy import select
|
||||
|
||||
from app.models import PlexItem, PlexLibrary, PlexState, User
|
||||
from app.plex.watch_sync import _pull_apply
|
||||
from app.routes.plex.playback import _PUSH_THROTTLE_S, _should_push_resume
|
||||
from app.routes.plex.playback import (
|
||||
_PUSH_THROTTLE_S,
|
||||
_resume_push_superseded,
|
||||
_should_push_resume,
|
||||
)
|
||||
|
||||
|
||||
def _seed_item_and_user(db):
|
||||
@@ -29,9 +33,10 @@ def _seed_item_and_user(db):
|
||||
return item.id, user.id
|
||||
|
||||
|
||||
def test_pull_plex_wins_over_newer_local_resume(db):
|
||||
"""The core S2 change: a locally-in-progress item whose local edit is NEWER than Plex's still
|
||||
resolves to PLEX's position on the incremental pull — Plex is authoritative, no LWW."""
|
||||
def test_pull_plex_wins_over_diverging_synced_local_resume(db):
|
||||
"""The core S2 change: a SYNCED local row (its state already reached Plex) that later diverges from
|
||||
Plex = a real external Plex-side change, so the incremental pull resolves to PLEX's position — Plex
|
||||
is authoritative, no LWW. (A DIRTY local row is protected instead — see the dirty-guard test.)"""
|
||||
item_id, user_id = _seed_item_and_user(db)
|
||||
now = datetime.now(timezone.utc)
|
||||
db.add(
|
||||
@@ -41,7 +46,7 @@ def test_pull_plex_wins_over_newer_local_resume(db):
|
||||
status="new",
|
||||
position_seconds=300,
|
||||
progress_updated_at=now, # local touched just now — "newer" than Plex
|
||||
synced_to_plex=False,
|
||||
synced_to_plex=True, # but already pushed → a later Plex divergence is an external change
|
||||
)
|
||||
)
|
||||
db.flush()
|
||||
@@ -62,8 +67,8 @@ def test_pull_plex_wins_over_newer_local_resume(db):
|
||||
assert st.synced_to_plex is True
|
||||
|
||||
|
||||
def test_pull_plex_watched_wins_over_local_in_progress(db):
|
||||
"""Plex marking the item watched propagates even over a fresh local in-progress row."""
|
||||
def test_pull_plex_watched_wins_over_synced_local_in_progress(db):
|
||||
"""Plex marking the item watched propagates over a SYNCED local in-progress row (external change)."""
|
||||
item_id, user_id = _seed_item_and_user(db)
|
||||
now = datetime.now(timezone.utc)
|
||||
db.add(
|
||||
@@ -73,7 +78,7 @@ def test_pull_plex_watched_wins_over_local_in_progress(db):
|
||||
status="new",
|
||||
position_seconds=300,
|
||||
progress_updated_at=now,
|
||||
synced_to_plex=False,
|
||||
synced_to_plex=True,
|
||||
)
|
||||
)
|
||||
db.flush()
|
||||
@@ -125,6 +130,104 @@ def test_pull_no_bounce_on_matching_row(db):
|
||||
assert st.position_seconds == 100, "no churn to the stored position"
|
||||
|
||||
|
||||
def test_pull_keeps_dirty_local_on_divergence(db):
|
||||
"""Durability guard: a DIRTY row (a local change whose immediate push failed → synced_to_plex=False)
|
||||
must NOT be overwritten by a diverging Plex signal, and must stay dirty so `_repush_dirty` can push
|
||||
the local value up. Overwriting-and-clearing here would strand the local change forever."""
|
||||
item_id, user_id = _seed_item_and_user(db)
|
||||
now = datetime.now(timezone.utc)
|
||||
db.add(
|
||||
PlexState(
|
||||
user_id=user_id,
|
||||
item_id=item_id,
|
||||
status="watched", # user just marked watched locally; the scrobble to Plex failed
|
||||
position_seconds=0,
|
||||
watched_at=now,
|
||||
progress_updated_at=now,
|
||||
synced_to_plex=False, # dirty — never reached Plex
|
||||
)
|
||||
)
|
||||
db.flush()
|
||||
|
||||
# Plex still lists it in on_deck at an old in-progress offset (it never got the watched scrobble).
|
||||
changed = _pull_apply(db, user_id, item_id, "new", 240, plex_ts=now - timedelta(seconds=60))
|
||||
db.flush()
|
||||
|
||||
assert changed is None, "a dirty local row must not be clobbered by the pull"
|
||||
st = (
|
||||
db.execute(
|
||||
select(PlexState).where(PlexState.user_id == user_id, PlexState.item_id == item_id)
|
||||
)
|
||||
.scalars()
|
||||
.one()
|
||||
)
|
||||
assert st.status == "watched", "local watched preserved"
|
||||
assert st.position_seconds == 0
|
||||
assert st.synced_to_plex is False, "stays dirty so _repush_dirty retries the push"
|
||||
|
||||
|
||||
def test_pull_settles_flag_on_matching_dirty_row(db):
|
||||
"""Anti-bounce, dirty branch: a row that already MATCHES Plex but is still dirty gets its
|
||||
synced_to_plex flag settled to True (no churn), so `_repush_dirty` stops re-pushing it forever."""
|
||||
item_id, user_id = _seed_item_and_user(db)
|
||||
now = datetime.now(timezone.utc)
|
||||
db.add(
|
||||
PlexState(
|
||||
user_id=user_id,
|
||||
item_id=item_id,
|
||||
status="new",
|
||||
position_seconds=100,
|
||||
progress_updated_at=now,
|
||||
synced_to_plex=False, # dirty, but already agrees with Plex
|
||||
)
|
||||
)
|
||||
db.flush()
|
||||
|
||||
changed = _pull_apply(db, user_id, item_id, "new", 102, plex_ts=now + timedelta(seconds=2))
|
||||
db.flush()
|
||||
|
||||
assert changed is None, "a matching row makes no change"
|
||||
st = (
|
||||
db.execute(
|
||||
select(PlexState).where(PlexState.user_id == user_id, PlexState.item_id == item_id)
|
||||
)
|
||||
.scalars()
|
||||
.one()
|
||||
)
|
||||
assert st.position_seconds == 100, "no churn to the stored position"
|
||||
assert st.synced_to_plex is True, "the dirty flag is settled so it isn't re-pushed endlessly"
|
||||
|
||||
|
||||
def test_should_push_resume_scopes_by_user_and_item():
|
||||
"""The throttle window is keyed by the FULL (user, item) tuple: pushing for one key must not
|
||||
suppress a distinct user or a distinct item within the same window."""
|
||||
now = datetime.now(timezone.utc)
|
||||
assert _should_push_resume(9101, 9201, now, final=False) is True # (u1, i1) first tick
|
||||
# Same window, different item for the same user → independent, still pushes.
|
||||
assert _should_push_resume(9101, 9202, now, final=False) is True
|
||||
# Same window, different user, same item → independent, still pushes.
|
||||
assert _should_push_resume(9102, 9201, now, final=False) is True
|
||||
# And the original key is still throttled within its window (proves the above weren't just "always").
|
||||
assert _should_push_resume(9101, 9201, now + timedelta(seconds=1), final=False) is False
|
||||
|
||||
|
||||
def test_resume_push_superseded_drops_older_pending_push():
|
||||
"""The write-ordering guard: an older still-pending resume push is dropped once a newer push for the
|
||||
same (user, item) has been scheduled, so a reordered stale position can't land at Plex last."""
|
||||
key = (9301, 9301)
|
||||
t0 = datetime.now(timezone.utc)
|
||||
# Schedule an initial push at t0 (records t0 as the last-push time for this key).
|
||||
assert _should_push_resume(*key, t0, final=False) is True
|
||||
# Nothing newer yet → the t0 push is current, not superseded.
|
||||
assert _resume_push_superseded(*key, t0) is False
|
||||
# A newer push (e.g. a backward seek) is scheduled at t1 > t0.
|
||||
t1 = t0 + timedelta(seconds=1)
|
||||
assert _should_push_resume(*key, t1, final=True) is True
|
||||
# Now the still-pending t0 push is superseded and must drop; the t1 push is current.
|
||||
assert _resume_push_superseded(*key, t0) is True
|
||||
assert _resume_push_superseded(*key, t1) is False
|
||||
|
||||
|
||||
def test_should_push_resume_final_always_pushes():
|
||||
"""A final checkpoint (pause / seek / stop) always pushes, bypassing the throttle."""
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
@@ -17,6 +17,7 @@ vi.mock("./NavigationProvider", () => ({
|
||||
}));
|
||||
|
||||
import { ChannelsProvider, useChannels, useChannelsActions } from "./ChannelsProvider";
|
||||
import { setActiveAccount } from "../lib/api/core";
|
||||
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<ChannelsProvider>{children}</ChannelsProvider>
|
||||
@@ -99,4 +100,33 @@ describe("ChannelsProvider — S8 shareable URL", () => {
|
||||
act(() => result.current.setView("subscribed"));
|
||||
expect(new URLSearchParams(window.location.search).has("ctab")).toBe(false);
|
||||
});
|
||||
|
||||
it("a PARTIAL shared link (fresh tab) does not clobber the account's stored sort/filters", () => {
|
||||
// Regression: on a fresh tab the account pins AFTER mount, so the seed's base for the OMITTED
|
||||
// dimensions was read as defaults. Persisting the whole seed used to overwrite the account's real
|
||||
// stored sort + filter map. The fix persists ONLY the URL-present dimensions.
|
||||
localStorage.setItem(
|
||||
"siftlode.channelSort.1",
|
||||
JSON.stringify({ key: "subscribers", dir: "desc" }),
|
||||
);
|
||||
localStorage.setItem("siftlode.channelsFilterMap.1", JSON.stringify({ sync: "needsFull" }));
|
||||
sessionStorage.removeItem("siftlode.activeAccount"); // account not pinned yet at mount
|
||||
window.history.replaceState({ sfPage: "channels" }, "", "/?ctab=hidden"); // only the tab in the URL
|
||||
|
||||
const { result } = render();
|
||||
act(() => setActiveAccount(1)); // App pins the account after /api/me
|
||||
|
||||
// The present dim (tab) is adopted from the link...
|
||||
expect(result.current.view).toBe("hidden");
|
||||
// ...but the omitted sort + filters keep the account's real stored values, in state AND storage.
|
||||
expect(result.current.sort).toEqual({ key: "subscribers", dir: "desc" });
|
||||
expect(result.current.filters).toEqual({ sync: "needsFull" });
|
||||
expect(JSON.parse(localStorage.getItem("siftlode.channelSort.1")!)).toEqual({
|
||||
key: "subscribers",
|
||||
dir: "desc",
|
||||
});
|
||||
expect(JSON.parse(localStorage.getItem("siftlode.channelsFilterMap.1")!)).toEqual({
|
||||
sync: "needsFull",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -73,14 +73,25 @@ function readStoredFilters(): FilterMap {
|
||||
// present, the URL wins over this account's stored view — read once at mount and applied over the slice
|
||||
// hooks (see the provider). The keys are channels-unique, so their mere presence identifies a shared
|
||||
// manager view; no ?page check is needed (the mirror below is page-gated so it never pollutes others).
|
||||
function readChannelsUrlSeed(): ChannelsUrlState | null {
|
||||
// The seed = the URL-merged state PLUS which dimensions the URL actually carried. `present` matters
|
||||
// because the base below is read at MOUNT, before the account is pinned (getAccountRaw returns null),
|
||||
// so an OMITTED dimension's base is a default, not this account's stored value. The provider seeds and
|
||||
// persists only the PRESENT dimensions and lets the absent ones re-hydrate from storage normally —
|
||||
// otherwise a partial shared link silently resets the omitted stored sort / filter map.
|
||||
type ChannelsSeedPresent = { view: boolean; sort: boolean; filters: boolean };
|
||||
type ChannelsUrlSeed = { state: ChannelsUrlState; present: ChannelsSeedPresent };
|
||||
function readChannelsUrlSeed(): ChannelsUrlSeed | null {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
if (!hasChannelsParams(params)) return null;
|
||||
return channelsParamsToState(params, {
|
||||
const state = channelsParamsToState(params, {
|
||||
view: clampView(getAccountRaw(LS.channelsView)),
|
||||
sort: hydrateSort(getAccountRaw(LS.channelSort), [], null),
|
||||
filters: readStoredFilters(),
|
||||
});
|
||||
return {
|
||||
state,
|
||||
present: { view: params.has("ctab"), sort: params.has("csort"), filters: params.has("cf") },
|
||||
};
|
||||
}
|
||||
|
||||
type ChannelsState = {
|
||||
@@ -185,9 +196,13 @@ export function ChannelsProvider({ children }: { children: ReactNode }) {
|
||||
useLayoutEffect(() => {
|
||||
if (!urlSeed || seededRef.current) return;
|
||||
seededRef.current = true;
|
||||
if (urlSeed.view !== view) setViewRaw(urlSeed.view);
|
||||
setFilters(urlSeed.filters);
|
||||
setSort(urlSeed.sort);
|
||||
// Only apply the dimensions the URL actually carried. An omitted dimension must NOT go through its
|
||||
// setter (which would markDirty and block the account-pin re-hydrate from loading the stored value)
|
||||
// — leave it to hydrate from storage normally. Fixes a partial link wiping the stored sort/filters.
|
||||
const { state, present } = urlSeed;
|
||||
if (present.view && state.view !== view) setViewRaw(state.view);
|
||||
if (present.filters) setFilters(state.filters);
|
||||
if (present.sort) setSort(state.sort);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
@@ -198,9 +213,12 @@ export function ChannelsProvider({ children }: { children: ReactNode }) {
|
||||
useEffect(() => {
|
||||
if (!urlSeed || accountId == null || persistedRef.current) return;
|
||||
persistedRef.current = true;
|
||||
setAccountRaw(LS.channelsView, urlSeed.view);
|
||||
setAccountRaw(LS.channelsFilterMap, JSON.stringify(urlSeed.filters));
|
||||
setAccountRaw(LS.channelSort, JSON.stringify(urlSeed.sort));
|
||||
// Persist ONLY the URL-present dimensions — the omitted ones were seeded from mount-time defaults
|
||||
// (no account yet), so writing them here would overwrite the account's real stored sort/filter map.
|
||||
const { state, present } = urlSeed;
|
||||
if (present.view) setAccountRaw(LS.channelsView, state.view);
|
||||
if (present.filters) setAccountRaw(LS.channelsFilterMap, JSON.stringify(state.filters));
|
||||
if (present.sort) setAccountRaw(LS.channelSort, JSON.stringify(state.sort));
|
||||
}, [urlSeed, accountId]);
|
||||
|
||||
// Latest-value ref so the STABLE setView can compare against the current tab without depending on
|
||||
|
||||
@@ -80,11 +80,12 @@ function mount() {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
qc.setQueryData(qk.tags(), [{ id: 1, name: "Docu" }]);
|
||||
vi.spyOn(api, "tags").mockResolvedValue([{ id: 1, name: "Docu" }] as never);
|
||||
return render(
|
||||
const utils = render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<PersonalViewsModal />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
return { ...utils, qc };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -92,6 +93,8 @@ beforeEach(() => {
|
||||
h.saveCurrent.mockClear();
|
||||
h.rename.mockClear();
|
||||
h.activeViewId = null;
|
||||
// Fresh baseline each test — one test mutates CTX.views to simulate a refetch.
|
||||
CTX.views = [{ id: 5, name: "Docs", filters: VIEW_FILTERS, position: 0, is_default: false }];
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
@@ -119,6 +122,39 @@ describe("PersonalViewsModal — S7 editable detail pane", () => {
|
||||
expect(h.updateFilters.mock.calls[1]![1]).toMatchObject({ tags: [1] });
|
||||
});
|
||||
|
||||
it("a same-view refetch (new-identity filters) does not wipe the undo history", async () => {
|
||||
// Guards the [selectedId]-only re-seed effect (its exhaustive-deps disable): after a chip prune the
|
||||
// real provider refetches and hands back the SAME view id with a fresh-identity filters object. The
|
||||
// draft must NOT re-seed on that (it would destroy the undo history and the Undo control). A deps
|
||||
// regression that added selected.filters would make this fail.
|
||||
const { rerender, qc } = mount();
|
||||
fireEvent.click(await screen.findByLabelText("feed.chips.remove"));
|
||||
await waitFor(() => expect(h.updateFilters).toHaveBeenCalledTimes(1));
|
||||
expect(await screen.findByLabelText("common.undo")).toBeTruthy();
|
||||
|
||||
// Simulate the post-write refetch: same id 5, new object identity, carrying the pruned value.
|
||||
CTX.views = [
|
||||
{
|
||||
id: 5,
|
||||
name: "Docs",
|
||||
filters: { ...VIEW_FILTERS, tags: [] },
|
||||
position: 0,
|
||||
is_default: false,
|
||||
},
|
||||
];
|
||||
rerender(
|
||||
<QueryClientProvider client={qc}>
|
||||
<PersonalViewsModal />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
// Undo history survived the refetch — the control is still there and still re-persists on click.
|
||||
const undo = await screen.findByLabelText("common.undo");
|
||||
fireEvent.click(undo);
|
||||
await waitFor(() => expect(h.updateFilters).toHaveBeenCalledTimes(2));
|
||||
expect(h.updateFilters.mock.calls[1]![1]).toMatchObject({ tags: [1] });
|
||||
});
|
||||
|
||||
it("Save-current stores the live filters into the view when it isn't already the active one", async () => {
|
||||
mount();
|
||||
fireEvent.click(await screen.findByText("views.saveCurrent"));
|
||||
|
||||
@@ -760,13 +760,24 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
mid-playback stall or in-flight seek (see the state); `loadError` surfaces a fatal failure so
|
||||
it can't hide behind a running video. Spinner on load/stall, error text otherwise. */}
|
||||
{(!ready || buffering || loadError) && (
|
||||
<div className="absolute inset-0 grid place-items-center px-6 text-center text-sm pointer-events-none">
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className="absolute inset-0 grid place-items-center px-6 text-center text-sm pointer-events-none"
|
||||
>
|
||||
{loadError ? (
|
||||
<span className="max-w-md text-danger">{loadError}</span>
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-3 text-white/80">
|
||||
<Loader2 className="w-10 h-10 animate-spin" aria-hidden />
|
||||
{!ready && <span>{t("plex.player.loading")}</span>}
|
||||
{/* Announce the stall to assistive tech. Initial load shows visible text; a mid-playback
|
||||
rebuffer/seek (ready, no error) has no visible label, so give it an sr-only one via the
|
||||
aria-live region so a screen reader distinguishes "buffering" from a frozen frame. */}
|
||||
{!ready ? (
|
||||
<span>{t("plex.player.loading")}</span>
|
||||
) : buffering ? (
|
||||
<span className="sr-only">{t("plex.player.buffering")}</span>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -131,15 +131,31 @@ function readPlexFilters(): PlexFilters {
|
||||
return EMPTY_PLEX_FILTERS;
|
||||
}
|
||||
}
|
||||
function readPlexUrlSeed(): PlexUrlState | null {
|
||||
// A shared-link seed = the URL-merged state PLUS which dimensions the URL actually carried. `present`
|
||||
// matters because the base below is read at MOUNT, before the account is pinned (getAccountRaw returns
|
||||
// null then), so the base for an OMITTED dimension is a hard default, NOT this account's stored value.
|
||||
// The provider must therefore persist/keep only the PRESENT dimensions on account-pin and re-read the
|
||||
// account's real stored value for the absent ones — otherwise a partial link silently resets them.
|
||||
type PlexSeedPresent = { scope: boolean; sort: boolean; show: boolean; filters: boolean };
|
||||
type PlexUrlSeed = { state: PlexUrlState; present: PlexSeedPresent };
|
||||
function readPlexUrlSeed(): PlexUrlSeed | null {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
if (!hasPlexParams(params)) return null;
|
||||
return plexParamsToState(params, {
|
||||
const state = plexParamsToState(params, {
|
||||
scope: readPlexScope(),
|
||||
sort: readPlexSort(),
|
||||
show: readPlexShow(),
|
||||
filters: readPlexFilters(),
|
||||
});
|
||||
return {
|
||||
state,
|
||||
present: {
|
||||
scope: params.has("pscope"),
|
||||
sort: params.has("psort"),
|
||||
show: params.has("pshow"),
|
||||
filters: params.has("pf"),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function PlexProvider({ children }: { children: ReactNode }) {
|
||||
@@ -148,12 +164,12 @@ export function PlexProvider({ children }: { children: ReactNode }) {
|
||||
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<string>(() => urlSeed?.scope ?? readPlexScope());
|
||||
const [show, setShowState] = useState<string>(() => urlSeed?.show ?? readPlexShow());
|
||||
const [sort, setSortState] = useState<SortState>(() => urlSeed?.sort ?? readPlexSort());
|
||||
const [scope, setScopeState] = useState<string>(() => urlSeed?.state.scope ?? readPlexScope());
|
||||
const [show, setShowState] = useState<string>(() => urlSeed?.state.show ?? readPlexShow());
|
||||
const [sort, setSortState] = useState<SortState>(() => urlSeed?.state.sort ?? readPlexSort());
|
||||
// The expanded Plex filters live as one JSON blob (the string-only account store serializes it).
|
||||
const [filtersRaw, setFiltersRaw] = useState<string>(() =>
|
||||
urlSeed ? JSON.stringify(urlSeed.filters) : readPlexFiltersRaw(),
|
||||
urlSeed ? JSON.stringify(urlSeed.state.filters) : readPlexFiltersRaw(),
|
||||
);
|
||||
const filters = useMemo<PlexFilters>(() => {
|
||||
try {
|
||||
@@ -180,10 +196,20 @@ export function PlexProvider({ children }: { children: ReactNode }) {
|
||||
// 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));
|
||||
// Per dimension: a dimension the URL carried (present) is the shared view — adopt it and persist
|
||||
// it to this now-pinned account (so it wins and survives a later plain reload). A dimension the
|
||||
// URL OMITTED was seeded from a mount-time default (no account yet), so do NOT persist that
|
||||
// default over the account's real stored value — re-read the stored value instead, exactly like a
|
||||
// normal load. This is the fix for a partial shared link silently resetting the omitted slices.
|
||||
const { state, present } = urlSeed;
|
||||
if (present.scope) setAccountRaw(LS.plexLibrary, state.scope);
|
||||
else setScopeState(readPlexScope());
|
||||
if (present.show) setAccountRaw(LS.plexShow, state.show);
|
||||
else setShowState(readPlexShow());
|
||||
if (present.sort) setAccountRaw(LS.plexSort, JSON.stringify(state.sort));
|
||||
else setSortState(readPlexSort());
|
||||
if (present.filters) setAccountRaw(LS.plexFilters, JSON.stringify(state.filters));
|
||||
else setFiltersRaw(readPlexFiltersRaw());
|
||||
setQState(readPlexQ());
|
||||
setFieldState(readPlexField());
|
||||
return;
|
||||
|
||||
@@ -12,7 +12,10 @@ vi.mock("react-i18next", async (importOriginal) => {
|
||||
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
|
||||
});
|
||||
|
||||
afterEach(cleanup);
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers(); // any fake-timer test resets here so it can't leak into the next
|
||||
});
|
||||
|
||||
const raise = () => screen.getByRole("button", { name: "channels.row.raisePriority" });
|
||||
const lower = () => screen.getByRole("button", { name: "channels.row.lowerPriority" });
|
||||
@@ -78,4 +81,57 @@ describe("PriorityStepper", () => {
|
||||
fireEvent.click(raise());
|
||||
expect(onPriority).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it("holding a step button auto-repeats (accelerating) and suppresses the trailing click", () => {
|
||||
vi.useFakeTimers();
|
||||
const onPriority = vi.fn();
|
||||
render(<PriorityStepper priority={0} onPriority={onPriority} onSet={vi.fn()} />);
|
||||
|
||||
fireEvent.pointerDown(raise());
|
||||
vi.advanceTimersByTime(349);
|
||||
expect(onPriority).not.toHaveBeenCalled(); // a tap below the 350ms threshold doesn't repeat
|
||||
vi.advanceTimersByTime(1); // cross the threshold → first auto-repeat tick
|
||||
expect(onPriority).toHaveBeenCalledTimes(1);
|
||||
|
||||
vi.advanceTimersByTime(1000); // several accelerating repeats while held
|
||||
const heldCount = onPriority.mock.calls.length;
|
||||
expect(heldCount).toBeGreaterThan(3);
|
||||
expect(onPriority.mock.calls.every(([d]) => d === 1)).toBe(true);
|
||||
|
||||
fireEvent.pointerUp(raise());
|
||||
vi.advanceTimersByTime(1000);
|
||||
expect(onPriority.mock.calls.length).toBe(heldCount); // stops repeating on release
|
||||
|
||||
fireEvent.click(raise()); // the click that trails a hold is swallowed (no double-count)
|
||||
expect(onPriority.mock.calls.length).toBe(heldCount);
|
||||
});
|
||||
|
||||
it("a quick tap (released before the hold threshold) still nudges exactly once", () => {
|
||||
vi.useFakeTimers();
|
||||
const onPriority = vi.fn();
|
||||
render(<PriorityStepper priority={0} onPriority={onPriority} onSet={vi.fn()} />);
|
||||
|
||||
fireEvent.pointerDown(lower());
|
||||
vi.advanceTimersByTime(100); // below the threshold → no auto-repeat armed
|
||||
fireEvent.pointerUp(lower());
|
||||
fireEvent.click(lower()); // the tap's click is NOT suppressed
|
||||
expect(onPriority.mock.calls).toEqual([[-1]]);
|
||||
});
|
||||
|
||||
it("clears the auto-repeat timer on unmount (no fire after the row is gone)", () => {
|
||||
vi.useFakeTimers();
|
||||
const onPriority = vi.fn();
|
||||
const { unmount } = render(
|
||||
<PriorityStepper priority={0} onPriority={onPriority} onSet={vi.fn()} />,
|
||||
);
|
||||
|
||||
fireEvent.pointerDown(raise());
|
||||
vi.advanceTimersByTime(350); // repeat armed + first tick
|
||||
const before = onPriority.mock.calls.length;
|
||||
expect(before).toBeGreaterThan(0);
|
||||
|
||||
unmount();
|
||||
vi.advanceTimersByTime(1000);
|
||||
expect(onPriority.mock.calls.length).toBe(before); // no post-unmount timer fire
|
||||
});
|
||||
});
|
||||
|
||||
@@ -154,6 +154,7 @@
|
||||
},
|
||||
"player": {
|
||||
"loading": "Loading…",
|
||||
"buffering": "Buffering…",
|
||||
"back": "Back",
|
||||
"skipIntro": "Skip intro",
|
||||
"skipCredits": "Skip credits",
|
||||
|
||||
@@ -154,6 +154,7 @@
|
||||
},
|
||||
"player": {
|
||||
"loading": "Betöltés…",
|
||||
"buffering": "Pufferelés…",
|
||||
"back": "Vissza",
|
||||
"skipIntro": "Intro átugrása",
|
||||
"skipCredits": "Stáblista átugrása",
|
||||
|
||||
@@ -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