Compare commits
37
Commits
6fb369ffcc
...
v0.74.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f52bc5898b | ||
|
|
d0033617e2 | ||
|
|
f80ed115ad | ||
|
|
7fbb996297 | ||
|
|
9cd4ae09d4 | ||
|
|
00527d0cf3 | ||
|
|
8dc4976c2c | ||
|
|
56a2ecdec3 | ||
|
|
8299ea72b7 | ||
|
|
6c918d1dff | ||
|
|
3f8257059d | ||
|
|
12d9561ba1 | ||
|
|
fe09879edd | ||
|
|
7104767ff9 | ||
|
|
17f8885fb1 | ||
|
|
549aae67cc | ||
|
|
fbb961b5b7 | ||
|
|
edcea87ea5 | ||
|
|
2c843e549b | ||
|
|
4ee0dab68f | ||
|
|
5d7be687d3 | ||
|
|
e2a8503496 | ||
|
|
a389306885 | ||
|
|
32c05897f4 | ||
|
|
2e319be569 | ||
|
|
852b227ea2 | ||
|
|
06b85586e6 | ||
|
|
fc32188fd2 | ||
|
|
9538fb1b7a | ||
|
|
5051809940 | ||
|
|
64dde6fd36 | ||
|
|
38921fb997 | ||
|
|
2d1a7ff374 | ||
|
|
361ca7872e | ||
|
|
81b7660232 | ||
|
|
7caa2636e4 | ||
|
|
1e894d378d |
@@ -12,12 +12,12 @@ provider.
|
||||
|
||||
## The gate — never merge or ship red
|
||||
|
||||
The local gate is **ruff + typecheck (3 tsc projects) + eslint + prettier --check + knip + vitest +
|
||||
pytest**. It runs on every `publish`, which **refuses to build on failure — there is deliberately no
|
||||
The local gate is **ruff (`check` + `format --check`) + typecheck (3 tsc projects) + eslint + prettier
|
||||
--check + knip + vitest + pytest**. It runs on every `publish`, which **refuses to build on failure — there is deliberately no
|
||||
`--skip` flag.** Run it and get it green **before** any merge, release, or handing work to review.
|
||||
|
||||
- Full gate (agents with the private ops dispatcher): `siftlode check`.
|
||||
- Directly, without the dispatcher: `cd frontend && npm run typecheck && npm run lint && npm run knip && npm run test`, plus backend `ruff check backend` and `pytest` (the DB-lane needs Postgres).
|
||||
- Directly, without the dispatcher: `cd frontend && npm run typecheck && npm run lint && npm run knip && npm run test`, plus backend `ruff check backend` + `ruff format --check backend` and `pytest` (the DB-lane needs Postgres).
|
||||
- New DB-backed test? It must actually run, not silently skip — assume `SIFTLODE_TEST_REQUIRE_DB=1`.
|
||||
|
||||
## Workflow
|
||||
@@ -26,12 +26,33 @@ pytest**. It runs on every `publish`, which **refuses to build on failure — th
|
||||
keep commits logical and Conventional-Commit-styled.
|
||||
- **No AI attribution in commits.** No `Co-Authored-By` trailer, no "generated by" lines. (This file
|
||||
being AI-written is the intended, visible exception; commits are not.)
|
||||
- **Release is a deliberate, user-owned step** (`siftlode release` from the feature branch: merge to
|
||||
`main` → publish → deploy → verify `/api/version`). Do not self-ship; bring work to a green,
|
||||
committed state and stop for the user's go-ahead.
|
||||
- **Release: user-owned in timing, agent-run in execution.** The user decides *when* to ship; on their
|
||||
go-ahead *you* run `siftlode release` from the feature branch (merge to `main` → publish → deploy →
|
||||
verify `/api/version`). Never self-ship — bring work to a green, committed state and stop for the
|
||||
go-ahead first, then run it yourself.
|
||||
- **Public repo.** Keep ops/topology/secrets out — those live in a separate private repo. Nothing here
|
||||
should assume or leak them.
|
||||
|
||||
## Collaboration — how to work with the human
|
||||
|
||||
The human is the product owner: they bring ideas and requirements. You are the technical lead — you own
|
||||
how it gets built. Concretely:
|
||||
|
||||
- **Plan before you build.** For anything non-trivial, present a short plan (sprint-level for larger
|
||||
work) and get a go-ahead before writing code.
|
||||
- **Offer choices, mark the optimal — and explain the trade-off.** When several solutions are viable,
|
||||
list them and mark the recommended one. This is the *one* place to spend extra words: give the *why*
|
||||
behind each option and the weight of the decision. These decision points are where the product owner
|
||||
engages with the build and learns from it, so make them understandable — a reasoned menu, not a bare one.
|
||||
- **Mockups for design/UX decisions.** For any visual or interaction choice, show mockup options to
|
||||
choose from before building.
|
||||
- **Challenge unsound requests.** If a request is technically ill-advised and you can back it with real
|
||||
arguments, push back or propose an alternative — don't comply blindly.
|
||||
- **Verify UI in the browser before calling it done.** tsc/unit/DOM tests miss visual and interaction
|
||||
bugs; do a real visual check of any UI change.
|
||||
- **Be terse everywhere else.** Outside those decision points, lead with the answer or decision. During
|
||||
implementation and debugging especially: no restating the same point reworded, no filler.
|
||||
|
||||
## Architecture shape (don't fight it)
|
||||
|
||||
`routes/*` (HTTP only: validation, auth-dependency, serialization) → domain packages `sync/`, `plex/`,
|
||||
@@ -79,5 +100,6 @@ CQRS, a repository/DI framework, a router library, a generic CRUD-router factory
|
||||
|
||||
---
|
||||
|
||||
*A fuller written-conventions doc (`docs/development.md`) is planned; once it lands, this file slims to a
|
||||
pointer plus the gate/workflow/no-attribution rules. Until then, the table above is the source of truth.*
|
||||
*Run-it-locally and the full gate commands live in [`docs/development.md`](docs/development.md); backend
|
||||
code conventions in [`backend/app/README.md`](backend/app/README.md). This file stays the source of
|
||||
truth for the canonical-patterns table and the gate/workflow/collaboration rules above.*
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -100,6 +100,11 @@ def serialize(row) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def _tag_visible(user: User):
|
||||
"""A ChannelTag row this user may see: a shared system tag (user_id NULL) or their own."""
|
||||
return or_(ChannelTag.user_id.is_(None), ChannelTag.user_id == user.id)
|
||||
|
||||
|
||||
def tags_by_category(db: Session, tag_ids: list[int]) -> dict[str, list[int]]:
|
||||
"""Group tag ids by their category (language/topic/other). The single source for the category split
|
||||
shared by the feed's tag WHERE (filtered_query) and the facet/count summary (feed_summary), so the
|
||||
@@ -118,6 +123,7 @@ def filtered_query(
|
||||
*,
|
||||
tags: list[int],
|
||||
tag_modes: dict[str, str],
|
||||
untagged: bool = False,
|
||||
channel_ids: list[str],
|
||||
q: str | None,
|
||||
min_duration: int | None,
|
||||
@@ -294,7 +300,7 @@ def filtered_query(
|
||||
# selections but still honours the other categories' filters).
|
||||
if exclude_tag_category:
|
||||
by_category.pop(exclude_tag_category, None)
|
||||
visible = or_(ChannelTag.user_id.is_(None), ChannelTag.user_id == user.id)
|
||||
visible = _tag_visible(user)
|
||||
for category, ids in by_category.items():
|
||||
if tag_modes.get(category, "or") == "and" and len(ids) > 1:
|
||||
sub = (
|
||||
@@ -307,6 +313,18 @@ def filtered_query(
|
||||
sub = select(ChannelTag.channel_id).where(ChannelTag.tag_id.in_(ids), visible)
|
||||
query = query.where(Video.channel_id.in_(sub))
|
||||
|
||||
# "No tag" filter (item B): keep only channels the USER has not personally tagged in the channel
|
||||
# manager — i.e. channels with no ChannelTag row owned by this user (user_id == me, source "user").
|
||||
# System/auto tags (user_id NULL) don't count: this is "channels I haven't tagged yet". It's a plain
|
||||
# channel-level narrowing (like channel_ids/date), NOT a tag-category selection, so feed_summary
|
||||
# leaves it applied in the base pass and needs no mirror in passes().
|
||||
if untagged:
|
||||
query = query.where(
|
||||
Video.channel_id.notin_(
|
||||
select(ChannelTag.channel_id).where(ChannelTag.user_id == user.id)
|
||||
)
|
||||
)
|
||||
|
||||
# Content type: Normal / Shorts / Live·Upcoming as a union of enabled types.
|
||||
# Explicit Watched/Saved/Hidden views show every type so nothing goes missing.
|
||||
explicit_view = show in ("watched", "hidden")
|
||||
@@ -355,8 +373,9 @@ def feed_summary(db: Session, user: User, **params) -> dict:
|
||||
within, a category in "and" mode requires ALL its selected tags)."""
|
||||
tag_modes = params.get("tag_modes", {})
|
||||
|
||||
# ONE pass: per-channel video counts for the base filter, tags stripped so every candidate channel
|
||||
# is present to facet over.
|
||||
# ONE pass: per-channel video counts for the base filter, the positive tag filter stripped so every
|
||||
# candidate channel is present to facet over. The "No tag" (untagged) filter is a plain channel-level
|
||||
# narrowing, so it STAYS applied here (not stripped) and the facets naturally drill down within it.
|
||||
base, _rank = filtered_query(db, user, **{**params, "tags": []})
|
||||
ch_col = base.with_only_columns(Video.channel_id).subquery()
|
||||
channel_counts: dict[str, int] = dict(
|
||||
@@ -386,7 +405,8 @@ def feed_summary(db: Session, user: User, **params) -> dict:
|
||||
|
||||
def passes(ch_tags: set[int], exclude_cat: str | None) -> bool:
|
||||
# The Python mirror of filtered_query's SQL tag block (AND across categories, OR within,
|
||||
# topic-AND requires all selected) — keep in lockstep; see the ⚠️ note there.
|
||||
# topic-AND requires all selected) — keep in lockstep; see the ⚠️ note there. (The "No tag"
|
||||
# filter is applied in the base query above, so it needs no mirror here.)
|
||||
for cat, sel in selected_by_cat.items():
|
||||
if cat == exclude_cat:
|
||||
continue
|
||||
@@ -417,6 +437,30 @@ def feed_summary(db: Session, user: User, **params) -> dict:
|
||||
return {"count": count, "facets": facets}
|
||||
|
||||
|
||||
def feed_years(db: Session, user: User, **params) -> list[dict]:
|
||||
"""Per-year video counts for the current filter context (item 8 / E8·S2): powers the year control's
|
||||
quick chips + the "more…" per-year-counts dialog. Every OTHER filter (tags, channels, content type,
|
||||
search, watch state, scope) still applies, but the publish-date narrowing itself is DROPPED —
|
||||
published_after/before and max_age_days — so the year list is the full set of years available and
|
||||
switching years stays a live drill-down (the same principle as the tag facets dropping their own
|
||||
category). One grouped query, newest year first."""
|
||||
base, _rank = filtered_query(
|
||||
db,
|
||||
user,
|
||||
**{**params, "published_after": None, "published_before": None, "max_age_days": None},
|
||||
)
|
||||
pub = base.with_only_columns(Video.published_at.label("pub")).subquery()
|
||||
# Bucket by UTC year to stay in lockstep with the year filter's boundaries (published_after/before
|
||||
# are pinned to UTC in filtered_query). published_at is timestamptz, so a bare extract('year', …)
|
||||
# would use the DB session timezone and, on a non-UTC session, bucket a video near a year boundary
|
||||
# into a different year than the filter includes — the count and the drill-down feed would disagree.
|
||||
year = func.extract("year", func.timezone("UTC", pub.c.pub))
|
||||
rows = db.execute(
|
||||
select(year.label("y"), func.count()).group_by(year).order_by(year.desc())
|
||||
).all()
|
||||
return [{"year": int(y), "count": n} for y, n in rows if y is not None]
|
||||
|
||||
|
||||
# --- Keyset (cursor) pagination ---------------------------------------------
|
||||
#
|
||||
# Each sort is described as an ordered list of key columns (expression + direction
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ from app.feedquery import (
|
||||
decode_cursor,
|
||||
encode_cursor,
|
||||
feed_summary,
|
||||
feed_years,
|
||||
filtered_query,
|
||||
keyset_predicate,
|
||||
order_by,
|
||||
@@ -53,6 +54,8 @@ def _feed_params(
|
||||
tag_mode_language: str = "or",
|
||||
tag_mode_topic: str = "or",
|
||||
tag_mode_other: str = "or",
|
||||
# "No tag" filter: keep only channels the user has not personally tagged (no user-owned ChannelTag).
|
||||
untagged: bool = False,
|
||||
channel_ids: list[str] = Query(default=[]),
|
||||
q: str | None = None,
|
||||
min_duration: int | None = None,
|
||||
@@ -74,6 +77,7 @@ def _feed_params(
|
||||
"topic": tag_mode_topic,
|
||||
"other": tag_mode_other,
|
||||
},
|
||||
"untagged": untagged,
|
||||
"channel_ids": channel_ids,
|
||||
"q": q,
|
||||
"min_duration": min_duration,
|
||||
@@ -203,6 +207,19 @@ def get_feed_summary(
|
||||
return feed_summary(db, user, **params)
|
||||
|
||||
|
||||
@router.get("/feed/years")
|
||||
def get_feed_years(
|
||||
params: dict = Depends(_feed_params),
|
||||
user: User = Depends(current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Per-year video counts for the year control (item 8 / E8·S2): the quick recent-year chips and the
|
||||
"more…" per-year-counts dialog. Every filter EXCEPT the publish-date narrowing applies, so picking a
|
||||
year re-queries the feed but not this list. Response: {"years": [{"year": <int>, "count": <int>}]},
|
||||
newest year first."""
|
||||
return {"years": feed_years(db, user, **params)}
|
||||
|
||||
|
||||
class VideoStateIn(BaseModel):
|
||||
status: str
|
||||
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -8,10 +8,22 @@ from app.auth import current_user
|
||||
from app.db import get_db
|
||||
from app.models import ChannelTag, Tag, User
|
||||
from app.routes._common import owned_or_404
|
||||
from app.sync.tagsuggest import compute_tag_suggestions
|
||||
|
||||
router = APIRouter(prefix="/api/tags", tags=["tags"])
|
||||
|
||||
|
||||
@router.get("/suggestions")
|
||||
def tag_suggestions(
|
||||
user: User = Depends(current_user), db: Session = Depends(get_db)
|
||||
) -> dict[str, list[dict]]:
|
||||
"""Suggested user tags for this user's still-untagged subscribed channels, learned from the
|
||||
auto-tag pattern of the channels they HAVE tagged (see app.sync.tagsuggest). Read-only — the
|
||||
Channel Manager shows these as one-tap-accept chips; accepting posts to the normal attach-tag
|
||||
endpoint. Keyed by channel id; channels with no suggestion are omitted."""
|
||||
return compute_tag_suggestions(db, user)
|
||||
|
||||
|
||||
@router.get("")
|
||||
def list_tags(user: User = Depends(current_user), db: Session = Depends(get_db)) -> list[dict]:
|
||||
rows = db.execute(
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
"""Suggest a user's own manual tags for channels they haven't tagged yet.
|
||||
|
||||
The signal is the auto-tagger's *topic* output: each channel carries ``source='auto'`` topic
|
||||
ChannelTags (see ``autotag.py``). For every user tag T the user has applied to enough channels,
|
||||
we learn which topics are *characteristic* of T (present in a high fraction of the channels the
|
||||
user tagged T), then suggest T for any still-untagged channel whose own topics cover enough of
|
||||
that profile.
|
||||
|
||||
Language auto-tags are deliberately EXCLUDED from the signal: they are near-universal within a
|
||||
language ("English" is on most channels), so they don't discriminate — including them floods the
|
||||
result (measured on real data: every untagged channel got a suggestion, most on a shared language
|
||||
alone). Topic overlap is what makes "you tagged similar channels this way" meaningful.
|
||||
|
||||
Purely read-only and deterministic — the Channel Manager surfaces the result as one-tap-accept
|
||||
"ghost" chips; nothing is written here (D4 = suggest-and-confirm, no silent auto-apply). The
|
||||
thresholds are tuning constants rather than admin knobs on purpose: they mirror how ``autotag.py``
|
||||
keeps its mappings inline, and sysconfig only stores int/str/bool (no float), so a knob would mean
|
||||
storing percentages — not worth the surface for values an operator has no reason to tune per-install.
|
||||
"""
|
||||
|
||||
from collections import Counter
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import ChannelTag, Subscription, Tag, User
|
||||
|
||||
# A user tag needs at least this many manually-tagged example channels before we trust its
|
||||
# profile — fewer is noise (one gaming-cooking channel shouldn't make "cooking" mean "gaming").
|
||||
MIN_EXAMPLES = 3
|
||||
# A topic counts as *characteristic* of a user tag when it appears in ≥60% of that tag's example
|
||||
# channels. High enough that an incidental shared topic (one channel's stray category) doesn't
|
||||
# enter the profile, low enough to survive a couple of outliers in a small example set.
|
||||
CHAR_SUPPORT = 0.6
|
||||
# Suggest tag T for channel C when C's topics cover ≥60% of T's characteristic topics. The
|
||||
# intersection-over-profile shape means a partial match scores proportionally: 1 topic of a
|
||||
# 2-topic profile = 0.5 (dropped), 2 of 3 = 0.67 (kept). Tuned against real data to surface
|
||||
# strong topical matches without flooding — 0.5 let a single shared topic carry a suggestion.
|
||||
SUGGEST_THRESHOLD = 0.6
|
||||
# A suggestion at/above this score is "high confidence" — a full-profile topic match — and is the
|
||||
# only kind the UI's "Accept all confident" action touches. Never applied silently (see docstring).
|
||||
AUTOAPPLY_THRESHOLD = 0.9
|
||||
# Cap suggestions per channel so a busy channel's chip row stays legible.
|
||||
MAX_PER_CHANNEL = 3
|
||||
|
||||
|
||||
def compute_tag_suggestions(db: Session, user: User) -> dict[str, list[dict]]:
|
||||
"""Map each of the user's still-untagged subscribed channels to suggested user tags.
|
||||
|
||||
Returns ``{channel_id: [{"tag_id": int, "confidence": float}, ...]}`` — ordered best-first,
|
||||
capped at ``MAX_PER_CHANNEL``. Channels with no suggestion are omitted (empty dict when the
|
||||
user has no tag with a usable profile). Deterministic for a fixed DB state.
|
||||
"""
|
||||
# Candidate universe: the channels this user is subscribed to. `subscribed` (incl. hidden) is the
|
||||
# SIGNAL set — a hidden channel is still a valid example / topic-feature source, since hiding only
|
||||
# affects the feed, not the tagging pattern. `hidden` is excluded as a suggestion TARGET below (the
|
||||
# Channel Manager shows ghost chips only on non-hidden rows, so suggesting for hidden channels would
|
||||
# inflate the header count past what's shown and let bulk-accept tag channels the user can't see).
|
||||
subs = db.execute(
|
||||
select(Subscription.channel_id, Subscription.hidden).where(Subscription.user_id == user.id)
|
||||
).all()
|
||||
subscribed: set[str] = {cid for cid, _ in subs}
|
||||
hidden: set[str] = {cid for cid, h in subs if h}
|
||||
if not subscribed:
|
||||
return {}
|
||||
|
||||
# The user's manual tag links: tag_id -> example channels, and each channel's user-tag set (so
|
||||
# a channel with ANY user tag is excluded from "untagged"). Intersect with `subscribed`: unsubscribe
|
||||
# deletes the Subscription but NOT the source='user' ChannelTag, so a tagged-then-unsubscribed
|
||||
# channel would otherwise stay an "example" — inflating a profile's CHAR_SUPPORT denominator while
|
||||
# contributing zero features (features are scanned over `subscribed` only), dropping characteristic
|
||||
# topics. Keeping examples ⊆ subscribed makes the support fraction honest.
|
||||
examples: dict[int, set[str]] = {}
|
||||
user_tagged: set[str] = set()
|
||||
for tag_id, cid in db.execute(
|
||||
select(ChannelTag.tag_id, ChannelTag.channel_id).where(
|
||||
ChannelTag.user_id == user.id, ChannelTag.source == "user"
|
||||
)
|
||||
).all():
|
||||
if cid not in subscribed:
|
||||
continue
|
||||
examples.setdefault(tag_id, set()).add(cid)
|
||||
user_tagged.add(cid)
|
||||
|
||||
# Topic features per channel: the auto TOPIC tags on the candidate channels. Language auto-tags
|
||||
# are joined out (Tag.category) — they don't discriminate (see the module docstring).
|
||||
features: dict[str, set[int]] = {}
|
||||
for cid, tid in db.execute(
|
||||
select(ChannelTag.channel_id, ChannelTag.tag_id)
|
||||
.join(Tag, Tag.id == ChannelTag.tag_id)
|
||||
.where(
|
||||
ChannelTag.user_id.is_(None),
|
||||
ChannelTag.source == "auto",
|
||||
ChannelTag.channel_id.in_(subscribed),
|
||||
Tag.category == "topic",
|
||||
)
|
||||
).all():
|
||||
features.setdefault(cid, set()).add(tid)
|
||||
|
||||
# Characteristic-feature profile per user tag with enough examples.
|
||||
profiles: dict[int, set[int]] = {}
|
||||
for tag_id, ex in examples.items():
|
||||
if len(ex) < MIN_EXAMPLES:
|
||||
continue
|
||||
counts: Counter[int] = Counter()
|
||||
for cid in ex:
|
||||
counts.update(features.get(cid, ()))
|
||||
threshold = CHAR_SUPPORT * len(ex)
|
||||
chars = {f for f, c in counts.items() if c >= threshold}
|
||||
if chars:
|
||||
profiles[tag_id] = chars
|
||||
if not profiles:
|
||||
return {}
|
||||
|
||||
suggestions: dict[str, list[dict]] = {}
|
||||
for cid in subscribed:
|
||||
if cid in hidden or cid in user_tagged: # hidden → no chips shown; tagged → not "untagged"
|
||||
continue
|
||||
cfeat = features.get(cid)
|
||||
if not cfeat:
|
||||
continue
|
||||
scored = [(tag_id, len(cfeat & chars) / len(chars)) for tag_id, chars in profiles.items()]
|
||||
hits = [(tid, s) for tid, s in scored if s >= SUGGEST_THRESHOLD]
|
||||
if not hits:
|
||||
continue
|
||||
# Best score first; tag_id as a stable tie-break so the order is deterministic.
|
||||
hits.sort(key=lambda x: (-x[1], x[0]))
|
||||
suggestions[cid] = [
|
||||
{"tag_id": tid, "confidence": round(s, 3)} for tid, s in hits[:MAX_PER_CHANNEL]
|
||||
]
|
||||
return suggestions
|
||||
@@ -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",
|
||||
|
||||
@@ -162,11 +162,154 @@ _SUMMARY_CASES = [
|
||||
"show=all&tags={O1}&tags={O2}&tag_mode_other=and",
|
||||
"show=all&tags={U1}", # user-owned tag (visibility == user.id branch)
|
||||
"show=all&tags={U1}&tags={L1}", # user tag AND a system tag, cross-category
|
||||
# "No tag" filter (item B): channels with no USER tag. It stays applied in the base pass, so the
|
||||
# facet/count lockstep must still hold with it on (alone, and combined with a positive tag filter).
|
||||
"show=all&untagged=true",
|
||||
"show=all&untagged=true&tags={L1}",
|
||||
"show=all&q=clip",
|
||||
"show=unwatched",
|
||||
]
|
||||
|
||||
|
||||
def test_untagged_filter_keeps_only_channels_the_user_has_not_tagged(db, as_user, make_user):
|
||||
"""The "No tag" filter (item B): `untagged=true` keeps only channels with NO tag the USER personally
|
||||
attached (no user-owned ChannelTag). System/auto tags (user_id NULL) don't count, and another user's
|
||||
tag on the same channel doesn't either."""
|
||||
user = make_user(email="untagged@example.com")
|
||||
other = make_user(email="someone-else@example.com")
|
||||
mine = Tag(user_id=user.id, name="Fav", category="other")
|
||||
sys_topic = Tag(user_id=None, name="Tech", category="topic")
|
||||
theirs = Tag(user_id=other.id, name="TheirTag", category="other")
|
||||
db.add_all([mine, sys_topic, theirs])
|
||||
chans = {c: Channel(id=c, title=c, subscriber_count=10) for c in ("chMine", "chSys", "chNone")}
|
||||
db.add_all(chans.values())
|
||||
db.flush()
|
||||
# chMine: the user's own tag → tagged. chSys: only a system tag + another user's tag → still
|
||||
# "untagged" for THIS user. chNone: nothing at all → untagged.
|
||||
db.add(ChannelTag(channel_id="chMine", tag_id=mine.id, user_id=user.id, source="user"))
|
||||
db.add(ChannelTag(channel_id="chSys", tag_id=sys_topic.id, user_id=None, source="auto"))
|
||||
db.add(ChannelTag(channel_id="chSys", tag_id=theirs.id, user_id=other.id, source="user"))
|
||||
for cid in chans:
|
||||
db.add(Subscription(user_id=user.id, channel_id=cid, priority=0, hidden=False))
|
||||
db.add(_video(f"{cid}v", cid, f"{cid} clip"))
|
||||
db.commit()
|
||||
client = as_user(user)
|
||||
|
||||
# Off: every subscribed channel shows.
|
||||
assert _feed_ids(client, "show=all") == {"chMinev", "chSysv", "chNonev"}
|
||||
# On: only channels the user has NOT personally tagged (a system tag / another user's tag don't count).
|
||||
assert _feed_ids(client, "show=all&untagged=true") == {"chSysv", "chNonev"}
|
||||
|
||||
|
||||
def test_feed_years_counts_ignore_the_date_narrowing_but_honour_other_filters(
|
||||
db, as_user, make_user
|
||||
):
|
||||
"""The year facet (item 8 / E8·S2): `/api/feed/years` returns a per-year video count, newest first,
|
||||
with the publish-date narrowing DROPPED (so switching years stays a drill-down) but every other
|
||||
filter still applied (here: a channel pick)."""
|
||||
user = make_user(email="years@example.com")
|
||||
chans = {c: Channel(id=c, title=c, subscriber_count=10) for c in ("chX", "chY")}
|
||||
db.add_all(chans.values())
|
||||
db.flush()
|
||||
# chX: 2 videos in 2024, 1 in 2022; chY: 1 in 2023 (should drop once we scope to chX).
|
||||
spread = {
|
||||
"chX": [(2024, 1), (2024, 2), (2022, 5)],
|
||||
"chY": [(2023, 3)],
|
||||
}
|
||||
for cid, dates in spread.items():
|
||||
db.add(Subscription(user_id=user.id, channel_id=cid, priority=0, hidden=False))
|
||||
for i, (yr, day) in enumerate(dates):
|
||||
db.add(
|
||||
Video(
|
||||
id=f"{cid}v{i}",
|
||||
channel_id=cid,
|
||||
published_at=datetime(yr, 1, day, tzinfo=timezone.utc),
|
||||
title=f"{cid} clip {i}",
|
||||
is_short=False,
|
||||
live_status="none",
|
||||
via_search=False,
|
||||
via_explore=False,
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
client = as_user(user)
|
||||
|
||||
# All channels: every year present, counted, newest first.
|
||||
resp = client.get("/api/feed/years?show=all")
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json()["years"] == [
|
||||
{"year": 2024, "count": 2},
|
||||
{"year": 2023, "count": 1},
|
||||
{"year": 2022, "count": 1},
|
||||
]
|
||||
|
||||
# A channel filter narrows the year list; a date narrowing is ignored (the point of the facet — so
|
||||
# a selected year never zeroes out the others). Scoping to chX drops 2023, keeps 2024 (×2) + 2022.
|
||||
resp = client.get("/api/feed/years?show=all&channel_ids=chX&published_after=2024-01-01")
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json()["years"] == [
|
||||
{"year": 2024, "count": 2},
|
||||
{"year": 2022, "count": 1},
|
||||
]
|
||||
|
||||
|
||||
def test_feed_years_buckets_by_utc_year_under_a_non_utc_session(db, make_user):
|
||||
"""feed_years must bucket by UTC year to stay in lockstep with the year filter's UTC-pinned
|
||||
published_after/before bounds. published_at is timestamptz; a bare extract('year', …) would use the
|
||||
DB session timezone, so a video just before midnight UTC on New Year's Eve would bucket into the
|
||||
wrong (local) year on a non-UTC session and disagree with the feed the year chip drills into. Called
|
||||
directly on this session (the HTTP client uses a separate connection SET TIME ZONE wouldn't reach)."""
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.feedquery import feed_years
|
||||
|
||||
user = make_user(email="tz-years@example.com")
|
||||
db.add(Channel(id="chTZ", title="chTZ", subscriber_count=1))
|
||||
db.add(Subscription(user_id=user.id, channel_id="chTZ", priority=0, hidden=False))
|
||||
# 2023-12-31 23:30 UTC — a positive-offset session tz local-shifts this into 2024.
|
||||
db.add(
|
||||
Video(
|
||||
id="tzv0",
|
||||
channel_id="chTZ",
|
||||
published_at=datetime(2023, 12, 31, 23, 30, tzinfo=timezone.utc),
|
||||
title="NYE clip",
|
||||
is_short=False,
|
||||
live_status="none",
|
||||
via_search=False,
|
||||
via_explore=False,
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
|
||||
params: dict = {
|
||||
"tags": [],
|
||||
"tag_modes": {"language": "or", "topic": "or", "other": "or"},
|
||||
"untagged": False,
|
||||
"channel_ids": [],
|
||||
"q": None,
|
||||
"min_duration": None,
|
||||
"max_duration": None,
|
||||
"max_age_days": None,
|
||||
"published_after": None,
|
||||
"published_before": None,
|
||||
"show_normal": True,
|
||||
"include_shorts": False,
|
||||
"include_live": False,
|
||||
"show": "all",
|
||||
"scope": "my",
|
||||
"library_source": "organic",
|
||||
}
|
||||
db.execute(
|
||||
text("SET TIME ZONE 'Asia/Tokyo'")
|
||||
) # +9: pushes the video's local wall-clock into 2024
|
||||
try:
|
||||
years = feed_years(db, user, **params)
|
||||
finally:
|
||||
db.execute(text("SET TIME ZONE 'UTC'")) # SET (not SET LOCAL) survives rollback — reset it
|
||||
# UTC bucketing keeps the boundary video in 2023; a bare session-tz extract would report 2024.
|
||||
assert years == [{"year": 2023, "count": 1}]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("case", _SUMMARY_CASES)
|
||||
def test_feed_summary_matches_count_plus_facets(db, as_user, make_user, case):
|
||||
# /feed/summary must return byte-identical numbers to the old /feed/count + /facets it replaces,
|
||||
|
||||
@@ -303,6 +303,54 @@ def test_remove_item_writes_through_to_plex(db, monkeypatch):
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def _resync_create(db, plex, lib, rk):
|
||||
"""Create-path resync stub: the new rating_key has no local row yet, so materialize it."""
|
||||
col = PlexCollection(rating_key=rk, library_id=lib.id, title="Fresh Coll", child_count=1)
|
||||
db.add(col)
|
||||
db.flush()
|
||||
return col
|
||||
|
||||
|
||||
def test_create_writes_through_to_plex(db, monkeypatch):
|
||||
_lib, user = _seed_editable(db)
|
||||
stub = _CollStub()
|
||||
client = _admin_client(db, user, monkeypatch, stub, resync=_resync_create)
|
||||
try:
|
||||
r = client.post(
|
||||
"/api/plex/collections",
|
||||
json={"library": "1", "title": "Fresh Coll", "item_rating_key": "m1"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
assert "create_collection" in stub.calls
|
||||
body = r.json()
|
||||
assert body["id"] == "NEW" # the stub's new ratingKey
|
||||
assert body["editable"] is True # create marks the new collection editable
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def test_create_resync_failure_is_422(db, monkeypatch):
|
||||
# The collection was created in Plex (wrote=True) but the follow-up resync read failed → 422 with
|
||||
# the honest "reconcile on the next sync" detail, not a false "write failed".
|
||||
_lib, user = _seed_editable(db)
|
||||
stub = _CollStub()
|
||||
|
||||
def _boom(db_, plex, lib_, rk):
|
||||
raise PlexError("read failed")
|
||||
|
||||
client = _admin_client(db, user, monkeypatch, stub, resync=_boom)
|
||||
try:
|
||||
r = client.post(
|
||||
"/api/plex/collections",
|
||||
json={"library": "1", "title": "Fresh Coll", "item_rating_key": "m1"},
|
||||
)
|
||||
assert r.status_code == 422, r.text
|
||||
assert "reconcile" in r.json()["detail"].lower()
|
||||
assert "create_collection" in stub.calls
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def test_mutation_failure_returns_422_write_failed(db, monkeypatch):
|
||||
# The Plex mutation itself raised → the write never landed → 422 "Plex write failed", and resync
|
||||
# is never reached (stub.calls holds only the mutating call).
|
||||
|
||||
@@ -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"}
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,286 @@
|
||||
"""DB-backed regression (E8·S4): the manual-tag-pattern suggester.
|
||||
|
||||
compute_tag_suggestions learns which auto TOPIC tags are *characteristic* of each user tag (from the
|
||||
channels the user tagged), then proposes that tag for still-untagged subscribed channels whose own
|
||||
topics cover ≥60% of the profile. The cases below pin the behaviours that would silently ship a
|
||||
useless suggester if they broke: the min-examples floor, the intersection-over-profile fraction that
|
||||
keeps a weak partial match out, the "language is not a signal" exclusion (its inclusion once flooded
|
||||
every channel with a suggestion), and the "already tagged → excluded" scope. Deterministic — no
|
||||
network, pure DB state.
|
||||
|
||||
Uses the DB lane (`db` fixture) — skipped when no Postgres is reachable.
|
||||
"""
|
||||
|
||||
from app.models import Channel, ChannelTag, Subscription, Tag, User
|
||||
from app.sync.tagsuggest import (
|
||||
AUTOAPPLY_THRESHOLD,
|
||||
CHAR_SUPPORT,
|
||||
MAX_PER_CHANNEL,
|
||||
SUGGEST_THRESHOLD,
|
||||
compute_tag_suggestions,
|
||||
)
|
||||
|
||||
|
||||
def _auto(db, name, category="topic"):
|
||||
tag = Tag(user_id=None, name=name, category=category)
|
||||
db.add(tag)
|
||||
db.flush()
|
||||
return tag
|
||||
|
||||
|
||||
def _subscribe(db, user, ch_id, *, auto_tag_ids=(), title=None, hidden=False):
|
||||
"""A subscribed channel carrying the given auto-tags (source='auto', user_id NULL)."""
|
||||
db.add(Channel(id=ch_id, title=title or ch_id))
|
||||
db.add(Subscription(user_id=user.id, channel_id=ch_id, hidden=hidden))
|
||||
for tid in auto_tag_ids:
|
||||
db.add(ChannelTag(channel_id=ch_id, tag_id=tid, user_id=None, source="auto"))
|
||||
|
||||
|
||||
def test_suggests_a_user_tag_for_a_channel_matching_its_topic_profile(db):
|
||||
me = User(email="me@example.com")
|
||||
db.add(me)
|
||||
db.flush()
|
||||
edu = _auto(db, "Education")
|
||||
film = _auto(db, "Film")
|
||||
eng = _auto(db, "English", category="language") # a language auto-tag → NOT part of the signal
|
||||
docu = Tag(user_id=me.id, name="docu", category="other")
|
||||
db.add(docu)
|
||||
db.flush()
|
||||
|
||||
# Three example channels the user tagged "docu", all Education+Film (+English). The topic profile
|
||||
# is {Education, Film}; the English language tag is excluded from the profile.
|
||||
for i in range(3):
|
||||
cid = f"UC_ex{i}"
|
||||
_subscribe(db, me, cid, auto_tag_ids=[edu.id, film.id, eng.id])
|
||||
db.add(ChannelTag(channel_id=cid, tag_id=docu.id, user_id=me.id, source="user"))
|
||||
|
||||
# An untagged subscribed channel sharing the full topic profile → full-confidence suggestion.
|
||||
_subscribe(db, me, "UC_match", auto_tag_ids=[edu.id, film.id, eng.id])
|
||||
# An untagged channel sharing ONLY the language → no topic overlap at all, so no suggestion. This
|
||||
# is the anti-flood guarantee: a shared language must never carry a suggestion on its own.
|
||||
_subscribe(db, me, "UC_english_only", auto_tag_ids=[eng.id])
|
||||
db.commit()
|
||||
|
||||
out = compute_tag_suggestions(db, me)
|
||||
|
||||
assert "UC_match" in out
|
||||
assert out["UC_match"][0]["tag_id"] == docu.id
|
||||
# Both profile topics present → full confidence, i.e. an "accept all confident" hit.
|
||||
assert out["UC_match"][0]["confidence"] >= AUTOAPPLY_THRESHOLD
|
||||
assert "UC_english_only" not in out
|
||||
# Example channels are already tagged → excluded from suggestions.
|
||||
assert "UC_ex0" not in out
|
||||
|
||||
|
||||
def test_a_tag_below_the_example_floor_produces_no_profile(db):
|
||||
me = User(email="me2@example.com")
|
||||
db.add(me)
|
||||
db.flush()
|
||||
edu = _auto(db, "Education")
|
||||
niche = Tag(user_id=me.id, name="niche", category="other")
|
||||
db.add(niche)
|
||||
db.flush()
|
||||
|
||||
# Only TWO example channels for "niche" (< MIN_EXAMPLES=3) → no profile, so no suggestion even
|
||||
# for a perfectly-matching untagged channel.
|
||||
for i in range(2):
|
||||
cid = f"UC_niche{i}"
|
||||
_subscribe(db, me, cid, auto_tag_ids=[edu.id])
|
||||
db.add(ChannelTag(channel_id=cid, tag_id=niche.id, user_id=me.id, source="user"))
|
||||
_subscribe(db, me, "UC_would_match", auto_tag_ids=[edu.id])
|
||||
db.commit()
|
||||
|
||||
assert compute_tag_suggestions(db, me) == {}
|
||||
|
||||
|
||||
def test_partial_topic_overlap_is_a_fraction_of_the_profile(db):
|
||||
# Guards the intersection-over-profile shape at the SUGGEST_THRESHOLD boundary: a 3-topic profile
|
||||
# matched by 1 topic scores 0.33 (dropped); matched by 2 scores 0.67 (kept). Pins the threshold
|
||||
# so a change to it can't silently widen/narrow what "a suggestion" means.
|
||||
assert SUGGEST_THRESHOLD == 0.6
|
||||
me = User(email="me3@example.com")
|
||||
db.add(me)
|
||||
db.flush()
|
||||
a, b, c = _auto(db, "A"), _auto(db, "B"), _auto(db, "C")
|
||||
tag = Tag(user_id=me.id, name="triad", category="other")
|
||||
db.add(tag)
|
||||
db.flush()
|
||||
for i in range(3):
|
||||
cid = f"UC_triad{i}"
|
||||
_subscribe(db, me, cid, auto_tag_ids=[a.id, b.id, c.id])
|
||||
db.add(ChannelTag(channel_id=cid, tag_id=tag.id, user_id=me.id, source="user"))
|
||||
_subscribe(db, me, "UC_one_of_three", auto_tag_ids=[a.id]) # 1/3 = 0.33 < 0.6 → dropped
|
||||
_subscribe(db, me, "UC_two_of_three", auto_tag_ids=[a.id, b.id]) # 2/3 = 0.67 ≥ 0.6 → kept
|
||||
db.commit()
|
||||
|
||||
out = compute_tag_suggestions(db, me)
|
||||
assert "UC_one_of_three" not in out
|
||||
assert "UC_two_of_three" in out
|
||||
assert out["UC_two_of_three"][0]["tag_id"] == tag.id
|
||||
assert out["UC_two_of_three"][0]["confidence"] == round(2 / 3, 3)
|
||||
|
||||
|
||||
def test_language_alone_is_never_a_profile_even_when_it_is_the_only_shared_tag(db):
|
||||
# The anti-flood invariant WITH TEETH. Every example channel shares only a language; each carries a
|
||||
# DIFFERENT topic, so no topic reaches CHAR_SUPPORT → the topic-only profile is empty → no
|
||||
# suggestion. If language were ever admitted to the signal the profile would collapse to {language}
|
||||
# and the language-only channel would score 1.0 and be suggested — the exact flood the exclusion
|
||||
# prevents. (test_suggests_… can't catch a regression here: there the language merely enlarges an
|
||||
# already topic-driven profile, so removing the exclusion flips no assertion.)
|
||||
me = User(email="lang@example.com")
|
||||
db.add(me)
|
||||
db.flush()
|
||||
eng = _auto(db, "English", category="language")
|
||||
topics = [_auto(db, f"Topic{i}") for i in range(3)]
|
||||
lang = Tag(user_id=me.id, name="english-stuff", category="other")
|
||||
db.add(lang)
|
||||
db.flush()
|
||||
for i in range(3):
|
||||
cid = f"UC_lang_ex{i}"
|
||||
_subscribe(db, me, cid, auto_tag_ids=[eng.id, topics[i].id])
|
||||
db.add(ChannelTag(channel_id=cid, tag_id=lang.id, user_id=me.id, source="user"))
|
||||
_subscribe(db, me, "UC_lang_only", auto_tag_ids=[eng.id])
|
||||
db.commit()
|
||||
|
||||
# No characteristic topic → no profile → nothing suggested for anyone (language never carries it).
|
||||
assert compute_tag_suggestions(db, me) == {}
|
||||
|
||||
|
||||
def test_char_support_admits_a_two_thirds_topic_and_excludes_a_one_third_topic(db):
|
||||
# Pins the characteristic-profile threshold (CHAR_SUPPORT) at its PARTIAL-support boundary: across 3
|
||||
# example channels a topic in 2/3 (0.67 ≥ 0.6) enters the profile, one in 1/3 (0.33) does not. The
|
||||
# other fixtures give every topic full 3/3 support, so a regression demanding full support (or
|
||||
# flipping ≥ to >) would ship green — this is the only case that exercises fractional support.
|
||||
assert CHAR_SUPPORT == 0.6
|
||||
me = User(email="chr@example.com")
|
||||
db.add(me)
|
||||
db.flush()
|
||||
p, q, r = _auto(db, "P"), _auto(db, "Q"), _auto(db, "R")
|
||||
mix = Tag(user_id=me.id, name="mix", category="other")
|
||||
db.add(mix)
|
||||
db.flush()
|
||||
# p in 3/3 (kept), q in 2/3 (kept, 0.67), r in 1/3 (excluded, 0.33) → profile = {p, q}.
|
||||
example_topics = [[p.id, q.id, r.id], [p.id, q.id], [p.id]]
|
||||
for i, topics in enumerate(example_topics):
|
||||
cid = f"UC_mix_ex{i}"
|
||||
_subscribe(db, me, cid, auto_tag_ids=topics)
|
||||
db.add(ChannelTag(channel_id=cid, tag_id=mix.id, user_id=me.id, source="user"))
|
||||
_subscribe(db, me, "UC_pq", auto_tag_ids=[p.id, q.id]) # {p,q}/{p,q} = 1.0 → suggested
|
||||
_subscribe(db, me, "UC_only_p", auto_tag_ids=[p.id]) # 1/2 = 0.5 < 0.6 → dropped
|
||||
_subscribe(db, me, "UC_p_and_r", auto_tag_ids=[p.id, r.id]) # {p}/{p,q} = 0.5 → dropped
|
||||
db.commit()
|
||||
|
||||
out = compute_tag_suggestions(db, me)
|
||||
assert "UC_pq" in out and out["UC_pq"][0]["confidence"] == 1.0
|
||||
# q (2/3) IS in the profile: p alone covers only half of it. Would be a full 1.0 match if the
|
||||
# builder wrongly required 3/3 support (profile would collapse to {p}).
|
||||
assert "UC_only_p" not in out
|
||||
# r (1/3) is NOT in the profile: {p,r} covers only p. Would score 0.67 (suggested) if r wrongly
|
||||
# entered the profile (a downward drift of CHAR_SUPPORT).
|
||||
assert "UC_p_and_r" not in out
|
||||
|
||||
|
||||
def test_suggestions_are_capped_and_ordered_best_first(db):
|
||||
# A channel matching many user-tag profiles gets at most MAX_PER_CHANNEL suggestions, ordered best
|
||||
# score first, with the weakest dropped by the cap. No other fixture matches >1 profile, so the sort
|
||||
# and the cap are both no-ops there — dropping either ships green. Four DISTINCT confidences keep the
|
||||
# order unambiguous. (The exact-tie tag_id tie-break in the sort key is a determinism detail, not
|
||||
# asserted here: on a stable sort it isn't catchable without relying on unspecified DB row order.)
|
||||
me = User(email="cap@example.com")
|
||||
db.add(me)
|
||||
db.flush()
|
||||
|
||||
def profile_tag(name, n_topics):
|
||||
"""A user tag whose characteristic profile is exactly n_topics (3 identical example channels)."""
|
||||
topics = [_auto(db, f"{name}_t{i}") for i in range(n_topics)]
|
||||
tag = Tag(user_id=me.id, name=name, category="other")
|
||||
db.add(tag)
|
||||
db.flush()
|
||||
for i in range(3):
|
||||
cid = f"UC_{name}_ex{i}"
|
||||
_subscribe(db, me, cid, auto_tag_ids=[t.id for t in topics])
|
||||
db.add(ChannelTag(channel_id=cid, tag_id=tag.id, user_id=me.id, source="user"))
|
||||
return tag, topics
|
||||
|
||||
t0, top0 = profile_tag("p0", 1) # profile size 1
|
||||
t1, top1 = profile_tag("p1", 5) # profile size 5
|
||||
t2, top2 = profile_tag("p2", 10) # profile size 10
|
||||
t3, top3 = profile_tag("p3", 10) # profile size 10
|
||||
|
||||
# One untagged channel matching all four at distinct confidences:
|
||||
# t0: 1/1 = 1.00
|
||||
# t1: 4/5 = 0.80
|
||||
# t2: 7/10 = 0.70
|
||||
# t3: 6/10 = 0.60 → a valid hit (≥ SUGGEST_THRESHOLD) but the weakest, dropped by the cap of 3
|
||||
matched = (
|
||||
[top0[0].id]
|
||||
+ [t.id for t in top1[:4]]
|
||||
+ [t.id for t in top2[:7]]
|
||||
+ [t.id for t in top3[:6]]
|
||||
)
|
||||
_subscribe(db, me, "UC_many", auto_tag_ids=matched)
|
||||
db.commit()
|
||||
|
||||
hits = compute_tag_suggestions(db, me)["UC_many"]
|
||||
assert len(hits) == MAX_PER_CHANNEL # 4 matches, capped to 3
|
||||
assert [h["tag_id"] for h in hits] == [t0.id, t1.id, t2.id] # best score first
|
||||
assert [h["confidence"] for h in hits] == [1.0, 0.8, 0.7]
|
||||
assert t3.id not in [
|
||||
h["tag_id"] for h in hits
|
||||
] # the weakest match (0.60) is the one the cap drops
|
||||
|
||||
|
||||
def test_unsubscribed_example_channel_does_not_inflate_the_profile_denominator(db):
|
||||
# Unsubscribe deletes the Subscription but NOT the source='user' ChannelTag, so a tagged-then-
|
||||
# unsubscribed channel must NOT count as a profile example: it contributes no topic features (those
|
||||
# are scanned over subscribed channels only) yet would inflate CHAR_SUPPORT's denominator and drop a
|
||||
# genuinely characteristic topic. Here 'X' is characteristic at 2/3 of the SUBSCRIBED examples
|
||||
# (threshold 1.8, count 2 → kept), but if the unsubscribed 4th example counted, threshold 2.4 would
|
||||
# drop it and suppress the suggestion.
|
||||
me = User(email="unsub@example.com")
|
||||
db.add(me)
|
||||
db.flush()
|
||||
x, y = _auto(db, "X"), _auto(db, "Y")
|
||||
wood = Tag(user_id=me.id, name="wood", category="other")
|
||||
db.add(wood)
|
||||
db.flush()
|
||||
# 3 subscribed examples: 2 carry topic X, 1 carries only Y → X support 2/3 among subscribed.
|
||||
for cid, topic in [("UC_w0", x), ("UC_w1", x), ("UC_w2", y)]:
|
||||
_subscribe(db, me, cid, auto_tag_ids=[topic.id])
|
||||
db.add(ChannelTag(channel_id=cid, tag_id=wood.id, user_id=me.id, source="user"))
|
||||
# A 4th channel tagged "wood" but NO LONGER subscribed (channel + user tag, no Subscription row).
|
||||
db.add(Channel(id="UC_gone", title="UC_gone"))
|
||||
db.add(ChannelTag(channel_id="UC_gone", tag_id=wood.id, user_id=me.id, source="user"))
|
||||
_subscribe(
|
||||
db, me, "UC_cand", auto_tag_ids=[x.id]
|
||||
) # untagged X match → should be suggested "wood"
|
||||
db.commit()
|
||||
|
||||
out = compute_tag_suggestions(db, me)
|
||||
assert "UC_cand" in out
|
||||
assert out["UC_cand"][0]["tag_id"] == wood.id
|
||||
|
||||
|
||||
def test_hidden_subscriptions_are_not_suggestion_targets(db):
|
||||
# The Channel Manager renders suggestion ghost chips only on non-hidden rows, so a hidden channel
|
||||
# must not be a suggestion target — otherwise the header count / bulk-accept would span channels with
|
||||
# no visible chip. (Hidden channels still count as example/feature signal; only targeting is excluded.)
|
||||
me = User(email="hid@example.com")
|
||||
db.add(me)
|
||||
db.flush()
|
||||
z = _auto(db, "Z")
|
||||
ztag = Tag(user_id=me.id, name="ztag", category="other")
|
||||
db.add(ztag)
|
||||
db.flush()
|
||||
for i in range(3):
|
||||
cid = f"UC_z{i}"
|
||||
_subscribe(db, me, cid, auto_tag_ids=[z.id])
|
||||
db.add(ChannelTag(channel_id=cid, tag_id=ztag.id, user_id=me.id, source="user"))
|
||||
# A hidden, untagged, perfectly-matching subscription → NOT a target; a visible one → suggested.
|
||||
_subscribe(db, me, "UC_hidden", auto_tag_ids=[z.id], hidden=True)
|
||||
_subscribe(db, me, "UC_visible", auto_tag_ids=[z.id])
|
||||
db.commit()
|
||||
|
||||
out = compute_tag_suggestions(db, me)
|
||||
assert "UC_visible" in out
|
||||
assert "UC_hidden" not in out
|
||||
+2
-1
@@ -29,8 +29,9 @@ A change is done when all of this is green. CI runs the same checks server-side
|
||||
**Backend**
|
||||
|
||||
```bash
|
||||
# lint
|
||||
# lint + format check
|
||||
python -m ruff check --config backend/ruff.toml backend scripts
|
||||
python -m ruff format --check --config backend/ruff.toml backend scripts
|
||||
|
||||
# tests — pure-logic + the DB-backed integration lane, against an ephemeral Postgres.
|
||||
# The backend tree is bind-mounted, so it always tests your working files.
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useLayoutEffect, useRef, useState } from "react";
|
||||
import { useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Filter, X } from "lucide-react";
|
||||
import type { ActiveFilter } from "../lib/useActiveFilters";
|
||||
import { decideChipLayout } from "../lib/chipOverflow";
|
||||
import { useChipOverflowFit } from "../lib/useChipOverflowFit";
|
||||
import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
|
||||
|
||||
// The applied-filter row above the feed: every narrowing currently in force, each removable on its
|
||||
@@ -131,52 +132,36 @@ export default function ActiveFilterChips({
|
||||
const chipRefs = useRef<Array<HTMLSpanElement | null>>([]);
|
||||
const plusRef = useRef<HTMLSpanElement | null>(null);
|
||||
const clearRef = useRef<HTMLSpanElement | null>(null);
|
||||
// How the row is currently laid out: all chips, a fit-count + "+N", or a single count pill.
|
||||
const [layout, setLayout] = useState<{ mode: "all" | "some" | "count"; visible: number }>({
|
||||
mode: "all",
|
||||
visible: filters.length,
|
||||
});
|
||||
|
||||
const n = filters.length;
|
||||
// Re-measure whenever the chip set changes (labels or count) — captures both in one dep.
|
||||
const chipsKey = filters.map((f) => f.key + f.label).join("|");
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const lane = laneRef.current;
|
||||
const labelEl = labelRef.current;
|
||||
if (!lane || !labelEl || n === 0) return;
|
||||
|
||||
const measure = () => {
|
||||
const rightOf = (el: HTMLElement | null | undefined) =>
|
||||
el ? el.offsetLeft + el.offsetWidth : 0;
|
||||
const chips = chipRefs.current.slice(0, n);
|
||||
const plusEl = plusRef.current;
|
||||
// Measure the real rendered gap off the box between the last chip and the "+N" sample, so it
|
||||
// scales with the text-size slider (gap-1.5 is rem) rather than being frozen in px.
|
||||
const lastChip = chips[n - 1];
|
||||
const gap = plusEl && lastChip ? Math.max(0, plusEl.offsetLeft - rightOf(lastChip)) : 6;
|
||||
// "all" mode also renders a Clear all button after the chips (when n > 1), so reserve its width.
|
||||
const clearW = n > 1 ? gap + (clearRef.current?.offsetWidth ?? 0) : 0;
|
||||
|
||||
setLayout(
|
||||
decideChipLayout({
|
||||
n,
|
||||
labelRight: rightOf(labelEl),
|
||||
chipRights: chips.map((c) => rightOf(c)),
|
||||
plusW: plusEl?.offsetWidth ?? 0,
|
||||
clearW,
|
||||
avail: lane.clientWidth,
|
||||
gap,
|
||||
narrowPx: NARROW_PX,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
measure();
|
||||
const ro = new ResizeObserver(measure);
|
||||
ro.observe(lane);
|
||||
return () => ro.disconnect();
|
||||
}, [n, chipsKey]);
|
||||
// The shared measurement core hands over the chip right edges + gap + pill width + lane width; this
|
||||
// policy adds the feed row's own framing (the leading label's right edge, and the trailing "Clear
|
||||
// all" button's width, reserved only when it renders — n > 1) before deciding all/some/count.
|
||||
const layout = useChipOverflowFit<{ mode: "all" | "some" | "count"; visible: number }>({
|
||||
rowRef: laneRef,
|
||||
chipsRef: chipRefs,
|
||||
pillRef: plusRef,
|
||||
deps: `${n}|${chipsKey}`,
|
||||
initial: { mode: "all", visible: n },
|
||||
decide: (g) => {
|
||||
const labelEl = labelRef.current;
|
||||
const labelRight = labelEl ? labelEl.offsetLeft + labelEl.offsetWidth : 0;
|
||||
const clearW = n > 1 ? g.gap + (clearRef.current?.offsetWidth ?? 0) : 0;
|
||||
return decideChipLayout({
|
||||
n,
|
||||
labelRight,
|
||||
chipRights: g.chipRights,
|
||||
plusW: g.pillW,
|
||||
clearW,
|
||||
avail: g.avail,
|
||||
gap: g.gap,
|
||||
narrowPx: NARROW_PX,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
if (n === 0) return null;
|
||||
|
||||
|
||||
@@ -77,8 +77,10 @@ export function Carousel({
|
||||
|
||||
const label = ariaLabel ?? (typeof title === "string" ? title : undefined);
|
||||
|
||||
// Mouse-only: hidden until the carousel is hovered or the arrow is keyboard-focused; `reveal-hover`
|
||||
// removes it on touch, where the strip scrolls by swipe — so an invisible edge button never eats a tap.
|
||||
const arrowBtn =
|
||||
"absolute top-1/2 -translate-y-1/2 z-10 grid h-9 w-9 place-items-center rounded-full " +
|
||||
"reveal-hover absolute top-1/2 -translate-y-1/2 z-10 grid h-9 w-9 place-items-center rounded-full " +
|
||||
"bg-black/55 text-white backdrop-blur-xs border border-white/15 shadow-lg transition " +
|
||||
"opacity-0 group-hover/car:opacity-100 focus-visible:opacity-100 hover:bg-black/75";
|
||||
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -469,6 +483,24 @@ export default function ChannelPage({
|
||||
<Ban className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
{/* 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 && (
|
||||
<button
|
||||
onClick={() => toggleHidden.mutate()}
|
||||
disabled={toggleHidden.isPending}
|
||||
title={ch.hidden ? t("channel.hiddenHint") : t("channel.hideHint")}
|
||||
aria-label={ch.hidden ? t("channel.unhide") : t("channel.hide")}
|
||||
className={`inline-flex items-center justify-center w-9 h-9 rounded-lg border disabled:opacity-50 transition ${
|
||||
ch.hidden
|
||||
? "border-accent text-accent"
|
||||
: "border-border text-muted hover:text-fg hover:border-fg/40"
|
||||
}`}
|
||||
>
|
||||
{ch.hidden ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
)}
|
||||
{!me.is_demo &&
|
||||
!ch?.blocked &&
|
||||
(ch?.subscribed ? (
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// Contract of the suggested-tag ghost chips (E8·S4): on an UNTAGGED channel, ChannelTagsControl renders
|
||||
// one dashed "ghost" chip per suggestion; clicking the chip body accepts it (onAcceptSuggestion with the
|
||||
// tag id), the trailing × dismisses it (onDismissSuggestion), and a suggestion whose tag is already
|
||||
// attached is never shown. i18n is key-passthrough; the control rides the floating-ui popover platform
|
||||
// and a ResizeObserver-driven fit measurer, so we stub the observer and wrap in a FloatingTree.
|
||||
import { describe, it, expect, afterEach, beforeAll, vi } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
||||
import { FloatingTree } from "@floating-ui/react";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
// Interpolate the `name` option so per-tag accept labels are distinguishable ("…acceptSuggestion:docu").
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
t: (key: string, opts?: { name?: string }) => (opts?.name ? `${key}:${opts.name}` : key),
|
||||
i18n: { language: "en" },
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
import { ChannelTagsControl, type SuggestedTag } from "./ChannelTags";
|
||||
import type { Tag } from "../lib/api";
|
||||
|
||||
beforeAll(() => {
|
||||
class Stub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
|
||||
});
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const tag = (id: number, name: string): Tag => ({
|
||||
id,
|
||||
name,
|
||||
color: null,
|
||||
category: "other",
|
||||
system: false,
|
||||
channel_count: 0,
|
||||
});
|
||||
const docu = tag(1, "docu");
|
||||
const history = tag(2, "history");
|
||||
const suggestions: SuggestedTag[] = [
|
||||
{ tag: docu, confidence: 0.95 },
|
||||
{ tag: history, confidence: 0.6 },
|
||||
];
|
||||
|
||||
function renderControl(props: Partial<Parameters<typeof ChannelTagsControl>[0]> = {}) {
|
||||
const onAccept = vi.fn();
|
||||
const onDismiss = vi.fn();
|
||||
render(
|
||||
<FloatingTree>
|
||||
<ChannelTagsControl
|
||||
userTags={[docu, history]}
|
||||
attachedIds={[]}
|
||||
onToggle={() => {}}
|
||||
onCreate={() => {}}
|
||||
suggestions={suggestions}
|
||||
onAcceptSuggestion={onAccept}
|
||||
onDismissSuggestion={onDismiss}
|
||||
{...props}
|
||||
/>
|
||||
</FloatingTree>,
|
||||
);
|
||||
return { onAccept, onDismiss };
|
||||
}
|
||||
|
||||
describe("ChannelTagsControl suggested ghost chips", () => {
|
||||
it("renders a ghost chip per suggestion and accepts on click", () => {
|
||||
const { onAccept } = renderControl();
|
||||
// Role query excludes the aria-hidden fit-measurer copy; the name carries the interpolated tag.
|
||||
fireEvent.click(screen.getByRole("button", { name: "channels.tags.acceptSuggestion:docu" }));
|
||||
expect(onAccept).toHaveBeenCalledWith(docu.id);
|
||||
});
|
||||
|
||||
it("dismisses via the × without accepting", () => {
|
||||
const { onAccept, onDismiss } = renderControl();
|
||||
const dismissButtons = screen.getAllByRole("button", {
|
||||
name: "channels.tags.dismissSuggestion",
|
||||
});
|
||||
// Two visible ghost chips (the measurer's copies are aria-hidden → excluded); order matches the
|
||||
// suggestions array, so the second × is the "history" suggestion.
|
||||
expect(dismissButtons).toHaveLength(2);
|
||||
fireEvent.click(dismissButtons[1]!);
|
||||
expect(onDismiss).toHaveBeenCalledWith(history.id);
|
||||
expect(onAccept).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows the attached lane (no ghost chips) once the channel carries a user tag", () => {
|
||||
renderControl({ attachedIds: [docu.id] });
|
||||
// A tagged channel is not "untagged" → the ghost lane is not rendered at all (the server excludes
|
||||
// tagged channels from suggestions; the control mirrors that with its attached-XOR-ghost branch).
|
||||
expect(
|
||||
screen.queryAllByRole("button", { name: "channels.tags.dismissSuggestion" }),
|
||||
).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -1,14 +1,16 @@
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { Check, Plus } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Check, Plus, Sparkles, X } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { type Tag } from "../lib/api";
|
||||
import { useAnchoredPopover, PopoverSurface, PopoverItem } from "./AnchoredPopover";
|
||||
import { useScrollFade } from "../lib/useScrollFade";
|
||||
import { fitChipsWithPill } from "../lib/chipOverflow";
|
||||
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.
|
||||
@@ -38,6 +40,157 @@ function TagChip({ tag, onClick, title }: TagChipProps) {
|
||||
);
|
||||
}
|
||||
|
||||
// A suggested user tag (E8·S4): a dashed "ghost" chip the user hasn't applied yet, learned from
|
||||
// their own tagging pattern. The chip body accepts it (one tap → attaches the real tag); the trailing
|
||||
// × dismisses it for the session. Rendered only on untagged channels, so it never competes with the
|
||||
// measured attached-chip row. (Two sibling buttons, not a button-in-button — invalid nesting.)
|
||||
export interface SuggestedTag {
|
||||
tag: Tag;
|
||||
confidence: number;
|
||||
}
|
||||
|
||||
function SuggestedChip({
|
||||
suggestion,
|
||||
onAccept,
|
||||
onDismiss,
|
||||
}: {
|
||||
suggestion: SuggestedTag;
|
||||
onAccept: () => void;
|
||||
onDismiss: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const pct = Math.round(suggestion.confidence * 100);
|
||||
return (
|
||||
<span className="shrink-0 inline-flex items-center rounded-full border border-dashed border-accent/50 text-accent/90 text-2xs font-medium pl-1 pr-0.5 py-0.5 max-w-36">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAccept}
|
||||
title={t("channels.tags.suggestedHint", { pct })}
|
||||
aria-label={t("channels.tags.acceptSuggestion", { name: suggestion.tag.name })}
|
||||
className="inline-flex items-center gap-1 min-w-0 hover:text-accent transition"
|
||||
>
|
||||
<Sparkles className="w-2.5 h-2.5 shrink-0" />
|
||||
<span className="truncate">{suggestion.tag.name}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDismiss}
|
||||
title={t("channels.tags.dismissSuggestion")}
|
||||
aria-label={t("channels.tags.dismissSuggestion")}
|
||||
className="shrink-0 ml-0.5 rounded-full text-accent/60 hover:text-danger transition"
|
||||
>
|
||||
<X className="w-2.5 h-2.5" />
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const GHOST_PILL =
|
||||
"shrink-0 text-3xs px-1.5 py-0.5 rounded-full border border-dashed border-accent/50 text-accent/90";
|
||||
|
||||
/** A single-line lane of suggested ghost chips that collapses its overflow into a "+N" pill (hover
|
||||
* reveals the rest, still accept/dismiss-able), reusing the SAME fit machinery as the attached-chip
|
||||
* lane. Rendered in the untagged-channel case only, so it owns the full tags-cell width — this is what
|
||||
* stops a 2nd suggestion from being clipped mid-word right next to the edit "+". */
|
||||
function SuggestedChipsLane({
|
||||
suggestions,
|
||||
onAccept,
|
||||
onDismiss,
|
||||
}: {
|
||||
suggestions: SuggestedTag[];
|
||||
onAccept: (tagId: number) => void;
|
||||
onDismiss: (tagId: number) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const rowRef = useRef<HTMLDivElement | null>(null);
|
||||
const chipRefs = useRef<Array<HTMLElement | null>>([]);
|
||||
const pillRef = useRef<HTMLSpanElement | null>(null);
|
||||
const key = suggestions.map((s) => `${s.tag.id}:${s.tag.name}`).join("|");
|
||||
const visible = useChipOverflowFit<number>({
|
||||
rowRef,
|
||||
chipsRef: chipRefs,
|
||||
pillRef,
|
||||
deps: key,
|
||||
initial: suggestions.length,
|
||||
decide: (g) =>
|
||||
fitChipsWithPill({ chipRights: g.chipRights, pillW: g.pillW, avail: g.avail, gap: g.gap }),
|
||||
});
|
||||
const hidden = suggestions.length - visible;
|
||||
|
||||
const [overflowOpen, setOverflowOpen] = useState(false);
|
||||
const overflowPop = useAnchoredPopover({
|
||||
open: overflowOpen,
|
||||
onOpenChange: setOverflowOpen,
|
||||
mode: "hover",
|
||||
placement: "bottom",
|
||||
});
|
||||
useEffect(() => {
|
||||
if (hidden === 0 && overflowOpen) setOverflowOpen(false);
|
||||
}, [hidden, overflowOpen]);
|
||||
|
||||
return (
|
||||
<div className="relative flex items-center gap-1 min-w-0 flex-1">
|
||||
{/* Hidden measurer — real ghost chips at natural width so the fit hook reads their right edges. */}
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none invisible absolute left-0 top-0 flex items-center gap-1 whitespace-nowrap"
|
||||
>
|
||||
{suggestions.map((s, i) => (
|
||||
<span
|
||||
key={s.tag.id}
|
||||
ref={(el) => {
|
||||
chipRefs.current[i] = el;
|
||||
}}
|
||||
className="inline-flex"
|
||||
>
|
||||
<SuggestedChip suggestion={s} onAccept={() => {}} onDismiss={() => {}} />
|
||||
</span>
|
||||
))}
|
||||
<span ref={pillRef} className={GHOST_PILL}>
|
||||
+{suggestions.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div ref={rowRef} className="flex items-center gap-1 min-w-0 flex-1 overflow-hidden">
|
||||
{suggestions.slice(0, visible).map((s) => (
|
||||
<SuggestedChip
|
||||
key={s.tag.id}
|
||||
suggestion={s}
|
||||
onAccept={() => onAccept(s.tag.id)}
|
||||
onDismiss={() => onDismiss(s.tag.id)}
|
||||
/>
|
||||
))}
|
||||
{hidden > 0 && (
|
||||
<button
|
||||
ref={overflowPop.refs.setReference}
|
||||
type="button"
|
||||
aria-label={t("channels.tags.moreCount", { count: hidden })}
|
||||
className={`${GHOST_PILL} hover:text-accent transition`}
|
||||
{...overflowPop.getReferenceProps()}
|
||||
>
|
||||
+{hidden}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Hover: the overflowed suggestions, each still one-tap accept / × dismiss. */}
|
||||
<PopoverSurface
|
||||
popover={overflowPop}
|
||||
className="glass-menu max-w-[18rem] rounded-xl p-1.5 flex flex-wrap items-center gap-1 animate-[popIn_0.16s_ease]"
|
||||
>
|
||||
{suggestions.slice(visible).map((s) => (
|
||||
<SuggestedChip
|
||||
key={s.tag.id}
|
||||
suggestion={s}
|
||||
onAccept={() => onAccept(s.tag.id)}
|
||||
onDismiss={() => onDismiss(s.tag.id)}
|
||||
/>
|
||||
))}
|
||||
</PopoverSurface>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The editor body: toggle the user's existing tags on/off for this channel, plus an inline
|
||||
* "create + attach" input. Rendered inside an anchored `menu` popover. (Exported for the channel
|
||||
* detail page in Part 2; internal for now.) */
|
||||
@@ -128,15 +281,18 @@ 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,
|
||||
onToggle,
|
||||
onCreate,
|
||||
onChipClick,
|
||||
suggestions,
|
||||
onAcceptSuggestion,
|
||||
onDismissSuggestion,
|
||||
bounded = true,
|
||||
}: {
|
||||
userTags: Tag[];
|
||||
@@ -145,6 +301,11 @@ export function ChannelTagsControl({
|
||||
onCreate: (name: string) => void;
|
||||
/** Manager only: clicking a chip filters the feed by that tag. Omit on the detail page. */
|
||||
onChipClick?: (tagId: number, name: string) => void;
|
||||
/** Manager only (E8·S4): suggested user tags for an UNTAGGED channel, shown as accept/dismiss ghost
|
||||
* chips after the (empty) attached row. Omit on the detail page. */
|
||||
suggestions?: SuggestedTag[];
|
||||
onAcceptSuggestion?: (tagId: number) => void;
|
||||
onDismissSuggestion?: (tagId: number) => void;
|
||||
/** Cap the chip row at a fixed 12rem so it can't widen a `table-layout:auto` column and reflow the
|
||||
* table (the manager). Set false where there is free width (the channel detail header), so tags
|
||||
* fit inline instead of needlessly collapsing into "+N". */
|
||||
@@ -152,64 +313,30 @@ export function ChannelTagsControl({
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const attached = userTags.filter((tg) => attachedIds.includes(tg.id));
|
||||
// Suggestions only surface for channels with no user tag yet, but guard anyway: never suggest a tag
|
||||
// the channel already carries (an optimistic accept adds it before the query refetches).
|
||||
const shownSuggestions = (suggestions ?? []).filter((s) => !attachedIds.includes(s.tag.id));
|
||||
|
||||
// Deterministic single-line fit: a hidden measurer lays out ALL chips + a sample "+N" pill at their
|
||||
// natural widths; we read each chip's right edge and compute EXACTLY how many fit, reserving the
|
||||
// pill's width whenever any overflow. One pass (no iterative re-render), so the "+N" count is always
|
||||
// right as tags are added/removed. Re-measures on width/scale change via ResizeObserver — off real
|
||||
// rem boxes, never a px guess.
|
||||
// natural widths; the shared measure core (useChipOverflowFit) reads each chip's right edge, the rem
|
||||
// gap and the pill width off real boxes, and fitChipsWithPill computes EXACTLY how many fit (reserving
|
||||
// the pill whenever any overflow). One pass — the count is right as tags are added/removed/renamed —
|
||||
// and it re-measures on width/scale change via the hook's ResizeObserver.
|
||||
const rowRef = useRef<HTMLDivElement | null>(null);
|
||||
const measureRef = useRef<HTMLDivElement | null>(null);
|
||||
const [visible, setVisible] = useState(attached.length);
|
||||
const chipRefs = useRef<Array<HTMLElement | null>>([]);
|
||||
const pillRef = useRef<HTMLSpanElement | null>(null);
|
||||
// Key on id AND name so a RENAME (which changes a chip's width) re-runs the fit, not just add/remove.
|
||||
const tagsKey = attached.map((tg) => `${tg.id}:${tg.name}`).join("|");
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const row = rowRef.current;
|
||||
const measure = measureRef.current;
|
||||
if (!row || !measure) return;
|
||||
const compute = () => {
|
||||
const nodes = Array.from(measure.children) as HTMLElement[];
|
||||
const pill = nodes[nodes.length - 1];
|
||||
if (!pill) return;
|
||||
const pillW = pill.offsetWidth;
|
||||
const chips = nodes.slice(0, -1);
|
||||
// Measure the real rendered gap (gap-1 is rem — it scales with the font slider) off the box
|
||||
// between the last chip and the sample pill, rather than assuming a px constant.
|
||||
const lastChip = chips[chips.length - 1];
|
||||
const gap = lastChip
|
||||
? Math.max(0, pill.offsetLeft - (lastChip.offsetLeft + lastChip.offsetWidth))
|
||||
: 0;
|
||||
const avail = row.clientWidth;
|
||||
// Whole-set fast path: if the LAST chip's right edge already fits, every chip fits and no "+N"
|
||||
// pill is needed. The reservation loop below reserves the pill's width for every chip except
|
||||
// the last and breaks at the first overflow, so on its own it never reaches the only "does the
|
||||
// whole set fit?" test — it would collapse chips that would have fitted (e.g. a short trailing
|
||||
// chip narrower than the pill), leaving "+N" beside empty space.
|
||||
const lastRight = lastChip ? lastChip.offsetLeft + lastChip.offsetWidth : 0;
|
||||
if (lastRight <= avail) {
|
||||
setVisible(chips.length);
|
||||
return;
|
||||
}
|
||||
let count = chips.length;
|
||||
for (let i = 0; i < chips.length; i++) {
|
||||
const chip = chips[i];
|
||||
if (!chip) break;
|
||||
const right = chip.offsetLeft + chip.offsetWidth; // right edge within the measurer
|
||||
const moreAfter = i < chips.length - 1;
|
||||
if (right > avail - (moreAfter ? pillW + gap : 0)) {
|
||||
count = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
setVisible(count);
|
||||
};
|
||||
compute();
|
||||
if (typeof ResizeObserver === "undefined") return;
|
||||
const ro = new ResizeObserver(compute);
|
||||
ro.observe(row);
|
||||
return () => ro.disconnect();
|
||||
}, [tagsKey]);
|
||||
const visible = useChipOverflowFit<number>({
|
||||
rowRef,
|
||||
chipsRef: chipRefs,
|
||||
pillRef,
|
||||
deps: tagsKey,
|
||||
initial: attached.length,
|
||||
decide: (g) =>
|
||||
fitChipsWithPill({ chipRights: g.chipRights, pillW: g.pillW, avail: g.avail, gap: g.gap }),
|
||||
});
|
||||
|
||||
const hidden = attached.length - visible;
|
||||
|
||||
@@ -249,50 +376,86 @@ export function ChannelTagsControl({
|
||||
<div
|
||||
className={`relative flex items-center gap-1 min-w-0 ${bounded ? "w-48 max-w-full" : "w-full"}`}
|
||||
>
|
||||
{/* Hidden measurer: all chips + a sample pill, laid out unbounded to read their true widths. */}
|
||||
<div
|
||||
ref={measureRef}
|
||||
aria-hidden
|
||||
className="pointer-events-none invisible absolute left-0 top-0 flex items-center gap-1 whitespace-nowrap"
|
||||
>
|
||||
{attached.map((tg) => (
|
||||
<TagChip key={tg.id} tag={tg} />
|
||||
))}
|
||||
<span className={pillClass}>+{attached.length}</span>
|
||||
</div>
|
||||
|
||||
<div ref={rowRef} className="flex items-center gap-1 min-w-0 flex-1 overflow-hidden">
|
||||
{attached.slice(0, visible).map((tg) => (
|
||||
<TagChip
|
||||
key={tg.id}
|
||||
tag={tg}
|
||||
onClick={onChipClick ? () => onChipClick(tg.id, tg.name) : undefined}
|
||||
title={onChipClick ? t("channels.row.filterFeedByTag", { name: tg.name }) : undefined}
|
||||
/>
|
||||
))}
|
||||
{hidden > 0 && (
|
||||
<button
|
||||
ref={overflowPop.refs.setReference}
|
||||
type="button"
|
||||
className={`${pillClass} hover:text-fg transition`}
|
||||
{...overflowPop.getReferenceProps()}
|
||||
>
|
||||
+{hidden}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 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). */}
|
||||
<button
|
||||
ref={editPop.refs.setReference}
|
||||
type="button"
|
||||
title={t("channels.row.editTags")}
|
||||
aria-label={t("channels.row.editTags")}
|
||||
className="shrink-0 w-5 h-5 inline-flex items-center justify-center rounded-full border border-dashed border-border text-muted hover:text-accent hover:border-accent transition"
|
||||
className={
|
||||
bounded
|
||||
? "shrink-0 w-5 h-5 inline-flex items-center justify-center rounded-full border border-dashed border-border text-muted hover:text-accent hover:border-accent transition"
|
||||
: "shrink-0 inline-flex items-center gap-1 h-6 pl-1.5 pr-2 rounded-full border border-dashed border-border text-2xs font-medium text-muted hover:text-accent hover:border-accent transition"
|
||||
}
|
||||
{...editPop.getReferenceProps()}
|
||||
>
|
||||
<Plus className="w-3 h-3" />
|
||||
<Plus className="w-3 h-3 shrink-0" />
|
||||
{!bounded && <span className="whitespace-nowrap">{t("channels.tags.add")}</span>}
|
||||
</button>
|
||||
|
||||
{/* 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
|
||||
never shows both, and each lane gets the full cell width instead of clipping the other. */}
|
||||
{attached.length === 0 && shownSuggestions.length > 0 ? (
|
||||
<SuggestedChipsLane
|
||||
suggestions={shownSuggestions}
|
||||
onAccept={(tagId) => onAcceptSuggestion?.(tagId)}
|
||||
onDismiss={(tagId) => onDismissSuggestion?.(tagId)}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{/* Hidden measurer: all chips + a sample pill, laid out unbounded to read their true widths.
|
||||
Each chip is wrapped in a ref span (index-aligned) so the shared fit hook can read its
|
||||
right edge; the pill is the sample the hook reserves room for. */}
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none invisible absolute left-0 top-0 flex items-center gap-1 whitespace-nowrap"
|
||||
>
|
||||
{attached.map((tg, i) => (
|
||||
<span
|
||||
key={tg.id}
|
||||
ref={(el) => {
|
||||
chipRefs.current[i] = el;
|
||||
}}
|
||||
className="inline-flex"
|
||||
>
|
||||
<TagChip tag={tg} />
|
||||
</span>
|
||||
))}
|
||||
<span ref={pillRef} className={pillClass}>
|
||||
+{attached.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div ref={rowRef} className="flex items-center gap-1 min-w-0 flex-1 overflow-hidden">
|
||||
{attached.slice(0, visible).map((tg) => (
|
||||
<TagChip
|
||||
key={tg.id}
|
||||
tag={tg}
|
||||
onClick={onChipClick ? () => onChipClick(tg.id, tg.name) : undefined}
|
||||
title={
|
||||
onChipClick ? t("channels.row.filterFeedByTag", { name: tg.name }) : undefined
|
||||
}
|
||||
/>
|
||||
))}
|
||||
{hidden > 0 && (
|
||||
<button
|
||||
ref={overflowPop.refs.setReference}
|
||||
type="button"
|
||||
aria-label={t("channels.tags.moreCount", { count: hidden })}
|
||||
className={`${pillClass} hover:text-fg transition`}
|
||||
{...overflowPop.getReferenceProps()}
|
||||
>
|
||||
+{hidden}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Hover: reveal every attached tag when some are collapsed into "+N". */}
|
||||
<PopoverSurface
|
||||
popover={overflowPop}
|
||||
|
||||
@@ -4,7 +4,8 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { Ban, Eye, EyeOff, History, LayoutGrid, Pencil, RefreshCw, Table, X } from "lucide-react";
|
||||
import ExcludedChannelList from "./ExcludedChannelList";
|
||||
import { api, type ManagedChannel } from "../lib/api";
|
||||
import { api, NO_TAG_SENTINEL, type ManagedChannel } from "../lib/api";
|
||||
import { Sparkles } from "lucide-react";
|
||||
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
|
||||
import { accountKey, LS } from "../lib/storage";
|
||||
import { buildChannelColumns, applySyncFilter } from "./channelColumns";
|
||||
@@ -15,10 +16,12 @@ import Tabs, { type TabDef } from "./Tabs";
|
||||
import ChannelLayoutGrid from "./ChannelLayoutGrid";
|
||||
import { LoadingState, StateMessage } from "./QueryState";
|
||||
import { useChannelTagActions } from "../lib/useChannelTagActions";
|
||||
import { useTagSuggestions } from "./useTagSuggestions";
|
||||
import { useChannelPriority } from "../lib/useChannelPriority";
|
||||
import ViewSwitcher, { type ViewOption } from "./ViewSwitcher";
|
||||
import ColumnSortControl from "./ColumnSortControl";
|
||||
import FilterChipGroup, { filterChipClass } from "./FilterChipGroup";
|
||||
import FilterChipGroup from "./FilterChipGroup";
|
||||
import TagFilterChips, { type FilterChip } from "./TagFilterChips";
|
||||
import ActionMenu, { type ActionMenuItem } from "./ActionMenu";
|
||||
import ChannelStatsPopover from "./ChannelStatsPopover";
|
||||
import Pager from "./Pager";
|
||||
@@ -164,10 +167,37 @@ export default function Channels() {
|
||||
),
|
||||
),
|
||||
// Keep the channel DETAIL cache in step with the manager list (the detail page reads its tags
|
||||
// from qk.channel(id)); mirrors the detail page invalidating the manager list on its side.
|
||||
onSuccess: (id) => qc.invalidateQueries({ queryKey: qk.channel(id) }),
|
||||
onError: () => qc.invalidateQueries({ queryKey: qk.channels() }),
|
||||
// from qk.channel(id)); mirrors the detail page invalidating the manager list on its side. Also
|
||||
// recompute suggestions: a channel that just gained/lost a user tag changes the untagged set and
|
||||
// the learned pattern (E8·S4).
|
||||
onSuccess: (id) => {
|
||||
qc.invalidateQueries({ queryKey: qk.channel(id) });
|
||||
qc.invalidateQueries({ queryKey: qk.tagSuggestions() });
|
||||
},
|
||||
// A failed attach/detach reverts the optimistic change by refetching the list. Also refetch
|
||||
// suggestions: acceptSuggestion optimistically dropped the channel from that cache, and on
|
||||
// failure the tag never actually attached — so the server still suggests it and the ghost chips
|
||||
// must come back rather than leaving an empty cell (honest states). Surface the write failure too.
|
||||
onError: () => {
|
||||
qc.invalidateQueries({ queryKey: qk.channels() });
|
||||
qc.invalidateQueries({ queryKey: qk.tagSuggestions() });
|
||||
notify({ level: "error", message: t("channels.tags.updateFailed") });
|
||||
},
|
||||
});
|
||||
// --- Suggested tags (E8·S4): learned from the user's own manual-tag pattern, shown as accept/dismiss
|
||||
// ghost chips on untagged channels. Dismissal is session-only (an accepted suggestion self-clears —
|
||||
// the channel becomes tagged and the server drops it; a dismissed one just hides until reload). The
|
||||
// /tags/suggestions query, the session dismiss set, and the optimistic accept/dismiss/bulk logic all
|
||||
// live in useTagSuggestions so they can be unit-tested apart from this component.
|
||||
const {
|
||||
suggestionsFor,
|
||||
dismissSuggestion,
|
||||
acceptSuggestion,
|
||||
visibleSuggestions,
|
||||
highConfidence,
|
||||
acceptAllHighConfidence,
|
||||
} = useTagSuggestions({ tags: tagsQuery.data ?? [], toggleTag });
|
||||
|
||||
const syncSubs = useMutation({
|
||||
mutationFn: () => api.syncSubscriptions(),
|
||||
onSuccess: (r: { subscriptions?: number }) => {
|
||||
@@ -217,11 +247,34 @@ export default function Channels() {
|
||||
// funnel share ONE state and stay in sync (bidirectional). Toggling a chip rewrites `filters.tags`;
|
||||
// filterRows / DataTable then applies it. Channel-name search stays separate (the shared top box).
|
||||
const tagFilter = Array.isArray(filters.tags) ? filters.tags : [];
|
||||
// Picking a real tag drops the "No tag" sentinel (item B) — they're mutually exclusive, as in the feed.
|
||||
const toggleTagFilter = (tagId: number) => {
|
||||
const id = String(tagId);
|
||||
const next = tagFilter.includes(id) ? tagFilter.filter((x) => x !== id) : [...tagFilter, id];
|
||||
const base = tagFilter.filter((x) => x !== NO_TAG_SENTINEL);
|
||||
const next = base.includes(id) ? base.filter((x) => x !== id) : [...base, id];
|
||||
setFilters({ ...filters, tags: next });
|
||||
};
|
||||
// The "No tag" chip: filter the table to channels you haven't personally tagged yet. Exclusive with
|
||||
// real tag chips (clicking it drops them); the Tags column's test reads the sentinel (channelColumns).
|
||||
const noTagOn = tagFilter.includes(NO_TAG_SENTINEL);
|
||||
const toggleNoTagFilter = () =>
|
||||
setFilters({ ...filters, tags: noTagOn ? [] : [NO_TAG_SENTINEL] });
|
||||
// The tag-filter chips (user tags + the "No tag" sentinel), fed to TagFilterChips which folds any
|
||||
// overflow into a "+N" pill so the row stays one line and the trailing "…" actions can't wrap.
|
||||
const tagFilterChips: FilterChip[] = [
|
||||
...userTags.map((tg) => ({
|
||||
key: `t${tg.id}`,
|
||||
label: tg.name,
|
||||
active: tagFilter.includes(String(tg.id)),
|
||||
onToggle: () => toggleTagFilter(tg.id),
|
||||
})),
|
||||
{
|
||||
key: NO_TAG_SENTINEL,
|
||||
label: t("channels.tags.noTag"),
|
||||
active: noTagOn,
|
||||
onToggle: toggleNoTagFilter,
|
||||
},
|
||||
];
|
||||
// A focus-channel intent (header "without full history" / tag manager) seeds the search box.
|
||||
useEffect(() => {
|
||||
if (focusChannelName) setSearch(focusChannelName);
|
||||
@@ -295,6 +348,9 @@ export default function Channels() {
|
||||
toggleTag,
|
||||
createAndAttachTag,
|
||||
onFilterByTag,
|
||||
suggestionsFor,
|
||||
onAcceptSuggestion: acceptSuggestion,
|
||||
onDismissSuggestion: dismissSuggestion,
|
||||
});
|
||||
|
||||
// Status chips sit in the table's controls row (next to paging) — compact and close to
|
||||
@@ -426,41 +482,52 @@ export default function Channels() {
|
||||
{tabs}
|
||||
{view === "subscribed" && (
|
||||
<div className="px-4 pt-2 pb-2 max-w-384 mx-auto">
|
||||
{/* Your tags (left) share one row with the two catalog-wide actions (right); the channel-name
|
||||
search lives in the shared top SearchBar, tag create/delete in the manager. */}
|
||||
<div className="flex items-center justify-between gap-4 flex-wrap">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{/* The "TAGS" label IS the tags-editor trigger (pencil), replacing the separate
|
||||
"Manage tags" button; the tooltip explains what tags are + that it's clickable. */}
|
||||
<Tooltip hint={t("channels.tags.yourTagsHint")}>
|
||||
<button
|
||||
onClick={() => setTagManagerOpen(true)}
|
||||
className="mr-1 inline-flex items-center gap-1.5 rounded-lg border border-border px-2 py-1 text-xs uppercase tracking-wide text-muted transition hover:border-accent hover:text-accent"
|
||||
>
|
||||
<Pencil className="h-3 w-3" />
|
||||
{t("channels.tags.label")}
|
||||
</button>
|
||||
</Tooltip>
|
||||
{userTags.map((tg) => (
|
||||
<button
|
||||
key={tg.id}
|
||||
onClick={() => toggleTagFilter(tg.id)}
|
||||
aria-pressed={tagFilter.includes(String(tg.id))}
|
||||
className={filterChipClass}
|
||||
>
|
||||
{tg.name}
|
||||
</button>
|
||||
))}
|
||||
{tagFilter.length > 0 && (
|
||||
<button
|
||||
onClick={() => setFilters({ ...filters, tags: [] })}
|
||||
className="text-xs px-2 py-1 text-muted hover:text-accent transition"
|
||||
>
|
||||
{t("datatable.clear")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{actionButtons}
|
||||
{/* One non-wrapping row: the TAGS editor trigger (left) + the tag-filter chip lane (folds
|
||||
overflow into "+N") + Clear, then the catalog "…" actions pinned right. It never wraps, so the
|
||||
"…" menu always stays on the first line. Channel-name search lives in the shared top SearchBar. */}
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
{/* The "TAGS" label IS the tags-editor trigger (pencil), replacing the separate
|
||||
"Manage tags" button; the tooltip explains what tags are + that it's clickable. */}
|
||||
<Tooltip hint={t("channels.tags.yourTagsHint")}>
|
||||
<button
|
||||
onClick={() => setTagManagerOpen(true)}
|
||||
className="shrink-0 inline-flex items-center gap-1.5 rounded-lg border border-border px-2 py-1 text-xs uppercase tracking-wide text-muted transition hover:border-accent hover:text-accent"
|
||||
>
|
||||
<Pencil className="h-3 w-3" />
|
||||
{t("channels.tags.label")}
|
||||
</button>
|
||||
</Tooltip>
|
||||
<TagFilterChips chips={tagFilterChips} className="flex-1" />
|
||||
{tagFilter.length > 0 && (
|
||||
<button
|
||||
onClick={() => setFilters({ ...filters, tags: [] })}
|
||||
className="shrink-0 text-xs px-2 py-1 text-muted hover:text-accent transition"
|
||||
>
|
||||
{t("datatable.clear")}
|
||||
</button>
|
||||
)}
|
||||
{/* Suggested-tags affordance (E8·S4): a sparkle count of the visible suggestions, with a
|
||||
one-click bulk-accept for the high-confidence ones. The per-row ghost chips handle the
|
||||
rest. Hidden when there's nothing to suggest. */}
|
||||
{visibleSuggestions.length > 0 && (
|
||||
<div className="shrink-0 flex items-center gap-1.5">
|
||||
<Tooltip hint={t("channels.tags.suggestionsHint")}>
|
||||
<span className="inline-flex items-center gap-1 rounded-lg border border-dashed border-accent/50 px-2 py-1 text-xs text-accent/90">
|
||||
<Sparkles className="h-3 w-3" />
|
||||
{t("channels.tags.suggestionsCount", { count: visibleSuggestions.length })}
|
||||
</span>
|
||||
</Tooltip>
|
||||
{highConfidence.length > 0 && (
|
||||
<button
|
||||
onClick={acceptAllHighConfidence}
|
||||
className="text-xs px-2 py-1 rounded-lg border border-border text-muted hover:border-accent hover:text-accent transition"
|
||||
>
|
||||
{t("channels.tags.acceptAllHigh", { count: highConfidence.length })}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="shrink-0">{actionButtons}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -17,6 +17,7 @@ vi.mock("./NavigationProvider", () => ({
|
||||
}));
|
||||
|
||||
import { ChannelsProvider, useChannels, useChannelsActions } from "./ChannelsProvider";
|
||||
import { setActiveAccount } from "../lib/api/core";
|
||||
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<ChannelsProvider>{children}</ChannelsProvider>
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string | null>(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 (
|
||||
<ActionsContext.Provider value={actions}>
|
||||
<LayoutContext.Provider value={layout}>
|
||||
|
||||
@@ -151,6 +151,7 @@ export default function DateFilterSelect({
|
||||
...filters,
|
||||
dateFrom: e.target.value || undefined,
|
||||
maxAgeDays: undefined,
|
||||
year: undefined,
|
||||
})
|
||||
}
|
||||
className={dateInputCls}
|
||||
@@ -169,6 +170,7 @@ export default function DateFilterSelect({
|
||||
...filters,
|
||||
dateTo: e.target.value || undefined,
|
||||
maxAgeDays: undefined,
|
||||
year: undefined,
|
||||
})
|
||||
}
|
||||
className={dateInputCls}
|
||||
|
||||
@@ -19,14 +19,6 @@ describe("loadAccountFilters — legacy tagMode migration on seed", () => {
|
||||
expect(f.q).toBe("hi"); // other fields preserved through the merge
|
||||
});
|
||||
|
||||
it("migrates a legacy tagMode:'and' from the default-view mirror when no last-session blob exists", () => {
|
||||
localStorage.setItem(
|
||||
accountKey(LS.defaultViewFilters, ID)!,
|
||||
JSON.stringify({ tagMode: "and" }),
|
||||
);
|
||||
expect(loadAccountFilters(ID).tagModes.topic).toBe("and");
|
||||
});
|
||||
|
||||
it("leaves tagModes at defaults when nothing is stored", () => {
|
||||
expect(loadAccountFilters(ID).tagModes).toEqual({ language: "or", topic: "or", other: "or" });
|
||||
});
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
api,
|
||||
getActiveAccount,
|
||||
coerceTagModes,
|
||||
coerceUntagged,
|
||||
DEFAULT_TAG_MODES,
|
||||
type FeedFilters,
|
||||
} from "../lib/api";
|
||||
@@ -39,6 +40,7 @@ import { useNavigation } from "./NavigationProvider";
|
||||
const DEFAULT_FILTERS: FeedFilters = {
|
||||
tags: [],
|
||||
tagModes: DEFAULT_TAG_MODES,
|
||||
untagged: false,
|
||||
channelIds: [],
|
||||
q: "",
|
||||
sort: "newest",
|
||||
@@ -51,16 +53,16 @@ const DEFAULT_FILTERS: FeedFilters = {
|
||||
|
||||
const FILTERS_KEY = LS.filters;
|
||||
|
||||
// This account's own persisted filters (per-account keys — never another account's). A chosen
|
||||
// default saved view wins over the last-session filters (SavedViewsWidget mirrors it here). When
|
||||
// the account isn't known yet (first load, before the tab is pinned), start from defaults; the
|
||||
// post-login effect loads the real ones once the id arrives.
|
||||
// This account's own persisted filters (per-account keys — never another account's). When the
|
||||
// account isn't known yet (first load, before the tab is pinned), start from defaults; the post-login
|
||||
// effect loads the real ones once the id arrives.
|
||||
export function loadAccountFilters(id: number | null): FeedFilters {
|
||||
// Your last-applied filters win, so a reload keeps whatever view you're on (a saved view you
|
||||
// picked, or manual tweaks) — setFilters persists them under LS.filters on every change. The
|
||||
// starred default view only SEEDS a fresh account that has never stored filters: it drives the
|
||||
// very first load, after which that state persists like any other. (Re-apply the default view
|
||||
// from the sidebar to return to it.)
|
||||
// picked, or manual tweaks) — setFilters persists them under LS.filters on every change. A starred
|
||||
// default view seeds a fresh account that has never stored filters, but that is applied ONCE by
|
||||
// SavedViewsProvider on its views query's first resolve (which then persists here like any edit) —
|
||||
// not read from a mirror on this synchronous path, which used to race that resolve. So here we only
|
||||
// ever read the account's own stored filters, else fall back to DEFAULT_FILTERS.
|
||||
const fKey = accountKey(FILTERS_KEY, id);
|
||||
let hasStored = false;
|
||||
try {
|
||||
@@ -68,18 +70,18 @@ export function loadAccountFilters(id: number | null): FeedFilters {
|
||||
} catch {
|
||||
/* localStorage may be unavailable */
|
||||
}
|
||||
// Migrate a persisted legacy global `tagMode` into the per-category `tagModes` on every seed path,
|
||||
// matching what the saved-view (asFilters) and URL (paramsToFilters) paths already do. coerceTagModes
|
||||
// MUST run on the RAW stored blob, not the object merged over DEFAULT_FILTERS: the merge injects
|
||||
// DEFAULT_TAG_MODES (all "or"), which coerceTagModes prefers over the legacy scalar and would mask it.
|
||||
// Migrate a persisted legacy global `tagMode` into the per-category `tagModes`, matching what the
|
||||
// saved-view (asFilters) and URL (paramsToFilters) paths already do. coerceTagModes MUST run on the
|
||||
// RAW stored blob, not the object merged over DEFAULT_FILTERS: the merge injects DEFAULT_TAG_MODES
|
||||
// (all "or"), which coerceTagModes prefers over the legacy scalar and would mask it.
|
||||
if (hasStored && fKey) {
|
||||
const raw = readJSON<Partial<FeedFilters>>(fKey, {});
|
||||
return { ...DEFAULT_FILTERS, ...raw, tagModes: coerceTagModes(raw) };
|
||||
}
|
||||
const dvKey = accountKey(LS.defaultViewFilters, id);
|
||||
const def = dvKey ? readJSON<Partial<FeedFilters> | null>(dvKey, null) : null;
|
||||
if (def) {
|
||||
return { ...DEFAULT_FILTERS, ...def, tagModes: coerceTagModes(def) };
|
||||
return {
|
||||
...DEFAULT_FILTERS,
|
||||
...raw,
|
||||
tagModes: coerceTagModes(raw),
|
||||
untagged: coerceUntagged(raw),
|
||||
};
|
||||
}
|
||||
return { ...DEFAULT_FILTERS };
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The scroll-restoration contract PageScroller implements: offsets are remembered per
|
||||
// (history entry id + content key), so Back to an entry restores where you left it while a forward
|
||||
// navigation (a new entry id) or a new content key opens at the top. jsdom lays nothing out, so we
|
||||
// feed the <main> fake metrics, drive restoreScroll's rAF loop synchronously, and steer the entry id
|
||||
// through window.history.state (what ensureNavId/peekNavId read) — firing the nav bus (notifyNav)
|
||||
// stands in for a Back/Forward that doesn't re-render the scroller.
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from "vitest";
|
||||
import { render, fireEvent, cleanup } from "@testing-library/react";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return { ...actual, useTranslation: () => ({ t: (k: string) => k }) };
|
||||
});
|
||||
|
||||
import PageScroller from "./PageScroller";
|
||||
import { notifyNav } from "../lib/history";
|
||||
|
||||
beforeAll(() => {
|
||||
// useScrollFade wires a ResizeObserver (watch-the-children); jsdom ships none.
|
||||
class Stub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
// restoreScroll re-applies the offset over a few rAF frames (the feed needs time to reach height);
|
||||
// run rAF synchronously so a restore lands within the test tick.
|
||||
vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => {
|
||||
cb(0);
|
||||
return 0;
|
||||
});
|
||||
vi.stubGlobal("cancelAnimationFrame", () => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function setEntry(id: number) {
|
||||
window.history.replaceState({ sfScroll: id }, "");
|
||||
}
|
||||
|
||||
function sizedMain(container: HTMLElement): HTMLElement {
|
||||
const main = container.querySelector("main")!;
|
||||
// Enough scrollable room for a 300px offset (restoreScroll only sets when scrollHeight-clientHeight
|
||||
// >= the target), and a writable scrollTop jsdom otherwise no-ops.
|
||||
Object.defineProperty(main, "scrollHeight", { configurable: true, value: 1000 });
|
||||
Object.defineProperty(main, "clientHeight", { configurable: true, value: 500 });
|
||||
Object.defineProperty(main, "scrollTop", { configurable: true, writable: true, value: 0 });
|
||||
return main;
|
||||
}
|
||||
|
||||
describe("PageScroller scroll memory", () => {
|
||||
it("restores per history entry: a forward nav opens at the top, Back restores the offset", () => {
|
||||
setEntry(1);
|
||||
const { container } = render(<PageScroller scrollKey="e:k1">x</PageScroller>);
|
||||
const main = sizedMain(container);
|
||||
|
||||
// The user scrolls entry 1 to 200 — recorded live on scroll.
|
||||
main.scrollTop = 200;
|
||||
fireEvent.scroll(main);
|
||||
|
||||
// Forward navigation → a fresh entry id, same content key → the view opens at the top.
|
||||
setEntry(2);
|
||||
notifyNav();
|
||||
expect(main.scrollTop).toBe(0);
|
||||
|
||||
// Back to entry 1 → its saved offset is restored.
|
||||
setEntry(1);
|
||||
notifyNav();
|
||||
expect(main.scrollTop).toBe(200);
|
||||
});
|
||||
|
||||
it("restores per content key within one entry: a new key resets to top, returning restores", () => {
|
||||
setEntry(10);
|
||||
const { container, rerender } = render(<PageScroller scrollKey="e10:a">x</PageScroller>);
|
||||
const main = sizedMain(container);
|
||||
|
||||
main.scrollTop = 300;
|
||||
fireEvent.scroll(main);
|
||||
|
||||
// A new content key (e.g. a fresh feed filter) in the SAME entry starts at the top.
|
||||
rerender(<PageScroller scrollKey="e10:b">x</PageScroller>);
|
||||
expect(main.scrollTop).toBe(0);
|
||||
|
||||
// Returning to the first key restores its offset.
|
||||
rerender(<PageScroller scrollKey="e10:a">x</PageScroller>);
|
||||
expect(main.scrollTop).toBe(300);
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown>) => true),
|
||||
saveCurrent: vi.fn(async (_id: number) => ({ saved: true }) as Record<string, unknown> | 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<typeof import("react-i18next")>();
|
||||
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<string, unknown>) => ({
|
||||
...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(
|
||||
<QueryClientProvider client={qc}>
|
||||
<PersonalViewsModal />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
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(
|
||||
<QueryClientProvider client={qc}>
|
||||
<PersonalViewsModal />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
// Undo history survived the refetch — the control is still there and still re-persists on click.
|
||||
const undo = await screen.findByLabelText("common.undo");
|
||||
fireEvent.click(undo);
|
||||
await waitFor(() => expect(h.updateFilters).toHaveBeenCalledTimes(2));
|
||||
expect(h.updateFilters.mock.calls[1]![1]).toMatchObject({ tags: [1] });
|
||||
});
|
||||
|
||||
it("Save-current stores the live filters into the view when it isn't already the active one", async () => {
|
||||
mount();
|
||||
fireEvent.click(await screen.findByText("views.saveCurrent"));
|
||||
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"));
|
||||
});
|
||||
});
|
||||
@@ -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<number | null>(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<Record<string, unknown>>(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<string, unknown>);
|
||||
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<PlexFilters>) =>
|
||||
filtersDraft.set({
|
||||
...coercePlexFilters(filtersDraft.value),
|
||||
...p,
|
||||
} as unknown as Record<string, unknown>);
|
||||
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 (
|
||||
<Modal title={t("views.manageTitle")} onClose={closeModal} maxWidth="max-w-2xl">
|
||||
<div className="grid min-h-[20rem] grid-cols-[minmax(0,14rem)_1fr] gap-4">
|
||||
@@ -148,15 +196,15 @@ export default function PersonalViewsModal() {
|
||||
</div>
|
||||
<div className="mt-2 flex items-center gap-1.5 border-t border-border/60 pt-2">
|
||||
<input
|
||||
value={draftName}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<button
|
||||
onClick={onCreate}
|
||||
disabled={!draftName.trim()}
|
||||
disabled={!createName.trim()}
|
||||
title={t("views.save")}
|
||||
className="inline-flex shrink-0 items-center gap-1 rounded-lg btn-primary px-2.5 py-1.5 text-sm font-semibold disabled:opacity-40"
|
||||
>
|
||||
@@ -168,18 +216,37 @@ export default function PersonalViewsModal() {
|
||||
{/* Right: the selected view's detail */}
|
||||
{selected ? (
|
||||
<div className="flex flex-col gap-3">
|
||||
<input
|
||||
key={selected.id}
|
||||
defaultValue={selected.name}
|
||||
onBlur={(e) => {
|
||||
const next = e.target.value.trim();
|
||||
if (next && next !== selected.name) void rename(selected.id, next);
|
||||
}}
|
||||
className="rounded-lg border border-border bg-surface px-3 py-2 text-sm font-medium focus:border-accent focus:outline-hidden focus:ring-2 focus:ring-accent/30"
|
||||
/>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<input
|
||||
value={nameDraft}
|
||||
onChange={(e) => setNameDraft(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && void onRename()}
|
||||
onBlur={() => void onRename()}
|
||||
className="min-w-0 flex-1 rounded-lg border border-border bg-surface px-3 py-2 text-sm font-medium focus:border-accent focus:outline-hidden focus:ring-2 focus:ring-accent/30"
|
||||
/>
|
||||
<button
|
||||
onClick={() => void onRename()}
|
||||
disabled={!nameChanged}
|
||||
title={t("views.rename")}
|
||||
aria-label={t("views.rename")}
|
||||
className="inline-flex shrink-0 items-center rounded-lg border border-border p-2 text-muted transition enabled:hover:border-accent enabled:hover:text-accent disabled:opacity-30"
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-1.5 text-2xs font-semibold uppercase tracking-wider text-muted">
|
||||
{t("views.capturedFilters")}
|
||||
<div className="mb-1.5 flex items-center justify-between gap-2">
|
||||
<span className="text-2xs font-semibold uppercase tracking-wider text-muted">
|
||||
{t("views.capturedFilters")}
|
||||
</span>
|
||||
{(filtersDraft.canUndo || filtersDraft.canRedo) && (
|
||||
<UndoToolbar
|
||||
canUndo={filtersDraft.canUndo}
|
||||
canRedo={filtersDraft.canRedo}
|
||||
onUndo={filtersDraft.undo}
|
||||
onRedo={filtersDraft.redo}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{captured.length === 0 ? (
|
||||
<p className="text-xs text-muted">{t("views.capturedNone")}</p>
|
||||
@@ -188,15 +255,23 @@ export default function PersonalViewsModal() {
|
||||
{captured.map((f) => (
|
||||
<span
|
||||
key={f.key}
|
||||
className="rounded-full bg-accent/10 px-2 py-0.5 text-2xs text-accent"
|
||||
className="group inline-flex items-center gap-1 rounded-full bg-accent/10 py-0.5 pl-2 pr-1 text-2xs text-accent"
|
||||
>
|
||||
{f.label}
|
||||
<button
|
||||
onClick={f.remove}
|
||||
title={t("feed.chips.remove", { name: f.label })}
|
||||
aria-label={t("feed.chips.remove", { name: f.label })}
|
||||
className="rounded-full p-0.5 opacity-60 transition hover:bg-accent/20 hover:opacity-100"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-auto flex items-center gap-2 border-t border-border/60 pt-3">
|
||||
<div className="mt-auto flex flex-wrap items-center gap-2 border-t border-border/60 pt-3">
|
||||
<button
|
||||
onClick={() => apply(selected)}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg btn-primary px-3 py-1.5 text-sm font-semibold"
|
||||
@@ -204,6 +279,22 @@ export default function PersonalViewsModal() {
|
||||
<Play className="h-3.5 w-3.5" />
|
||||
{t("views.apply")}
|
||||
</button>
|
||||
<button
|
||||
onClick={async () => {
|
||||
const saved = await saveCurrent(selected.id);
|
||||
// Re-seed the draft to what we just stored (the live filters) so the chip row
|
||||
// reflects the save; clears the now-stale undo history.
|
||||
if (saved) resetDraft(saved);
|
||||
}}
|
||||
disabled={saveCurrentDisabled}
|
||||
title={
|
||||
saveCurrentDisabled ? t("views.saveCurrentUpToDate") : t("views.saveCurrentHint")
|
||||
}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-border px-3 py-1.5 text-sm text-muted transition enabled:hover:border-accent enabled:hover:text-accent disabled:opacity-40"
|
||||
>
|
||||
<Save className="h-3.5 w-3.5" />
|
||||
{t("views.saveCurrent")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => void toggleDefault(selected)}
|
||||
title={t("views.default")}
|
||||
|
||||
@@ -31,7 +31,7 @@ import {
|
||||
import Avatar from "./Avatar";
|
||||
import AddToPlaylist from "./AddToPlaylist";
|
||||
import { PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
|
||||
import { pushLayer, removeLayer, anyLayerAbove } from "../lib/layerStack";
|
||||
import { pushLayer, removeLayer, anyLayerAbove, escapeHandled } from "../lib/layerStack";
|
||||
import DownloadButton from "./DownloadButton";
|
||||
import { api, type Video, type VideoStatus } from "../lib/api";
|
||||
import type { YTPlayer } from "../lib/youtube";
|
||||
@@ -337,7 +337,10 @@ export default function PlayerModal({
|
||||
if (e.key === "Escape") {
|
||||
// Defer to any layer opened above the player FIRST (a Modal like the download dialog, or an
|
||||
// anchored popover): it takes the Escape and we do nothing — so we don't also step out of
|
||||
// maximise or close the player underneath it.
|
||||
// maximise or close the player underneath it. The consumed-event flag is the reliable signal
|
||||
// (UX-8): the layer's `document` listener runs before ours and its close can pop it off the
|
||||
// stack synchronously, so `anyLayerAbove` alone would miss it and close the player too.
|
||||
if (escapeHandled(e)) return;
|
||||
if (layerIdRef.current != null && anyLayerAbove(layerIdRef.current)) return;
|
||||
// In fullscreen, let the browser's Esc exit fullscreen only — don't also close the modal.
|
||||
if (fullscreenElement()) return;
|
||||
|
||||
@@ -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<SortState> = 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<SortState | null>(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
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Check, Plus } from "lucide-react";
|
||||
import { api } from "../lib/api";
|
||||
import { notifyActionError } from "../lib/actionErrors";
|
||||
import { notifyCollectionEditError } from "../lib/actionErrors";
|
||||
import { useScrollFade } from "../lib/useScrollFade";
|
||||
import Modal from "./Modal";
|
||||
import { useConfirm } from "./ConfirmProvider";
|
||||
@@ -102,8 +102,9 @@ export default function PlexCollectionEditor({
|
||||
setMembers((prev) => new Set(prev).add(col.id));
|
||||
refresh();
|
||||
} catch (e) {
|
||||
// Surfaces the 409 when the name duplicates an existing collection (Plex conflates same names).
|
||||
notifyActionError(e, t("plex.collection.editFailed"));
|
||||
// The backend rejects a duplicate name with 409 (Plex conflates same names) — localize it to
|
||||
// nameTaken rather than surfacing the server's raw English detail.
|
||||
notifyCollectionEditError(e, t("plex.collection.editFailed"), t("plex.collection.nameTaken"));
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// S4 Play-all — buildQueue's show→episode expansion and playAll's decision branch, the honest-states
|
||||
// behaviour the deep-review flagged as untested. A leaf member queues as itself; a show member is
|
||||
// expanded (lazily, on click) into its episodes in season→episode order; a show that expands to zero
|
||||
// episodes must NOT silently no-op but surface the `playEmpty` info notice. api.plexCollection /
|
||||
// api.plexShow are stubbed so the queries resolve synchronously; notify is spied to assert the notice.
|
||||
import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor, cleanup } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
beforeAll(() => {
|
||||
// Poster grids / carousels wire a ResizeObserver; jsdom ships none.
|
||||
class Stub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
|
||||
});
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
|
||||
});
|
||||
vi.mock("./ConfirmProvider", () => ({ useConfirm: () => async () => true }));
|
||||
|
||||
// Spy notify without dropping the module's other exports (the render subtree uses them).
|
||||
const h = vi.hoisted(() => ({ notify: vi.fn() }));
|
||||
vi.mock("../lib/notifications", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("../lib/notifications")>()),
|
||||
notify: (...a: unknown[]) => h.notify(...a),
|
||||
}));
|
||||
|
||||
import PlexCollectionView from "./PlexCollectionView";
|
||||
import { api, type PlexCard, type PlexCollectionDetail, type PlexShowDetail } from "../lib/api";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
h.notify.mockClear();
|
||||
});
|
||||
|
||||
const card = (id: string, type: "movie" | "show"): PlexCard =>
|
||||
({
|
||||
id,
|
||||
type,
|
||||
title: id,
|
||||
year: 2000,
|
||||
duration_seconds: 3600,
|
||||
thumb: `/api/plex/image/${id}`,
|
||||
playable: "direct",
|
||||
status: "new",
|
||||
position_seconds: 0,
|
||||
}) as unknown as PlexCard;
|
||||
|
||||
const detail = (items: PlexCard[]): PlexCollectionDetail =>
|
||||
({
|
||||
id: "col1",
|
||||
title: "My Collection",
|
||||
thumb: "/api/plex/image/col1",
|
||||
year_min: null,
|
||||
year_max: null,
|
||||
content_rating: null,
|
||||
child_count: items.length,
|
||||
total_runtime_s: 0,
|
||||
items,
|
||||
can_edit: false,
|
||||
editable: false,
|
||||
}) as unknown as PlexCollectionDetail;
|
||||
|
||||
const showDetail = (episodeIds: string[][]): PlexShowDetail =>
|
||||
({
|
||||
seasons: episodeIds.map((eps) => ({ episodes: eps.map((id) => ({ id })) })),
|
||||
}) as unknown as PlexShowDetail;
|
||||
|
||||
function mount(items: PlexCard[], onPlay = vi.fn()) {
|
||||
vi.spyOn(api, "plexCollection").mockResolvedValue(detail(items));
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<PlexCollectionView
|
||||
collectionId="col1"
|
||||
onBack={() => {}}
|
||||
onPlay={onPlay}
|
||||
onOpenCard={() => {}}
|
||||
onInfoCard={() => {}}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
return onPlay;
|
||||
}
|
||||
|
||||
describe("PlexCollectionView — Play-all", () => {
|
||||
it("queues a leaf member as itself", async () => {
|
||||
const onPlay = mount([card("movie1", "movie")]);
|
||||
fireEvent.click(await screen.findByText("plex.collection.playAll"));
|
||||
await waitFor(() => expect(onPlay).toHaveBeenCalledTimes(1));
|
||||
expect(onPlay).toHaveBeenCalledWith("movie1", ["movie1"]);
|
||||
expect(h.notify).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("expands a show member into its episodes in season→episode order", async () => {
|
||||
const spyShow = vi
|
||||
.spyOn(api, "plexShow")
|
||||
.mockResolvedValue(showDetail([["s1e1", "s1e2"], ["s2e1"]]));
|
||||
const onPlay = mount([card("show1", "show")]);
|
||||
fireEvent.click(await screen.findByText("plex.collection.playAll"));
|
||||
await waitFor(() => expect(onPlay).toHaveBeenCalledTimes(1));
|
||||
expect(spyShow).toHaveBeenCalledWith("show1");
|
||||
expect(onPlay).toHaveBeenCalledWith("s1e1", ["s1e1", "s1e2", "s2e1"]);
|
||||
});
|
||||
|
||||
it("surfaces the playEmpty notice (not a silent no-op) when a show expands to zero episodes", async () => {
|
||||
vi.spyOn(api, "plexShow").mockResolvedValue(showDetail([])); // no seasons → empty queue
|
||||
const onPlay = mount([card("show1", "show")]);
|
||||
fireEvent.click(await screen.findByText("plex.collection.playAll"));
|
||||
await waitFor(() =>
|
||||
expect(h.notify).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ level: "info", message: "plex.collection.playEmpty" }),
|
||||
),
|
||||
);
|
||||
expect(onPlay).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -5,11 +5,13 @@ import { Play, Shuffle, Pencil, Trash2, Plus, X, Lock, Check, Search } from "luc
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { api, isIntrinsicReadOnly, type PlexCard, type PlexCollectionDetail } from "../lib/api";
|
||||
import { formatRuntime } from "../lib/format";
|
||||
import { notifyActionError } from "../lib/actionErrors";
|
||||
import { useDebounced } from "../lib/useDebounced";
|
||||
import { notifyActionError, notifyCollectionEditError } from "../lib/actionErrors";
|
||||
import { notify } from "../lib/notifications";
|
||||
import { showEpisodeKeys } from "../lib/plexQueue";
|
||||
import { useConfirm } from "./ConfirmProvider";
|
||||
import { BackBtn, ActionBtn, DetailHero } from "../lib/plexDetailUi";
|
||||
import { PlexPosterCard } from "./plexCards";
|
||||
import PlexLibraryPicker from "./PlexLibraryPicker";
|
||||
import { toggleWatchState } from "../lib/plexWatch";
|
||||
import { LoadingState, StateMessage } from "./QueryState";
|
||||
|
||||
@@ -57,6 +59,9 @@ export default function PlexCollectionView({
|
||||
const [name, setName] = useState("");
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
// Building the play queue can await show → episode expansion (see buildQueue); disable Play/Shuffle
|
||||
// meanwhile so a double-tap can't fire two queues.
|
||||
const [queueBusy, setQueueBusy] = useState(false);
|
||||
|
||||
const invalidate = () => {
|
||||
qc.invalidateQueries({ queryKey: qk.plexCollections() });
|
||||
@@ -64,8 +69,48 @@ export default function PlexCollectionView({
|
||||
};
|
||||
|
||||
const items = d?.items ?? [];
|
||||
// The Play queue is the playable-leaf members (movies/episodes) in order — a show can't queue directly.
|
||||
const playable = items.filter((c) => c.type !== "show").map((c) => c.id);
|
||||
// Movies/episodes are playable leaves that queue as themselves; a show can't queue directly, so
|
||||
// Play-all expands it into its episodes (buildQueue). Either kind means there's something to play —
|
||||
// this is what a show-collection needs: before, `playable` held only leaves, so a show-collection's
|
||||
// Play-all was empty and did nothing.
|
||||
const leafKeys = items.filter((c) => c.type !== "show").map((c) => c.id);
|
||||
const hasShows = items.some((c) => c.type === "show");
|
||||
const canPlay = leafKeys.length > 0 || hasShows;
|
||||
|
||||
// Build the ordered play queue: leaf members as themselves, each show member expanded into its
|
||||
// episodes (season then episode order). Shows are fetched lazily HERE (on Play-all/Shuffle click),
|
||||
// in parallel, but concatenated in member order so the queue follows the grid — not on page load,
|
||||
// which would fetch every show up front for a button that may never be pressed.
|
||||
async function buildQueue(): Promise<string[]> {
|
||||
const perMember = await Promise.all(
|
||||
items.map(async (c) => {
|
||||
if (c.type !== "show") return [c.id];
|
||||
const detail = await qc.fetchQuery({
|
||||
queryKey: qk.plexShow(c.id),
|
||||
queryFn: () => api.plexShow(c.id),
|
||||
});
|
||||
return showEpisodeKeys(detail);
|
||||
}),
|
||||
);
|
||||
return perMember.flat();
|
||||
}
|
||||
|
||||
async function playAll(shuffle: boolean) {
|
||||
setQueueBusy(true);
|
||||
try {
|
||||
let queue = await buildQueue();
|
||||
if (shuffle) queue = shuffled(queue);
|
||||
// canPlay gates on "has a show member", but a show can expand to zero episodes (unmatched /
|
||||
// empty). An enabled Play-all that silently no-ops is a dishonest state, so say so plainly.
|
||||
if (queue.length > 0) onPlay(queue[0]!, queue);
|
||||
else notify({ level: "info", message: t("plex.collection.playEmpty") });
|
||||
} catch (e) {
|
||||
notifyActionError(e, t("plex.collection.playFailed"));
|
||||
} finally {
|
||||
setQueueBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
const canEdit = isAdmin && !!d?.can_edit;
|
||||
// A plain (non-smart, non-external) collection can be admin-enabled for editing. Shares
|
||||
// isIntrinsicReadOnly with the browse tile so the lock/edit affordance can't drift between them.
|
||||
@@ -99,7 +144,8 @@ export default function PlexCollectionView({
|
||||
invalidate();
|
||||
setRenaming(false);
|
||||
} catch (e) {
|
||||
notifyActionError(e, t("plex.collection.editFailed"));
|
||||
// The backend enforces per-library name uniqueness — localize its 409 to nameTaken.
|
||||
notifyCollectionEditError(e, t("plex.collection.editFailed"), t("plex.collection.nameTaken"));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -170,9 +216,9 @@ export default function PlexCollectionView({
|
||||
<DetailHero
|
||||
thumb={d.thumb ?? `/api/plex/image/${d.id}`}
|
||||
posterTitle={t("plex.play")}
|
||||
posterDisabled={playable.length === 0}
|
||||
showPlayOverlay={playable.length > 0}
|
||||
onPosterClick={() => playable.length > 0 && onPlay(playable[0]!, playable)}
|
||||
posterDisabled={!canPlay || queueBusy}
|
||||
showPlayOverlay={canPlay}
|
||||
onPosterClick={() => canPlay && !queueBusy && playAll(false)}
|
||||
customizeMenu={null}
|
||||
>
|
||||
{renaming ? (
|
||||
@@ -231,19 +277,18 @@ export default function PlexCollectionView({
|
||||
)}
|
||||
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{playable.length > 0 && (
|
||||
{canPlay && (
|
||||
<>
|
||||
<ActionBtn
|
||||
primary
|
||||
onClick={() => onPlay(playable[0]!, playable)}
|
||||
onClick={() => playAll(false)}
|
||||
disabled={queueBusy}
|
||||
icon={Play}
|
||||
label={t("plex.collection.playAll")}
|
||||
/>
|
||||
<ActionBtn
|
||||
onClick={() => {
|
||||
const order = shuffled(playable);
|
||||
onPlay(order[0]!, order);
|
||||
}}
|
||||
onClick={() => playAll(true)}
|
||||
disabled={queueBusy}
|
||||
icon={Shuffle}
|
||||
label={t("plex.collection.shuffle")}
|
||||
/>
|
||||
@@ -342,14 +387,7 @@ function AddItemPanel({
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [term, setTerm] = useState("");
|
||||
const debounced = useDebounced(term.trim(), 300);
|
||||
const [added, setAdded] = useState<Set<string>>(new Set());
|
||||
const q = useQuery({
|
||||
queryKey: qk.plexLibrary("collection-add", scope, debounced),
|
||||
queryFn: () => api.plexLibrary({ scope, q: debounced, limit: 12 }),
|
||||
enabled: debounced.length > 0,
|
||||
});
|
||||
const results = q.data?.items ?? [];
|
||||
|
||||
async function add(card: PlexCard) {
|
||||
try {
|
||||
@@ -381,43 +419,24 @@ function AddItemPanel({
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
{debounced.length === 0 ? (
|
||||
<p className="text-muted text-sm">{t("plex.collection.addHint")}</p>
|
||||
) : q.isLoading ? (
|
||||
<LoadingState label={t("plex.loading")} />
|
||||
) : q.isError ? (
|
||||
<p className="text-muted text-sm">{t("common.loadError")}</p>
|
||||
) : results.length === 0 ? (
|
||||
<p className="text-muted text-sm">{t("plex.collection.addNoResults")}</p>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-1">
|
||||
{results.map((c) => {
|
||||
const inColl = existing.has(c.id) || added.has(c.id);
|
||||
return (
|
||||
<li key={c.id} className="flex items-center gap-3">
|
||||
<img
|
||||
src={c.thumb}
|
||||
alt=""
|
||||
className="h-12 w-8 shrink-0 rounded object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate text-sm">
|
||||
{c.title}
|
||||
{c.year != null && <span className="text-muted"> · {c.year}</span>}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => add(c)}
|
||||
disabled={inColl}
|
||||
className="glass-card glass-hover inline-flex items-center gap-1 rounded-lg px-2.5 py-1 text-xs disabled:opacity-50"
|
||||
>
|
||||
{inColl ? <Check className="h-3.5 w-3.5" /> : <Plus className="h-3.5 w-3.5" />}
|
||||
{inColl ? t("plex.collection.added") : t("plex.collection.add")}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
<PlexLibraryPicker
|
||||
scope={scope}
|
||||
term={term}
|
||||
emptyHint={<p className="text-muted text-sm">{t("plex.collection.addHint")}</p>}
|
||||
renderAction={(c) => {
|
||||
const inColl = existing.has(c.id) || added.has(c.id);
|
||||
return (
|
||||
<button
|
||||
onClick={() => add(c)}
|
||||
disabled={inColl}
|
||||
className="glass-card glass-hover inline-flex items-center gap-1 rounded-lg px-2.5 py-1 text-xs disabled:opacity-50"
|
||||
>
|
||||
{inColl ? <Check className="h-3.5 w-3.5" /> : <Plus className="h-3.5 w-3.5" />}
|
||||
{inColl ? t("plex.collection.added") : t("plex.collection.add")}
|
||||
</button>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// Contract of the shared library search-and-pick list (F6): it owns the debounced query and the
|
||||
// full loading/error/empty/no-results/list state machine so the two callers (AddItemPanel in
|
||||
// PlexCollectionView, NewCollectionPanel in plexBrowseLists) can't drift. The two things a caller
|
||||
// steers — the `enabled` gate (NewCollectionPanel holds it off until the collection name is valid)
|
||||
// and the `emptyHint` shown before a term is typed — are the pieces with no test before this file, so
|
||||
// they're covered here. i18n is key-passthrough and api/debounce are mocked so the test drives state
|
||||
// directly.
|
||||
import { type ComponentProps } from "react";
|
||||
import { describe, it, expect, afterEach, beforeEach, vi } from "vitest";
|
||||
import { render, screen, cleanup, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
|
||||
});
|
||||
// Identity debounce so a passed term is reflected immediately.
|
||||
vi.mock("../lib/useDebounced", () => ({ useDebounced: (v: unknown) => v }));
|
||||
|
||||
// api.plexLibrary stub — returns whatever the current test queued.
|
||||
const plexLibrary = vi.fn();
|
||||
vi.mock("../lib/api", () => ({ api: { plexLibrary: (...a: unknown[]) => plexLibrary(...a) } }));
|
||||
|
||||
import PlexLibraryPicker from "./PlexLibraryPicker";
|
||||
|
||||
const card = (id: string, title: string, year = 2000) => ({
|
||||
id,
|
||||
type: "movie" as const,
|
||||
title,
|
||||
year,
|
||||
thumb: `/api/plex/image/${id}`,
|
||||
});
|
||||
const result = (items: ReturnType<typeof card>[]) => ({
|
||||
scope: "both",
|
||||
total: items.length,
|
||||
offset: 0,
|
||||
limit: 12,
|
||||
items,
|
||||
episodes: [],
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
plexLibrary.mockReset();
|
||||
plexLibrary.mockResolvedValue(result([]));
|
||||
});
|
||||
afterEach(cleanup);
|
||||
|
||||
const renderPicker = (props: Partial<ComponentProps<typeof PlexLibraryPicker>> = {}) => {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<PlexLibraryPicker
|
||||
scope="both"
|
||||
term=""
|
||||
renderAction={(c) => <button>act:{c.id}</button>}
|
||||
{...props}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
};
|
||||
|
||||
describe("PlexLibraryPicker", () => {
|
||||
it("renders nothing and fires no query while disabled, even with a term", () => {
|
||||
const { container } = renderPicker({ enabled: false, term: "matrix" });
|
||||
expect(container.firstChild).toBeNull();
|
||||
expect(plexLibrary).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows the emptyHint (and fires no query) before a term is typed", () => {
|
||||
renderPicker({ term: "", emptyHint: <p>EMPTY_HINT</p> });
|
||||
expect(screen.getByText("EMPTY_HINT")).toBeTruthy();
|
||||
expect(plexLibrary).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("queries the library once enabled + a term are both present and renders the rows via renderAction", async () => {
|
||||
plexLibrary.mockResolvedValue(result([card("m1", "The Matrix"), card("m2", "Reloaded")]));
|
||||
renderPicker({ term: "matrix" });
|
||||
|
||||
await waitFor(() => expect(screen.getByText("act:m1")).toBeTruthy());
|
||||
expect(screen.getByText("act:m2")).toBeTruthy();
|
||||
expect(screen.getByText("The Matrix")).toBeTruthy();
|
||||
expect(plexLibrary).toHaveBeenCalledWith({ scope: "both", q: "matrix", limit: 12 });
|
||||
});
|
||||
|
||||
it("renders the honest error state (not an empty/no-results state) when the query fails", async () => {
|
||||
plexLibrary.mockRejectedValue(new Error("boom"));
|
||||
renderPicker({ term: "matrix" });
|
||||
|
||||
await waitFor(() => expect(screen.getByText("common.loadError")).toBeTruthy());
|
||||
expect(screen.queryByText("plex.collection.addNoResults")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows the no-results message when the query returns an empty list", async () => {
|
||||
plexLibrary.mockResolvedValue(result([]));
|
||||
renderPicker({ term: "zzzz" });
|
||||
|
||||
await waitFor(() => expect(screen.getByText("plex.collection.addNoResults")).toBeTruthy());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import { type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { api, type PlexCard } from "../lib/api";
|
||||
import { useDebounced } from "../lib/useDebounced";
|
||||
import { LoadingState } from "./QueryState";
|
||||
|
||||
// A debounced library search-and-pick list, shared by the "Add title" panel (PlexCollectionView) and
|
||||
// the "New collection" first-title panel (plexBrowseLists) — both search the library the same way and
|
||||
// render the same result rows, differing only in the per-row action. Callers keep their own chrome
|
||||
// (search input, name field, close button) and supply `term` + `renderAction`; this owns the query,
|
||||
// the loading/error/empty states, and the row markup so those can't drift between the two surfaces.
|
||||
export default function PlexLibraryPicker({
|
||||
scope,
|
||||
term,
|
||||
enabled = true,
|
||||
emptyHint = null,
|
||||
renderAction,
|
||||
}: {
|
||||
scope: string;
|
||||
/** The raw search term; debounced internally. */
|
||||
term: string;
|
||||
/** Gate the query off (e.g. until a collection name is valid). When false, renders nothing. */
|
||||
enabled?: boolean;
|
||||
/** Shown while the (debounced) term is empty — a hint, or nothing. */
|
||||
emptyHint?: ReactNode;
|
||||
/** The trailing control for each result row (Add / Create-with / …). */
|
||||
renderAction: (card: PlexCard) => ReactNode;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const debounced = useDebounced(term.trim(), 300);
|
||||
const q = useQuery({
|
||||
queryKey: qk.plexLibrary("collection-pick", scope, debounced),
|
||||
queryFn: () => api.plexLibrary({ scope, q: debounced, limit: 12 }),
|
||||
enabled: enabled && debounced.length > 0,
|
||||
});
|
||||
const results = q.data?.items ?? [];
|
||||
|
||||
if (!enabled) return null;
|
||||
if (debounced.length === 0) return <>{emptyHint}</>;
|
||||
if (q.isLoading) return <LoadingState label={t("plex.loading")} />;
|
||||
if (q.isError) return <p className="text-muted text-sm">{t("common.loadError")}</p>;
|
||||
if (results.length === 0)
|
||||
return <p className="text-muted text-sm">{t("plex.collection.addNoResults")}</p>;
|
||||
|
||||
return (
|
||||
<ul className="flex flex-col gap-1">
|
||||
{results.map((c) => (
|
||||
<li key={c.id} className="flex items-center gap-3">
|
||||
<img
|
||||
src={c.thumb}
|
||||
alt=""
|
||||
className="h-12 w-8 shrink-0 rounded object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate text-sm">
|
||||
{c.title}
|
||||
{c.year != null && <span className="text-muted"> · {c.year}</span>}
|
||||
</span>
|
||||
{renderAction(c)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
Info,
|
||||
Keyboard,
|
||||
Languages,
|
||||
Loader2,
|
||||
Maximize,
|
||||
Minimize,
|
||||
Pause,
|
||||
@@ -53,7 +54,7 @@ import { LS, useAccountPersistedObject } from "../lib/storage";
|
||||
import { useDismiss } from "../lib/useDismiss";
|
||||
import { useScrollFade } from "../lib/useScrollFade";
|
||||
import { useBackToClose } from "../lib/history";
|
||||
import { pushLayer, removeLayer, anyLayerAbove } from "../lib/layerStack";
|
||||
import { pushLayer, removeLayer, anyLayerAbove, escapeHandled } from "../lib/layerStack";
|
||||
|
||||
// The rich info overlay (poster/cast/ratings) reuses the same component as the card's info page.
|
||||
const PlexInfo = lazy(() => import("./PlexInfo"));
|
||||
@@ -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<string | null>(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 <track> 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]);
|
||||
|
||||
@@ -602,7 +628,10 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
case "Escape":
|
||||
// Defer to any layer opened above the player (Modal or anchored popover) — let it take the
|
||||
// Escape, not us. A player MENU registers as a layer too, so it closes first and the next
|
||||
// Escape reaches the auto-skip-cancel below (the old blanket-stopPropagation bug).
|
||||
// Escape reaches the auto-skip-cancel below (the old blanket-stopPropagation bug). Check the
|
||||
// consumed-event flag first (UX-8): a layer's `document` listener runs before this `window`
|
||||
// one and its close can pop it off the stack synchronously, so `anyLayerAbove` alone misses it.
|
||||
if (escapeHandled(e)) break;
|
||||
if (layerIdRef.current != null && anyLayerAbove(layerIdRef.current)) break;
|
||||
if (skipProgressRef.current != null) cancelAutoSkipRef.current();
|
||||
else if (menuOpenRef.current) {
|
||||
@@ -727,12 +756,29 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
)}
|
||||
</video>
|
||||
|
||||
{!ready && (
|
||||
<div className="absolute inset-0 grid place-items-center px-6 text-center text-sm pointer-events-none">
|
||||
{/* 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) && (
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className="absolute inset-0 grid place-items-center px-6 text-center text-sm pointer-events-none"
|
||||
>
|
||||
{loadError ? (
|
||||
<span className="max-w-md text-danger">{loadError}</span>
|
||||
) : (
|
||||
<span className="text-white/70">{t("plex.player.loading")}</span>
|
||||
<div className="flex flex-col items-center gap-3 text-white/80">
|
||||
<Loader2 className="w-10 h-10 animate-spin" aria-hidden />
|
||||
{/* Announce the stall to assistive tech. Initial load shows visible text; a mid-playback
|
||||
rebuffer/seek (ready, no error) has no visible label, so give it an sr-only one via the
|
||||
aria-live region so a screen reader distinguishes "buffering" from a frozen frame. */}
|
||||
{!ready ? (
|
||||
<span>{t("plex.player.loading")}</span>
|
||||
) : buffering ? (
|
||||
<span className="sr-only">{t("plex.player.buffering")}</span>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -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,57 @@ 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<string, unknown>);
|
||||
} catch {
|
||||
return EMPTY_PLEX_FILTERS;
|
||||
}
|
||||
}
|
||||
// A shared-link seed = the URL-merged state PLUS which dimensions the URL actually carried. `present`
|
||||
// matters because the base below is read at MOUNT, before the account is pinned (getAccountRaw returns
|
||||
// null then), so the base for an OMITTED dimension is a hard default, NOT this account's stored value.
|
||||
// The provider must therefore persist/keep only the PRESENT dimensions on account-pin and re-read the
|
||||
// account's real stored value for the absent ones — otherwise a partial link silently resets them.
|
||||
type PlexSeedPresent = { scope: boolean; sort: boolean; show: boolean; filters: boolean };
|
||||
type PlexUrlSeed = { state: PlexUrlState; present: PlexSeedPresent };
|
||||
function readPlexUrlSeed(): PlexUrlSeed | null {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
if (!hasPlexParams(params)) return null;
|
||||
const state = plexParamsToState(params, {
|
||||
scope: readPlexScope(),
|
||||
sort: readPlexSort(),
|
||||
show: readPlexShow(),
|
||||
filters: readPlexFilters(),
|
||||
});
|
||||
return {
|
||||
state,
|
||||
present: {
|
||||
scope: params.has("pscope"),
|
||||
sort: params.has("psort"),
|
||||
show: params.has("pshow"),
|
||||
filters: params.has("pf"),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function PlexProvider({ children }: { children: ReactNode }) {
|
||||
// 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<string>(readPlexScope);
|
||||
const [show, setShowState] = useState<string>(readPlexShow);
|
||||
const [sort, setSortState] = useState<SortState>(readPlexSort);
|
||||
const [scope, setScopeState] = useState<string>(() => urlSeed?.state.scope ?? readPlexScope());
|
||||
const [show, setShowState] = useState<string>(() => urlSeed?.state.show ?? readPlexShow());
|
||||
const [sort, setSortState] = useState<SortState>(() => urlSeed?.state.sort ?? readPlexSort());
|
||||
// The expanded Plex filters live as one JSON blob (the string-only account store serializes it).
|
||||
const [filtersRaw, setFiltersRaw] = useState<string>(readPlexFiltersRaw);
|
||||
const [filtersRaw, setFiltersRaw] = useState<string>(() =>
|
||||
urlSeed ? JSON.stringify(urlSeed.state.filters) : readPlexFiltersRaw(),
|
||||
);
|
||||
const filters = useMemo<PlexFilters>(() => {
|
||||
try {
|
||||
// coercePlexFilters fills defaults, drops the legacy `sortDir` (direction now lives in the sort
|
||||
@@ -140,7 +191,29 @@ export function PlexProvider({ children }: { children: ReactNode }) {
|
||||
const [jumpTo, setJumpTo] = useState<PlexJumpTarget | null>(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) {
|
||||
// Per dimension: a dimension the URL carried (present) is the shared view — adopt it and persist
|
||||
// it to this now-pinned account (so it wins and survives a later plain reload). A dimension the
|
||||
// URL OMITTED was seeded from a mount-time default (no account yet), so do NOT persist that
|
||||
// default over the account's real stored value — re-read the stored value instead, exactly like a
|
||||
// normal load. This is the fix for a partial shared link silently resetting the omitted slices.
|
||||
const { state, present } = urlSeed;
|
||||
if (present.scope) setAccountRaw(LS.plexLibrary, state.scope);
|
||||
else setScopeState(readPlexScope());
|
||||
if (present.show) setAccountRaw(LS.plexShow, state.show);
|
||||
else setShowState(readPlexShow());
|
||||
if (present.sort) setAccountRaw(LS.plexSort, JSON.stringify(state.sort));
|
||||
else setSortState(readPlexSort());
|
||||
if (present.filters) setAccountRaw(LS.plexFilters, JSON.stringify(state.filters));
|
||||
else setFiltersRaw(readPlexFiltersRaw());
|
||||
setQState(readPlexQ());
|
||||
setFieldState(readPlexField());
|
||||
return;
|
||||
}
|
||||
setScopeState(readPlexScope());
|
||||
setShowState(readPlexShow());
|
||||
setSortState(readPlexSort());
|
||||
@@ -208,6 +281,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 (
|
||||
<ActionsContext.Provider value={actions}>
|
||||
<StateContext.Provider value={state}>{children}</StateContext.Provider>
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
// @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<typeof import("react-i18next")>();
|
||||
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers(); // any fake-timer test resets here so it can't leak into the next
|
||||
});
|
||||
|
||||
const raise = () => screen.getByRole("button", { name: "channels.row.raisePriority" });
|
||||
const lower = () => screen.getByRole("button", { name: "channels.row.lowerPriority" });
|
||||
const valueBtn = () => screen.getByRole("button", { name: "channels.row.editPriority" });
|
||||
|
||||
describe("PriorityStepper", () => {
|
||||
it("shows the current priority and both step buttons", () => {
|
||||
render(<PriorityStepper priority={12} onPriority={vi.fn()} onSet={vi.fn()} />);
|
||||
expect(valueBtn().textContent).toBe("12");
|
||||
expect(raise()).toBeTruthy();
|
||||
expect(lower()).toBeTruthy();
|
||||
});
|
||||
|
||||
it("tapping + / − nudges by ±1", () => {
|
||||
const onPriority = vi.fn();
|
||||
render(<PriorityStepper priority={0} onPriority={onPriority} onSet={vi.fn()} />);
|
||||
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(<PriorityStepper priority={7} onPriority={vi.fn()} onSet={vi.fn()} />);
|
||||
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(<PriorityStepper priority={7} onPriority={vi.fn()} onSet={onSet} />);
|
||||
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(<PriorityStepper priority={7} onPriority={vi.fn()} onSet={onSet} />);
|
||||
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(<PriorityStepper priority={7} onPriority={vi.fn()} onSet={onSet} />);
|
||||
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(<PriorityStepper priority={3} onPriority={onPriority} onSet={vi.fn()} size="md" />);
|
||||
expect(valueBtn().textContent).toBe("3");
|
||||
fireEvent.click(raise());
|
||||
expect(onPriority).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it("holding a step button auto-repeats (accelerating) and suppresses the trailing click", () => {
|
||||
vi.useFakeTimers();
|
||||
const onPriority = vi.fn();
|
||||
render(<PriorityStepper priority={0} onPriority={onPriority} onSet={vi.fn()} />);
|
||||
|
||||
fireEvent.pointerDown(raise());
|
||||
vi.advanceTimersByTime(349);
|
||||
expect(onPriority).not.toHaveBeenCalled(); // a tap below the 350ms threshold doesn't repeat
|
||||
vi.advanceTimersByTime(1); // cross the threshold → first auto-repeat tick
|
||||
expect(onPriority).toHaveBeenCalledTimes(1);
|
||||
|
||||
vi.advanceTimersByTime(1000); // several accelerating repeats while held
|
||||
const heldCount = onPriority.mock.calls.length;
|
||||
expect(heldCount).toBeGreaterThan(3);
|
||||
expect(onPriority.mock.calls.every(([d]) => d === 1)).toBe(true);
|
||||
|
||||
fireEvent.pointerUp(raise());
|
||||
vi.advanceTimersByTime(1000);
|
||||
expect(onPriority.mock.calls.length).toBe(heldCount); // stops repeating on release
|
||||
|
||||
fireEvent.click(raise()); // the click that trails a hold is swallowed (no double-count)
|
||||
expect(onPriority.mock.calls.length).toBe(heldCount);
|
||||
});
|
||||
|
||||
it("a quick tap (released before the hold threshold) still nudges exactly once", () => {
|
||||
vi.useFakeTimers();
|
||||
const onPriority = vi.fn();
|
||||
render(<PriorityStepper priority={0} onPriority={onPriority} onSet={vi.fn()} />);
|
||||
|
||||
fireEvent.pointerDown(lower());
|
||||
vi.advanceTimersByTime(100); // below the threshold → no auto-repeat armed
|
||||
fireEvent.pointerUp(lower());
|
||||
fireEvent.click(lower()); // the tap's click is NOT suppressed
|
||||
expect(onPriority.mock.calls).toEqual([[-1]]);
|
||||
});
|
||||
|
||||
it("clears the auto-repeat timer on unmount (no fire after the row is gone)", () => {
|
||||
vi.useFakeTimers();
|
||||
const onPriority = vi.fn();
|
||||
const { unmount } = render(
|
||||
<PriorityStepper priority={0} onPriority={onPriority} onSet={vi.fn()} />,
|
||||
);
|
||||
|
||||
fireEvent.pointerDown(raise());
|
||||
vi.advanceTimersByTime(350); // repeat armed + first tick
|
||||
const before = onPriority.mock.calls.length;
|
||||
expect(before).toBeGreaterThan(0);
|
||||
|
||||
unmount();
|
||||
vi.advanceTimersByTime(1000);
|
||||
expect(onPriority.mock.calls.length).toBe(before); // no post-unmount timer fire
|
||||
});
|
||||
});
|
||||
@@ -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}
|
||||
>
|
||||
<Icon className="w-3.5 h-3.5" />
|
||||
<Icon className={sizeCls.icon} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<ReturnType<typeof setTimeout> | null>(null);
|
||||
const holdRepeat = useRef<ReturnType<typeof setTimeout> | 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 (
|
||||
<Tooltip hint={t("channels.row.priorityHint")}>
|
||||
<div className="inline-flex items-center gap-1.5 cursor-help">
|
||||
{/* 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. */}
|
||||
<span className="relative inline-block w-6 h-5 text-right leading-5">
|
||||
<div
|
||||
role="group"
|
||||
aria-label={t("channels.row.priorityHint")}
|
||||
className={`inline-flex items-center cursor-help ${framed}`}
|
||||
>
|
||||
<PriorityStepButton
|
||||
delta={-1}
|
||||
Icon={Minus}
|
||||
label={t("channels.row.lowerPriority")}
|
||||
sizeCls={cls}
|
||||
onHoldStart={start}
|
||||
onStop={stop}
|
||||
onTap={onTap}
|
||||
/>
|
||||
<span
|
||||
className={`relative inline-flex items-center justify-center border-x ${
|
||||
editing ? "border-accent" : "border-border"
|
||||
} ${cls.value}`}
|
||||
>
|
||||
<button
|
||||
onClick={beginEdit}
|
||||
aria-label={t("channels.row.editPriority")}
|
||||
title={t("channels.row.editPriority")}
|
||||
className={`text-sm font-medium tabular-nums hover:text-accent ${editing ? "invisible" : ""}`}
|
||||
className={`w-full text-center font-medium tabular-nums hover:text-accent ${editing ? "invisible" : ""}`}
|
||||
>
|
||||
{priority}
|
||||
</button>
|
||||
@@ -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"
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
<span className="inline-flex flex-col">
|
||||
<PriorityStepButton
|
||||
delta={1}
|
||||
Icon={ArrowUp}
|
||||
label={t("channels.row.raisePriority")}
|
||||
onHoldStart={start}
|
||||
onStop={stop}
|
||||
onTap={onTap}
|
||||
/>
|
||||
<PriorityStepButton
|
||||
delta={-1}
|
||||
Icon={ArrowDown}
|
||||
label={t("channels.row.lowerPriority")}
|
||||
onHoldStart={start}
|
||||
onStop={stop}
|
||||
onTap={onTap}
|
||||
/>
|
||||
</span>
|
||||
<PriorityStepButton
|
||||
delta={1}
|
||||
Icon={Plus}
|
||||
label={t("channels.row.raisePriority")}
|
||||
sizeCls={cls}
|
||||
onHoldStart={start}
|
||||
onStop={stop}
|
||||
onTap={onTap}
|
||||
/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// S2 — the single default-application path. On the saved-views query's FIRST resolve, SavedViewsProvider
|
||||
// seeds the starred default view's filters into a genuinely fresh module (no stored filters), for BOTH
|
||||
// the feed and Plex — but leaves them alone once the account has its own stored filters. This is the
|
||||
// fix for the old feed-only LS mirror that raced FeedFiltersProvider's login effect and never applied a
|
||||
// Plex default at all. The sub-providers are mocked so we can assert exactly which setFilters is called;
|
||||
// api.savedViews is stubbed and driven through a real QueryClient.
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, waitFor, cleanup, act } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
const h = vi.hoisted(() => ({
|
||||
page: "feed" as "feed" | "plex",
|
||||
setFeedFilters: vi.fn(),
|
||||
setPlexFilters: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
|
||||
});
|
||||
vi.mock("./NavigationProvider", () => ({ useNavigation: () => ({ page: h.page }) }));
|
||||
vi.mock("./FeedFiltersProvider", () => ({
|
||||
useFeedFilters: () => ({
|
||||
tags: [],
|
||||
channelIds: [],
|
||||
tagModes: { language: "or", topic: "or", other: "or" },
|
||||
q: "",
|
||||
sort: "newest",
|
||||
scope: "my",
|
||||
includeNormal: true,
|
||||
includeShorts: false,
|
||||
includeLive: false,
|
||||
show: "unwatched",
|
||||
}),
|
||||
useFeedFiltersActions: () => ({ setFilters: h.setFeedFilters }),
|
||||
}));
|
||||
vi.mock("./PlexProvider", () => ({
|
||||
usePlex: () => ({ filters: {} }),
|
||||
usePlexActions: () => ({ setFilters: h.setPlexFilters }),
|
||||
}));
|
||||
vi.mock("./ConfirmProvider", () => ({ useConfirm: () => async () => true }));
|
||||
|
||||
import { SavedViewsProvider, useSavedViews } from "./SavedViewsProvider";
|
||||
import { api, setActiveAccount, type SavedView } from "../lib/api";
|
||||
import { LS, accountKey } from "../lib/storage";
|
||||
import { ACTIVE_ACCOUNT_KEY } from "../lib/activeAccount";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
|
||||
const ID = 7;
|
||||
const view = (over: Partial<SavedView>): SavedView => ({
|
||||
id: 1,
|
||||
name: "v",
|
||||
filters: {},
|
||||
position: 0,
|
||||
is_default: false,
|
||||
...over,
|
||||
});
|
||||
|
||||
function Probe() {
|
||||
const { loading, views } = useSavedViews();
|
||||
return (
|
||||
<div data-testid="loading" data-count={views.length}>
|
||||
{String(loading)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
async function mountResolved(views: SavedView[]) {
|
||||
// Fresh array + object copies per call, like a real fetch — so a refetch changes viewsQuery.data's
|
||||
// identity and re-runs the seed effect (which is what makes the per-source guard actually load-bearing
|
||||
// in the re-seed test; a shared reference would skip the effect and hide a missing guard).
|
||||
vi.spyOn(api, "savedViews").mockImplementation(async () => views.map((v) => ({ ...v })));
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<SavedViewsProvider>
|
||||
<Probe />
|
||||
</SavedViewsProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
// Wait for the query to resolve so the first-resolve effect has run.
|
||||
await waitFor(() => expect(screen.getByTestId("loading").textContent).toBe("false"));
|
||||
return qc;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
sessionStorage.setItem(ACTIVE_ACCOUNT_KEY, String(ID));
|
||||
// Reset the URL: feedCanSeedDefault reads window.location.search, and a stale ?sort= from a prior
|
||||
// case would suppress seeding in the others.
|
||||
window.history.replaceState({}, "", "/");
|
||||
h.page = "feed";
|
||||
h.setFeedFilters.mockClear();
|
||||
h.setPlexFilters.mockClear();
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("SavedViewsProvider — default-view seeding on first resolve", () => {
|
||||
it("feed: seeds the starred default into a fresh module (no stored filters)", async () => {
|
||||
await mountResolved([
|
||||
view({ id: 2, is_default: true, filters: { scope: "all", show: "watched" } }),
|
||||
]);
|
||||
expect(h.setFeedFilters).toHaveBeenCalledTimes(1);
|
||||
expect(h.setFeedFilters.mock.calls[0]![0]).toMatchObject({ scope: "all", show: "watched" });
|
||||
});
|
||||
|
||||
it("feed: on a fresh tab the query resolves before the account pins — defers, then seeds on the pin", async () => {
|
||||
// The provider mounts above <App>, which pins the tab-account only after /api/me; the ungated
|
||||
// views query resolves first. Seeding must WAIT for the account (not seed nothing and burn its
|
||||
// one-shot — the pre-fix regression that left a fresh tab on DEFAULT_FILTERS until a reload).
|
||||
sessionStorage.removeItem(ACTIVE_ACCOUNT_KEY);
|
||||
await mountResolved([
|
||||
view({ id: 2, is_default: true, filters: { scope: "all", show: "watched" } }),
|
||||
]);
|
||||
expect(h.setFeedFilters).not.toHaveBeenCalled();
|
||||
// App pins the account; the reactive id re-fires the effect and the deferred seed lands.
|
||||
act(() => setActiveAccount(ID));
|
||||
await waitFor(() => expect(h.setFeedFilters).toHaveBeenCalledTimes(1));
|
||||
expect(h.setFeedFilters.mock.calls[0]![0]).toMatchObject({ scope: "all", show: "watched" });
|
||||
});
|
||||
|
||||
it("feed: does NOT seed when the account already has stored filters", async () => {
|
||||
localStorage.setItem(accountKey(LS.filters, ID)!, JSON.stringify({ scope: "my" }));
|
||||
await mountResolved([view({ id: 2, is_default: true, filters: { scope: "all" } })]);
|
||||
expect(h.setFeedFilters).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("feed: does NOT seed when no view is starred as default", async () => {
|
||||
await mountResolved([view({ id: 2, is_default: false, filters: { scope: "all" } })]);
|
||||
expect(h.setFeedFilters).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("feed: does NOT seed when the URL carries filter params (a shared/bookmarked view wins)", async () => {
|
||||
// A link like ?sort=oldest is an explicit filter the user arrived on; the starred default must not
|
||||
// stomp it. feedCanSeedDefault() bails on hasFilterParams — plex ignores the URL, so this is feed-only.
|
||||
window.history.replaceState({}, "", "/?sort=oldest");
|
||||
await mountResolved([
|
||||
view({ id: 2, is_default: true, filters: { scope: "all", show: "watched" } }),
|
||||
]);
|
||||
expect(h.setFeedFilters).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does NOT re-seed on a background refetch that returns a changed list (per-source one-shot guard)", async () => {
|
||||
// The 60s refetch re-resolves the query; when it returns a CHANGED list (e.g. the user just added a
|
||||
// view elsewhere) react-query hands down a new reference and the seed effect re-runs. seededRef must
|
||||
// stop it re-applying the default over filters the user has since edited. (An identical refetch is
|
||||
// deduped by structural sharing and never reaches the effect, so the changed list is the real test.)
|
||||
const def = view({ id: 2, is_default: true, filters: { scope: "all", show: "watched" } });
|
||||
let calls = 0;
|
||||
vi.spyOn(api, "savedViews").mockImplementation(async () => {
|
||||
calls += 1;
|
||||
return calls === 1 ? [def] : [def, view({ id: 9, name: "added-later" })];
|
||||
});
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<SavedViewsProvider>
|
||||
<Probe />
|
||||
</SavedViewsProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
await waitFor(() => expect(screen.getByTestId("loading").textContent).toBe("false"));
|
||||
expect(h.setFeedFilters).toHaveBeenCalledTimes(1);
|
||||
await act(async () => {
|
||||
await qc.refetchQueries({ queryKey: qk.savedViews("feed") });
|
||||
});
|
||||
// Wait until the changed list has actually reached the component, so the seed effect has had its
|
||||
// chance to (wrongly) re-fire — otherwise the assertion could win by racing ahead of the re-render.
|
||||
await waitFor(() => expect(screen.getByTestId("loading").dataset.count).toBe("2"));
|
||||
expect(h.setFeedFilters).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("plex: seeds the starred default into a fresh module (parity with the feed)", async () => {
|
||||
h.page = "plex";
|
||||
await mountResolved([view({ id: 3, is_default: true, filters: { genres: ["Action"] } })]);
|
||||
expect(h.setPlexFilters).toHaveBeenCalledTimes(1);
|
||||
expect(h.setPlexFilters.mock.calls[0]![0]).toMatchObject({ genres: ["Action"] });
|
||||
});
|
||||
|
||||
it("plex: does NOT seed when plex filters are already stored", async () => {
|
||||
h.page = "plex";
|
||||
localStorage.setItem(accountKey(LS.plexFilters, ID)!, JSON.stringify({ genres: ["Comedy"] }));
|
||||
await mountResolved([view({ id: 3, is_default: true, filters: { genres: ["Action"] } })]);
|
||||
expect(h.setPlexFilters).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,12 @@
|
||||
import { createContext, useContext, useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
@@ -6,16 +14,18 @@ import {
|
||||
api,
|
||||
coercePlexFilters,
|
||||
coerceTagModes,
|
||||
coerceUntagged,
|
||||
getActiveAccount,
|
||||
plexFiltersSignature,
|
||||
type FeedFilters,
|
||||
type PlexFilters,
|
||||
type SavedView,
|
||||
} from "../lib/api";
|
||||
import { filtersToParams } from "../lib/urlState";
|
||||
import { preferSticky } from "../lib/savedViews";
|
||||
import { filtersToParams, hasFilterParams } from "../lib/urlState";
|
||||
import { pickDefaultToSeed, preferSticky } from "../lib/savedViews";
|
||||
import { notifyActionError } from "../lib/actionErrors";
|
||||
import { accountKey, LS, writeJSON } from "../lib/storage";
|
||||
import { accountKey, LS } from "../lib/storage";
|
||||
import { useActiveAccountId } from "../lib/activeAccount";
|
||||
import { useConfirm } from "./ConfirmProvider";
|
||||
import { useNavigation } from "./NavigationProvider";
|
||||
import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider";
|
||||
@@ -33,6 +43,7 @@ export const asFilters = (raw: FeedFilters): FeedFilters => ({
|
||||
tags: raw.tags ?? [],
|
||||
channelIds: raw.channelIds ?? [],
|
||||
tagModes: coerceTagModes(raw),
|
||||
untagged: coerceUntagged(raw),
|
||||
});
|
||||
|
||||
type ViewSource = "feed" | "plex";
|
||||
@@ -46,20 +57,35 @@ interface ViewConfig {
|
||||
keyOf: (f: Record<string, unknown>) => string;
|
||||
asFilters: (raw: Record<string, unknown>) => Record<string, unknown>;
|
||||
stripForSave: (f: Record<string, unknown>) => Record<string, unknown>;
|
||||
/** Feed only: mirror the default view's filters to localStorage so it seeds before the network returns. */
|
||||
syncDefaultMirror?: (views: SavedView[]) => void;
|
||||
/** Whether this module is a genuinely fresh start the starred default may seed on first load: no
|
||||
* stored filters for the active account, and — feed only — no URL filter override. */
|
||||
canSeedDefault: () => boolean;
|
||||
}
|
||||
|
||||
const feedKeyOf = (f: Record<string, unknown>) =>
|
||||
filtersToParams(asFilters(f as unknown as FeedFilters)).toString();
|
||||
const plexKeyOf = (f: Record<string, unknown>) => plexFiltersSignature(coercePlexFilters(f));
|
||||
|
||||
// Mirror the feed default view's filters to localStorage (read by FeedFiltersProvider on load).
|
||||
function syncFeedDefaultMirror(views: SavedView[]): void {
|
||||
const key = accountKey(LS.defaultViewFilters, getActiveAccount());
|
||||
if (!key) return;
|
||||
const def = views.find((v) => v.is_default);
|
||||
writeJSON(key, def ? def.filters : null);
|
||||
// True once this account has its own persisted filters for `lsKey` — the signal that the last
|
||||
// view / manual tweaks (not the default) should win. Account unknown or localStorage unavailable →
|
||||
// report "stored" so a default never seeds blind over an indeterminate state.
|
||||
function storedFiltersPresent(lsKey: string): boolean {
|
||||
const key = accountKey(lsKey, getActiveAccount());
|
||||
if (!key) return true;
|
||||
try {
|
||||
return localStorage.getItem(key) != null;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// Feed also yields to a URL that carries filter params (a shared/bookmarked view).
|
||||
function feedCanSeedDefault(): boolean {
|
||||
if (hasFilterParams(new URLSearchParams(window.location.search))) return false;
|
||||
return !storedFiltersPresent(LS.filters);
|
||||
}
|
||||
// Plex views are facets-only, so LS.plexFilters is the "has the user set this module's filters" key.
|
||||
function plexCanSeedDefault(): boolean {
|
||||
return !storedFiltersPresent(LS.plexFilters);
|
||||
}
|
||||
|
||||
export interface SavedViewsContext {
|
||||
@@ -77,6 +103,11 @@ export interface SavedViewsContext {
|
||||
clearActive: () => void;
|
||||
create: (name: string) => Promise<boolean>;
|
||||
rename: (id: number, name: string) => Promise<boolean>;
|
||||
/** Overwrite a view's captured filters (used by the modal's removable chips). */
|
||||
updateFilters: (id: number, filters: Record<string, unknown>) => Promise<boolean>;
|
||||
/** Save the module's CURRENTLY-applied filters into a view (the live→stored inverse of `apply`);
|
||||
* resolves to the saved snapshot on success (so the caller can resync its editor) or null. */
|
||||
saveCurrent: (id: number) => Promise<Record<string, unknown> | null>;
|
||||
toggleDefault: (v: SavedView) => Promise<void>;
|
||||
remove: (v: SavedView) => Promise<boolean>;
|
||||
reorder: (nextIds: number[]) => Promise<void>;
|
||||
@@ -105,6 +136,7 @@ export function SavedViewsProvider({ children }: { children: ReactNode }) {
|
||||
keyOf: plexKeyOf,
|
||||
asFilters: (raw) => coercePlexFilters(raw) as unknown as Record<string, unknown>,
|
||||
stripForSave: (f) => f, // Plex has no transient field to drop
|
||||
canSeedDefault: plexCanSeedDefault,
|
||||
};
|
||||
}
|
||||
return {
|
||||
@@ -119,7 +151,7 @@ export function SavedViewsProvider({ children }: { children: ReactNode }) {
|
||||
const { seed: _seed, ...rest } = f;
|
||||
return rest;
|
||||
},
|
||||
syncDefaultMirror: syncFeedDefaultMirror,
|
||||
canSeedDefault: feedCanSeedDefault,
|
||||
};
|
||||
}, [page, feedFilters, plexFilters, setFeedFilters, setPlexFilters]);
|
||||
|
||||
@@ -140,13 +172,35 @@ export function SavedViewsProvider({ children }: { children: ReactNode }) {
|
||||
setModalOpen(false);
|
||||
}, [source]);
|
||||
|
||||
// Feed only: keep the default-view mirror (read on load) in sync with the server state. Depend on the
|
||||
// mirror fn (a module-stable ref, or undefined for plex) NOT the whole `cfg` object — cfg re-memoizes
|
||||
// on every live filter edit, which would fire this synchronous localStorage write far beyond its intent.
|
||||
const syncMirror = cfg.syncDefaultMirror;
|
||||
// The ONE default-application path for both modules. On the views query's FIRST resolve for a
|
||||
// source, seed the starred default view's filters — but only into a genuinely fresh module (no
|
||||
// stored filters, no URL override; see cfg.canSeedDefault). Applying via cfg.setFilters persists
|
||||
// it, so it happens once and the normal last-view-wins path takes over, exactly as the storage
|
||||
// semantics document. This replaced the feed-only localStorage mirror that raced FeedFiltersProvider's
|
||||
// login effect (on a fresh browser the feed could seed DEFAULT_FILTERS and never re-apply until a
|
||||
// reload) — and it gives Plex the default it previously never applied at all. Per-source guard so a
|
||||
// 60s background refetch never re-seeds over the filters you're editing.
|
||||
//
|
||||
// WAIT FOR THE ACCOUNT PIN. This provider mounts above <App>, which pins the tab-account only after
|
||||
// /api/me — but viewsQuery is ungated, so on a fresh tab it resolves FIRST, while the account is null.
|
||||
// canSeedDefault() reads the per-account stored-filters key, which is indeterminate (→ "assume stored")
|
||||
// with no account, so evaluating it too early would decline to seed AND burn the one-shot guard, never
|
||||
// re-running once the account pins. Gating on the reactive account id makes the effect re-fire on the
|
||||
// pin (useActiveAccountId subscribes), and defers consuming the guard until canSeedDefault is real.
|
||||
const accountId = useActiveAccountId();
|
||||
const seededRef = useRef<Set<ViewSource>>(new Set());
|
||||
useEffect(() => {
|
||||
if (viewsQuery.data) syncMirror?.(viewsQuery.data);
|
||||
}, [viewsQuery.data, syncMirror]);
|
||||
const data = viewsQuery.data;
|
||||
if (!data || accountId == null || seededRef.current.has(source)) return;
|
||||
seededRef.current.add(source);
|
||||
const def = pickDefaultToSeed(data, cfg.canSeedDefault());
|
||||
if (!def) return;
|
||||
cfg.setFilters(cfg.asFilters(def.filters));
|
||||
setStickyId(def.id);
|
||||
// cfg is read fresh on this resolve and the per-source guard makes the body run once, so cfg
|
||||
// (which re-memoizes on every filter edit) is deliberately kept out of the deps.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [viewsQuery.data, source, accountId]);
|
||||
|
||||
const currentKey = cfg.keyOf(cfg.filters);
|
||||
const activeView = useMemo(
|
||||
@@ -209,6 +263,16 @@ export function SavedViewsProvider({ children }: { children: ReactNode }) {
|
||||
if (!next) return false;
|
||||
return runViewMutation(() => api.updateView(id, { name: next }), t("views.actionFailed"));
|
||||
},
|
||||
updateFilters: (id, filters) =>
|
||||
runViewMutation(() => api.updateView(id, { filters }), t("views.actionFailed")),
|
||||
saveCurrent: async (id) => {
|
||||
const filters = cfg.stripForSave(cfg.filters);
|
||||
const ok = await runViewMutation(
|
||||
() => api.updateView(id, { filters }),
|
||||
t("views.actionFailed"),
|
||||
);
|
||||
return ok ? filters : null;
|
||||
},
|
||||
toggleDefault: (v) =>
|
||||
runViewMutation(
|
||||
() => api.updateView(v.id, { is_default: !v.is_default }),
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { SlidersHorizontal } from "lucide-react";
|
||||
import { api, type FeedFilters, type Tag, type TagCategory } from "../lib/api";
|
||||
import { api, NO_TAG_SENTINEL, type FeedFilters, type Tag, type TagCategory } from "../lib/api";
|
||||
import { defaultLayout, type PanelLayout } from "../lib/panelLayout";
|
||||
import { useDebounced } from "../lib/useDebounced";
|
||||
import { useActiveFilters } from "../lib/useActiveFilters";
|
||||
@@ -15,6 +15,7 @@ import { type PanelItem } from "./PanelGroups";
|
||||
import { type MediaSourceKind } from "../lib/mediaSource";
|
||||
import ChannelPicker from "./ChannelPicker";
|
||||
import DateFilterSelect from "./DateFilterSelect";
|
||||
import YearFilter from "./YearFilter";
|
||||
import Combobox, { type ComboboxOption } from "./Combobox";
|
||||
import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider";
|
||||
import { useChannelsActions } from "./ChannelsProvider";
|
||||
@@ -104,12 +105,48 @@ export default function Sidebar({
|
||||
const setTagMode = (category: TagCategory, mode: "or" | "and") =>
|
||||
setFilters({ ...filters, tagModes: { ...filters.tagModes, [category]: mode } });
|
||||
|
||||
// The "Your tags" combobox carries the "No tag" sentinel (item B) at the top: selecting it filters to
|
||||
// channels you HAVEN'T personally tagged in the channel manager (`filters.untagged`). It's mutually
|
||||
// exclusive with picking real user tags — choosing it clears your tag picks, and picking a tag clears
|
||||
// it. Language/Topic are auto/system tags, so "No tag" is meaningless there and only lives here.
|
||||
function userTagsCombo(): ReactNode {
|
||||
const noneOn = !!filters.untagged;
|
||||
const inList = (id: number) => userTags.some((tg) => tg.id === id);
|
||||
const onChange = (next: string[]) => {
|
||||
if (next.includes(NO_TAG_SENTINEL) && !noneOn) {
|
||||
setFilters({ ...filters, tags: filters.tags.filter((id) => !inList(id)), untagged: true });
|
||||
} else {
|
||||
const reals = next.filter((v) => v !== NO_TAG_SENTINEL).map(Number);
|
||||
setFilters({
|
||||
...filters,
|
||||
tags: [...filters.tags.filter((id) => !inList(id)), ...reals],
|
||||
untagged: false,
|
||||
});
|
||||
}
|
||||
};
|
||||
return (
|
||||
<Combobox
|
||||
label={t("sidebar.widget.tags")}
|
||||
options={[
|
||||
{ value: NO_TAG_SENTINEL, label: t("sidebar.noTagOption") },
|
||||
...visibleChips(userTags).map(tagOption),
|
||||
]}
|
||||
selected={noneOn ? [NO_TAG_SENTINEL] : selectedIn(userTags)}
|
||||
onChange={onChange}
|
||||
emptyText={t("sidebar.noMatchingTags")}
|
||||
matchMode={filters.tagModes.other}
|
||||
onMatchModeChange={(m) => setTagMode("other", m)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// The badge counts exactly what the chip row above the feed shows — same list, so they can't
|
||||
// disagree about what's applied.
|
||||
const activeCount = useActiveFilters(filters, setFilters).length;
|
||||
|
||||
const available: Record<string, boolean> = {
|
||||
date: true,
|
||||
year: true,
|
||||
channel: true,
|
||||
language: languages.length > 0,
|
||||
topic: topics.length > 0,
|
||||
@@ -124,7 +161,9 @@ export default function Sidebar({
|
||||
return (
|
||||
<DateFilterSelect filters={filters} setFilters={setFilters} presets={DATE_PRESETS} />
|
||||
);
|
||||
case "language": {
|
||||
case "year":
|
||||
return <YearFilter filters={filters} setFilters={setFilters} />;
|
||||
case "language":
|
||||
return (
|
||||
<Combobox
|
||||
label={t("sidebar.widget.language")}
|
||||
@@ -136,8 +175,7 @@ export default function Sidebar({
|
||||
onMatchModeChange={(m) => setTagMode("language", m)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
case "topic": {
|
||||
case "topic":
|
||||
return (
|
||||
<Combobox
|
||||
label={t("sidebar.widget.topic")}
|
||||
@@ -149,22 +187,10 @@ export default function Sidebar({
|
||||
onMatchModeChange={(m) => setTagMode("topic", m)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
case "tags": {
|
||||
case "tags":
|
||||
// The widget's own "Manage" trigger is its title (see the PanelItem onTitleClick below), so the
|
||||
// body is just the picker.
|
||||
return (
|
||||
<Combobox
|
||||
label={t("sidebar.widget.tags")}
|
||||
options={visibleChips(userTags).map(tagOption)}
|
||||
selected={selectedIn(userTags)}
|
||||
onChange={(next) => setTagSelection(userTags, next)}
|
||||
emptyText={t("sidebar.noMatchingTags")}
|
||||
matchMode={filters.tagModes.other}
|
||||
onMatchModeChange={(m) => setTagMode("other", m)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
// body is just the picker — user tags plus the "No tag" sentinel.
|
||||
return userTagsCombo();
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { fitChipsWithPill } from "../lib/chipOverflow";
|
||||
import { useChipOverflowFit } from "../lib/useChipOverflowFit";
|
||||
import { filterChipClass } from "./FilterChipGroup";
|
||||
import { useAnchoredPopover, PopoverSurface } from "./AnchoredPopover";
|
||||
|
||||
// A single-line row of toggle filter chips that collapses its overflow into a trailing "+N" pill whose
|
||||
// popover holds the rest (still toggleable) — the same compression the feed's applied-filter row uses
|
||||
// (ActiveFilterChips), sharing the same measure core (useChipOverflowFit + fitChipsWithPill). Used by
|
||||
// the channel manager's tag-filter row so it never wraps to a second line and the trailing "…" actions
|
||||
// stay pinned on the first line. Any fixed leading (the TAGS editor trigger) / trailing (Clear, the "…"
|
||||
// menu) chrome lives OUTSIDE this lane in the caller, so only the chips themselves fold into "+N".
|
||||
|
||||
export interface FilterChip {
|
||||
key: string;
|
||||
label: string;
|
||||
active: boolean;
|
||||
onToggle: () => void;
|
||||
}
|
||||
|
||||
function Chip({ chip, extra }: { chip: FilterChip; extra?: string }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={chip.onToggle}
|
||||
aria-pressed={chip.active}
|
||||
className={`${filterChipClass}${extra ? ` ${extra}` : ""}`}
|
||||
>
|
||||
{chip.label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TagFilterChips({
|
||||
chips,
|
||||
className,
|
||||
}: {
|
||||
chips: FilterChip[];
|
||||
className?: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const rowRef = useRef<HTMLDivElement | null>(null);
|
||||
const chipRefs = useRef<Array<HTMLElement | null>>([]);
|
||||
const pillRef = useRef<HTMLSpanElement | null>(null);
|
||||
// Re-fit when the set, the labels, OR the pressed state change (the pressed paint can change a chip's
|
||||
// width via the `.pill` recipe, so an aria-pressed flip must re-measure too).
|
||||
const key = chips.map((c) => `${c.key}:${c.label}:${c.active ? 1 : 0}`).join("|");
|
||||
|
||||
const visible = useChipOverflowFit<number>({
|
||||
rowRef,
|
||||
chipsRef: chipRefs,
|
||||
pillRef,
|
||||
deps: key,
|
||||
initial: chips.length,
|
||||
decide: (g) =>
|
||||
fitChipsWithPill({ chipRights: g.chipRights, pillW: g.pillW, avail: g.avail, gap: g.gap }),
|
||||
});
|
||||
|
||||
const hidden = chips.length - visible;
|
||||
const [open, setOpen] = useState(false);
|
||||
const pop = useAnchoredPopover({
|
||||
open,
|
||||
onOpenChange: setOpen,
|
||||
mode: "menu",
|
||||
placement: "bottom-end",
|
||||
});
|
||||
// If nothing overflows anymore, the "+N" trigger unmounts — force the popover closed so it can't
|
||||
// linger against a gone reference.
|
||||
useEffect(() => {
|
||||
if (hidden === 0 && open) setOpen(false);
|
||||
}, [hidden, open]);
|
||||
|
||||
const moreLabel = t("channels.tags.moreCount", { count: hidden });
|
||||
|
||||
return (
|
||||
<div className={`relative flex items-center gap-1.5 min-w-0 ${className ?? ""}`}>
|
||||
{/* Hidden measurer: all chips + a sample "+N" pill at natural widths, so the shared fit core can
|
||||
read each chip's right edge, the rem gap and the pill width off real boxes. */}
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none invisible absolute left-0 top-0 flex items-center gap-1.5 whitespace-nowrap"
|
||||
>
|
||||
{chips.map((c, i) => (
|
||||
<span
|
||||
key={c.key}
|
||||
ref={(el) => {
|
||||
chipRefs.current[i] = el;
|
||||
}}
|
||||
className="inline-flex"
|
||||
>
|
||||
<span className={filterChipClass}>{c.label}</span>
|
||||
</span>
|
||||
))}
|
||||
<span ref={pillRef} className={filterChipClass}>
|
||||
+{chips.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div ref={rowRef} className="flex items-center gap-1.5 min-w-0 flex-1 overflow-hidden">
|
||||
{chips.slice(0, visible).map((c) => (
|
||||
<Chip key={c.key} chip={c} extra="shrink-0" />
|
||||
))}
|
||||
{hidden > 0 && (
|
||||
<button
|
||||
ref={pop.refs.setReference}
|
||||
type="button"
|
||||
aria-label={moreLabel}
|
||||
className={`${filterChipClass} shrink-0 ${open ? "border-accent text-accent" : ""}`}
|
||||
{...pop.getReferenceProps()}
|
||||
>
|
||||
+{hidden}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<PopoverSurface
|
||||
popover={pop}
|
||||
ariaLabel={moreLabel}
|
||||
className="glass-menu flex max-w-[22rem] flex-wrap items-center gap-1.5 rounded-xl p-2 shadow-lg animate-[popIn_0.16s_ease]"
|
||||
>
|
||||
{chips.slice(visible).map((c) => (
|
||||
<Chip key={c.key} chip={c} />
|
||||
))}
|
||||
</PopoverSurface>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
import Avatar from "./Avatar";
|
||||
import AddToPlaylist from "./AddToPlaylist";
|
||||
import DownloadButton from "./DownloadButton";
|
||||
import VideoInfoCard from "./VideoInfoCard";
|
||||
import clsx from "clsx";
|
||||
import type { Video, VideoStatus } from "../lib/api";
|
||||
import { FEED_VIEW_SPEC, type FeedView } from "../lib/feedView";
|
||||
@@ -105,6 +106,17 @@ function Actions({
|
||||
);
|
||||
}
|
||||
|
||||
// The hover-infobox trigger skins (E8·S3). The ⓘ is hover-revealed and dropped on touch (`reveal-hover`
|
||||
// + the group opacity toggle), and lives OUTSIDE the six-button action budget — as a thumbnail-corner
|
||||
// overlay for the thumbnailed views, and inline beside the actions for the one-line row.
|
||||
const INFO_REVEAL =
|
||||
"reveal-hover opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 focus-visible:opacity-100 transition-opacity";
|
||||
const INFO_OVERLAY = clsx(
|
||||
INFO_REVEAL,
|
||||
"absolute bottom-1.5 left-1.5 z-10 rounded-md bg-black/70 p-1 text-white hover:bg-black/85",
|
||||
);
|
||||
const INFO_INLINE = clsx(INFO_REVEAL, "rounded-md p-1.5 text-muted hover:bg-surface hover:text-fg");
|
||||
|
||||
// Plain left-click opens the in-app player (the experiment); Ctrl/Cmd/middle-click
|
||||
// keep the native "open youtube.com in a new tab" behavior via the underlying href.
|
||||
function openInApp(
|
||||
@@ -262,9 +274,12 @@ function Thumb({
|
||||
<Bookmark className="w-3.5 h-3.5" />
|
||||
</span>
|
||||
)}
|
||||
{/* Hover overlay: Play everywhere; Continue + Restart once the video is in progress. */}
|
||||
{/* Hover overlay: Play everywhere; Continue + Restart once the video is in progress. Mouse-only:
|
||||
reveal on hover AND on keyboard focus-within (else a Tab-focused button is invisible), and
|
||||
`reveal-hover` drops it entirely on touch — the card tap already plays/resumes there, so a
|
||||
permanent dark scrim over every mobile thumbnail (and an invisible tap target) is not wanted. */}
|
||||
{onOpen && (
|
||||
<div className="absolute inset-0 flex items-center justify-center gap-2 bg-black/45 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<div className="absolute inset-0 flex items-center justify-center gap-2 bg-black/45 opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 transition-opacity reveal-hover">
|
||||
{inProgress ? (
|
||||
<>
|
||||
<button
|
||||
@@ -309,6 +324,10 @@ function Thumb({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* Hover infobox trigger (E8·S3), docked bottom-left so it clears the centered play overlay, the
|
||||
corner badges (saved / stream, top corners) and the duration (bottom-right). Revealed with the
|
||||
rest of the hover chrome, dropped on touch. */}
|
||||
<VideoInfoCard video={video} className={INFO_OVERLAY} />
|
||||
{/* Resume progress bar (sits at the very bottom, under the duration badge). */}
|
||||
{pct > 0 && (
|
||||
<div className="absolute bottom-0 left-0 right-0 h-1 bg-black/40">
|
||||
@@ -327,7 +346,7 @@ function Thumb({
|
||||
// Keep this table and the classes in step; each number is measured against the worst case in the
|
||||
// LIBRARY and in BOTH languages — see the row's own comment for where each comes from.
|
||||
const ROW_COL = {
|
||||
actions: 192, // w-48 — six buttons at 28 + five 4px gaps = 188
|
||||
actions: 224, // w-48 (six buttons: 6×28 + 5×4 = 188) + gap-1 + the 28px inline info trigger
|
||||
channel: 128, // w-32
|
||||
meta: 104, // RowMetaCells' own grid: 4rem + 1rem + 1rem + two 4px gaps
|
||||
views: 64, // w-16
|
||||
@@ -505,8 +524,13 @@ function VideoCard({
|
||||
className={clsx(rowShell, "flex items-center gap-3 px-3 py-2", watched && "opacity-55")}
|
||||
>
|
||||
{/* Actions lead: this is where the eye already is (the title is the thing you read), so
|
||||
they're the shortest pointer trip from it. */}
|
||||
<div className="shrink-0 w-48">{actions}</div>
|
||||
they're the shortest pointer trip from it. This row has no thumbnail to host the info
|
||||
trigger, so it sits inline right after the actions (still outside their six-button
|
||||
budget). ROW_COL.actions already counts its width. */}
|
||||
<div className="shrink-0 flex items-center gap-1">
|
||||
<div className="w-48">{actions}</div>
|
||||
<VideoInfoCard video={video} className={INFO_INLINE} />
|
||||
</div>
|
||||
{/* Everything else is shrink-0, so the title is the only thing that can give — which means
|
||||
once the fixed columns outgrow the row, it gives ALL of it and vanishes. Columns drop
|
||||
from the right as the row narrows, least important first: a title with no date beats a
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// Contract of the feed-card hover infobox (E8·S3): the trigger stays closed until opened; on open the
|
||||
// header (full title + channel) renders IMMEDIATELY from the Video we already hold, while the
|
||||
// description (videoDetail) and the channel's tags (channelDetail.tag_ids resolved against the tag
|
||||
// list) fill in from their on-demand queries; an empty description falls back to the "no description"
|
||||
// line. i18n is key-passthrough and the AnchoredPopover platform is floating-ui (hover mode → open via
|
||||
// focus), so we stub the observers autoUpdate wires and wrap in a FloatingTree + QueryClient.
|
||||
import { describe, it, expect, afterEach, beforeAll, vi } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
||||
import { FloatingTree } from "@floating-ui/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
|
||||
};
|
||||
});
|
||||
|
||||
const videoDetail = vi.fn();
|
||||
const channelDetail = vi.fn();
|
||||
const tags = vi.fn();
|
||||
vi.mock("../lib/api", () => ({
|
||||
api: {
|
||||
videoDetail: (...a: unknown[]) => videoDetail(...a),
|
||||
channelDetail: (...a: unknown[]) => channelDetail(...a),
|
||||
tags: (...a: unknown[]) => tags(...a),
|
||||
},
|
||||
}));
|
||||
|
||||
import VideoInfoCard from "./VideoInfoCard";
|
||||
import type { Video } from "../lib/api";
|
||||
|
||||
beforeAll(() => {
|
||||
class Stub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
|
||||
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
const video = (over: Partial<Video> = {}): Video =>
|
||||
({
|
||||
id: "v1",
|
||||
title: "The full untruncated title",
|
||||
channel_id: "c1",
|
||||
channel_title: "Test Channel",
|
||||
channel_thumbnail: null,
|
||||
channel_url: "https://youtube.com/c1",
|
||||
published_at: "2026-02-24T00:00:00Z",
|
||||
thumbnail_url: null,
|
||||
duration_seconds: 754,
|
||||
view_count: 1512,
|
||||
is_short: false,
|
||||
live_status: "none",
|
||||
status: "new",
|
||||
position_seconds: 0,
|
||||
saved: false,
|
||||
watch_url: "https://youtu.be/v1",
|
||||
...over,
|
||||
}) as Video;
|
||||
|
||||
const open = (v: Video = video()) => {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<FloatingTree>
|
||||
<VideoInfoCard video={v} className="info-trigger" />
|
||||
</FloatingTree>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
fireEvent.focus(screen.getByRole("button", { name: "card.info" }));
|
||||
};
|
||||
|
||||
describe("VideoInfoCard", () => {
|
||||
it("renders a trigger, panel closed until opened (no fetch at rest)", () => {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<FloatingTree>
|
||||
<VideoInfoCard video={video()} className="info-trigger" />
|
||||
</FloatingTree>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
expect(screen.getByRole("button", { name: "card.info" })).toBeTruthy();
|
||||
expect(screen.queryByText("The full untruncated title")).toBeNull();
|
||||
// `enabled: open` gates the queries — nothing fired while closed.
|
||||
expect(videoDetail).not.toHaveBeenCalled();
|
||||
expect(channelDetail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows the header immediately from the Video, then fills in description + tags on load", async () => {
|
||||
videoDetail.mockResolvedValue({ id: "v1", description: "A longer description snippet." });
|
||||
channelDetail.mockResolvedValue({ id: "c1", tag_ids: [1, 3] });
|
||||
tags.mockResolvedValue([
|
||||
{ id: 1, name: "English", category: "language" },
|
||||
{ id: 2, name: "Unused", category: "topic" },
|
||||
{ id: 3, name: "Astronomy", category: "topic" },
|
||||
]);
|
||||
open();
|
||||
// Header is from the Video prop — present without waiting on any query.
|
||||
expect(screen.getByText("The full untruncated title")).toBeTruthy();
|
||||
expect(screen.getByText("Test Channel")).toBeTruthy();
|
||||
// Description + only the channel's own tags (1 + 3, not 2) once the queries resolve.
|
||||
expect(await screen.findByText("A longer description snippet.")).toBeTruthy();
|
||||
expect(await screen.findByText("English")).toBeTruthy();
|
||||
expect(await screen.findByText("Astronomy")).toBeTruthy();
|
||||
expect(screen.queryByText("Unused")).toBeNull();
|
||||
});
|
||||
|
||||
it("falls back to the no-description line when the video has none", async () => {
|
||||
videoDetail.mockResolvedValue({ id: "v1", description: null });
|
||||
channelDetail.mockResolvedValue({ id: "c1", tag_ids: [] });
|
||||
tags.mockResolvedValue([]);
|
||||
open();
|
||||
expect(await screen.findByText("card.noDescription")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Info } from "lucide-react";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { api, type Video } from "../lib/api";
|
||||
import { formatDate, formatDuration, formatViews } from "../lib/format";
|
||||
import { linkify } from "../lib/linkify";
|
||||
import Avatar from "./Avatar";
|
||||
import { PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
|
||||
|
||||
// The feed card's "more than the card face" hover infobox (E8·S3, decision D3): full (un-clamped)
|
||||
// title, channel, views·duration·date, a description snippet, and the channel's tags. It hangs off a
|
||||
// small ⓘ button — a thumbnail-corner overlay in the four thumbnailed views, an inline button beside
|
||||
// the actions in the one-line row (see the caller). It reveals only with the other hover controls on a
|
||||
// mouse and is dropped entirely on touch (`.reveal-hover`), where a tap already plays/resumes and a
|
||||
// hover card would only be an invisible trap. Hover mode also opens it on keyboard focus, so it stays
|
||||
// reachable by Tab.
|
||||
//
|
||||
// The card face already carries title/channel/date/duration/views, so the box's real added value is
|
||||
// the FULL title (the card clamps to 2 lines) plus the description + tags, which aren't in the feed
|
||||
// list payload. Both are fetched ON DEMAND on open (like AboutCell): the description via
|
||||
// `videoDetail`, the tags by resolving the channel's `tag_ids` (from `channelDetail`, shared with the
|
||||
// channel page's cache) against the tag list. Until they land the header still shows from the Video
|
||||
// we already have, so the box is never empty.
|
||||
//
|
||||
// The caller owns the trigger's placement + reveal via `className`: a thumbnail-corner overlay for the
|
||||
// four thumbnailed views, an inline button beside the actions for the one-line row. The action ROW
|
||||
// itself is budgeted to exactly six buttons (a seventh wraps in every view — see VideoCard's ROW_COL),
|
||||
// so the trigger deliberately lives OUTSIDE it.
|
||||
export default function VideoInfoCard({
|
||||
video,
|
||||
className,
|
||||
}: {
|
||||
video: Video;
|
||||
/** Trigger-button classes (placement + hover-reveal + skin). The reveal/touch-suppression rules
|
||||
* live here so each host can match its surface (overlay vs inline). */
|
||||
className?: string;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const pop = useAnchoredPopover({
|
||||
open,
|
||||
onOpenChange: setOpen,
|
||||
mode: "hover",
|
||||
placement: "bottom-end",
|
||||
offset: 6,
|
||||
// A touch of open intent (so brushing past the button while reaching another control doesn't
|
||||
// pop it), a longer close grace so the cursor can travel into the interactive card.
|
||||
hoverDelay: { open: 140, close: 160 },
|
||||
fitHeight: true,
|
||||
});
|
||||
|
||||
const { data: detail } = useQuery({
|
||||
queryKey: qk.videoDetail(video.id),
|
||||
queryFn: () => api.videoDetail(video.id),
|
||||
enabled: open,
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
const { data: channel } = useQuery({
|
||||
queryKey: qk.channel(video.channel_id),
|
||||
queryFn: () => api.channelDetail(video.channel_id),
|
||||
enabled: open && !!video.channel_id,
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
const { data: tags } = useQuery({
|
||||
queryKey: qk.tags(),
|
||||
queryFn: () => api.tags(),
|
||||
enabled: open,
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
|
||||
// The channel's tags (`tag_ids`), in the channel's own id order, resolved to names via the tag list.
|
||||
// These are the channel-tags the channel actually carries; an unknown id (a just-deleted tag) drops.
|
||||
const tagNames =
|
||||
channel && tags
|
||||
? channel.tag_ids
|
||||
.map((id) => tags.find((tg) => tg.id === id))
|
||||
.filter((tg): tg is NonNullable<typeof tg> => !!tg)
|
||||
: [];
|
||||
|
||||
const description = detail?.description?.trim();
|
||||
const metaBits = [
|
||||
video.view_count != null ? `${formatViews(video.view_count)} ${t("card.views")}` : null,
|
||||
video.duration_seconds != null ? formatDuration(video.duration_seconds) : null,
|
||||
video.published_at ? formatDate(video.published_at, i18n.language) : null,
|
||||
].filter(Boolean);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
ref={pop.refs.setReference}
|
||||
{...pop.getReferenceProps()}
|
||||
// The overlay trigger sits inside the thumbnail's <a> (like the play buttons), so a click must
|
||||
// both preventDefault (no navigation) and stopPropagation (no card open); harmless inline too.
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
aria-label={t("card.info")}
|
||||
title={t("card.info")}
|
||||
className={className}
|
||||
>
|
||||
<Info className="w-4 h-4" />
|
||||
</button>
|
||||
<PopoverSurface
|
||||
popover={pop}
|
||||
ariaLabel={t("card.info")}
|
||||
className="glass w-[340px] max-w-[92vw] overflow-y-auto rounded-lg px-3.5 py-3 animate-[fadeIn_0.12s_ease]"
|
||||
style={{ maxHeight: "min(26rem, var(--fit-max-h, 26rem))" }}
|
||||
>
|
||||
<p className="text-sm font-medium leading-snug wrap-break-word">{video.title}</p>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<Avatar
|
||||
src={video.channel_thumbnail}
|
||||
fallback={video.channel_title ?? ""}
|
||||
className="w-6 h-6 rounded-full shrink-0"
|
||||
/>
|
||||
<span className="text-xs text-muted truncate">{video.channel_title}</span>
|
||||
</div>
|
||||
{metaBits.length > 0 && <p className="mt-1.5 text-xs text-muted">{metaBits.join(" · ")}</p>}
|
||||
{description ? (
|
||||
<p className="mt-2.5 text-xs leading-relaxed text-muted line-clamp-5 wrap-break-word">
|
||||
{linkify(description)}
|
||||
</p>
|
||||
) : detail ? (
|
||||
<p className="mt-2.5 text-xs italic text-muted">{t("card.noDescription")}</p>
|
||||
) : null}
|
||||
{tagNames.length > 0 && (
|
||||
<div className="mt-3 flex flex-wrap gap-1.5">
|
||||
{tagNames.map((tg) => (
|
||||
<span key={tg.id} className="glass-card px-2 py-0.5 rounded-full text-2xs">
|
||||
{tg.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</PopoverSurface>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { api, type FeedFilters } from "../lib/api";
|
||||
import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
|
||||
import { filterChipClass } from "./FilterChipGroup";
|
||||
import CountBadge from "./CountBadge";
|
||||
import { useDebounced } from "../lib/useDebounced";
|
||||
|
||||
// The feed rail's "Year" control (item 8 / E8·S2): quick chips for the most recent years plus a
|
||||
// "More…" popover listing ALL years with per-year counts, sortable by year or count. Selecting a year
|
||||
// filters the feed to that calendar year (a whole-year published_after/before range, applied in the
|
||||
// api layer); it's a single-select toggle (click the active year to clear it) and mutually exclusive
|
||||
// with the Upload-date control — picking a year clears any maxAge/custom range, and vice versa.
|
||||
//
|
||||
// The per-year counts come from /feed/years, which drops the publish-date narrowing itself, so the
|
||||
// list is keyed on the date-stripped filters: picking a year re-queries the FEED but never this list
|
||||
// (its numbers wouldn't change) — the same drill-down principle as the tag facets dropping their
|
||||
// own category.
|
||||
|
||||
const QUICK = 6; // recent-year chips shown before the "More…" overflow
|
||||
|
||||
export default function YearFilter({
|
||||
filters,
|
||||
setFilters,
|
||||
}: {
|
||||
filters: FeedFilters;
|
||||
setFilters: (f: FeedFilters) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [sortByCount, setSortByCount] = useState(false);
|
||||
|
||||
// Debounce the search term (like the sibling summary facet in Sidebar) so typing doesn't refire the
|
||||
// per-year count query on every keystroke — each distinct q is a fresh cache key, so without this
|
||||
// an N-char query would issue N /feed/years requests. The year/date narrowings are dropped: this
|
||||
// list is a live drill-down that must not change when a year (or upload-date) is picked.
|
||||
const facetFilters: FeedFilters = {
|
||||
...filters,
|
||||
q: useDebounced(filters.q, 300),
|
||||
year: undefined,
|
||||
maxAgeDays: undefined,
|
||||
dateFrom: undefined,
|
||||
dateTo: undefined,
|
||||
};
|
||||
const yearsQuery = useQuery({
|
||||
queryKey: qk.feedYears(facetFilters),
|
||||
queryFn: () => api.feedYears(facetFilters),
|
||||
staleTime: 30_000,
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
const years = yearsQuery.data?.years ?? [];
|
||||
const selected = filters.year;
|
||||
|
||||
// A year pick and the Upload-date control are mutually exclusive publish-date narrowings.
|
||||
const pick = (year?: number) =>
|
||||
setFilters({ ...filters, year, maxAgeDays: undefined, dateFrom: undefined, dateTo: undefined });
|
||||
|
||||
// Quick chips = the most recent years, plus the selected year if it falls outside that window (so the
|
||||
// active pick is always visible), re-sorted newest-first.
|
||||
const quick = useMemo(() => {
|
||||
const top = years.slice(0, QUICK);
|
||||
if (selected != null && !top.some((y) => y.year === selected)) {
|
||||
const sel = years.find((y) => y.year === selected) ?? { year: selected, count: 0 };
|
||||
return [...top, sel].sort((a, b) => b.year - a.year);
|
||||
}
|
||||
return top;
|
||||
}, [years, selected]);
|
||||
|
||||
const sorted = useMemo(
|
||||
() =>
|
||||
[...years].sort((a, b) =>
|
||||
sortByCount ? b.count - a.count || b.year - a.year : b.year - a.year,
|
||||
),
|
||||
[years, sortByCount],
|
||||
);
|
||||
|
||||
const pop = useAnchoredPopover({
|
||||
open,
|
||||
onOpenChange: setOpen,
|
||||
mode: "menu",
|
||||
placement: "bottom-start",
|
||||
});
|
||||
|
||||
// Honest states: a first-load / errored fetch must not read as "no years" (an empty chip row).
|
||||
if (years.length === 0) {
|
||||
if (yearsQuery.isLoading) {
|
||||
return (
|
||||
<p className="text-muted px-1 py-1.5 text-xs" role="status" aria-live="polite">
|
||||
{t("common.loading")}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
if (yearsQuery.isError) {
|
||||
return (
|
||||
<p className="text-muted px-1 py-1.5 text-xs" role="alert">
|
||||
{t("common.loadError")}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
return <p className="text-muted px-1 py-1.5 text-xs">{t("sidebar.year.none")}</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{quick.map((y) => {
|
||||
const on = selected === y.year;
|
||||
return (
|
||||
<button
|
||||
key={y.year}
|
||||
type="button"
|
||||
aria-pressed={on}
|
||||
onClick={() => pick(on ? undefined : y.year)}
|
||||
className={filterChipClass}
|
||||
>
|
||||
<span className="tabular-nums">{y.year}</span>
|
||||
{y.count > 0 && <CountBadge count={y.count} active={on} />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
{years.length > quick.length && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
ref={pop.refs.setReference}
|
||||
{...pop.getReferenceProps()}
|
||||
aria-label={t("sidebar.year.moreLabel")}
|
||||
className={filterChipClass}
|
||||
>
|
||||
<span>{t("sidebar.year.more")}</span>
|
||||
<ChevronDown className="text-muted h-3.5 w-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
|
||||
<PopoverSurface
|
||||
popover={pop}
|
||||
ariaLabel={t("sidebar.year.moreLabel")}
|
||||
className="glass-menu min-w-44 rounded-xl p-1.5 shadow-lg animate-[popIn_0.16s_ease]"
|
||||
>
|
||||
<div className="mb-1 flex items-center gap-2 border-b border-border px-1 pb-2">
|
||||
<span className="text-2xs text-muted">{t("sidebar.year.sortBy")}</span>
|
||||
<div className="ml-auto flex gap-1">
|
||||
{(
|
||||
[
|
||||
["sortYear", false],
|
||||
["sortCount", true],
|
||||
] as const
|
||||
).map(([key, v]) => (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
aria-pressed={sortByCount === v}
|
||||
onClick={() => setSortByCount(v)}
|
||||
className={`text-2xs rounded-md border px-2 py-0.5 transition ${
|
||||
sortByCount === v
|
||||
? "border-accent bg-accent/15 text-accent"
|
||||
: "border-border text-muted hover:border-accent/50 hover:text-fg"
|
||||
}`}
|
||||
>
|
||||
{t("sidebar.year." + key)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="max-h-64 overflow-y-auto no-scrollbar"
|
||||
role="group"
|
||||
aria-label={t("sidebar.year.moreLabel")}
|
||||
>
|
||||
{sorted.map((y) => {
|
||||
const on = selected === y.year;
|
||||
return (
|
||||
<PopoverItem
|
||||
key={y.year}
|
||||
role="menuitemradio"
|
||||
checked={on}
|
||||
onSelect={() => {
|
||||
pick(on ? undefined : y.year);
|
||||
setOpen(false);
|
||||
}}
|
||||
label={String(y.year)}
|
||||
className={`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm transition ${
|
||||
on ? "text-fg" : "text-muted"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`h-1.5 w-1.5 shrink-0 rounded-full ${on ? "bg-accent" : "bg-transparent"}`}
|
||||
/>
|
||||
<span className="flex-1 tabular-nums">{y.year}</span>
|
||||
<span className="text-2xs text-muted shrink-0 tabular-nums">{y.count}</span>
|
||||
</PopoverItem>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</PopoverSurface>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,8 +5,8 @@
|
||||
// "fully" | "needsFull" | "" vocab + predicate — asserted here so a regression can't silently change
|
||||
// which channels the top chips show.
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { applySyncFilter } from "./channelColumns";
|
||||
import { type ManagedChannel } from "../lib/api";
|
||||
import { applySyncFilter, tagsColumnTest } from "./channelColumns";
|
||||
import { NO_TAG_SENTINEL, type ManagedChannel } from "../lib/api";
|
||||
|
||||
const ch = (over: Partial<ManagedChannel>): ManagedChannel => ({
|
||||
id: "c",
|
||||
@@ -51,3 +51,23 @@ describe("applySyncFilter", () => {
|
||||
expect(applySyncFilter(rows, "needsFull").map((r) => r.id)).toEqual(["recentOnly", "none"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("tagsColumnTest (item B — 'No tag')", () => {
|
||||
const tagged = ch({ id: "tagged", tag_ids: [1, 2] });
|
||||
const untagged = ch({ id: "untagged", tag_ids: [] });
|
||||
|
||||
it("the No-tag sentinel keeps only channels with no user tag", () => {
|
||||
expect(tagsColumnTest(tagged, [NO_TAG_SENTINEL])).toBe(false);
|
||||
expect(tagsColumnTest(untagged, [NO_TAG_SENTINEL])).toBe(true);
|
||||
});
|
||||
|
||||
it("real tag values match ANY selected id (sentinel absent)", () => {
|
||||
expect(tagsColumnTest(tagged, ["2"])).toBe(true);
|
||||
expect(tagsColumnTest(tagged, ["3"])).toBe(false);
|
||||
expect(tagsColumnTest(untagged, ["1"])).toBe(false);
|
||||
});
|
||||
|
||||
it("the sentinel wins if it somehow co-exists with real ids", () => {
|
||||
expect(tagsColumnTest(tagged, [NO_TAG_SENTINEL, "1"])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,14 +2,14 @@ import type { ComponentProps } from "react";
|
||||
import type { TFunction } from "i18next";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Check, Eye, EyeOff, History, UserMinus } from "lucide-react";
|
||||
import type { ManagedChannel } from "../lib/api";
|
||||
import { NO_TAG_SENTINEL, type ManagedChannel } from "../lib/api";
|
||||
import { formatCountOrDash, formatTotalHours, relativeTime } from "../lib/format";
|
||||
import type { Column } from "./DataTable";
|
||||
import Tooltip from "./Tooltip";
|
||||
import { PriorityStepper } from "./PriorityStepper";
|
||||
import ChannelLink from "./ChannelLink";
|
||||
import AboutCell from "./AboutCell";
|
||||
import { ChannelTagsControl } from "./ChannelTags";
|
||||
import { ChannelTagsControl, type SuggestedTag } from "./ChannelTags";
|
||||
|
||||
/** The shared "subscribers" DataTable column (right-aligned, sortable, em-dash for null) — identical
|
||||
* in the Channels manager and the ChannelDiscovery table, which show subscriber counts on different
|
||||
@@ -42,6 +42,14 @@ export function applySyncFilter(rows: ManagedChannel[], value: unknown): Managed
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** The Tags column filter predicate (item B): the "No tag" sentinel keeps channels the user hasn't
|
||||
* personally tagged (empty `tag_ids`); it's exclusive with real tag picks (the chip row enforces that),
|
||||
* so when present it wins. Otherwise a channel passes if it carries ANY selected tag id. */
|
||||
export function tagsColumnTest(c: ManagedChannel, values: string[]): boolean {
|
||||
if (values.includes(NO_TAG_SENTINEL)) return c.tag_ids.length === 0;
|
||||
return values.some((v) => c.tag_ids.includes(Number(v)));
|
||||
}
|
||||
|
||||
// The Stored count, with a "fully synced" ✓ charm to the left of the number when recent uploads and
|
||||
// full history are both in — folding the retired Sync column's at-a-glance status into the value it
|
||||
// describes. Full detail stays in the hover tooltip.
|
||||
@@ -158,6 +166,10 @@ export interface ChannelColumnDeps {
|
||||
toggleTag: (channelId: string, tagId: number, hasTag: boolean) => void;
|
||||
createAndAttachTag: (channelId: string, name: string) => void;
|
||||
onFilterByTag: (tagId: number, name: string) => void;
|
||||
// E8·S4 — suggested user tags per channel (resolved to Tag + confidence), plus accept/dismiss.
|
||||
suggestionsFor: (channelId: string) => SuggestedTag[];
|
||||
onAcceptSuggestion: (channelId: string, tagId: number) => void;
|
||||
onDismissSuggestion: (channelId: string, tagId: number) => void;
|
||||
}
|
||||
|
||||
export function buildChannelColumns(deps: ChannelColumnDeps): Column<ManagedChannel>[] {
|
||||
@@ -177,13 +189,16 @@ export function buildChannelColumns(deps: ChannelColumnDeps): Column<ManagedChan
|
||||
toggleTag,
|
||||
createAndAttachTag,
|
||||
onFilterByTag,
|
||||
suggestionsFor,
|
||||
onAcceptSuggestion,
|
||||
onDismissSuggestion,
|
||||
} = deps;
|
||||
return [
|
||||
{
|
||||
key: "priority",
|
||||
header: t("channels.cols.priority"),
|
||||
align: "center",
|
||||
width: "3.5rem",
|
||||
width: "5.5rem",
|
||||
sortable: true,
|
||||
hideInCard: true,
|
||||
sortValue: (c) => c.priority,
|
||||
@@ -289,7 +304,7 @@ export function buildChannelColumns(deps: ChannelColumnDeps): Column<ManagedChan
|
||||
filter: {
|
||||
kind: "multi",
|
||||
options: userTags.map((tg) => ({ value: String(tg.id), label: tg.name })),
|
||||
test: (c, values) => values.some((v) => c.tag_ids.includes(Number(v))),
|
||||
test: tagsColumnTest,
|
||||
},
|
||||
render: (c) => (
|
||||
<ChannelTagsControl
|
||||
@@ -298,6 +313,9 @@ export function buildChannelColumns(deps: ChannelColumnDeps): Column<ManagedChan
|
||||
onToggle={(tagId) => toggleTag(c.id, tagId, c.tag_ids.includes(tagId))}
|
||||
onCreate={(name) => createAndAttachTag(c.id, name)}
|
||||
onChipClick={onFilterByTag}
|
||||
suggestions={suggestionsFor(c.id)}
|
||||
onAcceptSuggestion={(tagId) => onAcceptSuggestion(c.id, tagId)}
|
||||
onDismissSuggestion={(tagId) => onDismissSuggestion(c.id, tagId)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -4,19 +4,13 @@ import type { TFunction } from "i18next";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Check, ListMusic, Lock, Plus, Search, X } from "lucide-react";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import {
|
||||
api,
|
||||
HttpError,
|
||||
isIntrinsicReadOnly,
|
||||
type PlexCard,
|
||||
type PlexCollection,
|
||||
} from "../lib/api";
|
||||
import { api, isIntrinsicReadOnly, type PlexCard, type PlexCollection } from "../lib/api";
|
||||
import { formatRuntime } from "../lib/format";
|
||||
import { notifyActionError } from "../lib/actionErrors";
|
||||
import { notify } from "../lib/notifications";
|
||||
import { notifyCollectionEditError } from "../lib/actionErrors";
|
||||
import { useDebounced } from "../lib/useDebounced";
|
||||
import { LoadingState, StateMessage } from "./QueryState";
|
||||
import PosterTile from "./PosterTile";
|
||||
import PlexLibraryPicker from "./PlexLibraryPicker";
|
||||
|
||||
// A collection tile's sub-line: item count · total runtime (each dropped when unknown, e.g. show-only
|
||||
// collections have no movie runtime).
|
||||
@@ -86,7 +80,6 @@ function NewCollectionPanel({
|
||||
const [name, setName] = useState("");
|
||||
const [term, setTerm] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const debounced = useDebounced(term.trim(), 300);
|
||||
const trimmed = name.trim();
|
||||
// Advisory only: `existingNames` is the search-scoped, all-library collection union, whereas the
|
||||
// backend enforces uniqueness PER library (Plex only conflates same-named collections within one
|
||||
@@ -94,12 +87,6 @@ function NewCollectionPanel({
|
||||
// same-name collection in another library would be un-creatable. The backend's 409 is authoritative.
|
||||
const dupHint = trimmed.length > 0 && existingNames.has(trimmed.toLowerCase());
|
||||
const nameOk = trimmed.length > 0;
|
||||
const q = useQuery({
|
||||
queryKey: qk.plexLibrary("collection-new", debounced),
|
||||
queryFn: () => api.plexLibrary({ scope: "both", q: debounced, limit: 12 }),
|
||||
enabled: nameOk && debounced.length > 0,
|
||||
});
|
||||
const results = q.data?.items ?? [];
|
||||
|
||||
async function create(card: PlexCard) {
|
||||
if (!nameOk || busy) return;
|
||||
@@ -108,13 +95,8 @@ function NewCollectionPanel({
|
||||
const col = await api.plexCreateCollection("", trimmed, card.id); // backend derives the library
|
||||
onCreated(col.id);
|
||||
} catch (e) {
|
||||
// The backend is authoritative on per-library name uniqueness — localize its 409 to nameTaken
|
||||
// (notifyActionError would otherwise surface the server's raw English detail).
|
||||
if (e instanceof HttpError && e.status === 409) {
|
||||
notify({ level: "error", message: t("plex.collection.nameTaken") });
|
||||
} else {
|
||||
notifyActionError(e, t("plex.collection.editFailed"));
|
||||
}
|
||||
// The backend is authoritative on per-library name uniqueness — its 409 is localized to nameTaken.
|
||||
notifyCollectionEditError(e, t("plex.collection.editFailed"), t("plex.collection.nameTaken"));
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
@@ -155,38 +137,21 @@ function NewCollectionPanel({
|
||||
className="glass-card flex-1 rounded-lg px-3 py-1.5 text-sm focus:outline-hidden focus:ring-2 focus:ring-accent/40 disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
{!nameOk || debounced.length === 0 ? null : q.isLoading ? (
|
||||
<LoadingState label={t("plex.loading")} />
|
||||
) : q.isError ? (
|
||||
<p className="text-muted text-sm">{t("common.loadError")}</p>
|
||||
) : results.length === 0 ? (
|
||||
<p className="text-muted text-sm">{t("plex.collection.addNoResults")}</p>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-1">
|
||||
{results.map((c) => (
|
||||
<li key={c.id} className="flex items-center gap-3">
|
||||
<img
|
||||
src={c.thumb}
|
||||
alt=""
|
||||
className="h-12 w-8 shrink-0 rounded object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate text-sm">
|
||||
{c.title}
|
||||
{c.year != null && <span className="text-muted"> · {c.year}</span>}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => create(c)}
|
||||
disabled={busy}
|
||||
className="glass-card glass-hover inline-flex items-center gap-1 rounded-lg px-2.5 py-1 text-xs disabled:opacity-50"
|
||||
>
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
{t("plex.collection.createWith")}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<PlexLibraryPicker
|
||||
scope="both"
|
||||
term={term}
|
||||
enabled={nameOk}
|
||||
renderAction={(c) => (
|
||||
<button
|
||||
onClick={() => create(c)}
|
||||
disabled={busy}
|
||||
className="glass-card glass-hover inline-flex items-center gap-1 rounded-lg px-2.5 py-1 text-xs disabled:opacity-50"
|
||||
>
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
{t("plex.collection.createWith")}
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { plexLang } from "../i18n";
|
||||
import { pickRatingBadge } from "../lib/plexRating";
|
||||
import { notifyActionError } from "../lib/actionErrors";
|
||||
import { toggleWatchState } from "../lib/plexWatch";
|
||||
import { showEpisodeKeys } from "../lib/plexQueue";
|
||||
import {
|
||||
ActionBtn,
|
||||
BackBtn,
|
||||
@@ -306,7 +307,7 @@ export function PlexShowView({
|
||||
|
||||
const show = d?.show;
|
||||
const showLib = show?.library ?? undefined;
|
||||
const allKeys = (d?.seasons ?? []).flatMap((se) => se.episodes.map((e) => e.id));
|
||||
const allKeys = showEpisodeKeys(d);
|
||||
const watched = show?.status === "watched";
|
||||
const {
|
||||
artBg,
|
||||
@@ -568,7 +569,7 @@ export function PlexSeasonView({
|
||||
// Play-through queue spans the WHOLE show (all seasons, in order) so a season's last episode rolls
|
||||
// straight into the next season's first — the same cross-season order the show page already uses.
|
||||
// seasonKeys stays only for the season-scoped "add to playlist" action.
|
||||
const allKeys = (d?.seasons ?? []).flatMap((s) => s.episodes.map((e) => e.id));
|
||||
const allKeys = showEpisodeKeys(d);
|
||||
const watched = se?.status === "watched";
|
||||
const { artBg, showCast, crossfade, toggleArtBg, toggleCast, toggleCrossfade } = useDetailPrefs();
|
||||
useCrossfadeBackdrop(
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// Container-logic contract of the tag-suggestion hook (E8·S4), the wiring the presentational
|
||||
// ChannelTagsControl test can't reach: suggestionsFor resolves tag ids to Tags and DROPS a suggestion
|
||||
// whose tag was deleted since the last compute; accept optimistically removes the WHOLE channel from
|
||||
// the /tags/suggestions cache and toggles the tag on; dismiss is session-only and scoped to one
|
||||
// (channel, tag); visible/high-confidence derive correctly. Runs against a real QueryClient so the
|
||||
// optimistic cache write is the actual code path (not a re-implementation).
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import type { ReactNode } from "react";
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({ t: (k: string) => k, i18n: { language: "en" } }),
|
||||
};
|
||||
});
|
||||
// The confirm dialog's answer is controllable per test (the bulk-accept path gates on it).
|
||||
const confirmState = vi.hoisted(() => ({ result: true }));
|
||||
vi.mock("./ConfirmProvider", () => ({
|
||||
useConfirm: () => () => Promise.resolve(confirmState.result),
|
||||
}));
|
||||
|
||||
import { api, type Tag } from "../lib/api";
|
||||
import type { TagSuggestions } from "../lib/api/tags";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { useTagSuggestions } from "./useTagSuggestions";
|
||||
|
||||
const tag = (id: number, name: string): Tag => ({
|
||||
id,
|
||||
name,
|
||||
color: null,
|
||||
category: "other",
|
||||
system: false,
|
||||
channel_count: 0,
|
||||
});
|
||||
const docu = tag(1, "docu");
|
||||
const history = tag(2, "history");
|
||||
const tags = [docu, history];
|
||||
|
||||
// UC_a: two suggestions (one high-confidence). UC_b: one. UC_ghost: a suggestion for a tag id that no
|
||||
// longer exists in the catalog (deleted since compute) → must be filtered out everywhere.
|
||||
const seed = (): TagSuggestions => ({
|
||||
UC_a: [
|
||||
{ tag_id: docu.id, confidence: 0.95 },
|
||||
{ tag_id: history.id, confidence: 0.6 },
|
||||
],
|
||||
UC_b: [{ tag_id: history.id, confidence: 0.7 }],
|
||||
UC_ghost: [{ tag_id: 999, confidence: 0.99 }],
|
||||
});
|
||||
|
||||
function setup() {
|
||||
const qc = new QueryClient({
|
||||
// Freeze the seeded cache so no refetch clobbers the optimistic writes under test.
|
||||
defaultOptions: { queries: { retry: false, staleTime: Infinity, refetchOnMount: false } },
|
||||
});
|
||||
qc.setQueryData(qk.tagSuggestions(), seed());
|
||||
vi.spyOn(api, "tagSuggestions").mockResolvedValue(seed());
|
||||
const toggleTag = vi.fn();
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
|
||||
);
|
||||
const { result } = renderHook(() => useTagSuggestions({ tags, toggleTag }), { wrapper });
|
||||
return { qc, toggleTag, result };
|
||||
}
|
||||
|
||||
describe("useTagSuggestions", () => {
|
||||
it("resolves tag ids to Tags and drops suggestions whose tag was deleted", () => {
|
||||
const { result } = setup();
|
||||
const forA = result.current.suggestionsFor("UC_a");
|
||||
expect(forA.map((s) => s.tag.id)).toEqual([docu.id, history.id]);
|
||||
// The suggestion for the missing tag id 999 is dropped, not rendered as a broken chip.
|
||||
expect(result.current.suggestionsFor("UC_ghost")).toEqual([]);
|
||||
});
|
||||
|
||||
it("derives visible and high-confidence suggestions, excluding the deleted-tag one", () => {
|
||||
const { result } = setup();
|
||||
// UC_a(docu 0.95) + UC_a(history 0.6) + UC_b(history 0.7) = 3; UC_ghost excluded.
|
||||
expect(result.current.visibleSuggestions).toHaveLength(3);
|
||||
// Only the ≥0.9 one (UC_a/docu) is high-confidence.
|
||||
expect(result.current.highConfidence).toEqual([
|
||||
{ cid: "UC_a", tagId: docu.id, confidence: 0.95 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("accept optimistically removes the whole channel from the cache and toggles the tag on", () => {
|
||||
const { qc, toggleTag, result } = setup();
|
||||
act(() => result.current.acceptSuggestion("UC_a", docu.id));
|
||||
// The real tag is attached (hasTag=false → attach).
|
||||
expect(toggleTag).toHaveBeenCalledWith("UC_a", docu.id, false);
|
||||
// The whole channel is dropped from the suggestions cache (attached lane XOR ghost lane), so its
|
||||
// OTHER ghost chip (history) goes too — not just the accepted one.
|
||||
const cache = qc.getQueryData<TagSuggestions>(qk.tagSuggestions());
|
||||
expect(cache?.UC_a).toBeUndefined();
|
||||
expect(cache?.UC_b).toBeDefined(); // other channels untouched
|
||||
});
|
||||
|
||||
it("dismiss hides only that one (channel, tag) for the session", () => {
|
||||
const { result } = setup();
|
||||
act(() => result.current.dismissSuggestion("UC_a", history.id));
|
||||
// UC_a loses only the history suggestion; docu stays.
|
||||
expect(result.current.suggestionsFor("UC_a").map((s) => s.tag.id)).toEqual([docu.id]);
|
||||
// The same tag on a DIFFERENT channel is not dismissed (dismiss is scoped to the pair).
|
||||
expect(result.current.suggestionsFor("UC_b").map((s) => s.tag.id)).toEqual([history.id]);
|
||||
});
|
||||
|
||||
it("bulk-accept applies ONLY the high-confidence suggestions after a confirm", async () => {
|
||||
confirmState.result = true;
|
||||
const { toggleTag, result } = setup();
|
||||
await act(async () => {
|
||||
await result.current.acceptAllHighConfidence();
|
||||
});
|
||||
// Only UC_a/docu (0.95 ≥ 0.9) is accepted — not history (0.6) or UC_b/history (0.7).
|
||||
expect(toggleTag).toHaveBeenCalledTimes(1);
|
||||
expect(toggleTag).toHaveBeenCalledWith("UC_a", docu.id, false);
|
||||
});
|
||||
|
||||
it("bulk-accept does nothing when the confirm is cancelled", async () => {
|
||||
confirmState.result = false;
|
||||
const { toggleTag, result } = setup();
|
||||
await act(async () => {
|
||||
await result.current.acceptAllHighConfidence();
|
||||
});
|
||||
expect(toggleTag).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { api, type Tag, type TagSuggestions } from "../lib/api";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { useConfirm } from "./ConfirmProvider";
|
||||
import type { SuggestedTag } from "./ChannelTags";
|
||||
|
||||
// A tag suggestion at/above this score is "high confidence" — the only ones the header's bulk
|
||||
// "accept all" acts on. Mirrors AUTOAPPLY_THRESHOLD in backend app/sync/tagsuggest.py (kept in
|
||||
// lockstep by intent: the two aren't imported across the stack, so a change must touch both).
|
||||
const HIGH_CONFIDENCE = 0.9;
|
||||
|
||||
type VisibleSuggestion = { cid: string; tagId: number; confidence: number };
|
||||
|
||||
/** Suggested-tag state for the Channel Manager (E8·S4): the /tags/suggestions query, a session-only
|
||||
* dismiss set, and the accept / dismiss / bulk-accept actions. Suggestions are learned from the
|
||||
* user's own manual-tag pattern and shown as ghost chips on UNTAGGED channels. Extracted as a hook
|
||||
* (like useChannelTagActions) so the optimistic-cache and dismiss logic is unit-testable apart from
|
||||
* the large Channels component. */
|
||||
export function useTagSuggestions({
|
||||
tags,
|
||||
toggleTag,
|
||||
}: {
|
||||
/** The full tag catalog, to resolve a suggestion's tag_id → Tag (and drop tags deleted since compute). */
|
||||
tags: Tag[];
|
||||
/** The shared attach/detach toggle. Its onError must also refetch suggestions (see Channels) so a
|
||||
* failed accept restores the ghost chips this hook optimistically removed. */
|
||||
toggleTag: (channelId: string, tagId: number, hasTag: boolean) => void;
|
||||
}) {
|
||||
const qc = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
const confirm = useConfirm();
|
||||
const suggestionsQuery = useQuery({ queryKey: qk.tagSuggestions(), queryFn: api.tagSuggestions });
|
||||
const suggestions: TagSuggestions = suggestionsQuery.data ?? {};
|
||||
const tagById = useMemo(() => new Map(tags.map((tg) => [tg.id, tg])), [tags]);
|
||||
const [dismissed, setDismissed] = useState<Set<string>>(() => new Set());
|
||||
const sKey = (cid: string, tagId: number) => `${cid}:${tagId}`;
|
||||
|
||||
// Resolve a channel's raw {tag_id, confidence} list to renderable {tag, confidence}, dropping any
|
||||
// that were dismissed this session or whose tag no longer exists (deleted since the last compute).
|
||||
const suggestionsFor = (cid: string): SuggestedTag[] =>
|
||||
(suggestions[cid] ?? []).flatMap((s) => {
|
||||
const tag = tagById.get(s.tag_id);
|
||||
return tag && !dismissed.has(sKey(cid, s.tag_id)) ? [{ tag, confidence: s.confidence }] : [];
|
||||
});
|
||||
|
||||
const dismissSuggestion = (cid: string, tagId: number) =>
|
||||
setDismissed((prev) => new Set(prev).add(sKey(cid, tagId)));
|
||||
|
||||
const acceptSuggestion = (cid: string, tagId: number) => {
|
||||
// Optimistically drop the WHOLE channel from the suggestions cache: attaching any tag makes it
|
||||
// "tagged", which the server excludes from suggestions entirely — so its other ghost chips would
|
||||
// vanish on the next refetch anyway. Removing them now keeps the ghost lane and the attached lane
|
||||
// mutually exclusive (no transient of both showing). toggleTag attaches the real tag; on success
|
||||
// its onSuccess re-invalidates suggestions, and on failure its onError refetches them — so a
|
||||
// failed accept restores these ghost chips rather than leaving an empty cell (see Channels' onError).
|
||||
qc.setQueryData<TagSuggestions>(qk.tagSuggestions(), (old) => {
|
||||
if (!old?.[cid]) return old;
|
||||
const next = { ...old };
|
||||
delete next[cid];
|
||||
return next;
|
||||
});
|
||||
toggleTag(cid, tagId, false);
|
||||
};
|
||||
|
||||
// Every currently-visible suggestion (all channels, minus dismissed) and the high-confidence subset
|
||||
// the bulk action accepts.
|
||||
const visibleSuggestions: VisibleSuggestion[] = Object.keys(suggestions).flatMap((cid) =>
|
||||
suggestionsFor(cid).map((s) => ({ cid, tagId: s.tag.id, confidence: s.confidence })),
|
||||
);
|
||||
const highConfidence = visibleSuggestions.filter((s) => s.confidence >= HIGH_CONFIDENCE);
|
||||
const acceptAllHighConfidence = async () => {
|
||||
const ok = await confirm({
|
||||
title: t("channels.tags.acceptAllTitle"),
|
||||
message: t("channels.tags.acceptAllConfirm", { count: highConfidence.length }),
|
||||
confirmLabel: t("channels.tags.acceptAllConfirmCta"),
|
||||
});
|
||||
if (!ok) return;
|
||||
highConfidence.forEach(({ cid, tagId }) => acceptSuggestion(cid, tagId));
|
||||
};
|
||||
|
||||
return {
|
||||
suggestionsFor,
|
||||
dismissSuggestion,
|
||||
acceptSuggestion,
|
||||
visibleSuggestions,
|
||||
highConfidence,
|
||||
acceptAllHighConfidence,
|
||||
};
|
||||
}
|
||||
@@ -17,5 +17,7 @@
|
||||
"restart": "Restart",
|
||||
"play": "Play",
|
||||
"stream": "stream",
|
||||
"views": "views"
|
||||
"views": "views",
|
||||
"info": "Details",
|
||||
"noDescription": "No description"
|
||||
}
|
||||
|
||||
@@ -22,6 +22,10 @@
|
||||
"block": "Block channel",
|
||||
"unblock": "Unblock channel",
|
||||
"blockedBadge": "Blocked",
|
||||
"hide": "Hide from feed",
|
||||
"unhide": "Unhide",
|
||||
"hideHint": "Hide this channel's videos from your feed. It stays subscribed; this doesn't unsubscribe on YouTube.",
|
||||
"hiddenHint": "Hidden — this channel's videos are kept out of your feed. Click to show them again.",
|
||||
"country": "Country",
|
||||
"language": "Language",
|
||||
"topics": "Topics",
|
||||
|
||||
@@ -47,11 +47,28 @@
|
||||
},
|
||||
"tags": {
|
||||
"label": "Tags",
|
||||
"add": "Tag",
|
||||
"yourTagsHint": "Your personal labels. Attach them to channels below, then filter the feed by tag from the sidebar. (Separate from the automatic language/topic tags.)",
|
||||
"noneYet": "No tags yet — create one below.",
|
||||
"create": "Create tag",
|
||||
"createPlaceholder": "New tag…",
|
||||
"createFailed": "Could not create the tag."
|
||||
"createFailed": "Could not create the tag.",
|
||||
"updateFailed": "Could not update the tag.",
|
||||
"noTag": "No tag",
|
||||
"moreCount_one": "{{count}} more tag",
|
||||
"moreCount_other": "{{count}} more tags",
|
||||
"suggestedHint": "Suggested from your tagging pattern ({{pct}}% match) — click to add",
|
||||
"acceptSuggestion": "Add suggested tag {{name}}",
|
||||
"dismissSuggestion": "Dismiss suggestion",
|
||||
"suggestionsHint": "Tags suggested for your untagged channels, learned from how you've tagged similar ones. Click a ghost chip to add it, or × to dismiss.",
|
||||
"suggestionsCount_one": "{{count}} suggestion",
|
||||
"suggestionsCount_other": "{{count}} suggestions",
|
||||
"acceptAllHigh_one": "Accept {{count}} confident",
|
||||
"acceptAllHigh_other": "Accept {{count}} confident",
|
||||
"acceptAllTitle": "Accept suggested tags",
|
||||
"acceptAllConfirm_one": "Add the {{count}} high-confidence suggested tag to its channel?",
|
||||
"acceptAllConfirm_other": "Add the {{count}} high-confidence suggested tags to their channels?",
|
||||
"acceptAllConfirmCta": "Accept all"
|
||||
},
|
||||
"loading": "Loading channels…",
|
||||
"empty": "No channels.",
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"label": "Filtered by",
|
||||
"remove": "Remove filter: {{name}}",
|
||||
"contentNone": "No content types",
|
||||
"untagged": "Untagged",
|
||||
"count_one": "{{count}} filter",
|
||||
"count_other": "{{count}} filters",
|
||||
"moreCount_one": "{{count}} more filter",
|
||||
|
||||
@@ -23,6 +23,8 @@
|
||||
"added": "Added",
|
||||
"deleteConfirm": "Delete the collection \"{{title}}\"? This removes it from Plex too.",
|
||||
"editFailed": "Collection edit failed",
|
||||
"playFailed": "Couldn't build the play queue — try again.",
|
||||
"playEmpty": "Nothing to play in this collection yet.",
|
||||
"newCollection": "New collection",
|
||||
"namePlaceholder": "Collection name",
|
||||
"nameTaken": "A collection with this name already exists.",
|
||||
@@ -43,6 +45,7 @@
|
||||
},
|
||||
"sort": {
|
||||
"added": "Recently added",
|
||||
"lastwatched": "Last watched",
|
||||
"title": "Title",
|
||||
"year": "Year",
|
||||
"rating": "IMDb rating",
|
||||
@@ -151,6 +154,7 @@
|
||||
},
|
||||
"player": {
|
||||
"loading": "Loading…",
|
||||
"buffering": "Buffering…",
|
||||
"back": "Back",
|
||||
"skipIntro": "Skip intro",
|
||||
"skipCredits": "Skip credits",
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
"clearDates": "clear dates",
|
||||
"reshuffle": "Reshuffle",
|
||||
"noMatchingTags": "No matching tags here",
|
||||
"noTagOption": "No tag",
|
||||
"manageTags": "Manage",
|
||||
"widget": {
|
||||
"savedviews": "Saved views",
|
||||
@@ -37,7 +38,16 @@
|
||||
"language": "Language",
|
||||
"topic": "Topic",
|
||||
"tags": "Your tags",
|
||||
"channel": "Channel"
|
||||
"channel": "Channel",
|
||||
"year": "Year"
|
||||
},
|
||||
"year": {
|
||||
"more": "More…",
|
||||
"moreLabel": "All years",
|
||||
"none": "No years yet",
|
||||
"sortBy": "Sort",
|
||||
"sortYear": "Year",
|
||||
"sortCount": "Count"
|
||||
},
|
||||
"show": {
|
||||
"unwatched": "Unwatched",
|
||||
|
||||
@@ -19,6 +19,9 @@
|
||||
"capturedFilters": "Captured filters",
|
||||
"capturedNone": "No filters — matches every video.",
|
||||
"default": "Default",
|
||||
"saveCurrent": "Save current",
|
||||
"saveCurrentHint": "Overwrite this view with the filters currently applied",
|
||||
"saveCurrentUpToDate": "This view already matches the current filters",
|
||||
"selectPrompt": "Select a view to see and edit it.",
|
||||
"modified": "Modified — the current filters differ from this saved view."
|
||||
}
|
||||
|
||||
@@ -17,5 +17,7 @@
|
||||
"restart": "Elölről",
|
||||
"play": "Lejátszás",
|
||||
"stream": "stream",
|
||||
"views": "megtekintés"
|
||||
"views": "megtekintés",
|
||||
"info": "Részletek",
|
||||
"noDescription": "Nincs leírás"
|
||||
}
|
||||
|
||||
@@ -22,6 +22,10 @@
|
||||
"block": "Csatorna tiltása",
|
||||
"unblock": "Tiltás feloldása",
|
||||
"blockedBadge": "Tiltva",
|
||||
"hide": "Elrejtés a hírfolyamból",
|
||||
"unhide": "Megjelenítés",
|
||||
"hideHint": "A csatorna videóinak elrejtése a hírfolyamodból. Feliratkozva marad; ez nem iratkoztat le a YouTube-on.",
|
||||
"hiddenHint": "Elrejtve — a csatorna videói nem jelennek meg a hírfolyamodban. Kattints, hogy újra láthatóak legyenek.",
|
||||
"country": "Ország",
|
||||
"language": "Nyelv",
|
||||
"topics": "Témák",
|
||||
|
||||
@@ -47,11 +47,28 @@
|
||||
},
|
||||
"tags": {
|
||||
"label": "Címkék",
|
||||
"add": "Címke",
|
||||
"yourTagsHint": "Saját személyes címkéid. Csatold őket az alábbi csatornákhoz, majd szűrd a hírfolyamot címke szerint az oldalsávból. (Az automatikus nyelvi/téma címkéktől függetlenül.)",
|
||||
"noneYet": "Még nincs címke — hozz létre egyet lent.",
|
||||
"create": "Címke létrehozása",
|
||||
"createPlaceholder": "Új címke…",
|
||||
"createFailed": "A címke létrehozása nem sikerült."
|
||||
"createFailed": "A címke létrehozása nem sikerült.",
|
||||
"updateFailed": "A címke frissítése nem sikerült.",
|
||||
"noTag": "Nincs címke",
|
||||
"moreCount_one": "még {{count}} címke",
|
||||
"moreCount_other": "még {{count}} címke",
|
||||
"suggestedHint": "A címkézési mintád alapján javasolt ({{pct}}% egyezés) — kattints a hozzáadáshoz",
|
||||
"acceptSuggestion": "{{name}} javasolt címke hozzáadása",
|
||||
"dismissSuggestion": "Javaslat elvetése",
|
||||
"suggestionsHint": "A címkézetlen csatornáidhoz javasolt címkék, abból tanulva, hogyan címkézted a hasonlókat. Kattints egy szellem-chipre a hozzáadáshoz, vagy ×-re az elvetéshez.",
|
||||
"suggestionsCount_one": "{{count}} javaslat",
|
||||
"suggestionsCount_other": "{{count}} javaslat",
|
||||
"acceptAllHigh_one": "{{count}} biztos elfogadása",
|
||||
"acceptAllHigh_other": "{{count}} biztos elfogadása",
|
||||
"acceptAllTitle": "Javasolt címkék elfogadása",
|
||||
"acceptAllConfirm_one": "Hozzáadod a(z) {{count}} nagy magabiztosságú javasolt címkét a csatornájához?",
|
||||
"acceptAllConfirm_other": "Hozzáadod a(z) {{count}} nagy magabiztosságú javasolt címkét a csatornáikhoz?",
|
||||
"acceptAllConfirmCta": "Mind elfogadása"
|
||||
},
|
||||
"loading": "Csatornák betöltése…",
|
||||
"empty": "Nincsenek csatornák.",
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"label": "Szűrve",
|
||||
"remove": "Szűrő eltávolítása: {{name}}",
|
||||
"contentNone": "Nincs tartalomtípus",
|
||||
"untagged": "Címkézetlen",
|
||||
"count_one": "{{count}} szűrő",
|
||||
"count_other": "{{count}} szűrő",
|
||||
"moreCount_one": "még {{count}} szűrő",
|
||||
|
||||
@@ -23,6 +23,8 @@
|
||||
"added": "Hozzáadva",
|
||||
"deleteConfirm": "Törlöd a(z) „{{title}}” kollekciót? Ez a Plexből is eltávolítja.",
|
||||
"editFailed": "A kollekció szerkesztése sikertelen",
|
||||
"playFailed": "Nem sikerült összeállítani a lejátszási sort — próbáld újra.",
|
||||
"playEmpty": "Ebben a kollekcióban még nincs mit lejátszani.",
|
||||
"newCollection": "Új kollekció",
|
||||
"namePlaceholder": "Kollekció neve",
|
||||
"nameTaken": "Már létezik ilyen nevű kollekció.",
|
||||
@@ -43,6 +45,7 @@
|
||||
},
|
||||
"sort": {
|
||||
"added": "Nemrég hozzáadott",
|
||||
"lastwatched": "Utoljára nézve",
|
||||
"title": "Cím",
|
||||
"year": "Év",
|
||||
"rating": "IMDb pont",
|
||||
@@ -151,6 +154,7 @@
|
||||
},
|
||||
"player": {
|
||||
"loading": "Betöltés…",
|
||||
"buffering": "Pufferelés…",
|
||||
"back": "Vissza",
|
||||
"skipIntro": "Intro átugrása",
|
||||
"skipCredits": "Stáblista átugrása",
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
"clearDates": "dátumok törlése",
|
||||
"reshuffle": "Újrakeverés",
|
||||
"noMatchingTags": "Nincs ide illő címke",
|
||||
"noTagOption": "Nincs címke",
|
||||
"manageTags": "Kezelés",
|
||||
"widget": {
|
||||
"savedviews": "Mentett nézetek",
|
||||
@@ -37,7 +38,16 @@
|
||||
"language": "Nyelv",
|
||||
"topic": "Téma",
|
||||
"tags": "Saját címkék",
|
||||
"channel": "Csatorna"
|
||||
"channel": "Csatorna",
|
||||
"year": "Év"
|
||||
},
|
||||
"year": {
|
||||
"more": "Több…",
|
||||
"moreLabel": "Összes év",
|
||||
"none": "Még nincs év",
|
||||
"sortBy": "Rendezés",
|
||||
"sortYear": "Év",
|
||||
"sortCount": "Darab"
|
||||
},
|
||||
"show": {
|
||||
"unwatched": "Nem nézett",
|
||||
|
||||
@@ -19,6 +19,9 @@
|
||||
"capturedFilters": "Rögzített szűrők",
|
||||
"capturedNone": "Nincs szűrő — minden videóra illeszkedik.",
|
||||
"default": "Alapértelmezett",
|
||||
"saveCurrent": "Aktuális mentése",
|
||||
"saveCurrentHint": "Felülírja ezt a nézetet a jelenleg alkalmazott szűrőkkel",
|
||||
"saveCurrentUpToDate": "Ez a nézet már megegyezik a jelenlegi szűrőkkel",
|
||||
"selectPrompt": "Válassz egy nézetet a megtekintéshez és szerkesztéshez.",
|
||||
"modified": "Módosítva — a jelenlegi szűrők eltérnek a mentett nézettől."
|
||||
}
|
||||
|
||||
@@ -581,6 +581,16 @@ body {
|
||||
.reveal-idle {
|
||||
display: none;
|
||||
}
|
||||
/* Sibling of `.reveal` for a MOUSE-ONLY hover-reveal: unlike `.reveal` (shown at rest on touch),
|
||||
a `.reveal-hover` control is removed entirely on a device that can't hover, so it is never an
|
||||
invisible tap/Tab target. Pair it with the site's own `opacity-0 group-hover:… group-focus-within:…`
|
||||
(which still governs the hover + keyboard case). For a full-card/edge affordance whose action is
|
||||
already reachable on touch another way — VideoCard's play overlay (tapping the card plays/resumes)
|
||||
and the Carousel arrows (swiping scrolls the strip) — where a permanent scrim/edge button is not
|
||||
wanted. Group-agnostic (encodes only the touch removal), so it works under any `.group`/`group\/…`. */
|
||||
.reveal-hover {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Dark mode: the ambient accent pools are faint over a near-black bg, so the docked frosted
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// jsdom (not node): importing ../api pulls in i18n, which reads navigator.language at module load.
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
// Spy on the toast sink so we can assert exactly what each error maps to.
|
||||
vi.mock("./notifications", () => ({ notify: vi.fn(() => 0) }));
|
||||
|
||||
import { HttpError } from "./api";
|
||||
import { notify } from "./notifications";
|
||||
import { notifyCollectionEditError } from "./actionErrors";
|
||||
|
||||
const notifyMock = vi.mocked(notify);
|
||||
beforeEach(() => notifyMock.mockClear());
|
||||
|
||||
describe("notifyCollectionEditError", () => {
|
||||
it("maps a backend 409 to the friendly nameTaken message (not the raw server detail)", () => {
|
||||
notifyCollectionEditError(new HttpError(409, "already exists"), "EDIT_FAILED", "NAME_TAKEN");
|
||||
expect(notifyMock).toHaveBeenCalledWith({ level: "error", message: "NAME_TAKEN" });
|
||||
});
|
||||
|
||||
it("falls back to the server detail for a non-409 HttpError", () => {
|
||||
notifyCollectionEditError(new HttpError(500, "server boom"), "EDIT_FAILED", "NAME_TAKEN");
|
||||
expect(notifyMock).toHaveBeenCalledWith({ level: "error", message: "server boom" });
|
||||
});
|
||||
|
||||
it("falls back to the generic edit-failed message for a non-HttpError", () => {
|
||||
notifyCollectionEditError(new Error("network"), "EDIT_FAILED", "NAME_TAKEN");
|
||||
expect(notifyMock).toHaveBeenCalledWith({ level: "error", message: "EDIT_FAILED" });
|
||||
});
|
||||
});
|
||||
@@ -12,3 +12,22 @@ export function notifyActionError(err: unknown, fallbackMessage: string): void {
|
||||
const detail = err instanceof HttpError ? err.detail : null;
|
||||
notify({ level: "error", message: detail || fallbackMessage });
|
||||
}
|
||||
|
||||
/**
|
||||
* Collection write errors. The backend enforces per-library name uniqueness and returns 409 on a
|
||||
* duplicate; localize that specific case to the friendly `nameTaken` copy (notifyActionError would
|
||||
* otherwise surface the server's raw English detail). Everything else falls back to the generic
|
||||
* edit-failed handling (which still prefers the server's reason when present). Both args are already
|
||||
* translated so this stays i18n-agnostic and unit-testable.
|
||||
*/
|
||||
export function notifyCollectionEditError(
|
||||
err: unknown,
|
||||
editFailedMessage: string,
|
||||
nameTakenMessage: string,
|
||||
): void {
|
||||
if (err instanceof HttpError && err.status === 409) {
|
||||
notify({ level: "error", message: nameTakenMessage });
|
||||
return;
|
||||
}
|
||||
notifyActionError(err, editFailedMessage);
|
||||
}
|
||||
|
||||
@@ -32,7 +32,13 @@ export type * from "./api/setup";
|
||||
import { feedApi } from "./api/feed";
|
||||
export type * from "./api/feed";
|
||||
export type * from "./api/tagModes";
|
||||
export { DEFAULT_TAG_MODES, TAG_CATEGORIES, coerceTagModes } from "./api/tagModes";
|
||||
export {
|
||||
DEFAULT_TAG_MODES,
|
||||
TAG_CATEGORIES,
|
||||
NO_TAG_SENTINEL,
|
||||
coerceTagModes,
|
||||
coerceUntagged,
|
||||
} from "./api/tagModes";
|
||||
import { channelsApi } from "./api/channels";
|
||||
export type * from "./api/channels";
|
||||
import { syncApi } from "./api/sync";
|
||||
@@ -56,6 +62,8 @@ export {
|
||||
plexFilterCount,
|
||||
coercePlexFilters,
|
||||
plexFiltersSignature,
|
||||
plexFiltersToParams,
|
||||
parsePlexFilters,
|
||||
isIntrinsicReadOnly,
|
||||
} from "./api/plex";
|
||||
import { messagingApi } from "./api/messaging";
|
||||
|
||||
@@ -32,6 +32,7 @@ export interface ChannelDetail {
|
||||
count_live: number;
|
||||
deep_requested: boolean;
|
||||
priority: number;
|
||||
hidden: boolean;
|
||||
backfill_done: boolean;
|
||||
from_explore: boolean;
|
||||
details_synced: boolean;
|
||||
|
||||
@@ -71,6 +71,9 @@ export interface FeedFilters {
|
||||
// Library (scope "all") only — provenance filter: "organic" (default) hides search-
|
||||
// discovered videos, "all" mixes them in, "search" shows only them. Ignored for "my".
|
||||
librarySource?: "organic" | "all" | "search" | "plex";
|
||||
/** "No tag" filter (item B): keep only channels the user has NOT personally tagged in the channel
|
||||
* manager (no user-owned ChannelTag). Auto/system tags don't count. */
|
||||
untagged?: boolean;
|
||||
/** OR-ed together: a video from ANY of these passes, and every other filter still applies. */
|
||||
channelIds: string[];
|
||||
maxAgeDays?: number;
|
||||
@@ -78,6 +81,16 @@ export interface FeedFilters {
|
||||
maxDuration?: number;
|
||||
dateFrom?: string;
|
||||
dateTo?: string;
|
||||
/** Filter-by-year (item 8): a single calendar year, expressed on the wire as a whole-year
|
||||
* published_after/before range. Mutually exclusive with maxAgeDays/dateFrom/dateTo (all
|
||||
* publish-date narrowings) — the setters clear the others, and `filterParams` lets year win. */
|
||||
year?: number;
|
||||
}
|
||||
|
||||
/** One year's video count for the year control's chips + "more…" dialog (item 8 / E8·S2). */
|
||||
export interface YearCount {
|
||||
year: number;
|
||||
count: number;
|
||||
}
|
||||
|
||||
// The filter half of the query (everything except paging/sort), shared by the feed and
|
||||
@@ -89,6 +102,7 @@ function filterParams(f: FeedFilters): URLSearchParams {
|
||||
for (const cat of TAG_CATEGORIES) {
|
||||
if (f.tagModes[cat] === "and") p.set(`tag_mode_${cat}`, "and");
|
||||
}
|
||||
if (f.untagged) p.set("untagged", "true");
|
||||
if (f.q) p.set("q", f.q);
|
||||
p.set("scope", f.scope);
|
||||
p.set("show_normal", String(f.includeNormal));
|
||||
@@ -96,9 +110,16 @@ function filterParams(f: FeedFilters): URLSearchParams {
|
||||
p.set("include_live", String(f.includeLive));
|
||||
p.set("show", f.show);
|
||||
f.channelIds.forEach((id) => p.append("channel_ids", id));
|
||||
if (f.maxAgeDays) p.set("max_age_days", String(f.maxAgeDays));
|
||||
if (f.dateFrom) p.set("published_after", f.dateFrom);
|
||||
if (f.dateTo) p.set("published_before", f.dateTo);
|
||||
// Year (item 8) is a whole-year publish-date range and wins over the maxAge/custom-range controls
|
||||
// (they're mutually exclusive publish-date narrowings — the setters enforce it, this guards the wire).
|
||||
if (f.year) {
|
||||
p.set("published_after", `${f.year}-01-01`);
|
||||
p.set("published_before", `${f.year}-12-31`);
|
||||
} else {
|
||||
if (f.maxAgeDays) p.set("max_age_days", String(f.maxAgeDays));
|
||||
if (f.dateFrom) p.set("published_after", f.dateFrom);
|
||||
if (f.dateTo) p.set("published_before", f.dateTo);
|
||||
}
|
||||
if (f.minDuration != null) p.set("min_duration", String(f.minDuration));
|
||||
if (f.maxDuration != null) p.set("max_duration", String(f.maxDuration));
|
||||
// Only relevant in Library scope; the backend ignores it for "my". Default = hide search.
|
||||
@@ -144,6 +165,10 @@ export const feedApi = {
|
||||
// the separate feedCount + facets the feed and sidebar used to fire on every filter change (PERF-13).
|
||||
feedSummary: (f: FeedFilters): Promise<{ count: number; facets: Record<string, number> }> =>
|
||||
req(`/api/feed/summary?${filterParams(f).toString()}`),
|
||||
// Per-year video counts for the year control (item 8 / E8·S2). The backend drops the publish-date
|
||||
// narrowing, so callers key this on the date-stripped filters (picking a year mustn't refetch it).
|
||||
feedYears: (f: FeedFilters): Promise<{ years: YearCount[] }> =>
|
||||
req(`/api/feed/years?${filterParams(f).toString()}`),
|
||||
// idempotent: setting a state / saving a progress checkpoint is safe to replay, so these
|
||||
// recover from a transient gateway/keepalive 502 instead of surfacing it (see req()).
|
||||
setState: (id: string, status: string) =>
|
||||
|
||||
@@ -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 }[];
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { coerceTagModes, DEFAULT_TAG_MODES } from "./tagModes";
|
||||
import { coerceTagModes, coerceUntagged, DEFAULT_TAG_MODES } from "./tagModes";
|
||||
|
||||
// coerceTagModes runs on every persisted saved-view blob (SavedViewsProvider.asFilters), on the URL
|
||||
// parse, and on the localStorage seed — the single migration point for the legacy global `tagMode`.
|
||||
@@ -40,3 +40,15 @@ describe("coerceTagModes", () => {
|
||||
expect(coerceTagModes({ tagModes: { language: 1 as unknown as "or" } }).language).toBe("or");
|
||||
});
|
||||
});
|
||||
|
||||
// coerceUntagged guards the "No tag" (item B) flag on the same persisted/URL/saved-view paths.
|
||||
describe("coerceUntagged", () => {
|
||||
it("is true only for an explicit untagged:true", () => {
|
||||
expect(coerceUntagged({ untagged: true })).toBe(true);
|
||||
expect(coerceUntagged(undefined)).toBe(false);
|
||||
expect(coerceUntagged({})).toBe(false);
|
||||
expect(coerceUntagged(null)).toBe(false);
|
||||
expect(coerceUntagged({ untagged: "true" })).toBe(false);
|
||||
expect(coerceUntagged({ untagged: ["other"] })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,6 +10,11 @@ export type TagModes = Record<TagCategory, TagMode>;
|
||||
export const TAG_CATEGORIES: readonly TagCategory[] = ["language", "topic", "other"];
|
||||
export const DEFAULT_TAG_MODES: TagModes = { language: "or", topic: "or", other: "or" };
|
||||
|
||||
/** UI sentinel for the "No tag" (item B) option — the feed's user-tags combobox and the channel
|
||||
* manager's tag chip row both use it to mean "channels I haven't personally tagged". Not a real tag
|
||||
* id (those are numeric strings), so it can never collide with one. */
|
||||
export const NO_TAG_SENTINEL = "__none__";
|
||||
|
||||
/** Normalise a persisted/shared filter's tag match into the per-category shape, migrating the legacy
|
||||
* global `tagMode` (which only ever affected topics) into the topic slot. */
|
||||
export function coerceTagModes(raw: unknown): TagModes {
|
||||
@@ -22,3 +27,8 @@ export function coerceTagModes(raw: unknown): TagModes {
|
||||
};
|
||||
return { language: pick("language"), topic: pick("topic"), other: pick("other") };
|
||||
}
|
||||
|
||||
/** Normalise a persisted/shared filter's "No tag" (item B) flag to a boolean. */
|
||||
export function coerceUntagged(raw: unknown): boolean {
|
||||
return (raw as { untagged?: unknown } | null)?.untagged === true;
|
||||
}
|
||||
|
||||
@@ -10,8 +10,17 @@ export interface Tag {
|
||||
channel_count: number;
|
||||
}
|
||||
|
||||
// One suggested user-tag for a channel, learned from the user's own manual-tag pattern (E8·S4).
|
||||
export interface TagSuggestion {
|
||||
tag_id: number;
|
||||
confidence: number;
|
||||
}
|
||||
// Keyed by channel id; channels with no suggestion are omitted by the server.
|
||||
export type TagSuggestions = Record<string, TagSuggestion[]>;
|
||||
|
||||
export const tagsApi = {
|
||||
tags: (): Promise<Tag[]> => req("/api/tags"),
|
||||
tagSuggestions: (): Promise<TagSuggestions> => req("/api/tags/suggestions"),
|
||||
createTag: (t: { name: string; color?: string; category?: string }): Promise<Tag> =>
|
||||
req("/api/tags", { method: "POST", body: JSON.stringify(t) }),
|
||||
updateTag: (id: number, patch: { name?: string; color?: string }) =>
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// S8 — the Channels-manager content state (active tab + column sort + the column-filter map) round-trips
|
||||
// through namespaced URL keys (ctab/csort/cf) so a manager view is shareable, the S4/plexUrlState
|
||||
// analogue. `cf` carries the mixed scalar/array FilterMap as a JSON blob.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
channelsParamsToState,
|
||||
channelsParamUpdates,
|
||||
hasChannelsParams,
|
||||
type ChannelsUrlState,
|
||||
} from "./channelsUrlState";
|
||||
import type { FilterMap } from "../components/tableHeader";
|
||||
|
||||
// A concrete non-default filter map exercising both wire shapes: scalars (the sync chip, a select) and
|
||||
// an array (a multi column).
|
||||
const RICH_FILTERS: FilterMap = {
|
||||
sync: "needsFull",
|
||||
priority: "3",
|
||||
tags: ["12", "34"],
|
||||
};
|
||||
|
||||
const BASE: ChannelsUrlState = { view: "subscribed", sort: null, filters: {} };
|
||||
|
||||
// Apply an updates delta onto a base URLSearchParams the way urlWithParams would (null deletes).
|
||||
function applyUpdates(updates: Record<string, string | null>): URLSearchParams {
|
||||
const p = new URLSearchParams();
|
||||
for (const [k, v] of Object.entries(updates)) {
|
||||
if (v != null) p.set(k, v);
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
describe("channelsParamUpdates", () => {
|
||||
it("emits nothing for the all-default state", () => {
|
||||
expect(channelsParamUpdates(BASE)).toEqual({ ctab: null, csort: null, cf: null });
|
||||
});
|
||||
|
||||
it("clears every key when passed null (off-manager page)", () => {
|
||||
expect(channelsParamUpdates(null)).toEqual({ ctab: null, csort: null, cf: null });
|
||||
});
|
||||
|
||||
it("emits only non-default keys", () => {
|
||||
const u = channelsParamUpdates({
|
||||
view: "hidden",
|
||||
sort: { key: "subscribers", dir: "desc" },
|
||||
filters: { sync: "fully" },
|
||||
});
|
||||
expect(u.ctab).toBe("hidden");
|
||||
expect(u.csort).toBe("subscribers:desc");
|
||||
expect(u.cf).toBe(JSON.stringify({ sync: "fully" }));
|
||||
});
|
||||
|
||||
it("drops empty filter entries so a cleared funnel emits no cf", () => {
|
||||
expect(
|
||||
channelsParamUpdates({ view: "subscribed", sort: null, filters: { tags: [], sync: "" } }).cf,
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("round-trip", () => {
|
||||
it("restores a rich state (scalar + array filters) from the emitted URL", () => {
|
||||
const state: ChannelsUrlState = {
|
||||
view: "blocked",
|
||||
sort: { key: "priority", dir: "asc" },
|
||||
filters: RICH_FILTERS,
|
||||
};
|
||||
const params = applyUpdates(channelsParamUpdates(state));
|
||||
const back = channelsParamsToState(params, BASE);
|
||||
expect(back.view).toBe("blocked");
|
||||
expect(back.sort).toEqual({ key: "priority", dir: "asc" });
|
||||
expect(back.filters).toEqual(RICH_FILTERS);
|
||||
// the array shape survives the JSON round-trip, not collapsed to a scalar
|
||||
expect(Array.isArray(back.filters.tags)).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps base values for absent keys (partial link)", () => {
|
||||
const params = new URLSearchParams("csort=title:asc");
|
||||
const back = channelsParamsToState(params, {
|
||||
view: "discovery",
|
||||
sort: null,
|
||||
filters: { sync: "needsFull" },
|
||||
});
|
||||
expect(back.view).toBe("discovery"); // from base, no ctab
|
||||
expect(back.filters).toEqual({ sync: "needsFull" }); // from base, no cf
|
||||
expect(back.sort).toEqual({ key: "title", dir: "asc" });
|
||||
});
|
||||
|
||||
it("ignores an out-of-vocabulary tab / bad sort / corrupt cf and falls back to base", () => {
|
||||
const params = new URLSearchParams("ctab=bogus&csort=key:sideways&cf=notjson");
|
||||
const back = channelsParamsToState(params, { view: "subscribed", sort: null, filters: {} });
|
||||
expect(back.view).toBe("subscribed"); // bogus tab → base
|
||||
expect(back.sort).toBeNull(); // bad dir → parseSortState null → base (null)
|
||||
expect(back.filters).toEqual({}); // corrupt cf → decodeFilters heals to empty
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasChannelsParams", () => {
|
||||
it("detects any namespaced key", () => {
|
||||
expect(hasChannelsParams(new URLSearchParams("ctab=hidden"))).toBe(true);
|
||||
expect(hasChannelsParams(new URLSearchParams("cf=%7B%22sync%22%3A%22fully%22%7D"))).toBe(true);
|
||||
});
|
||||
it("ignores the feed's and plex's keys", () => {
|
||||
expect(hasChannelsParams(new URLSearchParams("scope=all&pscope=movie&sort=oldest"))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
// Serialize the Channels-manager content state (active tab + column sort + the column-filter map) to/
|
||||
// from the address bar so a manager view is bookmarkable/shareable — the S8 analogue of the feed's
|
||||
// urlState and Plex's plexUrlState (S4). Keys are NAMESPACED (`ctab`/`csort`/`cf`) so they're
|
||||
// channels-unique (like `?v=`/`?pl`/`pscope`): no other provider ever reads or clears them, and the
|
||||
// feed's URL-stripping can't delete them out from under the manager. Only non-default values are
|
||||
// emitted, so URLs stay clean. The manager's name search is deliberately EPHEMERAL (like the feed/plex
|
||||
// rail search) and stays OUT of the shared contract.
|
||||
import { decodeSortParam, encodeSortParam, type SortState } from "./columnSort";
|
||||
import { type FilterMap } from "../components/tableHeader";
|
||||
|
||||
// The content state S8 mirrors. `view` is the raw tab string (the provider clamps it); `sort` is the
|
||||
// raw provider SortState (healed against live columns only at the point of use), so the user's real
|
||||
// preference round-trips through the URL.
|
||||
export interface ChannelsUrlState {
|
||||
view: string;
|
||||
sort: SortState;
|
||||
filters: FilterMap;
|
||||
}
|
||||
|
||||
const KEYS = ["ctab", "csort", "cf"] as const;
|
||||
const VIEWS = ["subscribed", "discovery", "hidden", "blocked"];
|
||||
|
||||
// Sort ⇄ `key:dir` (e.g. `subscribers:desc`) via the shared codec in columnSort. There is no default
|
||||
// sort here (fallback null), so any sort emits — no default short-circuit needed.
|
||||
// The FilterMap mixes scalar column filters (priority=select, name=text, the sync chip) with array ones
|
||||
// (tags=multi), so it's carried as a compact JSON blob rather than a nested query string — JSON round-
|
||||
// trips both shapes exactly (and future multi columns for free), and the values are opaque enum/id
|
||||
// tokens where a readable querystring buys little. Empty entries are dropped so a cleared funnel emits
|
||||
// no `cf`.
|
||||
function encodeFilters(map: FilterMap): string | null {
|
||||
const clean: FilterMap = {};
|
||||
for (const [k, v] of Object.entries(map)) {
|
||||
if (Array.isArray(v) ? v.length : v) clean[k] = v;
|
||||
}
|
||||
return Object.keys(clean).length ? JSON.stringify(clean) : null;
|
||||
}
|
||||
// A URL blob is untrusted input: accept only a plain object of string / string[] entries, anything else
|
||||
// heals to empty (same discipline as useFilterState's storage hydrate).
|
||||
function decodeFilters(raw: string): FilterMap {
|
||||
try {
|
||||
const v = JSON.parse(raw);
|
||||
if (!v || typeof v !== "object" || Array.isArray(v)) return {};
|
||||
const out: FilterMap = {};
|
||||
for (const [k, val] of Object.entries(v)) {
|
||||
if (typeof val === "string") out[k] = val;
|
||||
else if (Array.isArray(val) && val.every((x) => typeof x === "string"))
|
||||
out[k] = val as string[];
|
||||
}
|
||||
return out;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/** The address-bar delta for the manager 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 manager keys — used when the app is off the Channels page (localStorage still holds the state; it
|
||||
* re-mirrors on return). Feed the result into urlWithParams(). */
|
||||
export function channelsParamUpdates(
|
||||
state: ChannelsUrlState | null,
|
||||
): Record<string, string | null> {
|
||||
if (!state) return { ctab: null, csort: null, cf: null };
|
||||
return {
|
||||
ctab: state.view && state.view !== "subscribed" ? state.view : null,
|
||||
csort: encodeSortParam(state.sort),
|
||||
cf: encodeFilters(state.filters),
|
||||
};
|
||||
}
|
||||
|
||||
/** True if the query string carries any manager content key we recognize (a shared manager link). */
|
||||
export function hasChannelsParams(params: URLSearchParams): boolean {
|
||||
return KEYS.some((k) => params.has(k));
|
||||
}
|
||||
|
||||
/** Merge the manager content keys from the URL over a base state (the localStorage-seeded values); an
|
||||
* absent or invalid key keeps the base value, so a partial link (only `?ctab=`) is honoured. */
|
||||
export function channelsParamsToState(
|
||||
params: URLSearchParams,
|
||||
base: ChannelsUrlState,
|
||||
): ChannelsUrlState {
|
||||
const tab = params.get("ctab");
|
||||
const csort = params.get("csort");
|
||||
return {
|
||||
view: tab && VIEWS.includes(tab) ? tab : base.view,
|
||||
sort: csort != null ? (decodeSortParam(csort) ?? base.sort) : base.sort,
|
||||
filters: params.has("cf") ? decodeFilters(params.get("cf") ?? "") : base.filters,
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { decideChipLayout, type ChipFitInput } from "./chipOverflow";
|
||||
import { decideChipLayout, fitChipsWithPill, type ChipFitInput } from "./chipOverflow";
|
||||
|
||||
// Build measurer-style right edges for `n` chips of a fixed width, starting after a label, with a gap.
|
||||
function rights(labelRight: number, widths: number[], gap: number): number[] {
|
||||
@@ -91,3 +91,29 @@ describe("decideChipLayout", () => {
|
||||
expect(decideChipLayout(input)).toEqual({ mode: "count", visible: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("fitChipsWithPill", () => {
|
||||
// Three chips of width 50, gap 6, starting at 0 -> right edges 50, 106, 162.
|
||||
const chipRights = rights(0, [50, 50, 50], 6);
|
||||
|
||||
it("shows all chips (no pill) when the last chip's right edge fits — the whole-set fast path", () => {
|
||||
expect(fitChipsWithPill({ chipRights, pillW: 40, gap: 6, avail: 200 })).toBe(3);
|
||||
});
|
||||
|
||||
it("returns 0 for an empty chip set", () => {
|
||||
expect(fitChipsWithPill({ chipRights: [], pillW: 40, gap: 6, avail: 200 })).toBe(0);
|
||||
});
|
||||
|
||||
it("reserves the pill width for all but the last chip when overflowing", () => {
|
||||
// avail 120: whole set (162) doesn't fit. chip0 right 50 <= 120-(40+6)=74 ok; chip1 right 106 > 74
|
||||
// -> dropped at i=1 -> visible 1.
|
||||
expect(fitChipsWithPill({ chipRights, pillW: 40, gap: 6, avail: 120 })).toBe(1);
|
||||
});
|
||||
|
||||
it("keeps the whole set via the fast path even when a reserve-for-all loop would drop chips", () => {
|
||||
// Right edges 56, 112, 118; avail 120: the whole set fits (118 <= 120). A naive loop that reserves
|
||||
// pill+gap (46) for every non-last chip would drop at chip1 (112 > 120-46=74) and show "+2" beside
|
||||
// empty space — the fast path returns all 3 instead.
|
||||
expect(fitChipsWithPill({ chipRights: [56, 112, 118], pillW: 40, gap: 6, avail: 120 })).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
// Pure fit decision for a single-line chip lane that must share its width with fixed trailing
|
||||
// controls (the feed's applied-filter row + Sort/View). Lifted out of ActiveFilterChips so the
|
||||
// all/some/count math is unit-testable without a layout engine (jsdom reports 0-width boxes), the
|
||||
// same split as lib/dateFilter and lib/columnSort. The caller measures real widths off a hidden
|
||||
// mirror (including the live rem gap, so nothing is frozen in px) and hands them here.
|
||||
//
|
||||
// A sibling one-line-fit measurer lives in ChannelTags (ChannelTagsControl); if a third appears,
|
||||
// promote the DOM-measurement half to a shared hook and keep this as its policy core.
|
||||
// Pure fit decisions for a single-line chip lane that collapses its overflow into a "+N" pill.
|
||||
// Lifted out of the components so the math is unit-testable without a layout engine (jsdom reports
|
||||
// 0-width boxes), the same split as lib/dateFilter and lib/columnSort. The shared DOM-measurement
|
||||
// half — the layout-effect + ResizeObserver that reads real rem boxes off a hidden mirror — lives in
|
||||
// lib/useChipOverflowFit; it hands the measured geometry to one of these policies:
|
||||
// - decideChipLayout: the feed's applied-filter row, which also shares its width with fixed trailing
|
||||
// controls (Sort/View), reserves a leading label + a trailing "Clear all", and collapses to a
|
||||
// single count pill on a narrow lane.
|
||||
// - fitChipsWithPill: the chips-only channel tag cell (ChannelTagsControl) — just "how many fit
|
||||
// before the +N pill".
|
||||
|
||||
export interface ChipFitInput {
|
||||
/** Number of chips. */
|
||||
@@ -51,3 +53,37 @@ export function decideChipLayout(p: ChipFitInput): ChipFit {
|
||||
if (k === 0 && labelRight + gap + plusW > avail) return { mode: "count", visible: 0 };
|
||||
return { mode: "some", visible: k };
|
||||
}
|
||||
|
||||
// The chips-only policy (ChannelTagsControl): how many chips fit on the line, reserving the "+N" pill's
|
||||
// width whenever any overflow. Returns the visible count.
|
||||
export interface ChipsWithPillInput {
|
||||
/** Right edge (offsetLeft + offsetWidth) of each chip, index-aligned. */
|
||||
chipRights: number[];
|
||||
/** Width of the "+N" pill. */
|
||||
pillW: number;
|
||||
/** Available lane width. */
|
||||
avail: number;
|
||||
/** The real (rem-scaled) inter-item gap. */
|
||||
gap: number;
|
||||
}
|
||||
|
||||
export function fitChipsWithPill(p: ChipsWithPillInput): number {
|
||||
const { chipRights, pillW, avail, gap } = p;
|
||||
const n = chipRights.length;
|
||||
if (n === 0) return 0;
|
||||
// Whole-set fast path: if the LAST chip's right edge already fits, every chip fits and NO pill is
|
||||
// needed. The reservation loop below reserves the pill for every chip except the last and breaks at
|
||||
// the first overflow, so on its own it would collapse a short trailing chip (narrower than the pill)
|
||||
// that would actually have fitted, leaving "+N" beside empty space.
|
||||
if ((chipRights[n - 1] ?? 0) <= avail) return n;
|
||||
let count = n;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const right = chipRights[i] ?? 0;
|
||||
const moreAfter = i < n - 1;
|
||||
if (right > avail - (moreAfter ? pillW + gap : 0)) {
|
||||
count = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
@@ -34,6 +34,22 @@ export function parseSortState(raw: unknown): SortState {
|
||||
return typeof key === "string" && (dir === "asc" || dir === "desc") ? { key, dir } : null;
|
||||
}
|
||||
|
||||
/** Encode a SortState to the compact `key:dir` wire form the shareable-URL modules (plex/channels)
|
||||
* put in the address bar. Sort keys are simple words (no colon), so the plain join round-trips. Returns
|
||||
* null for no-sort. A module with a default sort short-circuits that to null at its own call site. */
|
||||
export function encodeSortParam(s: SortState): string | null {
|
||||
return s ? `${s.key}:${s.dir}` : null;
|
||||
}
|
||||
|
||||
/** Inverse of `encodeSortParam`: parse `key:dir` (or a bare key → asc) from the URL back into a
|
||||
* SortState, healing anything malformed to null. One codec for both URL-state modules — no drift. */
|
||||
export function decodeSortParam(raw: string): SortState {
|
||||
const i = raw.indexOf(":");
|
||||
const key = i === -1 ? raw : raw.slice(0, i);
|
||||
const dir = i === -1 ? "asc" : raw.slice(i + 1);
|
||||
return parseSortState({ key, dir });
|
||||
}
|
||||
|
||||
/** The header-click cycle: unsorted → ascending → descending → unsorted. */
|
||||
export function cycleSort(current: SortState, key: string): SortState {
|
||||
if (!current || current.key !== key) return { key, dir: "asc" };
|
||||
|
||||
@@ -87,4 +87,11 @@ describe("selectDateChip", () => {
|
||||
const change = selectDateChip("30", base({ q: "hello", tags: [1, 2] }));
|
||||
expect(change.filters).toMatchObject({ q: "hello", tags: [1, 2], maxAgeDays: 30 });
|
||||
});
|
||||
|
||||
it("clears a selected year on any date pick (they're mutually exclusive)", () => {
|
||||
// preset, custom, and 'any' all drop the year — it's the same publish-date narrowing.
|
||||
expect(selectDateChip("30", base({ year: 2024 })).filters.year).toBeUndefined();
|
||||
expect(selectDateChip("custom", base({ year: 2024 })).filters.year).toBeUndefined();
|
||||
expect(selectDateChip("any", base({ year: 2024 })).filters.year).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -32,18 +32,32 @@ export function selectDateChip(
|
||||
key: string,
|
||||
filters: FeedFilters,
|
||||
): { filters: FeedFilters; customOpen: boolean } {
|
||||
// A date pick and the year control are mutually exclusive publish-date narrowings, so any date
|
||||
// selection clears the year (and vice versa, in YearFilter).
|
||||
if (key === "custom") {
|
||||
return { filters: { ...filters, maxAgeDays: undefined }, customOpen: true };
|
||||
return { filters: { ...filters, maxAgeDays: undefined, year: undefined }, customOpen: true };
|
||||
}
|
||||
if (key === "any" || key === "") {
|
||||
return {
|
||||
filters: { ...filters, maxAgeDays: undefined, dateFrom: undefined, dateTo: undefined },
|
||||
filters: {
|
||||
...filters,
|
||||
maxAgeDays: undefined,
|
||||
dateFrom: undefined,
|
||||
dateTo: undefined,
|
||||
year: undefined,
|
||||
},
|
||||
customOpen: false,
|
||||
};
|
||||
}
|
||||
const days = Number(key);
|
||||
return {
|
||||
filters: { ...filters, maxAgeDays: days, dateFrom: undefined, dateTo: undefined },
|
||||
filters: {
|
||||
...filters,
|
||||
maxAgeDays: days,
|
||||
dateFrom: undefined,
|
||||
dateTo: undefined,
|
||||
year: undefined,
|
||||
},
|
||||
customOpen: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// Guards the UX-8 fix: one Escape must close only the TOPMOST layer, never both an open popover AND
|
||||
// the player beneath it. The bug was that the popover's Escape listener (on `document`) fires before
|
||||
// the player's (on `window`), and closing the popover flushes its layer off the stack synchronously —
|
||||
// so by the time the player's `anyLayerAbove()` lookup runs, the stack already reads "nothing above"
|
||||
// and the player closes too. The fix stamps the shared KeyboardEvent when a layer acts; the player
|
||||
// reads the stamp instead of the (now-mutated) stack. This test reproduces that exact ordering with
|
||||
// the stack primitives — the layer removes itself between the two listeners, mimicking the flush.
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import {
|
||||
pushLayer,
|
||||
removeLayer,
|
||||
anyLayerAbove,
|
||||
isTopmost,
|
||||
markEscapeHandled,
|
||||
escapeHandled,
|
||||
} from "./layerStack";
|
||||
|
||||
// Keep the module-level stack clean between cases (ids never repeat, but membership must not leak).
|
||||
const opened: number[] = [];
|
||||
function open(): number {
|
||||
const id = pushLayer();
|
||||
opened.push(id);
|
||||
return id;
|
||||
}
|
||||
afterEach(() => {
|
||||
while (opened.length) removeLayer(opened.pop()!);
|
||||
});
|
||||
|
||||
describe("layerStack Escape coordination (UX-8)", () => {
|
||||
it("a player defers even when the acting layer is popped mid-dispatch", () => {
|
||||
const playerId = open();
|
||||
const popoverId = open();
|
||||
const playerClose = vi.fn();
|
||||
|
||||
// Popover: the `document` listener from useLayer — topmost acts, stamps the event, and (the
|
||||
// regression) its close synchronously removes the layer before the window listener runs.
|
||||
const onDocumentKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape" && isTopmost(popoverId)) {
|
||||
markEscapeHandled(e);
|
||||
removeLayer(popoverId); // the synchronous React flush the real close triggers
|
||||
}
|
||||
};
|
||||
// Player: the `window` listener — fires AFTER the document one, reading an already-mutated stack.
|
||||
const onWindowKey = (e: KeyboardEvent) => {
|
||||
if (e.key !== "Escape") return;
|
||||
if (escapeHandled(e)) return; // the fix
|
||||
if (anyLayerAbove(playerId)) return; // the old, now-insufficient guard
|
||||
playerClose();
|
||||
};
|
||||
|
||||
const e = new KeyboardEvent("keydown", { key: "Escape" });
|
||||
onDocumentKey(e);
|
||||
onWindowKey(e);
|
||||
|
||||
// The stamp holds even though the stack no longer shows a layer above the player...
|
||||
expect(escapeHandled(e)).toBe(true);
|
||||
expect(anyLayerAbove(playerId)).toBe(false); // ...which is exactly why anyLayerAbove alone failed
|
||||
expect(playerClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("a plain Escape with no layer above still closes the player", () => {
|
||||
const playerId = open();
|
||||
const playerClose = vi.fn();
|
||||
|
||||
const e = new KeyboardEvent("keydown", { key: "Escape" });
|
||||
if (!escapeHandled(e) && !anyLayerAbove(playerId)) playerClose();
|
||||
|
||||
expect(playerClose).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("stamps are per-event: a fresh Escape is not treated as already-handled", () => {
|
||||
const first = new KeyboardEvent("keydown", { key: "Escape" });
|
||||
markEscapeHandled(first);
|
||||
const second = new KeyboardEvent("keydown", { key: "Escape" });
|
||||
expect(escapeHandled(second)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// Integration guard for the UX-8 single-Escape fix — the sibling of layerStack.dom.test.ts, but wired
|
||||
// through the REAL `useLayer` hook and a REAL dispatched keydown instead of hand-rolled listeners. It
|
||||
// mounts the two production listener shapes as React components:
|
||||
// - the POPOVER exactly as AnchoredPopover uses it: `useLayer(open, onEscape)` installs the real
|
||||
// `document` keydown listener that stamps the event and closes on topmost;
|
||||
// - the PLAYER exactly as PlayerModal reads it: a `window` keydown listener that bails on
|
||||
// `escapeHandled(e)` first, then on `anyAbove()`.
|
||||
// The load-bearing subtlety the primitives test can only fake: the popover's close is wrapped in
|
||||
// `flushSync`, so React tears the popover down (and `useLayer` cleanup pops its layer) SYNCHRONOUSLY
|
||||
// between the `document` and `window` listeners — the real mid-dispatch flush. That makes the stack read
|
||||
// "nothing above the player" by the time the player's listener runs, so ONLY the event stamp can stop
|
||||
// the player from also closing. If the stamp regressed, test 1 would see the player close on the first
|
||||
// Escape. floating-ui isn't involved here (we drive `useLayer` directly), so no observer stubs needed.
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { useEffect, useState } from "react";
|
||||
import { flushSync } from "react-dom";
|
||||
import { render, act, cleanup } from "@testing-library/react";
|
||||
import { useLayer, escapeHandled } from "./layerStack";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
// The player: registers a layer (no onEscape — it owns a richer handler) and a `window` listener that
|
||||
// mirrors PlayerModal's guard order (consumed-stamp first, then anyAbove).
|
||||
function Player({ onClose }: { onClose: () => void }) {
|
||||
const { anyAbove } = useLayer(true);
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key !== "Escape") return;
|
||||
if (escapeHandled(e)) return; // the UX-8 fix
|
||||
if (anyAbove()) return; // the old, race-prone guard
|
||||
onClose();
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [anyAbove, onClose]);
|
||||
return <div data-testid="player" />;
|
||||
}
|
||||
|
||||
// The popover: the real AnchoredPopover usage. flushSync makes the unmount (and its layer removal) run
|
||||
// synchronously inside the `document` listener — the production mid-dispatch flush the fix must survive.
|
||||
function Popover({ onClosed }: { onClosed: () => void }) {
|
||||
const [open, setOpen] = useState(true);
|
||||
useLayer(open, () => {
|
||||
flushSync(() => setOpen(false));
|
||||
onClosed();
|
||||
});
|
||||
return open ? <div data-testid="popover" /> : null;
|
||||
}
|
||||
|
||||
function dispatchEscape(): KeyboardEvent {
|
||||
const e = new KeyboardEvent("keydown", { key: "Escape", bubbles: true });
|
||||
act(() => {
|
||||
document.dispatchEvent(e);
|
||||
});
|
||||
return e;
|
||||
}
|
||||
|
||||
describe("layerStack Escape coordination — live useLayer + real dispatch (UX-8)", () => {
|
||||
it("one Escape closes only the topmost popover; a second closes the player", () => {
|
||||
const playerClose = vi.fn();
|
||||
const popoverClosed = vi.fn();
|
||||
const { queryByTestId } = render(
|
||||
<>
|
||||
<Player onClose={playerClose} />
|
||||
<Popover onClosed={popoverClosed} />
|
||||
</>,
|
||||
);
|
||||
// Player registers first, popover second → popover is topmost.
|
||||
expect(queryByTestId("popover")).not.toBeNull();
|
||||
|
||||
// First Escape: popover acts (stamps + flushSync-closes, removing its layer synchronously), player
|
||||
// defers on the stamp even though the stack now shows nothing above it.
|
||||
const first = dispatchEscape();
|
||||
expect(escapeHandled(first)).toBe(true);
|
||||
expect(popoverClosed).toHaveBeenCalledOnce();
|
||||
expect(queryByTestId("popover")).toBeNull();
|
||||
expect(playerClose).not.toHaveBeenCalled();
|
||||
|
||||
// Second Escape: nothing above the player, unstamped → the player closes.
|
||||
const second = dispatchEscape();
|
||||
expect(escapeHandled(second)).toBe(false);
|
||||
expect(playerClose).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("with no layer above, a lone player closes on the first Escape", () => {
|
||||
const playerClose = vi.fn();
|
||||
render(<Player onClose={playerClose} />);
|
||||
dispatchEscape();
|
||||
expect(playerClose).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -38,6 +38,23 @@ export function anyLayerAbove(id: number): boolean {
|
||||
return i !== -1 && i < stack.length - 1;
|
||||
}
|
||||
|
||||
// UX-8 — a layer that ACTS on an Escape stamps the event here; a lower layer with its own key handler
|
||||
// (the players, listening on `window`) reads the stamp instead of re-reading the stack. This closes
|
||||
// the "one Escape closes BOTH the popover and the player" bug: the popover's Escape listener is on
|
||||
// `document` and fires before the player's on `window` (bubble order), and closing the popover flushes
|
||||
// its layer off the stack synchronously — so by the time the player's `anyLayerAbove()` runs, the stack
|
||||
// already reads "nothing above" and the player ALSO closes. Tagging the one shared KeyboardEvent object
|
||||
// (every listener in a single dispatch is handed the same instance) is immune to that mid-dispatch
|
||||
// mutation; `anyLayerAbove` stays as the fallback for a layer above with no Escape handler of its own.
|
||||
// WeakSet so a spent event is GC'd without a manual sweep.
|
||||
const handledEscapes = new WeakSet<KeyboardEvent>();
|
||||
export function markEscapeHandled(e: KeyboardEvent): void {
|
||||
handledEscapes.add(e);
|
||||
}
|
||||
export function escapeHandled(e: KeyboardEvent): boolean {
|
||||
return handledEscapes.has(e);
|
||||
}
|
||||
|
||||
/** Register an open layer for as long as `active` is true, and get live predicates to read inside your
|
||||
* own key handler. Pass `onEscape` to also install a topmost-only Escape listener (the common case —
|
||||
* modals, popovers). Omit it for a layer that owns a bigger key handler and only needs `anyAbove()`
|
||||
@@ -57,7 +74,12 @@ export function useLayer(
|
||||
let onKey: ((e: KeyboardEvent) => void) | undefined;
|
||||
if (escRef.current) {
|
||||
onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape" && isTopmost(id)) escRef.current?.();
|
||||
if (e.key === "Escape" && isTopmost(id)) {
|
||||
// Stamp BEFORE closing so a player's `window` listener (fires after this `document` one)
|
||||
// sees the Escape as consumed even though closing us pops this layer off the stack.
|
||||
markEscapeHandled(e);
|
||||
escRef.current?.();
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", onKey);
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ export interface PanelLayout {
|
||||
// Unknown/stale ids are dropped and any missing (e.g. a group added in a later version) are
|
||||
// appended by normalizeLayout, so nothing silently disappears.
|
||||
const PANEL_ORDER: Record<PanelId, string[]> = {
|
||||
feed: ["date", "channel", "tags", "language", "topic"],
|
||||
feed: ["date", "year", "channel", "tags", "language", "topic"],
|
||||
plex: [
|
||||
"playlists",
|
||||
"watch",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { showEpisodeKeys } from "./plexQueue";
|
||||
import type { PlexCard, PlexSeasonDetail, PlexShowDetail } from "./api";
|
||||
|
||||
const ep = (id: string): PlexCard => ({ id }) as PlexCard;
|
||||
const season = (episodes: PlexCard[]): PlexSeasonDetail =>
|
||||
({ episodes }) as unknown as PlexSeasonDetail;
|
||||
const show = (seasons: PlexSeasonDetail[]): PlexShowDetail =>
|
||||
({ seasons }) as unknown as PlexShowDetail;
|
||||
|
||||
describe("showEpisodeKeys", () => {
|
||||
it("flattens seasons in order, episodes in order, concatenated across seasons", () => {
|
||||
const detail = show([
|
||||
season([ep("s1e1"), ep("s1e2")]),
|
||||
season([ep("s2e1"), ep("s2e2"), ep("s2e3")]),
|
||||
]);
|
||||
expect(showEpisodeKeys(detail)).toEqual(["s1e1", "s1e2", "s2e1", "s2e2", "s2e3"]);
|
||||
});
|
||||
|
||||
it("returns [] for a show with no seasons", () => {
|
||||
expect(showEpisodeKeys(show([]))).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns [] for a season with no episodes (the enabled-but-empty Play-all case)", () => {
|
||||
expect(showEpisodeKeys(show([season([])]))).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns [] for null/undefined so an in-flight query's data passes straight through", () => {
|
||||
expect(showEpisodeKeys(null)).toEqual([]);
|
||||
expect(showEpisodeKeys(undefined)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { PlexShowDetail } from "./api";
|
||||
|
||||
// The ordered episode rating-keys of a show: season order, then episode order within each season —
|
||||
// the play-queue order the show/season pages and the collection Play-all all rely on. Kept in ONE
|
||||
// place so that invariant (and any future explicit sort, e.g. pushing season 0 "Specials" last)
|
||||
// lives at a single site rather than the three hand-rolled `seasons.flatMap(...)` copies it grew
|
||||
// into. `detail` is nullable so callers can pass an in-flight query's `data` straight through.
|
||||
export function showEpisodeKeys(detail: PlexShowDetail | null | undefined): string[] {
|
||||
return (detail?.seasons ?? []).flatMap((s) => s.episodes.map((e) => e.id));
|
||||
}
|
||||
@@ -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[] {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user