From 12d9561ba17f9357f06e2b517965e0ad7250ec6d Mon Sep 17 00:00:00 2001 From: npeter83 Date: Thu, 27 Aug 2026 00:50:58 +0200 Subject: [PATCH 01/11] fix(plex): warm up HLS buffer on session start + player loading/buffering spinner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Plex player stalled ~2s a few seconds into every auto-advanced next episode. Root cause: each auto-advance spins up a cold ffmpeg HLS remux session, and for a fresh start (start_s==0) start_session returned immediately with zero buffer — hls.js drained the first segment, then rebuffered waiting for the still-warming ffmpeg to write the next one. Once ffmpeg gets ahead (stream-copy is faster than realtime) it never stalls again. - stream.py: before reporting a session ready, wait for the first _WARMUP_SEGMENTS (3) video segments to exist — the seg0 wait is now unconditional (was start_s>0 only) and warm-up runs for all starts. Best-effort and bounded: stop at the first segment not ready in time. - PlexPlayer.tsx: a loading/buffering spinner. The existing overlay only covered the INITIAL load (!ready); add a `buffering` state driven by the video's waiting/stalled/seeking (set) and playing/canplay/seeked/pause (clear) events so a mid-playback stall or in-flight seek reads as "loading" instead of a frozen frame, and surface a fatal loadError even once playback has started. Spinner uses the shared Loader2. Merges roadmap E6·item1 (spinner while loading/seeking) into this fix, since the server-side warm-up deliberately holds ready a beat longer. Tests: 3 new in test_plex_stream.py (warm-up waits for the first few segments; stops at the first missing one; no warm-up when seg0 never arrives). Gate: backend pytest 460 (Docker lane), FE tsc/lint/knip/ prettier + vitest 483. --- backend/app/plex/stream.py | 38 +++++++++++--- backend/tests/test_plex_stream.py | 71 ++++++++++++++++++++++++++ frontend/src/components/PlexPlayer.tsx | 36 ++++++++++++- 3 files changed, 135 insertions(+), 10 deletions(-) diff --git a/backend/app/plex/stream.py b/backend/app/plex/stream.py index f10b4744..c8aa4ca6 100644 --- a/backend/app/plex/stream.py +++ b/backend/app/plex/stream.py @@ -38,6 +38,17 @@ _HLS_ROOT = ( else Path(settings.download_root) / ".plex-hls" ) _SEG_SECONDS = 6 +# Cold-start warm-up. A freshly-spawned ffmpeg has produced only the first segment (or two) by the +# time we could report the session ready; hls.js parses the EVENT playlist, drains that tiny buffer, +# then STALLS a few seconds into playback waiting for the next segment the still-warming ffmpeg +# hasn't written yet. Very visible on auto-advance to the next episode (start_s==0, which previously +# returned with ZERO buffer). So before reporting ready we wait for the first few segments to exist: +# the client then starts with ~_WARMUP_SEGMENTS×_SEG_SECONDS of buffer and, since stream-copy runs +# faster than realtime, ffmpeg stays ahead from there and never stalls again. Best-effort and bounded +# — we stop at the first segment that isn't ready within the per-segment budget (a slow miss is no +# worse than the old zero-buffer behaviour). +_WARMUP_SEGMENTS = 3 +_WARMUP_SEG_TIMEOUT_S = 8.0 _SESSION_IDLE_S = 600 # reap a session with no access for this long # A session directory is named `{rating_key}_{int(start_s)}_{tag}_{aoff:+.2f}` (see start_session); # before multi-audio it was just `{rating_key}_{int(start_s)}`. `sweep_orphans` deletes ONLY names @@ -296,16 +307,27 @@ def start_session( # Admin-configurable concurrency cap (Configuration → Plex → Max concurrent transcodes); # at least 1 so playback can't be capped to zero. _enforce_cap(max(1, sysconfig.get_int(db, "plex_max_transcodes"))) - # Learn the REAL start offset K from the first VIDEO segment (outside the lock — this blocks on - # ffmpeg producing it, and we must not hold up other sessions). The client reads `media_start_s`, so - # its absolute clock + subtitle shift key off the true keyframe, not the requested offset. On - # timeout/probe failure we keep the provisional requested offset (old behaviour, small drift). - if start_s > 0: - seg0 = directory / ("seg_0_0.ts" if is_multi else "seg_0.ts") # multi: video variant = v0 - if wait_for(seg0, timeout=25.0): - k = _probe_first_pts(seg0) + + # Wait for the first video segment, then warm up a few more — all outside the lock (this blocks on + # ffmpeg producing them, and we must not hold up other sessions). + def _seg(i: int) -> Path: + # multi-audio: the video rendition is variant v0 → seg_{i}_0.ts; single-audio → seg_{i}.ts + return directory / (f"seg_{i}_0.ts" if is_multi else f"seg_{i}.ts") + + if wait_for(_seg(0), timeout=25.0): + # For a seek (start_s>0) learn the REAL start offset K from the first segment's absolute PTS, + # so the client's absolute clock + subtitle shift key off the true keyframe, not the requested + # offset. On probe failure we keep the provisional requested offset (old behaviour, small drift). + if start_s > 0: + k = _probe_first_pts(_seg(0)) if k is not None and k >= 0: s.media_start_s = k + # Warm up the buffer (see _WARMUP_SEGMENTS) so the client doesn't outrun the cold ffmpeg. Stop + # at the first segment that isn't ready in time — ffmpeg produces them faster than realtime once + # warm, so a miss means something slow and we proceed rather than block the request further. + for i in range(1, _WARMUP_SEGMENTS): + if not wait_for(_seg(i), timeout=_WARMUP_SEG_TIMEOUT_S): + break log.info( "plex hls session start key=%s req=%.1f real=%.3f multi=%s audio=%s", key, diff --git a/backend/tests/test_plex_stream.py b/backend/tests/test_plex_stream.py index 5b10e9ad..f23453a6 100644 --- a/backend/tests/test_plex_stream.py +++ b/backend/tests/test_plex_stream.py @@ -154,3 +154,74 @@ class TestSweepOrphans: os.utime(f, (f.stat().st_mtime - 3600,) * 2) # old enough that only is_dir() saves it assert stream.sweep_orphans(older_than=_cutoff(hls_root)) == 0 assert f.exists() + + +class _FakeProc: + """Stand-in for the ffmpeg Popen — start_session only stores it (and _kill/reap poll it later).""" + + def poll(self): + return None + + +class _FakeItem: + def __init__(self, rating_key="12345", file_path="/media/movie.mkv"): + self.rating_key = rating_key + self.file_path = file_path + self.playable = "remux" + + +class TestColdStartWarmup: + """The cold-start buffer warm-up: before reporting a session ready, start_session waits for the + first _WARMUP_SEGMENTS video segments so hls.js doesn't drain a one-segment buffer and stall a + few seconds in (the auto-advance-to-next-episode stall). We spy on wait_for instead of running + ffmpeg.""" + + @pytest.fixture + def fake_env(self, tmp_path, monkeypatch): + monkeypatch.setattr(stream, "_HLS_ROOT", tmp_path) + monkeypatch.setattr(stream, "_sessions", {}) + src = tmp_path / "movie.mkv" + src.write_bytes(b"x") + monkeypatch.setattr(stream.paths, "local_media_path", lambda db, fp: src) + monkeypatch.setattr(stream.sysconfig, "get_int", lambda db, k: 2) + monkeypatch.setattr(stream.subprocess, "Popen", lambda *a, **k: _FakeProc()) + return tmp_path + + def test_warmup_waits_for_the_first_few_segments(self, fake_env, monkeypatch): + waited = [] + + def spy(path, timeout=20.0): + waited.append(path.name) + return True + + monkeypatch.setattr(stream, "wait_for", spy) + s = stream.start_session(db=None, item=_FakeItem(), start_s=0.0) + assert s is not None + # seg_0 (unconditional now) + the warm-up run up to _WARMUP_SEGMENTS. + assert waited == [f"seg_{i}.ts" for i in range(stream._WARMUP_SEGMENTS)] + + def test_warmup_stops_at_the_first_segment_that_isnt_ready(self, fake_env, monkeypatch): + waited = [] + + def spy(path, timeout=20.0): + waited.append(path.name) + return path.name != "seg_1.ts" # seg_1 never appears → warm-up gives up there + + monkeypatch.setattr(stream, "wait_for", spy) + s = stream.start_session(db=None, item=_FakeItem(), start_s=0.0) + assert s is not None + assert waited == ["seg_0.ts", "seg_1.ts"] # never asked for seg_2 + + def test_no_warmup_wait_when_the_first_segment_never_arrives(self, fake_env, monkeypatch): + # If seg_0 itself times out we don't warm up at all (nothing to build on) — and the session is + # still returned so the client's own segment requests can drive it, as before. + waited = [] + + def spy(path, timeout=20.0): + waited.append(path.name) + return False + + monkeypatch.setattr(stream, "wait_for", spy) + s = stream.start_session(db=None, item=_FakeItem(), start_s=0.0) + assert s is not None + assert waited == ["seg_0.ts"] diff --git a/frontend/src/components/PlexPlayer.tsx b/frontend/src/components/PlexPlayer.tsx index 76a7d8de..383625d7 100644 --- a/frontend/src/components/PlexPlayer.tsx +++ b/frontend/src/components/PlexPlayer.tsx @@ -12,6 +12,7 @@ import { Info, Keyboard, Languages, + Loader2, Maximize, Minimize, Pause, @@ -108,6 +109,11 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) { const [seekableEnd, setSeekableEnd] = useState(0); // absolute end of the loaded region const { fs, toggleFs } = useFullscreen(wrapRef); const [ready, setReady] = useState(false); + // True while playback is stalled waiting for data (mid-stream rebuffer) or a seek is in flight — drives + // a spinner so a stall reads as "loading, hang on" instead of a frozen frame. Distinct from `ready`, + // which only covers the INITIAL session load; this covers stalls AFTER playback has started (e.g. the + // brief cold-ffmpeg rebuffer on auto-advance, now largely prevented by the server-side segment warm-up). + const [buffering, setBuffering] = useState(false); const [loadError, setLoadError] = useState(null); // The current session's real media-start K (reactive mirror of sessionStartRef). Subtitles are // absolute-time VTT shifted by K to the session's zero-based clock, so the src/key keys off @@ -178,6 +184,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) { const video = videoRef.current; if (!video) return; setReady(false); + setBuffering(false); setLoadError(null); let sess; try { @@ -362,12 +369,24 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) { // button lie (⏸ shown while actually paused). Resync the flag from reality on the settling events // so the icon can never contradict the video. const syncPlaying = () => setPlaying(!video.paused); + // Buffering spinner: `waiting`/`stalled` = playback stopped for lack of data; `seeking` = a seek is + // in flight. Cleared once data flows again (`playing`/`canplay`/`seeked`) or the user pauses (a + // deliberate pause isn't a stall, and would otherwise leave the spinner stuck). + const onWaiting = () => setBuffering(true); + const onResumed = () => setBuffering(false); video.addEventListener("timeupdate", onTime); video.addEventListener("play", onPlay); video.addEventListener("pause", onPause); video.addEventListener("emptied", syncPlaying); video.addEventListener("canplay", syncPlaying); video.addEventListener("seeked", syncPlaying); + video.addEventListener("waiting", onWaiting); + video.addEventListener("stalled", onWaiting); + video.addEventListener("seeking", onWaiting); + video.addEventListener("playing", onResumed); + video.addEventListener("canplay", onResumed); + video.addEventListener("seeked", onResumed); + video.addEventListener("pause", onResumed); return () => { video.removeEventListener("timeupdate", onTime); video.removeEventListener("play", onPlay); @@ -375,6 +394,13 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) { video.removeEventListener("emptied", syncPlaying); video.removeEventListener("canplay", syncPlaying); video.removeEventListener("seeked", syncPlaying); + video.removeEventListener("waiting", onWaiting); + video.removeEventListener("stalled", onWaiting); + video.removeEventListener("seeking", onWaiting); + video.removeEventListener("playing", onResumed); + video.removeEventListener("canplay", onResumed); + video.removeEventListener("seeked", onResumed); + video.removeEventListener("pause", onResumed); }; }, [id]); @@ -730,12 +756,18 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) { )} - {!ready && ( + {/* Loading / buffering / error overlay. `!ready` = initial session load; `buffering` = a + 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) && (
{loadError ? ( {loadError} ) : ( - {t("plex.player.loading")} +
+ + {!ready && {t("plex.player.loading")}} +
)}
)} From 3f8257059d336e7f3296360b2e69f66a742202fe Mon Sep 17 00:00:00 2001 From: npeter83 Date: Thu, 27 Aug 2026 02:07:45 +0200 Subject: [PATCH 02/11] =?UTF-8?q?feat(plex):=20Plex-authoritative=20watch-?= =?UTF-8?q?sync=20=E2=80=94=20resume=20write-back=20+=20faster=20pull=20(S?= =?UTF-8?q?2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make Plex the source of truth for watch state: - Write-back during playback: item_progress now pushes the resume position to Plex on the periodic checkpoint too (not only the final flush), throttled to one push per item every ~10s (in-memory, single-process rate limiter). The FE now also fires an immediate (debounced) push on seek. - Faster incremental pull: 30 → 15 min (plex_watch_sync_interval_min). - Plex-authoritative pull: _pull_apply resolves a genuine divergence to Plex's state instead of last-write-wins; the _same_state + synced_to_plex short-circuit still prevents a freshly-pushed row from bouncing. Removed the now-unused _LWW_TOLERANCE_S / _sift_ts. Tests: 5 new in test_plex_watch_sync.py (Plex-wins resume/watched, no-bounce, throttle final vs periodic). --- backend/app/config.py | 4 +- backend/app/plex/watch_sync.py | 29 +++-- backend/app/routes/plex/playback.py | 38 ++++++- backend/tests/test_plex_watch_sync.py | 148 ++++++++++++++++++++++++++ frontend/src/lib/plexPlayerHooks.ts | 12 +++ 5 files changed, 208 insertions(+), 23 deletions(-) create mode 100644 backend/tests/test_plex_watch_sync.py diff --git a/backend/app/config.py b/backend/app/config.py index f01f7216..edb511eb 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -232,7 +232,9 @@ class Settings(BaseSettings): plex_sync_interval_min: int = 360 # How often the incremental Plex→Siftlode watch-state reconcile runs, in minutes (Phase C): # a cheap history + on-deck pull plus a re-push of any states that failed their immediate push. - plex_watch_sync_interval_min: int = 30 + # 15 min (S2): with Plex now the source of truth (prompt write-back on the ~10s checkpoint + + # Plex-authoritative pull), a faster incremental pull keeps Siftlode close to Plex-side changes. + plex_watch_sync_interval_min: int = 15 # How often the FULL watch-state reconcile runs, in minutes (Phase C). A whole-section rescan # that catches what the incremental history feed can't — notably an un-watch done on the Plex # side — so it runs far less often (daily) than the incremental pass. diff --git a/backend/app/plex/watch_sync.py b/backend/app/plex/watch_sync.py index 1d0755ed..1ce461f6 100644 --- a/backend/app/plex/watch_sync.py +++ b/backend/app/plex/watch_sync.py @@ -11,11 +11,14 @@ already pages) and upsert them into the owner's `plex_states`. Plex wins on over exist only in Siftlode are left untouched (union, Plex-authoritative on the intersection). Phases B (Siftlode→Plex push via scrobble/timeline) and C (incremental Plex→Siftlode via the watch -history + onDeck, last-write-wins) build on this and land in later ships. +history + onDeck) build on this and land in later ships. As of S2 Plex is the source of truth: the +player pushes the resume position back to Plex on the periodic checkpoint (not just the final flush), +and the incremental pull resolves a genuine divergence to Plex's state (see `_pull_apply`) rather than +running a last-write-wins comparison. """ import logging -from datetime import datetime, timedelta, timezone +from datetime import datetime, timezone from sqlalchemy import func, select, update from sqlalchemy.dialects.postgresql import insert as pg_insert @@ -293,12 +296,6 @@ def push_bulk_state_to_plex(user_id: int, changes: list[tuple[int, str, str]]) - # --- Phase C: incremental + full Plex ↔ Siftlode reconcile (scheduler jobs) ------------------------ -# Clock-skew / round-trip slack when comparing a Plex timestamp to a Siftlode one. A state we just -# pushed (Plex stamps viewedAt a beat after our watched_at) must not read as "newer on Plex" and get -# pulled back with a different shape. Genuine Plex-side edits are minutes from our writes — far -# outside this — so the window only absorbs skew, never real changes. -_LWW_TOLERANCE_S = 90 - def _active_sync_links(db: Session) -> list[PlexLink]: """Every link with two-way sync actually running (owner MVP = one row). Iterated uniformly so @@ -338,11 +335,6 @@ def _resolve_account_id(db: Session, plex: PlexClient, link: PlexLink) -> int: return acct_id -def _sift_ts(st: PlexState) -> datetime | None: - """The freshest Siftlode-side timestamp on a row, for last-write-wins.""" - return max([t for t in (st.watched_at, st.progress_updated_at) if t], default=None) - - def _same_state(st: PlexState | None, status: str, pos: int) -> bool: """Whether a row already matches a target (status, pos) — the core ping-pong guard: when nothing would change we skip the write entirely (no row churn, no timestamp bump, no re-pull).""" @@ -370,10 +362,13 @@ def _pull_apply( if st is not None and not st.synced_to_plex: st.synced_to_plex = True # already matches Plex → just settle the flag, no churn return None - # Conflict: keep Siftlode when it's newer than Plex (plus skew slack). - sts = _sift_ts(st) if st else None - if sts and plex_ts and plex_ts <= sts + timedelta(seconds=_LWW_TOLERANCE_S): - return None + # Plex-authoritative (S2): on a genuine divergence for an item Plex knows about, Plex wins — no + # last-write-wins tug-of-war. This is safe because prompt write-back (playback.py pushes the resume + # position to Plex on the ~10s checkpoint, not just on the final flush) keeps Plex current, so a + # local edit that's "newer" than Plex has almost always already been pushed and matched above by + # `_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 None: st = PlexState(user_id=user_id, item_id=item_id) db.add(st) diff --git a/backend/app/routes/plex/playback.py b/backend/app/routes/plex/playback.py index 8d690ce8..7314087a 100644 --- a/backend/app/routes/plex/playback.py +++ b/backend/app/routes/plex/playback.py @@ -3,6 +3,7 @@ import hashlib import os import tempfile +import threading from datetime import datetime, timezone import httpx @@ -155,6 +156,29 @@ _PROGRESS_MIN_S = 5 _FINISH_MARGIN_S = 30 +# S2 write-back throttle: the resume position is now pushed to Plex on the periodic (non-final) +# checkpoint too — not only on the final pause/unmount flush — so Plex (the source of truth) stays +# current in near-real-time. To keep a single watch from spraying `/:/timeline` calls, a push is +# rate-limited to at most one per item per user every _PUSH_THROTTLE_S seconds; a `final` checkpoint +# (pause / seek / stop) always pushes immediately and resets the window. This is an in-memory, single- +# process rate-limiter (the deployment is one FastAPI process): after a restart the map is empty, so at +# most one extra push happens on the first checkpoint — harmless. It is NOT durable domain state. +_PUSH_THROTTLE_S = 10 +_last_push_at: dict[tuple[int, int], datetime] = {} +_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.""" + 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 + return True + return False + @router.get("/person-image") def person_image(u: str, request: Request) -> Response: @@ -232,9 +256,10 @@ def item_progress( ) -> dict: """Checkpoint the resume position while playing (near the end → mark watched + clear it). - `final` (sent on the pause/pagehide/unmount flush, not the periodic 10s checkpoint) is what - debounces the Plex resume push: we mirror the settled position to Plex only on a final flush, so - a single watch doesn't spray `/:/timeline` calls at the server every 10 seconds.""" + S2 write-back: the resume position is mirrored to Plex (the source of truth) on the periodic + checkpoint too, not only on the final flush — throttled to ≥_PUSH_THROTTLE_S per item (see + `_should_push_resume`). `final` (sent on pause / seek / pagehide / unmount) pushes immediately and + resets the throttle window, so Plex sees a settled position the instant the user pauses or seeks.""" it = item_or_404(db, rating_key) position = max(0, int(body.position_seconds)) duration = int(body.duration_seconds) @@ -280,10 +305,13 @@ def item_progress( db.add(st) st.position_seconds = position st.progress_updated_at = now - if final: + push_now = _should_push_resume(user.id, it.id, now, final) + if push_now: + # Mark dirty before the (background) push so the reconcile knows to re-push if it fails, and so + # `push_state_to_plex` can settle synced_to_plex once Plex confirms. st.synced_to_plex = False db.commit() - if final: + if push_now: _push_watch( background, db, diff --git a/backend/tests/test_plex_watch_sync.py b/backend/tests/test_plex_watch_sync.py new file mode 100644 index 00000000..e4047066 --- /dev/null +++ b/backend/tests/test_plex_watch_sync.py @@ -0,0 +1,148 @@ +"""S2 watch-sync semantics: Plex is the source of truth. + +Two independent pieces: +- `_pull_apply` (DB lane): on a genuine divergence for an item Plex knows about, Plex now WINS — + the old "keep Siftlode when it's newer within a tolerance" tug-of-war is gone. The anti-bounce + short-circuit (`_same_state` + `synced_to_plex`) must still keep a freshly-pushed row from bouncing. +- `_should_push_resume` (pure): the in-memory write-back throttle — a `final` checkpoint always pushes, + a periodic one pushes at most once per _PUSH_THROTTLE_S seconds per (user, item). +""" + +from datetime import datetime, timedelta, timezone + +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 + + +def _seed_item_and_user(db): + lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True) + db.add(lib) + db.flush() + item = PlexItem(rating_key="m1", library_id=lib.id, kind="movie", title="A Movie") + db.add(item) + user = User(email="watch@example.com") + db.add(user) + db.flush() + 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.""" + 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=300, + progress_updated_at=now, # local touched just now — "newer" than Plex + synced_to_plex=False, + ) + ) + db.flush() + + # Plex reports a DIFFERENT (older) resume position for the same item. + changed = _pull_apply(db, user_id, item_id, "new", 120, plex_ts=now - timedelta(seconds=60)) + db.flush() + + assert changed == "in_progress" + st = ( + db.execute( + select(PlexState).where(PlexState.user_id == user_id, PlexState.item_id == item_id) + ) + .scalars() + .one() + ) + assert st.position_seconds == 120, "Plex's position must win, even though local was newer" + 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.""" + 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=300, + progress_updated_at=now, + synced_to_plex=False, + ) + ) + db.flush() + + changed = _pull_apply(db, user_id, item_id, "watched", 0, plex_ts=now - timedelta(seconds=60)) + db.flush() + + assert changed == "watched" + st = ( + db.execute( + select(PlexState).where(PlexState.user_id == user_id, PlexState.item_id == item_id) + ) + .scalars() + .one() + ) + assert st.status == "watched" + assert st.position_seconds == 0 + + +def test_pull_no_bounce_on_matching_row(db): + """Anti-bounce short-circuit: when the row already matches what Plex reports (a just-pushed + resume), the pull makes NO change and only settles the synced flag — no churn, no re-pull.""" + 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=True, # freshly pushed + ) + ) + db.flush() + + # Plex echoes the same position a beat later (well within the _PROGRESS_MIN_S fuzz). + 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 must not bounce" + 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" + + +def test_should_push_resume_final_always_pushes(): + """A final checkpoint (pause / seek / stop) always pushes, bypassing the throttle.""" + now = datetime.now(timezone.utc) + key = (9001, 9001) + assert _should_push_resume(*key, now, final=True) is True + # A second final a fraction of a second later still pushes. + assert _should_push_resume(*key, now + timedelta(seconds=1), final=True) is True + + +def test_should_push_resume_periodic_is_throttled(): + """Periodic (non-final) checkpoints push at most once per _PUSH_THROTTLE_S per (user, item).""" + now = datetime.now(timezone.utc) + key = (9002, 9002) + assert _should_push_resume(*key, now, final=False) is True # first tick pushes + # A tick within the window is suppressed. + assert ( + _should_push_resume(*key, now + timedelta(seconds=_PUSH_THROTTLE_S - 1), final=False) + is False + ) + # Once the window elapses, the next tick pushes again. + assert _should_push_resume(*key, now + timedelta(seconds=_PUSH_THROTTLE_S), final=False) is True diff --git a/frontend/src/lib/plexPlayerHooks.ts b/frontend/src/lib/plexPlayerHooks.ts index 91baf42a..3705b049 100644 --- a/frontend/src/lib/plexPlayerHooks.ts +++ b/frontend/src/lib/plexPlayerHooks.ts @@ -102,6 +102,16 @@ export function usePlexProgress(opts: { const onPauseFlush = () => flush(true); const video = videoRef.current; video?.addEventListener("pause", onPauseFlush); + // A seek is a settled position too (S2: Plex is the source of truth, so it must see the new spot + // right away, not up to 10s later). Debounced so scrubbing — many `seeked` events in a row — pushes + // just ONCE, once the user lands. `final=true` bypasses the backend's ≥10s push-throttle, which is + // exactly why the debounce lives here: it bounds a scrub to one immediate Plex timeline call. + let seekTimer: number | undefined; + const onSeekFlush = () => { + window.clearTimeout(seekTimer); + seekTimer = window.setTimeout(() => flush(true), 800); + }; + video?.addEventListener("seeked", onSeekFlush); // Save on page unload too (F5/close/navigate): React effect cleanup does NOT run on a full reload, // so without this the resume point lags up to 10s — e.g. a seek right before F5 was lost. Uses a // keepalive beacon so the POST survives the unload (a final checkpoint — the user is leaving). @@ -112,8 +122,10 @@ export function usePlexProgress(opts: { window.addEventListener("pagehide", onHide); return () => { window.clearInterval(iv); + window.clearTimeout(seekTimer); window.removeEventListener("pagehide", onHide); video?.removeEventListener("pause", onPauseFlush); + video?.removeEventListener("seeked", onSeekFlush); flush(true); // Back (unmount) persists the position server-side via flush(), but reopening the same item in // the SAME session read react-query's CACHED detail — whose position_seconds predated this From 6c918d1dff470f31d4804f5b165b294ba4d458bb Mon Sep 17 00:00:00 2001 From: npeter83 Date: Thu, 27 Aug 2026 02:33:42 +0200 Subject: [PATCH 03/11] docs(plex): refresh watch-sync job docstrings for Plex-authoritative pull (S2) The incremental/reconcile job docstrings still described last-write-wins and a 30-min cadence; update them to match the S2 behavior (Plex wins on divergence, 15-min incremental) so the "why" doesn't drift from the code. --- backend/app/plex/watch_sync.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/backend/app/plex/watch_sync.py b/backend/app/plex/watch_sync.py index 1ce461f6..96304701 100644 --- a/backend/app/plex/watch_sync.py +++ b/backend/app/plex/watch_sync.py @@ -432,10 +432,11 @@ def _repush_dirty(db: Session, plex: PlexClient, link: PlexLink) -> int: def run_plex_watch_sync(db: Session) -> dict: - """Scheduler job (default 30 min) — the cheap incremental pass. Pulls Plex-side changes since the - high-water mark (watch history + on-deck, filtered to the owner account) into Siftlode under - last-write-wins, then re-pushes any still-unsynced local states. Does NOT propagate un-watches - (the history feed only carries positive views) — that's `run_plex_watch_reconcile`'s job.""" + """Scheduler job (default 15 min) — the cheap incremental pass. Pulls Plex-side changes since the + high-water mark (watch history + on-deck, filtered to the owner account) into Siftlode, resolving a + divergence to Plex (the source of truth — see `_pull_apply`), then re-pushes any still-unsynced + local states. Does NOT propagate un-watches (the history feed only carries positive views) — that's + `run_plex_watch_reconcile`'s job.""" if not sysconfig.get_bool(db, "plex_enabled"): return {"skipped": "disabled"} total = {"links": 0, "watched": 0, "in_progress": 0, "repushed": 0} @@ -486,7 +487,7 @@ def run_plex_watch_sync(db: Session) -> dict: def run_plex_watch_reconcile(db: Session) -> dict: """Scheduler job (default daily) — the authoritative full reconcile. Rescans every section and, per item, uses `synced_to_plex` to settle what the incremental feed can't: - - Plex has a state, Siftlode differs → last-write-wins (Plex usually the newer here). + - Plex has a state, Siftlode differs → Plex wins (the source of truth — see `_pull_apply`). - Siftlode has a state, Plex has NO record: · synced_to_plex=True → Plex un-watched it since our last sync → clear it (un-watch). · synced_to_plex=False → a local change that never reached Plex → re-push it (via From 8299ea72b7dc59d8b1cfc886c756f2f790ffb895 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Thu, 27 Aug 2026 02:50:41 +0200 Subject: [PATCH 04/11] feat(plex): 'last watched' browse sort + auto-apply in the in-progress view (S3) Add a 'lastwatched' sort key to the unified Plex browse. Movies rank by coalesce(progress_updated_at, watched_at); shows by max(coalesce(...)) across their episodes (rollup.resumed_at, now broadened from progress-only so watched episodes count too). Never-watched titles sink via nullslast. Frontend auto-applies lastwatched:desc when entering the in-progress watch-state view and restores the pre-entry sort on exit; a manual sort change while there still wins. i18n HU+EN. Backend tests cover both union legs + mixed nullslast. --- backend/app/plex/rollup.py | 10 +- backend/app/routes/plex/browse.py | 9 +- backend/tests/test_plex_last_watched_sort.py | 158 +++++++++++++++++++ frontend/src/components/PlexBrowse.tsx | 27 ++++ frontend/src/i18n/locales/en/plex.json | 1 + frontend/src/i18n/locales/hu/plex.json | 1 + frontend/src/lib/plexSort.ts | 12 +- 7 files changed, 212 insertions(+), 6 deletions(-) create mode 100644 backend/tests/test_plex_last_watched_sort.py diff --git a/backend/app/plex/rollup.py b/backend/app/plex/rollup.py index cf945b93..4ea9d228 100644 --- a/backend/app/plex/rollup.py +++ b/backend/app/plex/rollup.py @@ -43,8 +43,10 @@ def rollup(cards: list[dict]) -> dict: def agg_subquery(db: Session, user_id: int, include_resumed: bool = False): """Per-show episode counts for a user: total, watched, in-progress. Reused to (a) filter the show grid by aggregate watch-state, (b) compute each card's badge, and (c) drive the Discover - continue-watching rail. `include_resumed` adds `resumed_at` = max(progress_updated_at) across the - show's episodes, so the rail can order shows by how recently they were last progressed.""" + continue-watching rail, and (d) the browse "last watched" sort. `include_resumed` adds `resumed_at` + = max(coalesce(progress_updated_at, watched_at)) across the show's episodes — i.e. how recently ANY + episode was last touched (resumed OR marked watched) — so callers can order shows by recency. A + fully-watched show (no resume progress) still gets a timestamp from its episodes' watched_at.""" ps = aliased(PlexState) cols = [ PlexItem.show_id.label("show_id"), @@ -63,7 +65,9 @@ def agg_subquery(db: Session, user_id: int, include_resumed: bool = False): ).label("inprog"), ] if include_resumed: - cols.append(func.max(ps.progress_updated_at).label("resumed_at")) + cols.append( + func.max(func.coalesce(ps.progress_updated_at, ps.watched_at)).label("resumed_at") + ) return ( select(*cols) .outerjoin(ps, and_(ps.item_id == PlexItem.id, ps.user_id == user_id)) diff --git a/backend/app/routes/plex/browse.py b/backend/app/routes/plex/browse.py index 27aaf434..163088a9 100644 --- a/backend/app/routes/plex/browse.py +++ b/backend/app/routes/plex/browse.py @@ -138,6 +138,9 @@ def unified_library( cast(null(), Integer).label("season_count"), func.coalesce(st.position_seconds, 0).label("position_seconds"), m_status.label("status"), + # "last watched" sort key: when the movie was last resumed, else when it was marked + # watched. NULL for never-touched movies → nullslast sinks them (see the sort dict). + func.coalesce(st.progress_updated_at, st.watched_at).label("last_watched"), ( func.ts_rank(vector_col(PlexItem, lang), tsq) if tsq is not None @@ -159,7 +162,7 @@ def unified_library( mq = mq.where(recheck(PlexItem, field, tsq, lang)) selects.append(mq) if want_shows and grid_field: - agg = rollup.agg_subquery(db, user.id).subquery() + agg = rollup.agg_subquery(db, user.id, include_resumed=True).subquery() tot = func.coalesce(agg.c.total, 0) wat = func.coalesce(agg.c.watched, 0) inp = func.coalesce(agg.c.inprog, 0) @@ -179,6 +182,9 @@ def unified_library( PlexShow.child_count.label("season_count"), literal(0).label("position_seconds"), s_status.label("status"), + # Show leg's "last watched": max(resume/watched time) across the show's episodes (see + # rollup.agg_subquery include_resumed). NULL for a never-watched show → nullslast. + agg.c.resumed_at.label("last_watched"), ( func.ts_rank(vector_col(PlexShow, lang), tsq) if tsq is not None @@ -212,6 +218,7 @@ def unified_library( "rating": u.c.rating, "duration": u.c.duration_s, "release": u.c.rel, + "lastwatched": u.c.last_watched, }.get(sort, u.c.added_at) order = [(col.asc() if sort_dir == "asc" else col.desc()).nullslast()] order.append(u.c.rk.desc()) diff --git a/backend/tests/test_plex_last_watched_sort.py b/backend/tests/test_plex_last_watched_sort.py new file mode 100644 index 00000000..60060591 --- /dev/null +++ b/backend/tests/test_plex_last_watched_sort.py @@ -0,0 +1,158 @@ +"""S3: the "last watched" browse sort (`sort=lastwatched`). Movies rank by +coalesce(progress_updated_at, watched_at); shows by max(coalesce(...)) across their episodes +(rollup.resumed_at). Never-watched titles sink to the bottom (nullslast). DB lane (`db` fixture) — +skipped without Postgres. +""" + +from datetime import datetime, timedelta, timezone + +from fastapi.testclient import TestClient + +import app.state as state +from app.auth import current_user +from app.db import get_db +from app.main import app +from app.models import PlexItem, PlexLibrary, PlexShow, PlexState, User + +NOW = datetime(2024, 6, 1, tzinfo=timezone.utc) + + +def _seed(db): + lib_m = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True) + lib_s = PlexLibrary(plex_key="2", title="Shows", kind="show", enabled=True) + db.add_all([lib_m, lib_s]) + db.flush() + + # Movies: newest resume, a watched-only (coalesce falls back to watched_at), an older resume, and a + # never-touched one. + mo_new = PlexItem(rating_key="mo_new", library_id=lib_m.id, kind="movie", title="Newest Resume") + mo_watched = PlexItem( + rating_key="mo_watched", library_id=lib_m.id, kind="movie", title="Watched Only" + ) + mo_old = PlexItem(rating_key="mo_old", library_id=lib_m.id, kind="movie", title="Older Resume") + mo_never = PlexItem(rating_key="mo_never", library_id=lib_m.id, kind="movie", title="Never") + + # Shows: a recently-resumed episode, a watched-only episode, and a never-watched show. + sh_recent = PlexShow( + rating_key="sh_recent", library_id=lib_s.id, title="Recent Show", child_count=1 + ) + sh_watched = PlexShow( + rating_key="sh_watched", library_id=lib_s.id, title="Watched Show", child_count=1 + ) + sh_never = PlexShow( + rating_key="sh_never", library_id=lib_s.id, title="Never Show", child_count=1 + ) + db.add_all([mo_new, mo_watched, mo_old, mo_never, sh_recent, sh_watched, sh_never]) + db.flush() + + ep_recent = PlexItem( + rating_key="sh_recent_e1", + library_id=lib_s.id, + kind="episode", + title="E1", + show_id=sh_recent.id, + season_number=1, + episode_number=1, + ) + ep_watched = PlexItem( + rating_key="sh_watched_e1", + library_id=lib_s.id, + kind="episode", + title="E1", + show_id=sh_watched.id, + season_number=1, + episode_number=1, + ) + db.add_all([ep_recent, ep_watched]) + + user = User(email="lastwatched@example.com") + db.add(user) + db.flush() + + db.add_all( + [ + PlexState( + user_id=user.id, + item_id=mo_new.id, + status="new", + position_seconds=100, + progress_updated_at=NOW, + ), + PlexState( + user_id=user.id, + item_id=mo_watched.id, + status="watched", + watched_at=NOW - timedelta(hours=1), + ), + PlexState( + user_id=user.id, + item_id=mo_old.id, + status="new", + position_seconds=100, + progress_updated_at=NOW - timedelta(hours=2), + ), + # Show episodes. + PlexState( + user_id=user.id, + item_id=ep_recent.id, + status="new", + position_seconds=50, + progress_updated_at=NOW - timedelta(minutes=30), + ), + PlexState( + user_id=user.id, + item_id=ep_watched.id, + status="watched", + watched_at=NOW - timedelta(minutes=90), + ), + ] + ) + db.commit() + return user + + +def _get(db, user, monkeypatch, path): + monkeypatch.setattr(state, "_configured_cache", True) + app.dependency_overrides[get_db] = lambda: db + app.dependency_overrides[current_user] = lambda: user + try: + return TestClient(app).get(path) + finally: + app.dependency_overrides.clear() + + +def _ids(resp): + return [i["id"] for i in resp.json()["items"]] + + +def test_movies_ordered_by_last_watched_desc(db, monkeypatch): + user = _seed(db) + resp = _get( + db, user, monkeypatch, "/api/plex/library?scope=movie&sort=lastwatched&sort_dir=desc" + ) + assert resp.status_code == 200, resp.text + # Newest resume, then the watched-only (coalesce → watched_at), then the older resume, then never. + assert _ids(resp) == ["mo_new", "mo_watched", "mo_old", "mo_never"] + + +def test_shows_ordered_by_last_watched_desc(db, monkeypatch): + user = _seed(db) + resp = _get( + db, user, monkeypatch, "/api/plex/library?scope=show&sort=lastwatched&sort_dir=desc" + ) + assert resp.status_code == 200, resp.text + # Recent resume first, then the watched-only show, then the never-watched one (nullslast). + assert _ids(resp) == ["sh_recent", "sh_watched", "sh_never"] + + +def test_last_watched_mixed_scope_nullslast(db, monkeypatch): + user = _seed(db) + resp = _get( + db, user, monkeypatch, "/api/plex/library?scope=both&sort=lastwatched&sort_dir=desc" + ) + assert resp.status_code == 200, resp.text + ids = _ids(resp) + # Global recency order across both legs: mo_new (NOW) > sh_recent (−30m) > mo_watched (−1h) > + # sh_watched (−90m) > mo_old (−2h); the two never-watched titles sink to the bottom. + assert ids[:5] == ["mo_new", "sh_recent", "mo_watched", "sh_watched", "mo_old"] + assert set(ids[5:]) == {"mo_never", "sh_never"} diff --git a/frontend/src/components/PlexBrowse.tsx b/frontend/src/components/PlexBrowse.tsx index 93da008d..f6df040d 100644 --- a/frontend/src/components/PlexBrowse.tsx +++ b/frontend/src/components/PlexBrowse.tsx @@ -11,6 +11,7 @@ import { User, X } from "lucide-react"; import { qk } from "../lib/queryKeys"; import { healPlexField } from "../lib/plexSearch"; import { healPlexSort, plexSortOptions, PLEX_SORT_FALLBACK } from "../lib/plexSort"; +import type { SortState } from "../lib/columnSort"; import { api, EMPTY_PLEX_FILTERS, @@ -52,6 +53,13 @@ const PlexCollectionView = lazy(() => import("./PlexCollectionView")); const PAGE = 40; +// D3: the "last watched" sort auto-applied when you enter the in-progress watch-state view (most +// recently watched first). Frozen for the same reason as PLEX_SORT_FALLBACK (handed out by reference). +const LASTWATCHED_SORT: NonNullable = Object.freeze({ + key: "lastwatched", + dir: "desc" as const, +}); + // Watch-state options for the toolbar pill (moved out of the filter rail — it's a top-level "what am // I looking at" control now, like the feed's Watch-state pill, not a rail widget). const SHOW_OPTS = ["all", "unwatched", "in_progress", "watched"] as const; @@ -185,6 +193,25 @@ export default function PlexBrowse() { return () => window.removeEventListener("keydown", onKey); }, [sub]); + // D3: entering the in-progress watch-state view auto-applies the "last watched" sort (most recently + // watched first); leaving restores the sort that was active before entering. Only the ENTER edge + // forces the sort, so a manual sort change while in the in-progress view still wins — it's just not + // carried back out (exit returns to the pre-entry preference). We stash the raw provider `sort`, not + // the scope-healed one, so the user's real preference round-trips. + const prevShow = useRef(show); + const preInProgressSort = useRef(null); + useEffect(() => { + const was = prevShow.current; + if (was !== "in_progress" && show === "in_progress") { + preInProgressSort.current = sort; + setSort(LASTWATCHED_SORT); + } else if (was === "in_progress" && show !== "in_progress") { + if (preInProgressSort.current) setSort(preInProgressSort.current); + preInProgressSort.current = null; + } + prevShow.current = show; + }, [show, sort, setSort]); + const browseQ = useInfiniteQuery({ queryKey: qk.plexLibrary(scope, dq, sortState, show, filters, fieldState, lang), // Gate on committedLang so a language switch fires ONE fetch (after the genre-clear effect diff --git a/frontend/src/i18n/locales/en/plex.json b/frontend/src/i18n/locales/en/plex.json index e46f7523..964f7af6 100644 --- a/frontend/src/i18n/locales/en/plex.json +++ b/frontend/src/i18n/locales/en/plex.json @@ -45,6 +45,7 @@ }, "sort": { "added": "Recently added", + "lastwatched": "Last watched", "title": "Title", "year": "Year", "rating": "IMDb rating", diff --git a/frontend/src/i18n/locales/hu/plex.json b/frontend/src/i18n/locales/hu/plex.json index 135848c0..c24b9be4 100644 --- a/frontend/src/i18n/locales/hu/plex.json +++ b/frontend/src/i18n/locales/hu/plex.json @@ -45,6 +45,7 @@ }, "sort": { "added": "Nemrég hozzáadott", + "lastwatched": "Utoljára nézve", "title": "Cím", "year": "Év", "rating": "IMDb pont", diff --git a/frontend/src/lib/plexSort.ts b/frontend/src/lib/plexSort.ts index 4b8617a4..a886e787 100644 --- a/frontend/src/lib/plexSort.ts +++ b/frontend/src/lib/plexSort.ts @@ -4,8 +4,16 @@ import { healSort, type SortOption, type SortState } from "./columnSort"; // SortControl. Direction lives in the SortState (no more sibling `sortDir` on PlexFilters); the wire // call (`plexLibrary`) still splits it into `sort` + `sort_dir` at the server boundary. -const MOVIE_SORTS = ["added", "release", "year", "rating", "duration", "title"] as const; -const SHOW_SORTS = ["added", "release", "year", "rating", "title"] as const; +const MOVIE_SORTS = [ + "added", + "lastwatched", + "release", + "year", + "rating", + "duration", + "title", +] as const; +const SHOW_SORTS = ["added", "lastwatched", "release", "year", "rating", "title"] as const; /** The sort keys a scope offers — movie libraries add `duration`; shows/both don't. */ function plexSortKeys(scope: string): string[] { From 56a2ecdec320dfe9c26debbf10058cb4eaa7ade2 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Thu, 27 Aug 2026 03:14:54 +0200 Subject: [PATCH 05/11] =?UTF-8?q?feat(plex):=20shareable=20Plex-browse=20s?= =?UTF-8?q?tate=20in=20the=20URL=20=E2=80=94=20scope+filters+sort+show=20(?= =?UTF-8?q?S4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- .../src/components/NavigationProvider.tsx | 16 ++- frontend/src/components/PlexProvider.tsx | 62 ++++++++- frontend/src/lib/api.ts | 2 + frontend/src/lib/api/plex.ts | 42 ++++++ frontend/src/lib/plexUrlState.dom.test.ts | 130 ++++++++++++++++++ frontend/src/lib/plexUrlState.ts | 73 ++++++++++ 6 files changed, 318 insertions(+), 7 deletions(-) create mode 100644 frontend/src/lib/plexUrlState.dom.test.ts create mode 100644 frontend/src/lib/plexUrlState.ts diff --git a/frontend/src/components/NavigationProvider.tsx b/frontend/src/components/NavigationProvider.tsx index f3a5e7f7..64008888 100644 --- a/frontend/src/components/NavigationProvider.tsx +++ b/frontend/src/components/NavigationProvider.tsx @@ -126,12 +126,22 @@ export function NavigationProvider({ children }: { children: ReactNode }) { // Push an in-app history entry so browser/mouse Back steps through pages instead of leaving the // app. A fresh { sfPage } (no carried-over _yt/_chan markers) so each page starts at its root, and // the address bar reflects the page (feed is implicit) while clearing the view-specific params - // (chan/v/pl) — a page switch resets to that module's root. Feed filters (owned by - // FeedFiltersProvider) are left intact. + // (chan/v/pl and the plex content keys pscope/psort/pshow/pf) — a page switch resets to that + // module's root. Feed filters (owned by FeedFiltersProvider) are left intact; the plex keys + // re-mirror from PlexProvider on return to the plex page. window.history.pushState( { sfPage: next, sfScroll: nextNavId() }, "", - urlWithParams({ page: next === "feed" ? null : next, chan: null, v: null, pl: null }), + urlWithParams({ + page: next === "feed" ? null : next, + chan: null, + v: null, + pl: null, + pscope: null, + psort: null, + pshow: null, + pf: null, + }), ); notifyNav(); }, diff --git a/frontend/src/components/PlexProvider.tsx b/frontend/src/components/PlexProvider.tsx index 2c18da71..ed4b97ed 100644 --- a/frontend/src/components/PlexProvider.tsx +++ b/frontend/src/components/PlexProvider.tsx @@ -3,8 +3,16 @@ import { coercePlexFilters, EMPTY_PLEX_FILTERS, type PlexFilters } from "../lib/ import { parseSortState, type SortState } from "../lib/columnSort"; import { parsePlexField, type PlexSearchField } from "../lib/plexSearch"; import { PLEX_SORT_FALLBACK } from "../lib/plexSort"; +import { + hasPlexParams, + plexParamsToState, + plexParamUpdates, + type PlexUrlState, +} from "../lib/plexUrlState"; import { getAccountRaw, LS, setAccountRaw } from "../lib/storage"; import { useAccountRehydrate } from "../lib/useAccountRehydrate"; +import { useUrlMirror } from "../lib/useUrlMirror"; +import { useNavigation } from "./NavigationProvider"; // Owns the Plex module's shared local state — the left-rail library scope / watch-state / sort / // expanded filters, the header search term, and the "open this playlist" hand-off — lifted out of @@ -112,14 +120,41 @@ function readPlexSort(): SortState { return { key: raw, dir }; } +// S4: a shared Plex-browse link carries the content state in namespaced URL keys (pscope/psort/pshow/ +// pf). When present, the URL wins over this account's stored view (like the feed's URL-over-localStorage +// seed) — read once at mount. The keys are Plex-unique, so their mere presence identifies a shared Plex +// view; no ?page check is needed (and the mirror below is page-gated so it never pollutes other pages). +function readPlexFilters(): PlexFilters { + try { + return coercePlexFilters(JSON.parse(readPlexFiltersRaw()) as Record); + } catch { + return EMPTY_PLEX_FILTERS; + } +} +function readPlexUrlSeed(): PlexUrlState | null { + const params = new URLSearchParams(window.location.search); + if (!hasPlexParams(params)) return null; + return plexParamsToState(params, { + scope: readPlexScope(), + sort: readPlexSort(), + show: readPlexShow(), + filters: readPlexFilters(), + }); +} + export function PlexProvider({ children }: { children: ReactNode }) { + // Captured once: the shared-link content state (or null). Seeds the slices below and, on account pin, + // is persisted to the account instead of being clobbered by the stored view (see the rehydrate). + 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(readPlexScope); - const [show, setShowState] = useState(readPlexShow); - const [sort, setSortState] = useState(readPlexSort); + const [scope, setScopeState] = useState(() => urlSeed?.scope ?? readPlexScope()); + const [show, setShowState] = useState(() => urlSeed?.show ?? readPlexShow()); + const [sort, setSortState] = useState(() => urlSeed?.sort ?? readPlexSort()); // The expanded Plex filters live as one JSON blob (the string-only account store serializes it). - const [filtersRaw, setFiltersRaw] = useState(readPlexFiltersRaw); + const [filtersRaw, setFiltersRaw] = useState(() => + urlSeed ? JSON.stringify(urlSeed.filters) : readPlexFiltersRaw(), + ); const filters = useMemo(() => { try { // coercePlexFilters fills defaults, drops the legacy `sortDir` (direction now lives in the sort @@ -140,7 +175,19 @@ export function PlexProvider({ children }: { children: ReactNode }) { const [jumpTo, setJumpTo] = useState(null); // Re-read every per-account slice once the account is pinned after mount (first load on a fresh tab). + // A shared-link seed is the exception: instead of reloading the account's stored view, PERSIST the URL + // state to this account (so the shared view wins and survives a later plain reload), and adopt only the + // 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)); + setQState(readPlexQ()); + setFieldState(readPlexField()); + return; + } setScopeState(readPlexScope()); setShowState(readPlexShow()); setSortState(readPlexSort()); @@ -208,6 +255,13 @@ export function PlexProvider({ children }: { children: ReactNode }) { [setScope, setShow, setSort, setFilters, setQ, setField], ); + // S4: mirror the content state (scope + sort + watch-state + filters) into the address bar under the + // namespaced keys so a Plex-browse view is shareable. Gated on page===plex: off the Plex page the keys + // are cleared (localStorage still holds the state; it re-mirrors on return). `q`/`field` stay OUT — the + // search term is deliberately transient-per-view and not part of the shared four-key contract. + const { page } = useNavigation(); + useUrlMirror(plexParamUpdates(page === "plex" ? { scope, sort, show, filters } : null)); + return ( {children} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index d83ce77e..b3e91cfd 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -62,6 +62,8 @@ export { plexFilterCount, coercePlexFilters, plexFiltersSignature, + plexFiltersToParams, + parsePlexFilters, isIntrinsicReadOnly, } from "./api/plex"; import { messagingApi } from "./api/messaging"; diff --git a/frontend/src/lib/api/plex.ts b/frontend/src/lib/api/plex.ts index 44156b70..b8950c4e 100644 --- a/frontend/src/lib/api/plex.ts +++ b/frontend/src/lib/api/plex.ts @@ -334,6 +334,48 @@ function appendPlexFilters(u: URLSearchParams, f: PlexFilters): void { if (f.collections?.length) u.set("collections", f.collections.join(",")); if (f.collectionMode === "all") u.set("collection_mode", "all"); } + +// Public wrapper over the private appendPlexFilters so the URL mirror (plexUrlState) serializes the +// filter set through the SAME single source the API request uses — the two can never drift. Display-only +// titles (collectionTitles) stay out, exactly like the wire params and the saved-view signature. +export function plexFiltersToParams(f: PlexFilters): URLSearchParams { + const u = new URLSearchParams(); + appendPlexFilters(u, f); + return u; +} + +// The inverse of appendPlexFilters: parse a filter sub-querystring back into a full PlexFilters (missing +// keys → EMPTY defaults). Kept next to its encoder so a new wire key can't be added on one side only. +export function parsePlexFilters(p: URLSearchParams): PlexFilters { + const csv = (k: string): string[] => { + const v = p.get(k); + return v ? v.split(",").filter(Boolean) : []; + }; + const num = (k: string): number | undefined => { + if (!p.has(k)) return undefined; + const n = Number(p.get(k)); + return Number.isFinite(n) ? n : undefined; + }; + const f: PlexFilters = { ...EMPTY_PLEX_FILTERS }; + f.genres = csv("genres"); + if (p.get("genre_mode") === "all") f.genreMode = "all"; + f.contentRatings = csv("content_ratings"); + f.yearMin = num("year_min"); + f.yearMax = num("year_max"); + f.ratingMin = num("rating_min"); + f.durationMin = num("duration_min"); + f.durationMax = num("duration_max"); + const aw = p.get("added_within"); + if (aw) f.addedWithin = aw; + f.directors = csv("directors"); + f.actors = csv("actors"); + if (p.get("actor_mode") === "all") f.actorMode = "all"; + f.studios = csv("studios"); + f.keywords = csv("keywords"); + f.collections = csv("collections"); + if (p.get("collection_mode") === "all") f.collectionMode = "all"; + return f; +} export interface PlexFacets { genres: { value: string; count: number }[]; content_ratings: { value: string; count: number }[]; diff --git a/frontend/src/lib/plexUrlState.dom.test.ts b/frontend/src/lib/plexUrlState.dom.test.ts new file mode 100644 index 00000000..3641d1a0 --- /dev/null +++ b/frontend/src/lib/plexUrlState.dom.test.ts @@ -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): 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); + }); +}); diff --git a/frontend/src/lib/plexUrlState.ts b/frontend/src/lib/plexUrlState.ts new file mode 100644 index 00000000..4bbcb0a3 --- /dev/null +++ b/frontend/src/lib/plexUrlState.ts @@ -0,0 +1,73 @@ +// Serialize the Plex-browse content state (library scope + watch-state + sort + metadata filters) to/ +// from the address bar so a Plex-browse view is bookmarkable/shareable — the S4 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 the app +// is 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. Only non- +// default values are emitted, so URLs stay clean. +import { parsePlexFilters, plexFiltersToParams, type PlexFilters } from "./api"; +import { parseSortState, 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 +// use), so the user's real preference round-trips through the URL. +export interface PlexUrlState { + scope: string; + sort: SortState; + show: string; + filters: PlexFilters; +} + +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. +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 }); +} + +/** The address-bar delta for the Plex content state: each namespaced key mapped to its emitted value, + * or null to delete it (so a control back at its default clears the key). Pass `null` to clear ALL Plex + * keys — used when the app is off the Plex page (localStorage still holds the state; it re-mirrors on + * return). Feed the result into urlWithParams(). */ +export function plexParamUpdates(state: PlexUrlState | null): Record { + if (!state) return { pscope: null, psort: null, pshow: null, pf: null }; + const pf = plexFiltersToParams(state.filters).toString(); + return { + pscope: state.scope && state.scope !== "both" ? state.scope : null, + psort: encodeSort(state.sort), + pshow: state.show && state.show !== "all" ? state.show : null, + pf: pf || null, + }; +} + +/** True if the query string carries any Plex content key we recognize (a shared Plex-browse link). */ +export function hasPlexParams(params: URLSearchParams): boolean { + return KEYS.some((k) => params.has(k)); +} + +/** Merge the Plex content keys from the URL over a base state (the localStorage-seeded defaults); an + * absent or invalid key keeps the base value, so a partial link (only `?psort=`) is honoured. */ +export function plexParamsToState(params: URLSearchParams, base: PlexUrlState): 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; + return { + scope: scope && SCOPES.includes(scope) ? scope : base.scope, + sort, + show: show && SHOWS.includes(show) ? show : base.show, + filters: params.has("pf") + ? parsePlexFilters(new URLSearchParams(params.get("pf") ?? "")) + : base.filters, + }; +} From 8dc4976c2c250e74dc93062fc7270246f9164e02 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Thu, 27 Aug 2026 03:29:42 +0200 Subject: [PATCH 06/11] =?UTF-8?q?feat(channels):=20horizontal=20=E2=88=92/?= =?UTF-8?q?=EF=BC=8B=20priority=20stepper=20with=20pop-free=20inline=20edi?= =?UTF-8?q?t=20(S5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Redesign the shared PriorityStepper (channel manager cell + detail header) from a value-plus-stacked-arrows control into a horizontal [−] value [+] segmented stepper. The value now sits centred between the buttons and opens an inline editor in a FIXED-width slot, so click-to-type no longer "pops" the control wider (the old absolutely-positioned overlay grew w-6 → w-14). One `size` prop drives the two surfaces: `sm` draws its own bordered pill for the standalone table cell; `md` drops the outer border and keeps only the segment dividers, blending into the detail header's lozenge instead of double-bordering it. Hold-to-repeat, negatives, and the debounced-flush persistence hook are unchanged. The manager priority column widens 3.5rem → 5.5rem to fit the horizontal control under fixed table layout. Tests: 7 new DOM tests for the stepper interaction contract. --- frontend/src/components/ChannelPage.tsx | 1 + .../components/PriorityStepper.dom.test.tsx | 81 +++++++++++++++ frontend/src/components/PriorityStepper.tsx | 99 ++++++++++++------- frontend/src/components/channelColumns.tsx | 2 +- 4 files changed, 146 insertions(+), 37 deletions(-) create mode 100644 frontend/src/components/PriorityStepper.dom.test.tsx diff --git a/frontend/src/components/ChannelPage.tsx b/frontend/src/components/ChannelPage.tsx index b23e49af..0595c20b 100644 --- a/frontend/src/components/ChannelPage.tsx +++ b/frontend/src/components/ChannelPage.tsx @@ -418,6 +418,7 @@ export default function ChannelPage({ priority={ch.priority} onPriority={(d) => bumpPriority(channelId, d)} onSet={(v) => setPriority(channelId, v)} + size="md" /> )} diff --git a/frontend/src/components/PriorityStepper.dom.test.tsx b/frontend/src/components/PriorityStepper.dom.test.tsx new file mode 100644 index 00000000..6e8e6f4f --- /dev/null +++ b/frontend/src/components/PriorityStepper.dom.test.tsx @@ -0,0 +1,81 @@ +// @vitest-environment jsdom +// +// Interaction contract of the shared PriorityStepper (channel manager cell + detail header). i18n is +// mocked to a key-passthrough so aria-label lookups read stable keys, not translations. The visual +// size/border split (`sm` vs `md`) is CSS-only, so we assert behaviour, not styling. +import { describe, it, expect, vi, afterEach } from "vitest"; +import { render, screen, fireEvent, cleanup } from "@testing-library/react"; +import { PriorityStepper } from "./PriorityStepper"; + +vi.mock("react-i18next", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, useTranslation: () => ({ t: (key: string) => key }) }; +}); + +afterEach(cleanup); + +const raise = () => screen.getByRole("button", { name: "channels.row.raisePriority" }); +const lower = () => screen.getByRole("button", { name: "channels.row.lowerPriority" }); +const valueBtn = () => screen.getByRole("button", { name: "channels.row.editPriority" }); + +describe("PriorityStepper", () => { + it("shows the current priority and both step buttons", () => { + render(); + expect(valueBtn().textContent).toBe("12"); + expect(raise()).toBeTruthy(); + expect(lower()).toBeTruthy(); + }); + + it("tapping + / − nudges by ±1", () => { + const onPriority = vi.fn(); + render(); + fireEvent.click(raise()); + fireEvent.click(lower()); + expect(onPriority.mock.calls).toEqual([[1], [-1]]); + }); + + it("clicking the value opens an inline editor seeded with the current value", () => { + render(); + fireEvent.click(valueBtn()); + const input = screen.getByRole("spinbutton") as HTMLInputElement; + expect(input.value).toBe("7"); + }); + + it("typing a new value and pressing Enter commits it via onSet (negatives allowed)", () => { + const onSet = vi.fn(); + render(); + fireEvent.click(valueBtn()); + const input = screen.getByRole("spinbutton"); + fireEvent.change(input, { target: { value: "-3" } }); + fireEvent.keyDown(input, { key: "Enter" }); + expect(onSet).toHaveBeenCalledWith(-3); + }); + + it("Escape cancels the edit without committing", () => { + const onSet = vi.fn(); + render(); + fireEvent.click(valueBtn()); + const input = screen.getByRole("spinbutton"); + fireEvent.change(input, { target: { value: "99" } }); + fireEvent.keyDown(input, { key: "Escape" }); + expect(onSet).not.toHaveBeenCalled(); + // editor closed → the value button is back + expect(valueBtn().textContent).toBe("7"); + }); + + it("committing an unchanged value does not call onSet", () => { + const onSet = vi.fn(); + render(); + fireEvent.click(valueBtn()); + fireEvent.keyDown(screen.getByRole("spinbutton"), { key: "Enter" }); + expect(onSet).not.toHaveBeenCalled(); + }); + + it("renders the same interactive contract at size=md", () => { + const onPriority = vi.fn(); + render(); + expect(valueBtn().textContent).toBe("3"); + fireEvent.click(raise()); + expect(onPriority).toHaveBeenCalledWith(1); + }); +}); diff --git a/frontend/src/components/PriorityStepper.tsx b/frontend/src/components/PriorityStepper.tsx index 24b83c70..5f92a036 100644 --- a/frontend/src/components/PriorityStepper.tsx +++ b/frontend/src/components/PriorityStepper.tsx @@ -1,19 +1,28 @@ import { useCallback, useEffect, useRef, useState } from "react"; -import { ArrowDown, ArrowUp } from "lucide-react"; +import { Minus, Plus } from "lucide-react"; import { useTranslation } from "react-i18next"; import Tooltip from "./Tooltip"; +type Size = "sm" | "md"; + +const SIZED = { + sm: { btn: "w-6 h-6", icon: "w-3.5 h-3.5", value: "w-8 h-6 text-sm" }, + md: { btn: "w-7 h-7", icon: "w-4 h-4", value: "w-10 h-7 text-sm" }, +} as const; + function PriorityStepButton({ delta, Icon, label, + sizeCls, onHoldStart, onStop, onTap, }: { delta: number; - Icon: typeof ArrowUp; + Icon: typeof Plus; label: string; + sizeCls: { btn: string; icon: string }; onHoldStart: (delta: number) => void; onStop: () => void; onTap: (delta: number) => void; @@ -32,28 +41,35 @@ function PriorityStepButton({ onPointerUp={onStop} onPointerCancel={onStop} onClick={() => onTap(delta)} - className="text-muted hover:text-accent leading-none" + className={`inline-flex items-center justify-center text-muted hover:text-accent ${sizeCls.btn}`} aria-label={label} > - + ); } /** - * A press-and-hold-repeat priority control with an inline-editable value (negatives allowed). - * Shared by the channel manager table (a cell) and the channel detail page (a header control), so - * the two never drift. The caller owns persistence: `onPriority(delta)` nudges by ±1 (with hold - * auto-repeat), `onSet(value)` writes a typed-in absolute value. + * A horizontal −/+ priority stepper with the value centred between the buttons and click-to-type + * inline editing (negatives allowed). Shared by the channel manager table (a cell, `size="sm"`) and + * the channel detail page (a header control, `size="md"`), so the two never drift. The caller owns + * persistence: `onPriority(delta)` nudges by ±1 (with hold auto-repeat), `onSet(value)` writes a + * typed-in absolute value. + * + * The `size="sm"` variant draws its own bordered pill (it stands alone in a table cell); `size="md"` + * drops the outer border and keeps only the segment dividers, so it blends into the detail header's + * surrounding lozenge instead of double-bordering it. */ export function PriorityStepper({ priority, onPriority, onSet, + size = "sm", }: { priority: number; onPriority: (delta: number) => void; onSet: (value: number) => void; + size?: Size; }) { const { t } = useTranslation(); const [editing, setEditing] = useState(false); @@ -61,6 +77,7 @@ export function PriorityStepper({ const holdDelay = useRef | null>(null); const holdRepeat = useRef | null>(null); const suppressClick = useRef(false); + const cls = SIZED[size]; const stop = useCallback(() => { if (holdDelay.current) clearTimeout(holdDelay.current); @@ -103,21 +120,40 @@ export function PriorityStepper({ setEditing(true); }; - // The value sits to the LEFT of the arrows (not between them) so the cursor — which rests on the - // arrows while you press-and-hold — never covers the live value you're setting. Clicking the value - // opens an inline edit to type any integer directly (negatives allowed). + // Segmented control: [−] value [+]. The value slot keeps a FIXED width (`cls.value`) whether it's + // showing the number or the edit input, so opening the editor never resizes the control or shifts + // the buttons — the old absolutely-positioned overlay "popped" wider, this doesn't. While editing + // the whole pill borders in `accent` to read as focused. + const framed = + size === "sm" + ? `border rounded-md overflow-hidden ${editing ? "border-accent" : "border-border"}` + : ""; return ( -
- {/* The number keeps its footprint (w-6) whether displayed or editing; the input is absolutely - positioned OVER it (out of flow) so opening the editor never changes the row height/width — - it just overlays, extending left for a longer number. */} - +
+ + @@ -134,28 +170,19 @@ export function PriorityStepper({ else if (e.key === "Escape") setEditing(false); }} aria-label={t("channels.row.priorityHint")} - className="absolute top-1/2 right-0 -translate-y-1/2 w-14 h-6 text-sm font-medium tabular-nums text-right bg-card border border-accent rounded-sm px-1 outline-hidden z-popover [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none" + className="absolute inset-0 w-full h-full text-center font-medium tabular-nums text-accent bg-card outline-hidden [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none" /> )} - - - - +
); diff --git a/frontend/src/components/channelColumns.tsx b/frontend/src/components/channelColumns.tsx index f5104fdd..bf9082c7 100644 --- a/frontend/src/components/channelColumns.tsx +++ b/frontend/src/components/channelColumns.tsx @@ -198,7 +198,7 @@ export function buildChannelColumns(deps: ChannelColumnDeps): Column c.priority, From 00527d0cf3482f8a7c3fac0cd8bd20601cc81f5b Mon Sep 17 00:00:00 2001 From: npeter83 Date: Thu, 27 Aug 2026 04:02:25 +0200 Subject: [PATCH 07/11] =?UTF-8?q?feat(channels):=20leading=20'=EF=BC=8B=20?= =?UTF-8?q?Tag'=20pill=20+=20detail-page=20Unhide=20affordance=20(S6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Item 6 — the tag add-trigger now LEADS the chip row (a stable left anchor) instead of trailing it, so on the detail page (bounded=false) adding chips grows the row to its right and the '+'/its editor popover no longer drift. Compact icon in the width-bounded manager cell; a labelled '+ Tag' pill where there's free width (the detail header). Item 7 — surface the per-subscription 'hidden' flag on the detail GET (channel_detail was omitting it) and add an Eye/EyeOff hide/unhide toggle next to Block on the channel page, mirroring the manager. FE ChannelDetail gains 'hidden'; new channel.* i18n keys (HU+EN). Tests: +3 backend (channel_detail surfaces hidden, both states + unsubscribed default). Gate green: backend pytest 471, FE vitest 498. --- backend/app/routes/channels.py | 5 +++ backend/tests/test_channel_backfill.py | 43 ++++++++++++++++++++++ frontend/src/components/ChannelPage.tsx | 31 ++++++++++++++++ frontend/src/components/ChannelTags.tsx | 40 ++++++++++++-------- frontend/src/i18n/locales/en/channel.json | 4 ++ frontend/src/i18n/locales/en/channels.json | 1 + frontend/src/i18n/locales/hu/channel.json | 4 ++ frontend/src/i18n/locales/hu/channels.json | 1 + frontend/src/lib/api/channels.ts | 1 + 9 files changed, 114 insertions(+), 16 deletions(-) diff --git a/backend/app/routes/channels.py b/backend/app/routes/channels.py index 9578b2dc..d848d6cd 100644 --- a/backend/app/routes/channels.py +++ b/backend/app/routes/channels.py @@ -229,6 +229,7 @@ def _channel_detail_dict( count_live: int, deep_requested: bool, priority: int, + hidden: bool, tag_ids: list[int], ) -> dict: return { @@ -262,6 +263,9 @@ def _channel_detail_dict( # This user's per-subscription feed priority (0 when not subscribed) so the channel page can # offer the same priority stepper as the manager. Editable only while subscribed. "priority": priority, + # This user's per-subscription "hide from feed" flag (False when not subscribed), so the + # channel page can offer the same Unhide toggle the manager has. Editable only while subscribed. + "hidden": hidden, "backfill_done": channel.backfill_done, "from_explore": channel.from_explore, "details_synced": channel.details_synced_at is not None, @@ -344,6 +348,7 @@ def channel_detail( count_live=count_live, deep_requested=bool(sub.deep_requested) if sub else False, priority=int(sub.priority) if sub else 0, + hidden=bool(sub.hidden) if sub else False, tag_ids=tag_ids, ) diff --git a/backend/tests/test_channel_backfill.py b/backend/tests/test_channel_backfill.py index 91bd4447..d1244641 100644 --- a/backend/tests/test_channel_backfill.py +++ b/backend/tests/test_channel_backfill.py @@ -55,6 +55,49 @@ def test_channel_detail_returns_normal_shorts_live_counts(db, monkeypatch): assert body["count_normal"] == 2 +@pytest.mark.parametrize("hidden", [True, False]) +def test_channel_detail_surfaces_subscription_hidden_flag(db, monkeypatch, hidden): + # S6: the detail GET must expose the per-subscription `hidden` flag so the channel page can offer + # the same Unhide toggle the manager has (the flag was previously omitted from the detail dict). + ch = Channel(id="UC_hid", title="Hidden?", published_at=_PUB) + db.add(ch) + me = User(email="hid@example.com") + db.add(me) + db.flush() + db.add(Subscription(user_id=me.id, channel_id=ch.id, hidden=hidden)) + db.commit() + + monkeypatch.setattr(state, "_configured_cache", True) + app.dependency_overrides[get_db] = lambda: db + app.dependency_overrides[current_user] = lambda: me + try: + resp = TestClient(app).get(f"/api/channels/{ch.id}") + finally: + app.dependency_overrides.clear() + + assert resp.status_code == 200, resp.text + assert resp.json()["hidden"] is hidden + + +def test_channel_detail_hidden_defaults_false_when_not_subscribed(db, monkeypatch): + # No subscription row → the flag can't be True; it must serialize as False, not raise. + ch = Channel(id="UC_hid_ns", title="Unsub", published_at=_PUB) + me = User(email="hidns@example.com") + db.add_all([ch, me]) + db.commit() + + monkeypatch.setattr(state, "_configured_cache", True) + app.dependency_overrides[get_db] = lambda: db + app.dependency_overrides[current_user] = lambda: me + try: + resp = TestClient(app).get(f"/api/channels/{ch.id}") + finally: + app.dependency_overrides.clear() + + assert resp.status_code == 200, resp.text + assert resp.json()["hidden"] is False + + def _setup_channel_and_sub(db, *, email, is_demo=False): ch = Channel( id="UC_wire", diff --git a/frontend/src/components/ChannelPage.tsx b/frontend/src/components/ChannelPage.tsx index 0595c20b..80ebce67 100644 --- a/frontend/src/components/ChannelPage.tsx +++ b/frontend/src/components/ChannelPage.tsx @@ -12,6 +12,8 @@ import { Ban, Check, ExternalLink, + Eye, + EyeOff, History, Loader2, Plus, @@ -137,6 +139,17 @@ export default function ChannelPage({ // a caller toast here would just double it. }); + // "Hide from feed": the same per-subscription flag the manager toggles (updateChannel {hidden}). + // Local DB op — keeps the subscription, only drops the channel's videos from the feed/explore. + const toggleHidden = useMutation({ + mutationFn: () => api.updateChannel(channelId, { hidden: !ch?.hidden }), + onSuccess: () => { + qc.invalidateQueries({ queryKey: qk.channel(channelId) }); + qc.invalidateQueries({ queryKey: qk.feed() }); + qc.invalidateQueries({ queryKey: qk.channels() }); + }, + }); + const subscribe = useMutation({ mutationFn: () => api.subscribeChannel(channelId), onSuccess: () => { @@ -470,6 +483,24 @@ export default function ChannelPage({ )} + {/* Hide-from-feed toggle — subscription-scoped, so offered only on a followed channel + (mirrors the manager's Eye/EyeOff). Keeps the subscription; only drops its videos + from the feed. EyeOff = currently hidden (click to unhide). */} + {ch?.subscribed && !me.is_demo && ( + + )} {!me.is_demo && !ch?.blocked && (ch?.subscribed ? ( diff --git a/frontend/src/components/ChannelTags.tsx b/frontend/src/components/ChannelTags.tsx index 030f40ec..f580d7a6 100644 --- a/frontend/src/components/ChannelTags.tsx +++ b/frontend/src/components/ChannelTags.tsx @@ -9,8 +9,8 @@ import { useChipOverflowFit } from "../lib/useChipOverflowFit"; // The channel tag surface, shared by the channel manager (table + cards) and the channel detail // page. Built on the R8 popover platform. Two design goals it fixes over the old inline TagsCell: -// 1. Stable edit anchor — the "+" edit trigger sits in a FIXED slot at the end, so toggling tags -// (which grows the chip row) never moves it, and the editor popover doesn't drift. +// 1. Stable edit anchor — the "+" edit trigger LEADS the row (fixed left slot), so toggling tags +// (which grows the chip row to its right) never moves it, and the editor popover doesn't drift. // 2. Fixed footprint — the chip row lives in a width-bounded, single-line, overflow-hidden box and // collapses the excess into a "+N" pill (hover reveals the full set). The cell therefore never // wraps to a 2nd line, so it can't widen its table column and reflow every other row. @@ -281,9 +281,9 @@ function TagEditorMenu({ ); } -/** The full tag control for a channel: a width-bounded chip row with "+N" overflow (hover reveals - * all), followed by a stable "+" trigger that opens the shared editor. Fills its container width, - * so a table cell bounds it via the column width and a card bounds it via the card. */ +/** The full tag control for a channel: a stable leading "+" trigger (opens the shared editor), + * followed by a width-bounded chip row with "+N" overflow (hover reveals all). Fills its container + * width, so a table cell bounds it via the column width and a card bounds it via the card. */ export function ChannelTagsControl({ userTags, attachedIds, @@ -376,6 +376,25 @@ export function ChannelTagsControl({
+ {/* The add trigger LEADS the row — a stable left anchor that never moves as chips are added + (they grow to its right), so its editor popover can't drift. Compact icon in the width- + bounded manager cell; a labelled "+ Tag" pill where there's free width (the detail header). */} + + {/* Untagged + has suggestions → the ghost-chip lane (its own overflow "+N"); otherwise the attached-chip lane. The two are mutually exclusive: suggestions are computed for untagged channels only, and accepting one removes the channel from the suggestion set — so a channel @@ -437,17 +456,6 @@ export function ChannelTagsControl({ )} - - {/* Hover: reveal every attached tag when some are collapsed into "+N". */} Date: Thu, 27 Aug 2026 04:22:07 +0200 Subject: [PATCH 08/11] =?UTF-8?q?feat(views):=20editable=20saved-view=20de?= =?UTF-8?q?tail=20=E2=80=94=20removable=20filter=20chips=20+=20undo=20+=20?= =?UTF-8?q?save-current=20(S7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Personal-views manager's detail pane becomes editable instead of read-only: - Captured filters render as REMOVABLE chips. Removing one prunes the set and writes it straight back to the stored view (PATCH filters), via the same live-row builders (useActiveFilters / buildPlexActiveFilters) that already compute the pruned snapshot — so the modal and the live chip row can't drift. The prune is wrapped in useUndoable + the shared UndoToolbar, so undo/redo re-persist the same way. The undoable draft is the single source of truth for the chip row, so a chip-prune refetch never clobbers the history. - A "Save current" button beside Apply stores the module's currently-applied filters INTO the view (the live→stored inverse of Apply). Disabled when the view already equals the live filters (it's the active one). On save the draft re-seeds to the saved snapshot and clears the stale undo history. - Rename gains a visible commit affordance: a check button beside the name input that enables once the name changes (Enter and blur still commit). Provider gains two methods mirroring `rename`: updateFilters(id, filters) and saveCurrent(id). One shared provider serves feed + plex, so both get it. PATCH filters was already supported end-to-end — FE-only. New i18n: views.saveCurrent / saveCurrentHint / saveCurrentUpToDate (HU+EN). Tests: 5 new in PersonalViewsModal.dom.test.tsx (chip-remove persists, undo re-persists, save-current fires + disables when up-to-date, rename commit). --- .../PersonalViewsModal.dom.test.tsx | 150 +++++++++++++++++ .../src/components/PersonalViewsModal.tsx | 151 ++++++++++++++---- .../src/components/SavedViewsProvider.tsx | 15 ++ frontend/src/i18n/locales/en/views.json | 3 + frontend/src/i18n/locales/hu/views.json | 3 + 5 files changed, 292 insertions(+), 30 deletions(-) create mode 100644 frontend/src/components/PersonalViewsModal.dom.test.tsx diff --git a/frontend/src/components/PersonalViewsModal.dom.test.tsx b/frontend/src/components/PersonalViewsModal.dom.test.tsx new file mode 100644 index 00000000..9b9a18dc --- /dev/null +++ b/frontend/src/components/PersonalViewsModal.dom.test.tsx @@ -0,0 +1,150 @@ +// @vitest-environment jsdom +// +// S7 — the Personal-views manager gains an EDITABLE detail pane: captured filters are now removable +// chips that write the pruned set back to the stored view (undoable), an explicit Save-current button +// stores the live filters into the view, and rename has a visible commit affordance. This drives the +// real modal against a mocked SavedViewsProvider so we can assert exactly which mutation fires; the +// captured-filter row uses the REAL useActiveFilters (fed a tag from a seeded react-query cache), so +// the prune snapshots are the genuine ones the live chip row computes. +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, waitFor, cleanup, fireEvent } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +const h = vi.hoisted(() => ({ + updateFilters: vi.fn(async (_id: number, _filters: Record) => true), + saveCurrent: vi.fn(async (_id: number) => ({ saved: true }) as Record | null), + rename: vi.fn(async (_id: number, _name: string) => true), + activeViewId: null as number | null, +})); + +vi.mock("react-i18next", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, useTranslation: () => ({ t: (key: string) => key }) }; +}); + +// A full feed filter set that differs from the sidebar default ONLY in `tags`, so useActiveFilters +// surfaces exactly one chip (the tag) — a partial object would read missing show/sort/content as +// "active" and spray spurious chips. +const VIEW_FILTERS = { + tags: [1], + tagModes: { language: "or", topic: "or", other: "or" }, + untagged: false, + channelIds: [], + sort: "newest", + includeNormal: true, + includeShorts: false, + includeLive: false, + show: "unwatched", + scope: "my", + q: "", +}; + +const CTX = { + source: "feed" as const, + views: [{ id: 5, name: "Docs", filters: VIEW_FILTERS, position: 0, is_default: false }], + loading: false, + displayView: null, + isDirty: false, + modalOpen: true, + openModal: vi.fn(), + closeModal: vi.fn(), + apply: vi.fn(), + clearActive: vi.fn(), + create: vi.fn(async () => true), + reorder: vi.fn(async () => {}), + remove: vi.fn(async () => true), + toggleDefault: vi.fn(async () => {}), +}; + +vi.mock("./SavedViewsProvider", () => ({ + useSavedViews: () => ({ + ...CTX, + activeViewId: h.activeViewId, + updateFilters: h.updateFilters, + saveCurrent: h.saveCurrent, + rename: h.rename, + }), + // Passthrough coercion — the modal only needs tags/channelIds to be arrays before the chip builder. + asFilters: (raw: Record) => ({ + ...raw, + tags: (raw.tags as unknown[]) ?? [], + channelIds: (raw.channelIds as unknown[]) ?? [], + }), +})); + +import PersonalViewsModal from "./PersonalViewsModal"; +import { api } from "../lib/api"; +import { qk } from "../lib/queryKeys"; + +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( + + + , + ); +} + +beforeEach(() => { + h.updateFilters.mockClear(); + h.saveCurrent.mockClear(); + h.rename.mockClear(); + h.activeViewId = null; +}); +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +describe("PersonalViewsModal — S7 editable detail pane", () => { + it("removing a captured-filter chip writes the pruned filters back to the stored view", async () => { + mount(); + const removeBtn = await screen.findByLabelText("feed.chips.remove"); + fireEvent.click(removeBtn); + await waitFor(() => expect(h.updateFilters).toHaveBeenCalledTimes(1)); + expect(h.updateFilters.mock.calls[0]![0]).toBe(5); + expect(h.updateFilters.mock.calls[0]![1]).toMatchObject({ tags: [] }); + }); + + it("the prune is undoable — Undo re-persists the original filters", async () => { + mount(); + fireEvent.click(await screen.findByLabelText("feed.chips.remove")); + await waitFor(() => expect(h.updateFilters).toHaveBeenCalledTimes(1)); + // The undo control only appears once there is history. + 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")); + await waitFor(() => expect(h.saveCurrent).toHaveBeenCalledWith(5)); + }); + + it("Save-current is disabled when the selected view is already the active one", async () => { + h.activeViewId = 5; + mount(); + const btn = (await screen.findByText("views.saveCurrent")).closest( + "button", + ) as HTMLButtonElement; + expect(btn.disabled).toBe(true); + fireEvent.click(btn); + expect(h.saveCurrent).not.toHaveBeenCalled(); + }); + + it("rename commits via the visible check button once the name changes", async () => { + mount(); + const input = await screen.findByDisplayValue("Docs"); + // No change yet → the commit button is disabled. + const commit = screen.getByLabelText("views.rename").closest("button") as HTMLButtonElement; + expect(commit.disabled).toBe(true); + fireEvent.change(input, { target: { value: "Documentaries" } }); + expect(commit.disabled).toBe(false); + fireEvent.click(commit); + await waitFor(() => expect(h.rename).toHaveBeenCalledWith(5, "Documentaries")); + }); +}); diff --git a/frontend/src/components/PersonalViewsModal.tsx b/frontend/src/components/PersonalViewsModal.tsx index cc027bc4..2e85c361 100644 --- a/frontend/src/components/PersonalViewsModal.tsx +++ b/frontend/src/components/PersonalViewsModal.tsx @@ -1,6 +1,6 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; -import { BookmarkPlus, GripVertical, Play, Star, Trash2 } from "lucide-react"; +import { BookmarkPlus, Check, GripVertical, Play, Save, Star, Trash2, X } from "lucide-react"; import { closestCenter, DndContext, @@ -16,15 +16,18 @@ import { verticalListSortingStrategy, } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; -import { coercePlexFilters, type FeedFilters, type SavedView } from "../lib/api"; +import { coercePlexFilters, type FeedFilters, type PlexFilters, type SavedView } from "../lib/api"; import Modal from "./Modal"; import { useSavedViews, asFilters } from "./SavedViewsProvider"; import { useActiveFilters } from "../lib/useActiveFilters"; import { buildPlexActiveFilters } from "./usePlexActiveFilters"; +import { useUndoable } from "../lib/useUndoable"; +import UndoToolbar from "./UndoToolbar"; // Master–detail manager for saved ("personal") views: the left column lists the views (drag to -// reorder) plus a "save the current filters" input; the right column shows the selected view's name, -// what filters it captures, its default flag, apply, and delete. Opened from the filter panel's title +// reorder) plus a "save the current filters" input; the right column shows the selected view's name +// (renamable), its captured filters as REMOVABLE chips (pruning writes back to the stored view, +// undoable), its default flag, apply, save-current, and delete. Opened from the filter panel's title // or the saved-views popover; all mutations flow through SavedViewsProvider. function ViewRow({ view, @@ -80,6 +83,8 @@ export default function PersonalViewsModal() { apply, create, rename, + updateFilters, + saveCurrent, toggleDefault, remove, reorder, @@ -89,21 +94,55 @@ export default function PersonalViewsModal() { const [selectedId, setSelectedId] = useState(activeViewId ?? views[0]?.id ?? null); const selected = views.find((v) => v.id === selectedId) ?? null; - const [draftName, setDraftName] = useState(""); + // Two independent name fields: the left one names a NEW view; the right one renames the selected one. + const [createName, setCreateName] = useState(""); + const [nameDraft, setNameDraft] = useState(""); - // The selected view's captured filters, as the same human labels its module's chip row shows. The - // feed hook runs unconditionally (fed empty filters off-feed); Plex uses the pure builder (no-op - // removes — this is display-only). A plex view captures its rail filters, not the watch-state. + // A local, undoable draft of the SELECTED view's captured filters. Removing a chip prunes the draft + // and — via onApply — writes the pruned set straight back to the stored view; Undo/Redo re-persist + // the same way. The draft is the single source of truth for the chip row, so a chip-prune refetch + // (which returns the value we just wrote) never clobbers the undo history. We only re-seed it on an + // external change of the visible filters: switching the selection, or Save-current below. + const filtersDraft = useUndoable>(selected?.filters ?? {}, { + onApply: (f) => { + if (selected) void updateFilters(selected.id, f); + }, + }); + const resetDraft = filtersDraft.reset; + useEffect(() => { + resetDraft(selected?.filters ?? {}); + setNameDraft(selected?.name ?? ""); + // Re-seed only when the selection changes (not on every same-view refetch), so pruning keeps its + // undo history. Save-current re-seeds explicitly in its own handler. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedId]); + + // The selected view's captured filters, as the same human labels its module's chip row shows, read + // from the undoable draft. The feed hook runs unconditionally (fed empty filters off-feed); Plex + // uses the pure builder. A plex view captures its rail filters, not the watch-state (show="all"). const noop = () => {}; + const setDraftFeed = (f: FeedFilters) => + filtersDraft.set(f as unknown as Record); const feedCaptured = useActiveFilters( source === "feed" && selected - ? asFilters(selected.filters as unknown as FeedFilters) + ? asFilters(filtersDraft.value as unknown as FeedFilters) : ({ tags: [], channelIds: [] } as never), - noop, + source === "feed" && selected ? setDraftFeed : noop, ); + const patchDraftPlex = (p: Partial) => + filtersDraft.set({ + ...coercePlexFilters(filtersDraft.value), + ...p, + } as unknown as Record); const captured = source === "plex" && selected - ? buildPlexActiveFilters(coercePlexFilters(selected.filters), "all", noop, noop, t) + ? buildPlexActiveFilters( + coercePlexFilters(filtersDraft.value), + "all", + patchDraftPlex, + noop, + t, + ) : feedCaptured; async function onDragEnd(e: DragEndEvent) { @@ -115,11 +154,20 @@ export default function PersonalViewsModal() { } async function onCreate() { - const name = draftName.trim(); + const name = createName.trim(); if (!name) return; - if (await create(name)) setDraftName(""); + if (await create(name)) setCreateName(""); } + const nameChanged = !!selected && nameDraft.trim() !== "" && nameDraft.trim() !== selected.name; + async function onRename() { + if (!selected || !nameChanged) return; + await rename(selected.id, nameDraft.trim()); + } + + // Save-current is a no-op when the live filters already equal this exact view (it's the active one). + const saveCurrentDisabled = selected != null && selected.id === activeViewId; + return (
@@ -148,15 +196,15 @@ export default function PersonalViewsModal() {
setDraftName(e.target.value)} + value={createName} + onChange={(e) => setCreateName(e.target.value)} onKeyDown={(e) => e.key === "Enter" && onCreate()} placeholder={t("views.namePlaceholder")} className="min-w-0 flex-1 rounded-lg border border-border bg-surface px-2 py-1.5 text-sm focus:border-accent focus:outline-hidden focus:ring-2 focus:ring-accent/30" /> +
-
- {t("views.capturedFilters")} +
+ + {t("views.capturedFilters")} + + {(filtersDraft.canUndo || filtersDraft.canRedo) && ( + + )}
{captured.length === 0 ? (

{t("views.capturedNone")}

@@ -188,15 +255,23 @@ export default function PersonalViewsModal() { {captured.map((f) => ( {f.label} + ))}
)}
-
+
+