diff --git a/VERSION b/VERSION
index 598d667c..57dc14fa 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-0.73.0
\ No newline at end of file
+0.74.0
\ No newline at end of file
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/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/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/app/plex/watch_sync.py b/backend/app/plex/watch_sync.py
index 1d0755ed..85ef6399 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,9 +362,20 @@ 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):
+ # 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 not None and not st.synced_to_plex:
+ # Durability guard: this row holds a local change that never reached Plex — its immediate push
+ # failed, so it's still dirty. Do NOT let the Plex-authoritative pull overwrite it and clear the
+ # flag: that would strand the local value forever, since `_repush_dirty` only retries dirty rows.
+ # Leave it dirty; `_repush_dirty` (later in this same run) pushes the local value up to Plex. This
+ # closes the "Plex flapped mid-session" lost-update — the one case the Plex-wins comment's
+ # "almost always already pushed" assumption above excludes.
return None
if st is None:
st = PlexState(user_id=user_id, item_id=item_id)
@@ -437,10 +440,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}
@@ -491,7 +495,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
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/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/app/routes/plex/playback.py b/backend/app/routes/plex/playback.py
index 8d690ce8..fab04797 100644
--- a/backend/app/routes/plex/playback.py
+++ b/backend/app/routes/plex/playback.py
@@ -3,7 +3,8 @@
import hashlib
import os
import tempfile
-from datetime import datetime, timezone
+import threading
+from datetime import datetime, timedelta, timezone
import httpx
from fastapi import (
@@ -155,6 +156,47 @@ _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
+ (and so `_resume_push_superseded` can drop an older, still-pending push — see there)."""
+ with _push_throttle_lock:
+ last = _last_push_at.get((user_id, item_id))
+ if final or last is None or (now - last).total_seconds() >= _PUSH_THROTTLE_S:
+ _last_push_at[(user_id, item_id)] = now
+ # Opportunistic eviction so the map can't grow unbounded over a long-lived process (one
+ # entry per item ever checkpointed). Anything older than a few windows is dead — a push
+ # completes in seconds, so a stale entry protects nothing.
+ if len(_last_push_at) > 2048:
+ cutoff = now - timedelta(seconds=_PUSH_THROTTLE_S * 6)
+ for k in [k for k, t in _last_push_at.items() if t < cutoff]:
+ del _last_push_at[k]
+ return True
+ return False
+
+
+def _resume_push_superseded(user_id: int, item_id: int, intent: datetime) -> bool:
+ """True if a newer resume push for this (user, item) was scheduled after `intent`. Called at the
+ HTTP boundary of a background resume push: because periodic and seek/pause pushes are independent
+ background tasks with no ordering guard, an older still-pending periodic push could otherwise land
+ at Plex AFTER a newer backward-seek push and — with the Plex-authoritative pull — durably revive the
+ stale position. Skipping a superseded push keeps the last-scheduled position the one Plex settles on."""
+ with _push_throttle_lock:
+ return _last_push_at.get((user_id, item_id), intent) > intent
+
@router.get("/person-image")
def person_image(u: str, request: Request) -> Response:
@@ -178,6 +220,26 @@ def person_image(u: str, request: Request) -> Response:
return imgcache.response(r.content, ct)
+def _push_resume_guarded(
+ user_id: int,
+ item_id: int,
+ rating_key: str,
+ time_ms: int,
+ duration_ms: int,
+ expect_ts: datetime | None,
+ intent: datetime,
+) -> None:
+ """Background entrypoint for a resume push that drops itself if a newer resume push for the same
+ (user, item) was scheduled after it (see `_resume_push_superseded`), then delegates to the real
+ push. This narrows the periodic-vs-seek write-ordering race to the (rare) case where the older push
+ has already left the process before the newer one is scheduled."""
+ if _resume_push_superseded(user_id, item_id, intent):
+ return
+ plex_watch.push_state_to_plex(
+ user_id, item_id, rating_key, "resume", time_ms, duration_ms, expect_ts
+ )
+
+
def _push_watch(
background: BackgroundTasks,
db: Session,
@@ -187,10 +249,14 @@ def _push_watch(
action: str,
time_ms: int = 0,
duration_ms: int = 0,
+ intent: datetime | None = None,
) -> None:
"""Schedule a best-effort Siftlode→Plex watch push, but only for a user whose owner link has
push enabled — non-owner users never touch Plex. Guarding here (with the request session) avoids
- spinning up a throwaway background session on every state change for the common no-link case."""
+ spinning up a throwaway background session on every state change for the common no-link case.
+
+ A `resume` push with an `intent` timestamp is routed through `_push_resume_guarded` so a superseded
+ (older) push can't land out of order; `watched`/`unwatched` scrobbles go straight through."""
if plex_watch.link_for_push(db, user_id) is None:
return
# Capture the row's freshest timestamp NOW so the background push can tell if a newer local edit
@@ -202,6 +268,12 @@ def _push_watch(
.scalars()
.first()
)
+ marker = plex_watch._state_marker(st)
+ if action == "resume" and intent is not None:
+ background.add_task(
+ _push_resume_guarded, user_id, item_id, rating_key, time_ms, duration_ms, marker, intent
+ )
+ return
background.add_task(
plex_watch.push_state_to_plex,
user_id,
@@ -210,7 +282,7 @@ def _push_watch(
action,
time_ms,
duration_ms,
- plex_watch._state_marker(st),
+ marker,
)
@@ -232,9 +304,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)
@@ -259,6 +332,10 @@ def item_progress(
st.position_seconds = 0
st.progress_updated_at = now
st.synced_to_plex = False
+ # Terminal state for resume write-back: drop the throttle/ordering entry so the map doesn't
+ # retain a dead key for every finished item.
+ with _push_throttle_lock:
+ _last_push_at.pop((user.id, it.id), None)
db.commit()
# Immediate on the watched transition; skip a repeat scrobble if it was already watched
# (a later checkpoint of the same finished item would otherwise bump Plex's viewCount again).
@@ -280,10 +357,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,
@@ -293,6 +373,7 @@ def item_progress(
"resume",
position * 1000,
duration * 1000,
+ intent=now,
)
return {"position_seconds": position}
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/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/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/backend/tests/test_plex_watch_sync.py b/backend/tests/test_plex_watch_sync.py
new file mode 100644
index 00000000..da20bfee
--- /dev/null
+++ b/backend/tests/test_plex_watch_sync.py
@@ -0,0 +1,251 @@
+"""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,
+ _resume_push_superseded,
+ _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_diverging_synced_local_resume(db):
+ """The core S2 change: a SYNCED local row (its state already reached Plex) that later diverges from
+ Plex = a real external Plex-side change, so the incremental pull resolves to PLEX's position — Plex
+ is authoritative, no LWW. (A DIRTY local row is protected instead — see the dirty-guard test.)"""
+ item_id, user_id = _seed_item_and_user(db)
+ now = datetime.now(timezone.utc)
+ db.add(
+ 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=True, # but already pushed → a later Plex divergence is an external change
+ )
+ )
+ 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_synced_local_in_progress(db):
+ """Plex marking the item watched propagates over a SYNCED local in-progress row (external change)."""
+ item_id, user_id = _seed_item_and_user(db)
+ now = datetime.now(timezone.utc)
+ db.add(
+ PlexState(
+ user_id=user_id,
+ item_id=item_id,
+ status="new",
+ position_seconds=300,
+ progress_updated_at=now,
+ synced_to_plex=True,
+ )
+ )
+ 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_pull_keeps_dirty_local_on_divergence(db):
+ """Durability guard: a DIRTY row (a local change whose immediate push failed → synced_to_plex=False)
+ must NOT be overwritten by a diverging Plex signal, and must stay dirty so `_repush_dirty` can push
+ the local value up. Overwriting-and-clearing here would strand the local change forever."""
+ item_id, user_id = _seed_item_and_user(db)
+ now = datetime.now(timezone.utc)
+ db.add(
+ PlexState(
+ user_id=user_id,
+ item_id=item_id,
+ status="watched", # user just marked watched locally; the scrobble to Plex failed
+ position_seconds=0,
+ watched_at=now,
+ progress_updated_at=now,
+ synced_to_plex=False, # dirty — never reached Plex
+ )
+ )
+ db.flush()
+
+ # Plex still lists it in on_deck at an old in-progress offset (it never got the watched scrobble).
+ changed = _pull_apply(db, user_id, item_id, "new", 240, plex_ts=now - timedelta(seconds=60))
+ db.flush()
+
+ assert changed is None, "a dirty local row must not be clobbered by the pull"
+ st = (
+ db.execute(
+ select(PlexState).where(PlexState.user_id == user_id, PlexState.item_id == item_id)
+ )
+ .scalars()
+ .one()
+ )
+ assert st.status == "watched", "local watched preserved"
+ assert st.position_seconds == 0
+ assert st.synced_to_plex is False, "stays dirty so _repush_dirty retries the push"
+
+
+def test_pull_settles_flag_on_matching_dirty_row(db):
+ """Anti-bounce, dirty branch: a row that already MATCHES Plex but is still dirty gets its
+ synced_to_plex flag settled to True (no churn), so `_repush_dirty` stops re-pushing it forever."""
+ item_id, user_id = _seed_item_and_user(db)
+ now = datetime.now(timezone.utc)
+ db.add(
+ PlexState(
+ user_id=user_id,
+ item_id=item_id,
+ status="new",
+ position_seconds=100,
+ progress_updated_at=now,
+ synced_to_plex=False, # dirty, but already agrees with Plex
+ )
+ )
+ db.flush()
+
+ changed = _pull_apply(db, user_id, item_id, "new", 102, plex_ts=now + timedelta(seconds=2))
+ db.flush()
+
+ assert changed is None, "a matching row makes no change"
+ st = (
+ db.execute(
+ select(PlexState).where(PlexState.user_id == user_id, PlexState.item_id == item_id)
+ )
+ .scalars()
+ .one()
+ )
+ assert st.position_seconds == 100, "no churn to the stored position"
+ assert st.synced_to_plex is True, "the dirty flag is settled so it isn't re-pushed endlessly"
+
+
+def test_should_push_resume_scopes_by_user_and_item():
+ """The throttle window is keyed by the FULL (user, item) tuple: pushing for one key must not
+ suppress a distinct user or a distinct item within the same window."""
+ now = datetime.now(timezone.utc)
+ assert _should_push_resume(9101, 9201, now, final=False) is True # (u1, i1) first tick
+ # Same window, different item for the same user → independent, still pushes.
+ assert _should_push_resume(9101, 9202, now, final=False) is True
+ # Same window, different user, same item → independent, still pushes.
+ assert _should_push_resume(9102, 9201, now, final=False) is True
+ # And the original key is still throttled within its window (proves the above weren't just "always").
+ assert _should_push_resume(9101, 9201, now + timedelta(seconds=1), final=False) is False
+
+
+def test_resume_push_superseded_drops_older_pending_push():
+ """The write-ordering guard: an older still-pending resume push is dropped once a newer push for the
+ same (user, item) has been scheduled, so a reordered stale position can't land at Plex last."""
+ key = (9301, 9301)
+ t0 = datetime.now(timezone.utc)
+ # Schedule an initial push at t0 (records t0 as the last-push time for this key).
+ assert _should_push_resume(*key, t0, final=False) is True
+ # Nothing newer yet → the t0 push is current, not superseded.
+ assert _resume_push_superseded(*key, t0) is False
+ # A newer push (e.g. a backward seek) is scheduled at t1 > t0.
+ t1 = t0 + timedelta(seconds=1)
+ assert _should_push_resume(*key, t1, final=True) is True
+ # Now the still-pending t0 push is superseded and must drop; the t1 push is current.
+ assert _resume_push_superseded(*key, t0) is True
+ assert _resume_push_superseded(*key, t1) is False
+
+
+def test_should_push_resume_final_always_pushes():
+ """A final checkpoint (pause / seek / stop) always pushes, bypassing the throttle."""
+ now = datetime.now(timezone.utc)
+ 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/components/ChannelPage.tsx b/frontend/src/components/ChannelPage.tsx
index b23e49af..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: () => {
@@ -418,6 +431,7 @@ export default function ChannelPage({
priority={ch.priority}
onPriority={(d) => bumpPriority(channelId, d)}
onSet={(v) => setPriority(channelId, v)}
+ size="md"
/>
)}
@@ -469,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". */}
({
}));
import { ChannelsProvider, useChannels, useChannelsActions } from "./ChannelsProvider";
+import { setActiveAccount } from "../lib/api/core";
const wrapper = ({ children }: { children: ReactNode }) => (
{children}
@@ -27,7 +28,10 @@ const VIEW_KEY = "siftlode.channelsView.1"; // account-scoped (accountKey suffix
beforeEach(() => {
localStorage.clear();
sessionStorage.setItem("siftlode.activeAccount", "1"); // pin the tab's account
- window.history.replaceState({ sfPage: "channels" }, ""); // setView only owns an entry on this page
+ // Reset BOTH the history state and the query string: setView only owns an entry on the channels page,
+ // and the S8 URL mirror writes ctab/csort/cf — a stale key left by a prior test would be read back by
+ // this provider's mount-time URL seed and change the starting tab.
+ window.history.replaceState({ sfPage: "channels" }, "", "/");
});
describe("ChannelsProvider tab history", () => {
@@ -71,3 +75,58 @@ describe("ChannelsProvider tab history", () => {
expect(result.current.view).toBe("hidden"); // unchanged
});
});
+
+describe("ChannelsProvider — S8 shareable URL", () => {
+ it("seeds tab + sort + filters from a shared manager link at mount (URL wins over stored)", () => {
+ // Stored view says "blocked"; the shared link says "hidden" + a sort + a filter → the URL wins.
+ localStorage.setItem(VIEW_KEY, "blocked");
+ const cf = encodeURIComponent(JSON.stringify({ sync: "needsFull" }));
+ window.history.replaceState(
+ { sfPage: "channels" },
+ "",
+ `/?ctab=hidden&csort=priority:desc&cf=${cf}`,
+ );
+ const { result } = render();
+ expect(result.current.view).toBe("hidden");
+ expect(result.current.sort).toEqual({ key: "priority", dir: "desc" });
+ expect(result.current.filters).toEqual({ sync: "needsFull" });
+ });
+
+ it("mirrors the tab into the address bar and clears it back to the default", () => {
+ const { result } = render();
+ act(() => result.current.setView("blocked"));
+ expect(new URLSearchParams(window.location.search).get("ctab")).toBe("blocked");
+ // Back to the default tab clears the key (only non-defaults are emitted).
+ act(() => result.current.setView("subscribed"));
+ expect(new URLSearchParams(window.location.search).has("ctab")).toBe(false);
+ });
+
+ it("a PARTIAL shared link (fresh tab) does not clobber the account's stored sort/filters", () => {
+ // Regression: on a fresh tab the account pins AFTER mount, so the seed's base for the OMITTED
+ // dimensions was read as defaults. Persisting the whole seed used to overwrite the account's real
+ // stored sort + filter map. The fix persists ONLY the URL-present dimensions.
+ localStorage.setItem(
+ "siftlode.channelSort.1",
+ JSON.stringify({ key: "subscribers", dir: "desc" }),
+ );
+ localStorage.setItem("siftlode.channelsFilterMap.1", JSON.stringify({ sync: "needsFull" }));
+ sessionStorage.removeItem("siftlode.activeAccount"); // account not pinned yet at mount
+ window.history.replaceState({ sfPage: "channels" }, "", "/?ctab=hidden"); // only the tab in the URL
+
+ const { result } = render();
+ act(() => setActiveAccount(1)); // App pins the account after /api/me
+
+ // The present dim (tab) is adopted from the link...
+ expect(result.current.view).toBe("hidden");
+ // ...but the omitted sort + filters keep the account's real stored values, in state AND storage.
+ expect(result.current.sort).toEqual({ key: "subscribers", dir: "desc" });
+ expect(result.current.filters).toEqual({ sync: "needsFull" });
+ expect(JSON.parse(localStorage.getItem("siftlode.channelSort.1")!)).toEqual({
+ key: "subscribers",
+ dir: "desc",
+ });
+ expect(JSON.parse(localStorage.getItem("siftlode.channelsFilterMap.1")!)).toEqual({
+ sync: "needsFull",
+ });
+ });
+});
diff --git a/frontend/src/components/ChannelsProvider.tsx b/frontend/src/components/ChannelsProvider.tsx
index c4d5b26f..3504a49d 100644
--- a/frontend/src/components/ChannelsProvider.tsx
+++ b/frontend/src/components/ChannelsProvider.tsx
@@ -3,16 +3,25 @@ import {
useCallback,
useContext,
useEffect,
+ useLayoutEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from "react";
-import { getAccountRaw, LS } from "../lib/storage";
+import { getAccountRaw, setAccountRaw, LS } from "../lib/storage";
import { parseChannelLayout, type ChannelLayout } from "../lib/channelLayout";
-import { type SortState } from "../lib/columnSort";
+import { hydrateSort, type SortState } from "../lib/columnSort";
import { useSortState } from "../lib/useSortState";
import { useFilterState } from "../lib/useFilterState";
+import { useActiveAccountId } from "../lib/activeAccount";
+import { useUrlMirror } from "../lib/useUrlMirror";
+import {
+ channelsParamsToState,
+ channelsParamUpdates,
+ hasChannelsParams,
+ type ChannelsUrlState,
+} from "../lib/channelsUrlState";
import { usePersistedTab } from "./Tabs";
import type { FilterMap } from "./tableHeader";
import { useNavigation, useNavigationActions } from "./NavigationProvider";
@@ -44,6 +53,47 @@ function migrateSyncChip(): FilterMap | null {
return null;
}
+// The manager's stored column-filter map, healed to a plain string/string[] object (same discipline as
+// useFilterState's hydrate). Used only to build the seed's base — the URL keeps whatever the stored map
+// has for any dimension a partial shared link omits.
+function readStoredFilters(): FilterMap {
+ const raw = getAccountRaw(LS.channelsFilterMap);
+ if (raw) {
+ try {
+ const v = JSON.parse(raw);
+ if (v && typeof v === "object" && !Array.isArray(v)) return v as FilterMap;
+ } catch {
+ /* fall through to the migrate seed */
+ }
+ }
+ return migrateSyncChip() ?? {};
+}
+
+// S8: a shared manager link carries the content state in namespaced URL keys (ctab/csort/cf). When
+// present, the URL wins over this account's stored view — read once at mount and applied over the slice
+// hooks (see the provider). The keys are channels-unique, so their mere presence identifies a shared
+// manager view; no ?page check is needed (the mirror below is page-gated so it never pollutes others).
+// The seed = the URL-merged state PLUS which dimensions the URL actually carried. `present` matters
+// because the base below is read at MOUNT, before the account is pinned (getAccountRaw returns null),
+// so an OMITTED dimension's base is a default, not this account's stored value. The provider seeds and
+// persists only the PRESENT dimensions and lets the absent ones re-hydrate from storage normally —
+// otherwise a partial shared link silently resets the omitted stored sort / filter map.
+type ChannelsSeedPresent = { view: boolean; sort: boolean; filters: boolean };
+type ChannelsUrlSeed = { state: ChannelsUrlState; present: ChannelsSeedPresent };
+function readChannelsUrlSeed(): ChannelsUrlSeed | null {
+ const params = new URLSearchParams(window.location.search);
+ if (!hasChannelsParams(params)) return null;
+ const state = channelsParamsToState(params, {
+ view: clampView(getAccountRaw(LS.channelsView)),
+ sort: hydrateSort(getAccountRaw(LS.channelSort), [], null),
+ filters: readStoredFilters(),
+ });
+ return {
+ state,
+ present: { view: params.has("ctab"), sort: params.has("csort"), filters: params.has("cf") },
+ };
+}
+
type ChannelsState = {
search: string;
// The subscribed table's column filters, lifted out of DataTable so the top status chips, the Sync
@@ -132,6 +182,45 @@ export function ChannelsProvider({ children }: { children: ReactNode }) {
const [focusName, setFocusName] = useState(null);
const [focusToken, setFocusToken] = useState(0);
+ // S8 — shareable manager view. Captured once at mount (or null when no manager URL keys are present).
+ const [urlSeed] = useState(readChannelsUrlSeed);
+ const accountId = useActiveAccountId();
+ const seededRef = useRef(false);
+ const persistedRef = useRef(false);
+
+ // Apply the shared-link seed once, BEFORE paint, through the slice setters. Going through the setters
+ // (not raw state) is deliberate: each one calls its hook's markDirty, so the account-pin re-hydrate
+ // (which fires later on a fresh tab) sees the slice as user-edited and won't reload the stored view
+ // over the shared one. setViewRaw (not setView) restores the tab without pushing a phantom history
+ // entry. A normal load has urlSeed === null and this is a no-op.
+ useLayoutEffect(() => {
+ if (!urlSeed || seededRef.current) return;
+ seededRef.current = true;
+ // Only apply the dimensions the URL actually carried. An omitted dimension must NOT go through its
+ // setter (which would markDirty and block the account-pin re-hydrate from loading the stored value)
+ // — leave it to hydrate from storage normally. Fixes a partial link wiping the stored sort/filters.
+ const { state, present } = urlSeed;
+ if (present.view && state.view !== view) setViewRaw(state.view);
+ if (present.filters) setFilters(state.filters);
+ if (present.sort) setSort(state.sort);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ // On a fresh tab the account pins AFTER mount, so the seed's setter writes above no-op'd (no account
+ // yet). Persist the seed to the account once it's known, so the shared view also survives a later
+ // PLAIN reload (no URL) — mirrors PlexProvider's rehydrate-persist. The slice hooks' own re-hydrate
+ // already skips (marked dirty above), so this only touches storage, not state.
+ useEffect(() => {
+ if (!urlSeed || accountId == null || persistedRef.current) return;
+ persistedRef.current = true;
+ // Persist ONLY the URL-present dimensions — the omitted ones were seeded from mount-time defaults
+ // (no account yet), so writing them here would overwrite the account's real stored sort/filter map.
+ const { state, present } = urlSeed;
+ if (present.view) setAccountRaw(LS.channelsView, state.view);
+ if (present.filters) setAccountRaw(LS.channelsFilterMap, JSON.stringify(state.filters));
+ if (present.sort) setAccountRaw(LS.channelSort, JSON.stringify(state.sort));
+ }, [urlSeed, accountId]);
+
// Latest-value ref so the STABLE setView can compare against the current tab without depending on
// it (a dep would recreate the action on every switch and re-render every actions consumer).
// Written during render — the sanctioned pattern NavigationProvider uses for `pageRef`.
@@ -218,6 +307,12 @@ export function ChannelsProvider({ children }: { children: ReactNode }) {
[setSearch, setFilters, setView, setLayout, setSort, focusChannel, goToFullHistory],
);
+ // S8: mirror the content state (tab + sort + column filters) into the address bar under the namespaced
+ // keys so a manager view is shareable. Gated on page===channels: off the manager the keys are cleared
+ // (localStorage still holds the state; it re-mirrors on return). The name `search` stays OUT — it's a
+ // deliberately transient rail-search, like the feed's and plex's.
+ useUrlMirror(channelsParamUpdates(page === "channels" ? { view, sort, filters } : null));
+
return (
diff --git a/frontend/src/components/NavigationProvider.tsx b/frontend/src/components/NavigationProvider.tsx
index f3a5e7f7..982841a2 100644
--- a/frontend/src/components/NavigationProvider.tsx
+++ b/frontend/src/components/NavigationProvider.tsx
@@ -126,12 +126,25 @@ 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/channels
+ // keys re-mirror from PlexProvider / ChannelsProvider on return to their 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,
+ ctab: null,
+ csort: null,
+ cf: null,
+ }),
);
notifyNav();
},
diff --git a/frontend/src/components/PersonalViewsModal.dom.test.tsx b/frontend/src/components/PersonalViewsModal.dom.test.tsx
new file mode 100644
index 00000000..93d7be25
--- /dev/null
+++ b/frontend/src/components/PersonalViewsModal.dom.test.tsx
@@ -0,0 +1,186 @@
+// @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);
+ const utils = render(
+
+
+ ,
+ );
+ return { ...utils, qc };
+}
+
+beforeEach(() => {
+ h.updateFilters.mockClear();
+ h.saveCurrent.mockClear();
+ h.rename.mockClear();
+ h.activeViewId = null;
+ // Fresh baseline each test — one test mutates CTX.views to simulate a refetch.
+ CTX.views = [{ id: 5, name: "Docs", filters: VIEW_FILTERS, position: 0, is_default: false }];
+});
+afterEach(() => {
+ cleanup();
+ 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("a same-view refetch (new-identity filters) does not wipe the undo history", async () => {
+ // Guards the [selectedId]-only re-seed effect (its exhaustive-deps disable): after a chip prune the
+ // real provider refetches and hands back the SAME view id with a fresh-identity filters object. The
+ // draft must NOT re-seed on that (it would destroy the undo history and the Undo control). A deps
+ // regression that added selected.filters would make this fail.
+ const { rerender, qc } = mount();
+ fireEvent.click(await screen.findByLabelText("feed.chips.remove"));
+ await waitFor(() => expect(h.updateFilters).toHaveBeenCalledTimes(1));
+ expect(await screen.findByLabelText("common.undo")).toBeTruthy();
+
+ // Simulate the post-write refetch: same id 5, new object identity, carrying the pruned value.
+ CTX.views = [
+ {
+ id: 5,
+ name: "Docs",
+ filters: { ...VIEW_FILTERS, tags: [] },
+ position: 0,
+ is_default: false,
+ },
+ ];
+ rerender(
+
+
+ ,
+ );
+
+ // Undo history survived the refetch — the control is still there and still re-persists on click.
+ const undo = await screen.findByLabelText("common.undo");
+ fireEvent.click(undo);
+ await waitFor(() => expect(h.updateFilters).toHaveBeenCalledTimes(2));
+ expect(h.updateFilters.mock.calls[1]![1]).toMatchObject({ tags: [1] });
+ });
+
+ it("Save-current stores the live filters into the view when it isn't already the active one", async () => {
+ mount();
+ fireEvent.click(await screen.findByText("views.saveCurrent"));
+ 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() {