Compare commits

..
37 Commits
Author SHA1 Message Date
peter b3be568e36 release: 0.114.2 (bug/joserfc-cve) 2026-10-30 22:39:07 +01:00
peter a8cbc7b338 chore(release): 0.114.2 2026-10-30 22:28:39 +01:00
peter cf8dc80428 fix(deps): joserfc 1.7.4 -> 1.7.6 for CVE-2026-119283 and CVE-2026-119284
One pin bumped in place (Authlib's dependency); pip check passes and pip-audit finds nothing.
2026-10-30 22:28:39 +01:00
peter da74b8b9bf release: 0.114.1 (bug/notice-language) 2026-10-30 03:10:42 +01:00
peter 6944622085 chore(release): 0.114.1 2026-10-30 02:58:51 +01:00
peter 2bdcebdb34 fix(notifications): a confirm names its user and channel again; t() and a notice's text are kept apart by a contract
The value sweep wrapped two plain t() values in raw(), so the delete-user and the YouTube subscribe
confirms read "[object Object]" where the email or the channel was. tsc cannot see that seam, so a
syntax contract now refuses a notice value handed to t() and an already-said text kept in a notice.
The raw() doc says what it is: text kept as fetched (a Plex title in the language it was fetched in).
seasonTitleText's no-number branches are tested.
2026-10-30 01:54:00 +01:00
peter 046dde44a4 test(notifications): the center re-says its rows after a switch, a repeat coalesces by content; e2e helper fails with a cause
The center's rows (title, message, refusal, live button) were never rendered across a language
switch, and nothing held the structural compare that coalescing now needs. The channel-link helper
swallowed whatever ended its poll into a bare false: a link that goes away mid-look now counts as
not hit, and no hittable link fails with the poll's own message.
2026-10-30 00:17:46 +01:00
peter 74f58ea001 fix(notifications): the English log formats its numbers in English; the ui/ contract sees msg() keys
A formatted value took the reader's language even when the notice was said in English for the
log. The platform-namespace contract matched only t("…"), so a domain key passed through msg()
in components/ui/ went unseen. Three comments pointed at localizeDetail's old home.
2026-10-30 00:15:22 +01:00
peter b515c16117 fix(notifications): a notice's button is said when shown, like its text
An action label was translated when the notice was raised, so after a language switch a live
toast or center row read in one language with its button in the other. The access-requests nudge
no longer re-issues on a switch for it. New tests: the button and a server refusal follow a
switch.
2026-10-30 00:10:38 +01:00
peter 4a0d4b0964 fix(notifications): a notice value is no plain string, so a translated name cannot freeze in one language
A name goes in as raw(), a translated word as its own msg(): tsc now lists every value, and the
ones that froze are said when shown — Watch later and an untitled channel in the pin and channel
notices, a Plex season's name in its removal toast (a new seasonTitleText, which seasonTitle says).
2026-10-30 00:03:48 +01:00
peter cb8b99e0e7 fix(notifications): keep the history under a key of its own, so an older build never reads its new shape
A build from before the change renders history rows as text; an object there crashed its
Notifications page after a rollback. The old key is read once while the new one does not exist,
and never written.
2026-10-29 23:54:48 +01:00
peter e5c75702e8 fix(views,tags): a rename onto a taken name names the taken one, not the row's old name
The rollback after the conflict puts the old name back on the row, so the refusal read
"you already have a view named 'hh'" when the taken name was 'gaming'.
2026-10-29 23:07:54 +01:00
peter 4ef772d37b fix(notifications): a notice keeps what to say and says it in the reader's language when shown 2026-10-29 22:37:14 +01:00
peter 6986eb1937 test(e2e): look for a hittable channel link until one is, not once on a fresh load 2026-10-29 22:37:14 +01:00
peter 41ad538edc release: 0.114.0 (feature/e13-notifications-order) 2026-10-29 02:18:56 +01:00
peter 7489cab3bc chore(release): 0.114.0 2026-10-29 02:06:52 +01:00
peter 91256b10b0 test(nav): pin the restore's last look and the clearing of the name it looks for 2026-10-29 01:31:41 +01:00
peter b22e903174 fix(notifications): Earlier opens on Back only for a row that can be nowhere else, and Mark read reads read at once
A namesake in Earlier while the row itself was still in Needs you or Recent opened the fold, which took the place match away and left the name twice on the page: the focus fell to <body>. Row names are trimmed as Back saves them, so a title ending in a space still matches. Mark read handed the focus to a row that still said it was unread until the write landed; the row now reads read in the press's own render, and unread again if the write is refused.
2026-10-29 01:31:40 +01:00
peter d6e72d7576 fix(notifications): Back to a row that folded away since opens Earlier on it 2026-10-29 00:47:52 +01:00
peter e3a4593acf fix(nav): Back looks for a moved item only in the page's lists, once its own list has had its chance, and its reveal stops the scroll restore
The cross-list match took a same-named row of the app-wide download tray, and took it at the first look while a virtualized list had yet to render the item. The reveal of a moved item ran before the scroll restore's first frame, which put the old offset back over it. The pending restore's name is readable, for a page that folds rows away.
2026-10-29 00:47:52 +01:00
peter fa271a31b9 fix(notifications): Mark all read waits for the fresh list, is not offered where it cannot act, and a row says it is unread
The mask over a stale read lifted when the write settled, before the refetch landed, and the band took the rows back. A band holding only what asks by its kind (an access-requests nudge) left the button enabled with nothing to do. The unread dot was only drawn: the spoken label now says it, apart from the name Back finds the row by, and follows the row while it holds the focus.
2026-10-29 00:38:34 +01:00
peter 947efaba6e docs(design): the notification order's decision row carries what it was chosen over and why 2026-10-29 00:38:34 +01:00
peter 22d873ed49 fix(nav): an item Back finds in another list is brought into view 2026-10-28 22:04:07 +01:00
peter d825aff30d fix(notifications): a row is named by what it is about, not its kind alone 2026-10-28 22:04:06 +01:00
peter 9dd56e94d4 docs(design): the notification center's order and its Earlier fold 2026-10-28 21:54:00 +01:00
peter 25ad2ee1ba test(ui): a self-closing dialog leaves the focus in a menu that stays open 2026-10-28 21:54:00 +01:00
peter c86ec7f7a5 fix(nav): Back finds an item that moved to another list by its name 2026-10-28 21:54:00 +01:00
peter 0469812499 fix(notifications): Mark all read empties a band of read rows, and a read landing mid-write cannot refill it 2026-10-28 21:54:00 +01:00
peter ee658b8bfc test(plex): pin the late-checkpoint race, its window, and the bulk mark's push supersede 2026-10-28 21:53:59 +01:00
peter 52b93d0014 fix(plex): a restart that fails to start still deletes the session it moved aside 2026-10-28 21:53:59 +01:00
peter c3ad7684ea test(ui): pin the notifications Earlier fold as a plain Pressable
Its text is the disclosure's own label, not a title to select.
2026-10-28 20:44:01 +01:00
peter a5666bfde9 style(notifications): the Earlier fold reads as a section heading, like its siblings
It was a ghost button in body type under two uppercase section labels. It is now
the heading itself, its text the disclosure's button (24px tall, past the
WCAG 2.5.8 floor).
2026-10-28 20:42:18 +01:00
peter f49dc6532f feat(notifications): what asks for you comes first, then one stream; old read rows fold
The center showed two fixed sections, the server's above this browser's, so an
admin's "access requests waiting" sat under every server row, week-old read
"Download ready" notices among them. One order now, for both sources
(lib/inboxOrder): "Needs you" (unread, an access-requests nudge, a notice
waiting on an interaction), then everything else newest first, and read rows
older than a week fold under "Earlier (n)", closed by default.

A row that asked in this visit keeps its place once read: marking it read no
longer moves it to another list under the reader. Mark all read empties the
band, the client notices of the visit included (the server rows read at once,
not when the write lands). Marking a row read from its button hands the focus
to the row instead of dropping it on <body> as the button goes.
2026-10-28 20:01:21 +01:00
peter 0d410592e2 fix(plex): a stale finish refusal no longer sticks, and a refusal keeps the player's detail
A refusal that landed after a replay raised the refusal flag for the finish that watch made later,
so leaving skipped the watched patch. Each finish now resets the flag and a refusal acts only for
the finish it answers.

A refusal while the player still sat on the item (the up-next card) removed the detail query it
observes, dropping the card and restarting its countdown, or losing it if the refetch failed. It is
now refetched under the player and dropped only when the player leaves.
2026-10-28 19:05:24 +01:00
peter 7e5d4a7d93 fix(ui): a closing dialog's focus restore reaches the dialog left standing above it, and survives a menu
A dialog closing on its own under a standing modal skipped its restore, and the standing modal's
own target was a control inside the closed one: closing it left the focus on <body>. The skipped
target is now handed to the standing dialog, which falls back to it.

Focus on a row of one of the dialog's own menus (the popover engine portals them out of the dialog
root) read as the user walking away, so Back with a menu open left the focus on <body>. A focus in
a popover portal now counts as held, and one still standing is not taken from.
2026-10-28 19:05:24 +01:00
peter 24190e4c16 fix(plex): a checkpoint sent just before the finish no longer lands on the watched row
The 10 s tick in flight when the player finishes (or a refusal rollback whose state write in fact
committed) wrote its mid-item position onto the freshly watched row and scheduled a resume push
after the scrobble, so Plex showed the item in progress again. The checkpoint is now a conditional
write that skips a row marked watched in the last 30 s and answers like a finish; a rewatch past
that is checkpointed as before. Marking an item (or a season/show) watched also supersedes resume
pushes still in flight, as the near-end finish already did.
2026-10-28 18:56:12 +01:00
peter e96a562f14 fix(plex): a segment name ffmpeg never spells is refused; teardown no longer holds the registry lock
The artifact guard parsed the sequence with int(), so a zero-padded or non-ASCII-digit name read
as a segment the session had reached and parked the sync route for the full 20 s wait. It now
also requires the canonical spelling segment_name() produces.

Stopping, replacing, culling or reaping a session waited up to 3 s for ffmpeg and deleted a whole
segmented film under _lock, which every segment request and checkpoint takes. Teardown now signals
the process and moves its directory aside under the lock, and waits/deletes after it. A new session
on the same path still waits for the dying ffmpeg first (it writes by path), and the orphan sweep
recognises a moved-aside directory left by a crash.
2026-10-28 18:48:21 +01:00
178 changed files with 3896 additions and 1026 deletions
+23
View File
@@ -7,6 +7,29 @@ edit the release notes and re-run the generator.
Upgrading, pinning a version and rolling back: [docs/self-hosting.md](docs/self-hosting.md#upgrading).
## 0.114.2 — 2026-10-30
### Fixed
- Security: a library the sign-in relies on is updated, closing two published vulnerabilities.
## 0.114.1 — 2026-10-30
### Fixed
- Notifications follow a language switch: a notice on screen and every row in the notification center are said again in the new language, their buttons too.
## 0.114.0 — 2026-10-29
### New
- Notifications: what asks for you comes first, then everything else as one stream, newest first. Read notices older than a week fold under Earlier, and Back returns to the row you left.
### Fixed
- Plex: a progress save sent just before an episode ends no longer leaves it unwatched, and a refused finish no longer sticks.
- After a dialog closes, the keyboard focus returns to where it was, also inside a menu that stays open.
## 0.113.0 — 2026-10-28
### New
+1 -1
View File
@@ -1 +1 @@
0.113.0
0.114.2
+30 -9
View File
@@ -6,9 +6,9 @@ The rest of the mirror's writes stay with the sync (`plex.sync`): a collection's
after a write-back, its local cleanup after a delete.
"""
from datetime import datetime, timezone
from datetime import datetime, timedelta, timezone
from sqlalchemy import CursorResult, delete, func, select, update
from sqlalchemy import CursorResult, delete, func, or_, select, update
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.orm import Session
@@ -271,6 +271,10 @@ def reorder_playlist(db: Session, pl: PlexPlaylist, rating_keys: list[str]) -> N
# --- a user's watch state --------------------------------------------------------------------
# How long after a watched mark a mid-item position is read as the finishing player's late tick
# (`checkpoint`): the tick is every 10 s, plus the request's own latency and a retry.
_LATE_CHECKPOINT_S = 30
def mark_finished(db: Session, user_id: int, item_id: int, now: datetime) -> bool:
"""Mark the item watched (position cleared) and say whether THIS call made it so. Two near-end
@@ -352,16 +356,33 @@ def checkpoint(
now: datetime,
*,
dirty: bool,
) -> PlexState:
"""Record a resume position. `dirty` marks it not yet mirrored to Plex — set before a push, so
the reconcile re-pushes it if the push fails and the push can settle it once Plex confirms."""
) -> bool:
"""Record a resume position, and say whether it was recorded. `dirty` marks it not yet mirrored
to Plex — set before a push, so the reconcile re-pushes it if the push fails and the push can
settle it once Plex confirms.
Not onto a row marked watched in the last `_LATE_CHECKPOINT_S`: that position is the player's
tick sent just before its own finish (or a mark from another tab), and writing it made a watched
item in progress again — on Plex too, through the resume push it carried. Decided by the write
itself, since the mark can commit between any read here and the write. A rewatch is checkpointed
as before once the mark is that far behind it."""
st = state_row(db, user, item_id, st)
st.position_seconds = position
st.progress_updated_at = now
values: dict[str, object] = {"position_seconds": position, "progress_updated_at": now}
if dirty:
st.synced_to_plex = False
values["synced_to_plex"] = False
not_just_finished = or_(
PlexState.status != "watched",
PlexState.watched_at.is_(None),
PlexState.watched_at <= now - timedelta(seconds=_LATE_CHECKPOINT_S),
)
result = db.execute(
update(PlexState)
.where(PlexState.id == st.id, not_just_finished)
.values(values)
.execution_options(synchronize_session="fetch")
)
db.flush()
return st
return isinstance(result, CursorResult) and result.rowcount > 0
def forget_state(db: Session, st: PlexState) -> None:
+152 -75
View File
@@ -66,7 +66,9 @@ _SESSION_IDLE_S = 600 # reap a session with no access for this long
# restart leaves them behind like any other. `sweep_orphans` deletes ONLY names
# matching one of those two shapes: `_HLS_ROOT` is admin-configurable (PLEX_HLS_DIR), so it may
# legitimately point at a shared scratch path we do not own, and a blanket "delete every directory
# under the root" would take the neighbours with it.
# under the root" would take the neighbours with it. A torn-down session's directory is renamed to
# its own name + `_TOMB` + 8 hex digits before it is deleted (see `_detach`), so a crash between the
# two leaves a name this sweep still recognises.
#
# The KEY is anchored to digits (a Plex rating_key is a number) — that is what keeps foreign names
# out. A `.+` there let `snapshot_2024_backup_+1.00` through, and pairing `.+` with an optional tail
@@ -74,8 +76,10 @@ _SESSION_IDLE_S = 600 # reap a session with no access for this long
# non-numeric rating_key would merely leave its directory unswept, which is the safe direction to
# fail. Covered by test_plex_stream.py — this regex is the only thing standing between the sweep
# and someone else's data.
_TOMB = ".dead-"
_SESSION_DIR_RE = re.compile(
r"^\d+_\d+(_[^_]+_[+-]\d+\.\d{2})?$|^\d+_u\d+_\d+_[^_]+_[+-]\d+\.\d{2}$"
r"^\d+_\d+(_[^_]+_[+-]\d+\.\d{2})?$"
rf"|^\d+_u\d+_\d+_[^_]+_[+-]\d+\.\d{{2}}({re.escape(_TOMB)}[0-9a-f]{{8}})?$"
)
# When this process started. `sweep_orphans` only deletes directories older than this: everything
# newer belongs to a session THIS run created, and since `start_session` reuses a directory path
@@ -93,7 +97,7 @@ _MTIME_GRACE_S = 2.0
_lock = threading.Lock()
# Keyed by VIEWER and item, (user_id, rating_key). Keyed by the item alone, a second account opening
# the same film `_kill`ed the first one's session: the displaced viewer got a 404, reclaimed at its
# the same film tore down the first one's session: the displaced viewer got a 404, reclaimed at its
# playhead, and so evicted the other in turn — two people could not watch one film at once. (One
# account on two devices still shares a session; that case ends in the player's bounded reclaim.)
_sessions: dict[tuple[int, str], "HlsSession"] = {}
@@ -641,29 +645,82 @@ def _probe_first_pts(seg: Path) -> float | None:
return None
def _kill(s: HlsSession) -> None:
# Teardown is split in two, because every segment request and checkpoint takes `_lock`: waiting up to
# 3 s for an ffmpeg to exit and deleting a whole segmented film under it stalled every other
# viewer's playback for one viewer's seek or one reap pass. `_detach` (under the lock) signals the
# process and renames its directory aside — so the deletion can never hit a NEW session that reuses
# the path — and `_finish` (after the lock is released) waits and deletes.
#
# What the rename cannot cover is the dying ffmpeg itself: it writes by path, so until it has exited
# it can still put a segment, or its final ENDLIST playlist, into a new session's directory on the
# same path. `_dying` remembers it by that path, and `_spawn` waits it out — under the lock, on
# purpose — before it creates the directory again.
_dying: dict[Path, subprocess.Popen] = {}
def _await_exit(key: str, proc: subprocess.Popen) -> None:
try:
s.proc.terminate()
try:
s.proc.wait(timeout=3)
proc.wait(timeout=3)
# silent-ok: the escalation IS the handling. A transcode that ignores TERM for three
# seconds gets KILL, which is exactly what this branch does.
except subprocess.TimeoutExpired:
s.proc.kill()
proc.kill()
except Exception as exc: # noqa: BLE001 — teardown finishes whatever the process does
# Not nothing: what survives this is an ffmpeg nobody owns any more, holding a VAAPI
# handle and a core, and the transcode cap that is supposed to bound them counts rows in
# `_sessions` rather than processes. One line is the only way that ever surfaces.
log.info(
"could not stop the transcode process",
extra=logs.event("plex.stream.kill_failed", key=key, reason=type(exc).__name__),
)
def _detach(s: HlsSession) -> tuple[HlsSession, Path | None]:
"""Caller holds _lock and has taken `s` out of `_sessions`. Hand the result to `_finish` once the
lock is released."""
try:
s.proc.terminate()
except Exception as exc: # noqa: BLE001 — teardown finishes whatever the process does
log.info(
"could not stop the transcode process",
extra=logs.event("plex.stream.kill_failed", key=s.key, reason=type(exc).__name__),
)
shutil.rmtree(s.dir, ignore_errors=True)
_dying[s.dir] = s.proc
tomb = s.dir.with_name(f"{s.dir.name}{_TOMB}{secrets.token_hex(4)}")
try:
s.dir.rename(tomb)
# silent-ok: nothing on disk (ffmpeg died before writing, or a sweep took it) — nothing to delete.
except FileNotFoundError:
return s, None
except OSError as exc:
# Cannot be moved aside, so it cannot be deleted outside the lock either: the path may be a
# new session's a moment from now. Deleted here instead, the old way.
log.info(
"a session directory could not be moved aside; deleted under the lock",
extra=logs.event("plex.stream.tomb_failed", key=s.key, reason=type(exc).__name__),
)
shutil.rmtree(s.dir, ignore_errors=True)
return s, None
return s, tomb
def _enforce_caps(user_id: int, max_transcodes: int, max_sessions: int) -> None:
"""Caller holds _lock. Three bounds, applied after a session is registered, each dropping the
least recently accessed sessions over it:
def _finish(doomed: "list[tuple[HlsSession, Path | None]]") -> None:
"""The slow half of a teardown, called WITHOUT `_lock`."""
for s, tomb in doomed:
_await_exit(s.key, s.proc)
with _lock:
if _dying.get(s.dir) is s.proc:
del _dying[s.dir]
if tomb is not None:
shutil.rmtree(tomb, ignore_errors=True)
def _enforce_caps(
user_id: int, max_transcodes: int, max_sessions: int
) -> "list[tuple[HlsSession, Path | None]]":
"""Caller holds _lock, and `_finish`es what this returns once it has released it. Three bounds,
applied after a session is registered, each dropping the least recently accessed sessions over it:
* this account's own sessions (`_MAX_SESSIONS_PER_USER`) — first, so a single account filling the
box evicts itself before anyone else;
@@ -676,6 +733,8 @@ def _enforce_caps(user_id: int, max_transcodes: int, max_sessions: int) -> None:
and the player restarts at the playhead, so from the server nothing failed — and "my film keeps
rebuffering" has no other explanation anywhere, hence the line."""
doomed: list[tuple[HlsSession, Path | None]] = []
def cull(pool: list[tuple[tuple[int, str], HlsSession]], cap: int, bound: str) -> None:
if len(pool) <= cap:
return
@@ -690,12 +749,13 @@ def _enforce_caps(user_id: int, max_transcodes: int, max_sessions: int) -> None:
sessions=len(pool),
),
)
_kill(sess)
_sessions.pop(reg, None)
doomed.append(_detach(sess))
cull([kv for kv in _sessions.items() if kv[0][0] == user_id], _MAX_SESSIONS_PER_USER, "user")
cull([kv for kv in _sessions.items() if kv[1].transcode], max_transcodes, "transcode")
cull(list(_sessions.items()), max_sessions, "total")
return doomed
def start_session(
@@ -759,68 +819,79 @@ def start_session(
# can't starve the co-located API/playback. preexec_fn runs in the forked child (POSIX); on a
# platform without fork (the API always runs in a Linux container in prod) it's simply omitted.
preexec = _sw_child_preexec if (transcode and not hw and os.name == "posix") else None
with _lock:
if replace_only is not None and _sessions.get(reg) is not replace_only:
return None # superseded/culled while we waited out of lock — don't clobber the newer one
old = _sessions.pop(reg, None)
if old is not None:
_kill(old)
shutil.rmtree(directory, ignore_errors=True)
directory.mkdir(parents=True, exist_ok=True)
proc = subprocess.Popen(
_ffmpeg_cmd(
src,
start_s,
doomed: list[tuple[HlsSession, Path | None]] = []
# Finished even when the new session fails to start (no space, no ffmpeg, a DB error): what
# was moved aside under the lock is otherwise left for a restart's sweep.
try:
with _lock:
if replace_only is not None and _sessions.get(reg) is not replace_only:
return None # superseded/culled while we waited out of lock — don't clobber the newer one
old = _sessions.pop(reg, None)
if old is not None:
doomed.append(_detach(old))
# An ffmpeg still exiting from THIS path (the old session, or one stopped a moment ago)
# would write into the directory about to be made; see `_dying`.
prev = _dying.pop(directory, None)
if prev is not None:
_await_exit(key, prev)
shutil.rmtree(directory, ignore_errors=True)
directory.mkdir(parents=True, exist_ok=True)
proc = subprocess.Popen(
_ffmpeg_cmd(
src,
start_s,
directory,
audio_ord,
aoff,
audio_langs if is_multi else None,
transcode=transcode,
hw=hw,
tonemap=tonemap,
preset=preset,
crf=crf,
max_height=max_height,
loudnorm=loudnorm,
),
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
preexec_fn=preexec,
)
s = HlsSession(
key,
directory,
audio_ord,
aoff,
audio_langs if is_multi else None,
proc,
start_s,
variants,
transcode=transcode,
hw=hw,
tonemap=tonemap,
preset=preset,
crf=crf,
max_height=max_height,
loudnorm=loudnorm,
),
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
preexec_fn=preexec,
)
s = HlsSession(
key,
directory,
proc,
start_s,
variants,
transcode=transcode,
hw=hw,
tonemap=tonemap,
user_id=user_id,
)
_sessions[reg] = s
# The one line that says which pipeline a session actually got. Everything downstream
# — the cap, the HW→SW fallback, the subtitle offset — is about this choice, and until
# now it was only inferable from the ffmpeg command nobody keeps.
log.info(
"an HLS session started",
extra=logs.event(
"plex.stream.session_started",
key=key,
transcode=transcode,
hw=hw,
tonemap=tonemap,
variants=len(variants or ()),
start_s=round(start_s, 1),
),
)
# Admin-configurable caps (Configuration → Plex); at least 1 each so playback can't be
# capped to zero.
_enforce_caps(
user_id,
max(1, sysconfig.get_int(db, "plex_max_transcodes")),
max(1, sysconfig.get_int(db, "plex_max_sessions")),
)
user_id=user_id,
)
_sessions[reg] = s
# The one line that says which pipeline a session actually got. Everything downstream
# — the cap, the HW→SW fallback, the subtitle offset — is about this choice, and until
# now it was only inferable from the ffmpeg command nobody keeps.
log.info(
"an HLS session started",
extra=logs.event(
"plex.stream.session_started",
key=key,
transcode=transcode,
hw=hw,
tonemap=tonemap,
variants=len(variants or ()),
start_s=round(start_s, 1),
),
)
# Admin-configurable caps (Configuration → Plex); at least 1 each so playback can't be
# capped to zero.
doomed += _enforce_caps(
user_id,
max(1, sysconfig.get_int(db, "plex_max_transcodes")),
max(1, sysconfig.get_int(db, "plex_max_sessions")),
)
finally:
_finish(doomed)
return s
s = _spawn(*_select_encode(db, item, transcode, src))
@@ -974,6 +1045,11 @@ def artifact_may_arrive(s: "HlsSession", filename: str) -> bool:
# session, absent for a single-audio one.
if variant not in (s.variants or (None,)):
return False
# And the name must be the one ffmpeg SPELLS for that sequence: `int()` also reads `seg_007.ts`
# and a non-ASCII digit run as a sequence the session may have reached, and neither file is
# ever written — a yes there parked the request for the whole wait.
if filename != segment_name(seq, variant):
return False
if seq == 0:
return True
try:
@@ -1108,16 +1184,16 @@ def reap_idle(finished_idle_s: float = _FINISHED_IDLE_S) -> int:
`reap_abandoned` bought the same safety by keeping every finished session for ten minutes — each
one a whole segmented copy of the film on a volume shared with the download center."""
now = time.time()
dropped = 0
doomed: list[tuple[HlsSession, Path | None]] = []
with _lock:
for reg, s in list(_sessions.items()):
done = s.proc.poll() is not None
idle = now - s.last_access
if idle > _SESSION_IDLE_S or (done and idle > finished_idle_s):
_kill(s)
_sessions.pop(reg, None)
dropped += 1
return dropped
doomed.append(_detach(s))
_finish(doomed)
return len(doomed)
def stop_session(user_id: int, key: str, session_id: str) -> bool:
@@ -1132,8 +1208,9 @@ def stop_session(user_id: int, key: str, session_id: str) -> bool:
s = _sessions.get((user_id, key))
if s is None or s.id != session_id:
return False
_kill(s)
_sessions.pop((user_id, key), None)
doomed = [_detach(s)]
_finish(doomed)
return True
+1 -1
View File
@@ -1,7 +1,7 @@
"""A request the app refuses, said in a way the UI can put in the reader's language.
Every refusal the API answers carries a structured `detail` — `{code, reason, …params}` — and the client
translates it (`lib/api/core.ts` `localizeDetail`). A bare English string reached the Hungarian UI
translates it (`lib/api/refusalText.ts` `localizeDetail`). A bare English string reached the Hungarian UI
verbatim: the blocking error dialog, and every inline surface that printed `HttpError.detail`, showed
whatever sentence the route happened to hand-write. `refused` is the generic family
(`errors.refused.<reason>`, the params interpolated into the sentence); the older specific codes
+1 -1
View File
@@ -139,7 +139,7 @@ def own_job_for_update(db: Session, user: User, job_id: int) -> "DownloadJob":
def quota_detail(e: quota.QuotaExceeded) -> dict:
"""Structured error the trilingual client localizes (reason key + numbers), instead of a
pre-baked English string with a dot-decimal GB value. See lib/api.ts localizeDetail()."""
pre-baked English string with a dot-decimal GB value. See lib/api/refusalText.ts localizeDetail()."""
return {"code": "quota", "reason": e.reason, "limit": e.limit, "current": e.current}
+25 -10
View File
@@ -234,6 +234,15 @@ def person_image(u: str, request: Request) -> Response:
return imgcache.response(data, ct)
def _supersede_resume_pushes(user_id: int, item_id: int, now: datetime) -> None:
"""A watched mark supersedes every resume push still in flight for the item: stamping it as the
latest push makes `_resume_push_superseded` drop them. Popping the entry instead (to keep the map
small) removed the very guard, and an older resume push could then land at Plex AFTER the
scrobble and revive a resume point on a watched item. Eviction bounds the map."""
with _push_throttle_lock:
_last_push_at[(user_id, item_id)] = now
def _push_resume_guarded(
user_id: int,
item_id: int,
@@ -329,12 +338,7 @@ def item_progress(
near_end = duration > 0 and position > duration - _FINISH_MARGIN_S
if near_end:
flipped = service.mark_finished(db, user.id, it.id, now)
# The finish supersedes every resume push still in flight for this item: stamping it as the
# latest push makes `_resume_push_superseded` drop them. Popping the entry instead (to keep
# the map small) removed the very guard, and an older resume push could then land at Plex
# AFTER the scrobble and revive a resume point on a watched item. Eviction bounds the map.
with _push_throttle_lock:
_last_push_at[(user.id, it.id)] = now
_supersede_resume_pushes(user.id, it.id, now)
db.commit()
# Only the checkpoint that flipped the row scrobbles: a later checkpoint of the same finished
# item — or a second one racing this one — would otherwise bump Plex's viewCount again.
@@ -352,7 +356,11 @@ def item_progress(
push_now = _should_push_resume(user.id, it.id, now, final)
# Marked 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.
service.checkpoint(db, user, it.id, st, position, now, dirty=push_now)
if not service.checkpoint(db, user, it.id, st, position, now, dirty=push_now):
# Marked watched a moment ago: this is the finishing player's late tick (see
# `service.checkpoint`), and the answer is the same one a near-end checkpoint gets.
db.commit()
return {"ok": True, "status": "watched", "position_seconds": 0}
db.commit()
if push_now:
_push_watch(
@@ -408,6 +416,8 @@ def item_state(
_push_watch(background, db, user.id, it.id, rating_key, "unwatched")
return {"ok": True, "status": "new"}
service.set_status(db, user, it.id, st, status)
if status == "watched":
_supersede_resume_pushes(user.id, it.id, datetime.now(timezone.utc))
db.commit()
if status == "watched" and prev_status != "watched":
_push_watch(background, db, user.id, it.id, rating_key, "watched")
@@ -427,6 +437,10 @@ def _bulk_state(
always reads the committed marks."""
pushable = plex_watch.link_for_push(db, user_id) is not None
to_push = service.bulk_state(db, user_id, eps, watched)
if watched:
now = datetime.now(timezone.utc)
for item_id, _rating_key, _action in to_push:
_supersede_resume_pushes(user_id, item_id, now)
if pushable and to_push:
# One coalesced background task for the whole batch (reuses a single session + Plex client),
# not one task per episode — a 200-ep show mark is 1 task, not 200.
@@ -632,9 +646,10 @@ def stream_hls_file(
try:
content = path.read_bytes()
except OSError:
# The session was torn down between the wait and the read — `_kill` rmtrees the whole
# directory, and nothing holds `_lock` across those two steps. It has to present as the same
# 404 a gone session does: the player's recovery is keyed on the STATUS (`code === 404`), so
# The session was torn down between the wait and the read — teardown moves the whole
# directory aside and deletes it, and nothing holds `_lock` across those two steps. It has
# to present as the same 404 a gone session does: the player's recovery is keyed on the
# STATUS (`code === 404`), so
# a 500 here skipped the reclaim and ended playback with "couldn't play" instead of
# restarting at the playhead. Ordinary trigger: `loadSession` awaits the new session — which
# kills and rmtrees the old one — before destroying the old hls instance, so the outgoing
+2 -1
View File
@@ -128,7 +128,8 @@ def update_view(
db.commit()
except IntegrityError:
db.rollback()
raise refused(409, "view_name_taken", name=view.name) from None
# The name asked for: the rollback has put the view's old name back on `view`.
raise refused(409, "view_name_taken", name=name) from None
return _serialize(view)
+2 -1
View File
@@ -107,7 +107,8 @@ def update_tag(
db.commit()
except IntegrityError:
db.rollback()
raise refused(409, "tag_name_taken", name=tag.name) from None
# The name asked for: the rollback has put the tag's old name back on `tag`.
raise refused(409, "tag_name_taken", name=name) from None
return {
"ok": True,
"id": tag.id,
+2 -2
View File
@@ -7,7 +7,7 @@
# `pip install -r requirements.lock && pip check` must pass. In place so far: urllib3 2.7.0 -> 2.8.0 (PYSEC-2026-4175/4176/4177); yt-dlp 2026.7.4 -> 2026.8.19
# (it marks the clients that need a stream PO token, so the downloader stops fetching into a 403);
# certifi 2026.7.22 -> 2026.10.19 (GHSA-6q48-6c7w-9gw2); brotli 1.2.0 -> 1.2.1 (PYSEC-2026-4322);
# Authlib 1.7.2 -> 1.9.1 (CVE-2026-96760, CVE-2026-114616).
# Authlib 1.7.2 -> 1.9.1 (CVE-2026-96760, CVE-2026-114616); joserfc 1.7.4 -> 1.7.6 (CVE-2026-119283, CVE-2026-119284).
APScheduler==3.11.3
Authlib==1.9.1
Mako==1.4.1
@@ -39,7 +39,7 @@ httptools==0.8.0
httpx==0.28.1
idna==3.18
itsdangerous==2.2.0
joserfc==1.7.4
joserfc==1.7.6
markdown-it-py==4.2.0
mdurl==0.1.2
mutagen==1.48.1
+1
View File
@@ -83,6 +83,7 @@ class _Proc:
class _Session:
def __init__(self, tmp_path, name: str, finished: bool, idle_s: float):
self.key = name
self.dir = tmp_path / name
self.dir.mkdir()
self.proc = _Proc(finished)
+158
View File
@@ -0,0 +1,158 @@
"""Tearing a session down does not stall everybody else's playback.
Every segment request (`current_session`) and every checkpoint (`touch_session`) takes the one
registry `_lock`. Stopping a session waits up to 3 s for its ffmpeg to exit and then deletes a whole
segmented film, ~1200 files — and that used to happen under the same lock, so one viewer's seek, a
cap cull or a reap pass held up every other viewer's HLS requests for seconds, each one a parked
threadpool worker and its pooled DB connection.
What still has to be atomic is pinned too: a session directory is reused for the same item, offset
and tag, so a new session on that path must not start before the old ffmpeg — which writes by path
— has exited.
"""
import threading
import time
import pytest
from app.plex import stream
class _SlowProc:
"""An ffmpeg that takes a while to exit after TERM: `wait` blocks until `exited` is set."""
def __init__(self, order: list[str], name: str):
self.order = order
self.name = name
self.waiting = threading.Event()
self.exited = threading.Event()
def poll(self):
return 0 if self.exited.is_set() else None
def terminate(self):
pass
def kill(self):
self.exited.set()
def wait(self, timeout=None):
self.waiting.set()
if not self.exited.wait(timeout):
raise stream.subprocess.TimeoutExpired("ffmpeg", timeout)
self.order.append(f"{self.name} exited")
return 0
class _Session:
def __init__(self, directory, proc, key: str):
self.id = f"sid-{key}"
self.key = key
self.dir = directory
self.proc = proc
self.last_access = time.time()
self.transcode = False
@pytest.fixture
def sessions(monkeypatch):
live: dict = {}
monkeypatch.setattr(stream, "_sessions", live)
monkeypatch.setattr(stream, "_dying", {}, raising=False)
return live
def test_stopping_a_session_does_not_hold_up_another_viewers_requests(tmp_path, sessions):
order: list[str] = []
slow = _SlowProc(order, "a")
d = tmp_path / "1_u1_0_0_+0.00"
d.mkdir()
(d / "seg_0.ts").write_bytes(b"x")
sessions[(1, "a")] = _Session(d, slow, "a")
sessions[(2, "b")] = _Session(tmp_path / "2_u2_0_0_+0.00", _SlowProc(order, "b"), "b")
stopper = threading.Thread(target=stream.stop_session, args=(1, "a", "sid-a"))
stopper.start()
try:
assert slow.waiting.wait(2.0), "the stop never waited for its ffmpeg"
got: list = []
reader = threading.Thread(target=lambda: got.append(stream.current_session(2, "b")))
reader.start()
reader.join(1.0)
assert not reader.is_alive(), "a segment request waited on another viewer's teardown"
assert got and got[0] is sessions[(2, "b")]
finally:
slow.exited.set()
stopper.join(5.0)
# The teardown still finishes: the session is gone and nothing of its directory is left behind.
assert (1, "a") not in sessions
assert sorted(p.name for p in tmp_path.iterdir()) == []
def test_a_reap_pass_does_not_hold_up_another_viewers_requests(tmp_path, sessions):
order: list[str] = []
slow = _SlowProc(order, "a")
d = tmp_path / "1_u1_0_0_+0.00"
d.mkdir()
gone = _Session(d, slow, "a")
gone.last_access = time.time() - stream._SESSION_IDLE_S - 5
sessions[(1, "a")] = gone
sessions[(2, "b")] = _Session(tmp_path / "2_u2_0_0_+0.00", _SlowProc(order, "b"), "b")
reaper = threading.Thread(target=stream.reap_idle)
reaper.start()
try:
assert slow.waiting.wait(2.0), "the reap never waited for its ffmpeg"
toucher = threading.Thread(target=stream.touch_session, args=(2, "b"))
toucher.start()
toucher.join(1.0)
assert not toucher.is_alive(), "a checkpoint waited on another viewer's teardown"
finally:
slow.exited.set()
reaper.join(5.0)
assert list(sessions) == [(2, "b")]
assert not d.exists()
class _FakeItem:
rating_key = "12345"
file_path = "/media/movie.mkv"
playable = "remux"
codec_video = "h264"
def test_a_new_session_on_a_dying_ones_path_starts_only_after_it_exited(
tmp_path, sessions, monkeypatch
):
"""The same item at the same second gets the same directory. The old ffmpeg writes by PATH, so
while it is still winding down after TERM it can write a segment — or, on a clean exit, a final
playlist with ENDLIST — into the NEW session's directory."""
order: list[str] = []
procs = iter([_SlowProc(order, "old"), _SlowProc(order, "new")])
def popen(*a, **k):
order.append("popen")
return next(procs)
monkeypatch.setattr(stream, "_HLS_ROOT", tmp_path)
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.sysconfig, "get_bool", lambda db, k: False)
monkeypatch.setattr(stream.subprocess, "Popen", popen)
monkeypatch.setattr(stream, "wait_for", lambda *a, **k: True)
first = stream.start_session(db=None, user_id=7, item=_FakeItem(), start_s=0.0)
assert first is not None
stopper = threading.Thread(target=stream.stop_session, args=(7, "12345", first.id))
stopper.start()
assert first.proc.waiting.wait(2.0)
threading.Timer(0.3, first.proc.exited.set).start()
second = stream.start_session(db=None, user_id=7, item=_FakeItem(), start_s=0.0)
stopper.join(5.0)
assert second is not None and second.dir == first.dir
assert order.index("old exited") < order.index("popen", 1)
assert second.dir.is_dir(), "the old teardown deleted the new session's directory"
second.proc.exited.set()
+46
View File
@@ -0,0 +1,46 @@
"""A rename onto a name already taken says WHICH name is taken: the one asked for, not the row's
old name (the rollback that follows the conflict puts the old name back on the row). DB lane.
"""
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 User
def _client(db, monkeypatch, override_dep):
u = User(email="taken@example.com")
db.add(u)
db.commit()
monkeypatch.setattr(
state, "_configured_cache", True
) # the request gate 503s an unconfigured app
override_dep(get_db, db.info["request"])
override_dep(current_user, lambda: u)
return TestClient(app)
def test_renaming_a_view_onto_a_taken_name_names_the_taken_one(db, monkeypatch, override_dep):
c = _client(db, monkeypatch, override_dep)
gaming = c.post("/api/saved-views", json={"name": "gaming", "filters": {}, "source": "feed"})
assert gaming.status_code == 200
hh = c.post("/api/saved-views", json={"name": "hh", "filters": {}, "source": "feed"}).json()
r = c.patch(f"/api/saved-views/{hh['id']}", json={"name": "gaming"})
assert r.status_code == 409
assert r.json()["detail"] == {"code": "refused", "reason": "view_name_taken", "name": "gaming"}
def test_renaming_a_tag_onto_a_taken_name_names_the_taken_one(db, monkeypatch, override_dep):
c = _client(db, monkeypatch, override_dep)
assert c.post("/api/tags", json={"name": "music"}).status_code == 200
news = c.post("/api/tags", json={"name": "news"}).json()
r = c.patch(f"/api/tags/{news['id']}", json={"name": "music"})
assert r.status_code == 409
assert r.json()["detail"] == {"code": "refused", "reason": "tag_name_taken", "name": "music"}
+148 -1
View File
@@ -13,7 +13,7 @@ from datetime import datetime, timedelta, timezone
from sqlalchemy import select
from app.db import SessionLocal
from app.plex.models import PlexItem, PlexLibrary, PlexState
from app.plex.models import PlexItem, PlexLibrary, PlexSeason, PlexShow, PlexState
from app.plex import service as plex_service
from app.routes.plex import playback
@@ -81,3 +81,150 @@ def test_the_finish_supersedes_a_resume_push_still_in_flight(db, as_user, make_u
)
assert r.json() == {"ok": True, "status": "watched", "position_seconds": 0}
assert playback._resume_push_superseded(user.id, item.id, earlier) is True
def test_a_checkpoint_sent_before_the_finish_does_not_land_on_the_watched_row(
db, as_user, make_user, monkeypatch
):
"""The 10 s tick posted at 2699 of 3000 s (not near the end) races the credits auto-skip's
"watched". When the mark commits first, the late checkpoint used to write its position onto the
watched row and schedule a resume push after the scrobble: Plex showed the item in progress, and
a Plex-authoritative pull imported it back."""
user = make_user()
item = _item(db)
monkeypatch.setattr(playback.plex_stream, "touch_session", lambda uid, key: None)
client = as_user(user)
assert client.post("/api/plex/item/m1/state", json={"status": "watched"}).status_code == 200
r = client.post(
"/api/plex/item/m1/progress", json={"position_seconds": 2699, "duration_seconds": 3000}
)
assert r.status_code == 200, r.text
assert r.json() == {"ok": True, "status": "watched", "position_seconds": 0}
db.expire_all()
st = db.execute(select(PlexState).where(PlexState.item_id == item.id)).scalars().one()
assert (st.status, st.position_seconds) == ("watched", 0)
def test_a_rewatch_records_its_position_once_the_finish_is_behind_it(
db, as_user, make_user, monkeypatch
):
"""The other half: a watched item played again is still checkpointed — only a position arriving
right on the heels of the mark is read as that mark's late tick."""
user = make_user()
item = _item(db)
long_ago = datetime.now(timezone.utc) - timedelta(days=3)
db.add(PlexState(user_id=user.id, item_id=item.id, status="watched", watched_at=long_ago))
db.commit()
monkeypatch.setattr(playback.plex_stream, "touch_session", lambda uid, key: None)
r = as_user(user).post(
"/api/plex/item/m1/progress", json={"position_seconds": 600, "duration_seconds": 3000}
)
assert r.json() == {"ok": True, "position_seconds": 600}
db.expire_all()
st = db.execute(select(PlexState).where(PlexState.item_id == item.id)).scalars().one()
assert (st.status, st.position_seconds) == ("watched", 600)
def test_marking_watched_supersedes_a_resume_push_still_in_flight(
db, as_user, make_user, monkeypatch
):
"""The same guard the near-end finish has: a resume push scheduled before the mark must not reach
Plex after its scrobble."""
user = make_user()
item = _item(db)
earlier = datetime.now(timezone.utc) - timedelta(seconds=5)
assert playback._should_push_resume(user.id, item.id, earlier, final=True)
r = as_user(user).post("/api/plex/item/m1/state", json={"status": "watched"})
assert r.status_code == 200, r.text
assert playback._resume_push_superseded(user.id, item.id, earlier) is True
def _state(db, user, item):
db.expire_all()
st = (
db.execute(
select(PlexState).where(PlexState.user_id == user.id, PlexState.item_id == item.id)
)
.scalars()
.one()
)
return st.status, st.position_seconds
def test_a_mark_committed_after_the_checkpoints_read_still_refuses_it(db, make_user):
"""The race itself, not its sequential shadow: the route read the row while it was still in
progress, and the finish committed from another session before the checkpoint wrote. Only a write
conditional on the row as it is NOW refuses that position."""
user = make_user()
item = _item(db)
db.add(PlexState(user_id=user.id, item_id=item.id, status="new", position_seconds=2690))
db.commit()
now = datetime.now(timezone.utc)
read = plex_service.state_row(db, user, item.id) # what the checkpoint's route read: "new"
assert read.status == "new"
other = SessionLocal()
try:
assert plex_service.mark_finished(other, user.id, item.id, now) is True
other.commit()
finally:
other.close()
assert plex_service.checkpoint(db, user, item.id, read, 2699, now, dirty=True) is False
db.commit()
assert _state(db, user, item) == ("watched", 0)
def test_a_tick_seconds_behind_the_mark_is_refused_a_rewatch_a_minute_later_is_not(db, make_user):
"""A position right behind the mark is that mark's late tick; one a minute later is a rewatch.
Literal times on purpose: a window widened to an hour must fail here, not move with it."""
user = make_user()
item = _item(db)
now = datetime.now(timezone.utc)
st = PlexState(user_id=user.id, item_id=item.id, status="watched", watched_at=now)
db.add(st)
db.commit()
st.watched_at = now - timedelta(seconds=20)
db.commit()
assert plex_service.checkpoint(db, user, item.id, st, 600, now, dirty=True) is False
db.commit()
st.watched_at = now - timedelta(seconds=60)
db.commit()
assert plex_service.checkpoint(db, user, item.id, st, 600, now, dirty=True) is True
db.commit()
assert _state(db, user, item) == ("watched", 600)
def test_marking_a_season_watched_supersedes_its_episodes_resume_pushes(db, as_user, make_user):
"""The bulk mark has the same guard as one item's: an episode's resume push scheduled before
the season was marked must not reach Plex after the scrobble."""
user = make_user()
lib = PlexLibrary(plex_key="2", title="Shows", kind="show", enabled=True)
db.add(lib)
db.flush()
sh = PlexShow(rating_key="sh1", library_id=lib.id, title="Show")
db.add(sh)
db.flush()
se = PlexSeason(rating_key="se1", show_id=sh.id, season_number=1)
db.add(se)
db.flush()
ep = PlexItem(
rating_key="e1",
library_id=lib.id,
kind="episode",
title="E1",
show_id=sh.id,
season_id=se.id,
season_number=1,
episode_number=1,
)
db.add(ep)
db.commit()
earlier = datetime.now(timezone.utc) - timedelta(seconds=5)
assert playback._should_push_resume(user.id, ep.id, earlier, final=True)
r = as_user(user).post("/api/plex/season/se1/state", json={"watched": True})
assert r.json() == {"ok": True, "changed": 1, "watched": True}
assert playback._resume_push_superseded(user.id, ep.id, earlier) is True
+26 -1
View File
@@ -198,6 +198,31 @@ def test_a_name_the_session_cannot_produce_fails_at_once(client, tmp_path, sessi
assert elapsed < 2.0, f"waited {elapsed:.1f}s for a playlist this session never writes"
@pytest.mark.parametrize(
("name", "audio_langs"),
[
("seg_00.ts", None), # `int()` reads it as segment 0, which needs no predecessor
("seg_007.ts", None), # …as segment 7, whose predecessor seg_6.ts is on disk
("seg_٧.ts", None), # an Arabic-Indic 7: `\d` and `int()` both take it
("seg_a0_07.ts", ["hu", "en"]),
("seg_0_00.ts", ["hu", "en"]),
],
)
def test_a_segment_name_ffmpeg_never_spells_is_refused(tmp_path, name, audio_langs):
"""ffmpeg writes `seg_%d.ts`: no padding, ASCII digits. A name that only PARSES to a sequence the
session has reached is still a file that will never appear, and saying yes to it parked the
request for the full 20 s wait — the parking this guard exists to end."""
d = tmp_path / "m1_0_0_+0.00"
d.mkdir()
for v in plex_stream.session_variants(audio_langs) or (None,):
(d / plex_stream.segment_name(6, v)).write_bytes(b"x")
sess = _Session("sid-a", d, running=True, audio_langs=audio_langs)
assert not plex_stream.artifact_may_arrive(sess, name)
# The canonical spelling of the same next segment is still accepted.
v = (plex_stream.session_variants(audio_langs) or (None,))[0]
assert plex_stream.artifact_may_arrive(sess, plex_stream.segment_name(7, v))
@pytest.mark.parametrize("digits", [100, 5000])
def test_an_absurd_segment_index_is_a_404_not_a_500(client, tmp_path, sessions, digits):
"""`int()` on an unbounded digit run raises ValueError past 4300 digits, and only OSError was
@@ -213,7 +238,7 @@ def test_an_absurd_segment_index_is_a_404_not_a_500(client, tmp_path, sessions,
def test_a_session_torn_down_during_the_read_is_a_404_not_a_500(
client, monkeypatch, tmp_path, sessions
):
"""`_kill` rmtrees the whole directory and nothing holds `_lock` across the wait and the read,
"""Teardown moves the whole directory aside and nothing holds `_lock` across the wait and the read,
so the file can vanish in between — on every seek-restart, since `loadSession` awaits the new
session before destroying the old hls instance. The player's recovery is keyed on the 404
STATUS, so a 500 here skipped the reclaim and ended playback with "couldn't play"."""
+4 -2
View File
@@ -348,7 +348,8 @@ def test_an_early_checkpoint_drops_a_bare_state_and_zeroes_a_marked_one(db, make
user = make_user()
lib = _lib(db)
bare, hid = _movie(db, lib, "bare"), _movie(db, lib, "hid")
st = service.checkpoint(db, user, bare.id, None, 120, NOW, dirty=True)
assert service.checkpoint(db, user, bare.id, None, 120, NOW, dirty=True)
st = queries.state(db, user, bare.id)
assert (st.position_seconds, st.synced_to_plex) == (120, False)
_state(db, user, hid, status="hidden", position_seconds=40)
service.clear_progress(db, queries.state(db, user, bare.id), NOW)
@@ -429,7 +430,8 @@ def test_a_first_write_that_lost_the_race_updates_the_winners_row(db, make_user)
user = make_user()
it = _movie(db, _lib(db), "m-race")
_state(db, user, it, position_seconds=10) # the request that won, committed
st = service.checkpoint(db, user, it.id, None, 42, NOW, dirty=True) # read "none" before it
assert service.checkpoint(db, user, it.id, None, 42, NOW, dirty=True) # read "none" before it
st = queries.state(db, user, it.id)
assert (st.position_seconds, st.synced_to_plex) == (42, False)
st = service.set_status(db, user, it.id, None, "hidden")
assert st.status == "hidden"
+1
View File
@@ -39,6 +39,7 @@ class _Proc:
class _Session:
def __init__(self, session_id: str, directory):
self.id = session_id
self.key = "m1"
self.dir = directory
self.proc = _Proc()
self.last_access = time.time()
+67 -1
View File
@@ -26,6 +26,9 @@ from app.plex.stream import _SESSION_DIR_RE
"12345_0_2_+0.00", # current shape, explicit audio_ord
"12345_842_multi_-1.25", # multi-audio, negative offset
"12345_842_multi_+12.50",
# a torn-down session's directory, moved aside to be deleted outside the lock — left behind
# only if the process died between the rename and the delete
"12345_u7_842_multi_+12.50.dead-0123abcd",
],
)
def test_our_session_directories_are_swept(name):
@@ -47,6 +50,9 @@ def test_our_session_directories_are_swept(name):
"snapshot_2024_backup_+1.00", # session-ish tail, but the key isn't a rating_key
"backup_2024_multi_+0.00",
".config",
"12345_u7_0_0_+0.00.dead-", # a tombstone suffix with no id
"12345_u7_0_0_+0.00.dead-notours!",
"backup_u7_0_0_+0.00.dead-0123abcd",
],
)
def test_foreign_directories_are_left_alone(name):
@@ -159,7 +165,7 @@ class TestSweepOrphans:
class _FakeProc:
"""Stand-in for the ffmpeg Popen — start_session only stores it (and _kill/reap poll it later)."""
"""Stand-in for the ffmpeg Popen — start_session only stores it (and teardown/reap poll it later)."""
def poll(self):
return None
@@ -1052,3 +1058,63 @@ class TestSessionsPerViewer:
self._start(monkeypatch, user_id=uid)
assert len(stream._sessions) == 3 and (1, "12345") not in stream._sessions
assert oldest.proc.killed and not oldest.transcode
class TestTeardownFinishes:
"""A session taken down by a cap or replaced by its own viewer is only renamed aside under the
lock; it is deleted after. The rename alone already moves the directory and `terminate()` alone
already marks the proc killed — so what is pinned here is the tomb: a whole segmented copy of the
film that only a restart's sweep would otherwise reclaim."""
capturing_env = TestTranscodeStartSession.capturing_env
@staticmethod
def _left_behind(root):
return sorted(p.name for p in root.iterdir() if stream._TOMB in p.name) + sorted(
str(p) for p in stream._dying
)
def test_a_culled_session_is_deleted_not_only_moved_aside(self, capturing_env, monkeypatch):
monkeypatch.setattr(stream, "_dying", {})
first = TestSessionsPerViewer._start(monkeypatch, user_id=1, rating_key="1")
first.last_access -= 60
TestSessionsPerViewer._start(monkeypatch, user_id=1, rating_key="2")
TestSessionsPerViewer._start(monkeypatch, user_id=1, rating_key="3")
assert first.proc.killed and not first.dir.exists()
assert self._left_behind(capturing_env) == []
def test_a_viewers_restart_deletes_the_session_it_replaces(self, capturing_env, monkeypatch):
monkeypatch.setattr(stream, "_dying", {})
monkeypatch.setattr(
stream, "_select_encode", lambda db, item, transcode, src: (False, False)
)
monkeypatch.setattr(
stream, "_probe_first_pts", lambda seg: None
) # no real segment to probe
old = stream.start_session(db=None, user_id=1, item=_FakeItem(), start_s=0.0)
new = stream.start_session(db=None, user_id=1, item=_FakeItem(), start_s=600.0)
assert old.proc.killed and new.dir.exists() and not old.dir.exists()
assert self._left_behind(capturing_env) == []
def test_a_restart_that_fails_to_start_still_deletes_what_it_replaced(
self, capturing_env, monkeypatch
):
# The new ffmpeg cannot start (a full disk, no ffmpeg, no memory): the old session is already
# moved aside, and nothing else in this process would ever delete it.
monkeypatch.setattr(stream, "_dying", {})
monkeypatch.setattr(
stream, "_select_encode", lambda db, item, transcode, src: (False, False)
)
monkeypatch.setattr(
stream, "_probe_first_pts", lambda seg: None
) # no real segment to probe
old = stream.start_session(db=None, user_id=1, item=_FakeItem(), start_s=0.0)
def no_ffmpeg(*args, **kwargs):
raise OSError(28, "No space left on device")
monkeypatch.setattr(stream.subprocess, "Popen", no_ffmpeg)
with pytest.raises(OSError):
stream.start_session(db=None, user_id=1, item=_FakeItem(), start_s=600.0)
assert old.proc.killed and not old.dir.exists()
assert self._left_behind(capturing_env) == []
+6 -1
View File
@@ -378,7 +378,11 @@ Which actions are visible and which live in a menu.
managing the shares, re-download, the copies, cancel / delete / remove
behind their confirms); a link inside a row keeps the browser's own right-click menu. A notification
row keeps its link and its unread mark, shows its dismiss on hover (in the "…" on touch), and its
"…" holds find / undo for an activity notice. The notification center's band keeps Mark all read
"…" holds find / undo for an activity notice. The notification center lists what asks for the
reader first ("Needs you · n": an unread row, a notice waiting on an action — one read during the
visit keeps its place there until Mark all read), then one stream of both sources, newest first,
and folds read rows older than a week under "Earlier (n)", a heading-style disclosure, closed — Back to a row
that folded away since opens it on that row. Its band keeps Mark all read
and puts Clear everything, now behind a confirm, in its "…"; the Scheduler's strip keeps Run all
and Pause and puts Purge discovery in its "…". A list whose row objects are rebuilt each render or replaced by a
poll passes `useContextMenu({ keyOf })` (its id), so the "…" still closes its own menu. A menu's
@@ -824,3 +828,4 @@ of a full-width panel and nothing on the right.
| 2026-10-25 | Player panel glyphs (owner 1A, 2A, 3) | In the side panel the queue row is glyph-only on one line, and Mark watched is a glyph toggle among the actions (✓ / ✓✓); the docked bar keeps its labels; the related switch at the default size, "Trending" | Labelled buttons wrapping onto three lines; glyphs on the docked bar too; Mark watched on its own line | The panel is narrow and the list controls are the same few every time; each glyph keeps its full name and shortcut in its tooltip; the docked bar has the width for words |
| 2026-10-25 | Auto-advance glyphs (owner E A, H a) | Auto-advance wears list glyphs (`ListEnd` next and off, `ListStart` previous, `Shuffle` random), in the panel and the docked bar; the auto-advance and loop cycles are `lit`, not `pressed` | The skip arrows for the mode; an X'd list for off; `aria-pressed` on a cycle | Beside Previous / Next the skip arrows read as a second Next; a cycle of three or four modes is no two-state switch, and its name already says which |
| 2026-10-25 | Restart and Reset glyphs (owner L A) | A menu's Play from the beginning wears `RotateCcw`, as the card's own Restart does; Reset (clear progress and status) wears `Eraser`; Redownload wears `RefreshCw`, the retry arrow | The skip arrow ⏮ on the menu's Restart; `RotateCcw` on both Restart and Reset; `TimerReset` for Restart | ⏮ is Previous beside the player; one action wore two glyphs, and two actions in one menu would have worn one |
| 2026-10-28 | Notification order (owner A-D1 a, A-D2 a, ★ a) | What asks for the reader first, from either source ("Needs you · n"), then one stream newest first; read rows older than a week fold under "Earlier (n)", closed, its label in the section-heading style with a chevron | One stream with no band; this browser's activity above the server's (the two fixed sections); a server-side retention that deletes old rows | An admin's access requests sat under up to a hundred server rows; week-old read notices crowded what is new; folding keeps the rows (nothing is deleted) and an unread row never folds, whatever its age |
+1 -1
View File
@@ -196,7 +196,7 @@ test.describe("a bookmark reopens the view it was taken of", () => {
test("a channel page's own tab, over the page it was opened from", async ({ page, browser }) => {
await openApp(page, "/?page=feed");
await expect(page.getByTestId("video-card").first()).toBeVisible();
expect(await clickVisibleChannelLink(page)).toBe(true);
await clickVisibleChannelLink(page);
const title = await page.getByTestId("channel-title").textContent();
await tab(page, "About").click();
const fresh = await bookmarked(browser, page);
+1 -1
View File
@@ -48,7 +48,7 @@ test.describe("channel view", () => {
// `.first()` here would grab an overscan card ABOVE the viewport, and Playwright's scroll-into-
// view would move the feed before navigating, so Back would restore that moved position, not
// the one under test. (This is what made the test fail while the app was in fact correct.)
expect(await clickVisibleChannelLink(page)).toBe(true);
await clickVisibleChannelLink(page);
await expect(page.getByTestId("channel-title")).toBeVisible();
await page.getByTestId("channel-back").click();
await expect(page.getByTestId("video-card").first()).toBeVisible();
+32 -14
View File
@@ -33,23 +33,41 @@ export async function gotoPage(page: Page, id: string): Promise<void> {
* that then asserts on where the feed was. Being inside the viewport is not enough either: a card
* can sit under the page chrome above the scroller (the update banner, the toolbar band), and the
* covered click makes Playwright scroll and retry — the same move (webkit failed 3 in 24).
* So the link must be the element hit at its own centre. Returns false if none found.
* So the link must be the element hit at its own centre; none within the wait fails the test, with
* the poll's own message. Looked for again until one is, not once: a fresh load is still settling
* when the first card shows (the links move down ~55px), and webkit can answer the hit test at a
* link's new centre with the card under it — every link missed in 1 of 30 runs. A link that goes
* away mid-look (its evaluate waits for it, or throws) counts as not hit, so the next look runs.
*/
export async function clickVisibleChannelLink(page: Page): Promise<boolean> {
export async function clickVisibleChannelLink(page: Page): Promise<void> {
const links = page.getByTestId("video-card-channel").filter({ visible: true });
const count = await links.count();
for (let i = 0; i < count; i++) {
const hittable = await links.nth(i).evaluate((el) => {
const r = el.getBoundingClientRect();
const hit = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2);
return hit !== null && el.contains(hit);
});
if (hittable) {
await links.nth(i).click();
return true;
let index = -1;
const findHittable = async () => {
const count = await links.count();
for (let i = 0; i < count; i++) {
const hittable = await links
.nth(i)
.evaluate(
(el) => {
const r = el.getBoundingClientRect();
const hit = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2);
return hit !== null && el.contains(hit);
},
undefined,
{ timeout: 1000 },
)
.catch(() => false);
if (hittable) return i;
}
}
return false;
return -1;
};
await expect
.poll(async () => (index = await findHittable()), {
message: "a channel link hit at its own centre",
timeout: 5000,
})
.toBeGreaterThan(-1);
await links.nth(index).click();
}
/**
@@ -22,7 +22,7 @@ vi.mock("react-i18next", async (importOriginal) => {
});
vi.mock("./components/ui/confirmContext", () => ({ useConfirm: () => async () => true }));
const { notify } = vi.hoisted(() => ({
notify: vi.fn((_n: { level: string; message: string }) => 0),
notify: vi.fn((_n: { level: string; message: NotifText }) => 0),
}));
vi.mock("./lib/notifications", async (importOriginal) => ({
...(await importOriginal<typeof import("./lib/notifications")>()),
@@ -35,6 +35,7 @@ import PlexPlaylistAdd from "./features/plexCards/PlexPlaylistAdd";
import TagManager from "./features/channel/TagManager";
import { api, HttpError, type PlexCard } from "./lib/api";
import { qk } from "./lib/queryKeys";
import type { NotifText } from "./lib/notifications";
beforeAll(() => {
class Stub {
@@ -98,7 +99,7 @@ describe("the Plex playlist page", () => {
() => fireEvent.contextMenu(screen.getByText("Movie 1").closest("[data-nav-index]")!),
"plex.playlist.removeFromPlaylist",
);
await waitFor(() => expect(errors()).toEqual(["gone playlist"]));
await waitFor(() => expect(errors()).toEqual([{ detail: gone("playlist").detailData }]));
await act(async () => void (await new Promise((r) => setTimeout(r, 20))));
expect(screen.getByText("Movie 1")).toBeTruthy();
});
@@ -119,7 +120,7 @@ describe("the Plex playlist page", () => {
const field = await screen.findByDisplayValue("Films");
fireEvent.change(field, { target: { value: "Movies" } });
fireEvent.keyDown(field, { key: "Enter" });
await waitFor(() => expect(errors()).toEqual(["gone playlist"]));
await waitFor(() => expect(errors()).toEqual([{ detail: gone("playlist").detailData }]));
expect(screen.getByDisplayValue("Movies")).toBeTruthy(); // still open to try again
});
@@ -154,7 +155,7 @@ describe("the add-to-playlist popover", () => {
);
fireEvent.click(screen.getByLabelText("playlists.addToPlaylist"));
fireEvent.click(await screen.findByRole("menuitemcheckbox", { hidden: true }));
await waitFor(() => expect(errors()).toEqual(["gone playlist"]));
await waitFor(() => expect(errors()).toEqual([{ detail: gone("playlist").detailData }]));
await waitFor(() => expect(lists.mock.calls.length).toBeGreaterThan(1));
});
@@ -282,7 +283,7 @@ describe("the add-to-playlist popover", () => {
fireEvent.click(await screen.findByText("Gone"));
// The server's own localized reason wins over the caller's fallback when the detail is
// STRUCTURED — same as the twin above. What is under test is that anything is said at all.
await waitFor(() => expect(errors()).toEqual(["gone playlist"]));
await waitFor(() => expect(errors()).toEqual([{ detail: gone("playlist").detailData }]));
await waitFor(() => expect(lists.mock.calls.length).toBeGreaterThan(1));
});
});
@@ -300,7 +301,7 @@ describe("the tag manager", () => {
</QueryClientProvider>,
);
fireEvent.click(await screen.findByLabelText("tagManager.delete"));
await waitFor(() => expect(errors()).toEqual(["gone tag"]));
await waitFor(() => expect(errors()).toEqual([{ detail: gone("tag").detailData }]));
await waitFor(() => expect(tags.mock.calls.length).toBeGreaterThan(1));
});
});
@@ -19,7 +19,7 @@ vi.mock("react-i18next", async (importOriginal) => {
});
vi.mock("./components/ui/confirmContext", () => ({ useConfirm: () => async () => true }));
const { notify } = vi.hoisted(() => ({
notify: vi.fn((_n: { level: string; title?: string; message: string }) => 0),
notify: vi.fn((_n: { level: string; title?: NotifText; message: NotifText }) => 0),
}));
vi.mock("./lib/notifications", async (importOriginal) => ({
...(await importOriginal<typeof import("./lib/notifications")>()),
@@ -30,6 +30,7 @@ import PlexCollectionEditor from "./modules/plex/PlexCollectionEditor";
import UploadDialog from "./modules/files/UploadDialog";
import DownloadDialog from "./features/downloads/DownloadDialog";
import { api, HttpError } from "./lib/api";
import { msg, type NotifText } from "./lib/notifications";
beforeAll(() => {
class Stub {
@@ -87,7 +88,7 @@ describe("the Plex collection editor", () => {
);
mount();
fireEvent.click(await screen.findByText("Marathon"));
await waitFor(() => expect(errors()).toEqual(["plex.collection.editFailed"]));
await waitFor(() => expect(errors()).toEqual([msg("plex.collection.editFailed")]));
});
it("says so when enabling editing is refused", async () => {
@@ -97,7 +98,7 @@ describe("the Plex collection editor", () => {
);
mount();
fireEvent.click(await screen.findByText("plex.collEditor.makeEditable"));
await waitFor(() => expect(errors()).toEqual(["plex.collection.editFailed"]));
await waitFor(() => expect(errors()).toEqual([msg("plex.collection.editFailed")]));
});
it("says so when a CONFIRMED delete does not happen", async () => {
@@ -108,7 +109,7 @@ describe("the Plex collection editor", () => {
);
mount();
fireEvent.click(await screen.findByText("plex.collEditor.delete"));
await waitFor(() => expect(errors()).toEqual(["plex.collection.deleteFailed"]));
await waitFor(() => expect(errors()).toEqual([msg("plex.collection.deleteFailed")]));
});
});
@@ -144,7 +145,7 @@ describe("the upload dialog", () => {
mount();
pick();
fireEvent.click(screen.getByText("downloads.upload.submit"));
await waitFor(() => expect(errors()).toEqual(["downloads.upload.failed"]));
await waitFor(() => expect(errors()).toEqual([msg("downloads.upload.failed")]));
});
it("says nothing when the user cancelled it themselves", async () => {
@@ -172,6 +173,6 @@ describe("the download dialog", () => {
);
await screen.findByText("Best");
fireEvent.click(screen.getByText("downloads.dialog.submit"));
await waitFor(() => expect(errors()).toEqual(["errors.writeFailed.enqueueDownload"]));
await waitFor(() => expect(errors()).toEqual([msg("errors.writeFailed.enqueueDownload")]));
});
});
+99 -5
View File
@@ -21,9 +21,9 @@ vi.mock("react-i18next", async (importOriginal) => {
vi.mock("../../lib/history", () => ({ useBackToClose: () => {} }));
import Modal from "./Modal";
import { clearAll, notify } from "../../lib/notifications";
import { clearAll, notify, raw } from "../../lib/notifications";
import { pushLayer, removeLayer, useLayer } from "../../lib/layerStack";
import { flushSync } from "react-dom";
import { createPortal, flushSync } from "react-dom";
// Fire a keydown on window (where Modal installs its handler), inside act.
const press = (key: string, shiftKey = false) =>
@@ -121,7 +121,7 @@ describe("Modal focus trap (offsetParent stubbed 'visible')", () => {
<Body />
</Modal>,
);
act(() => void notify({ level: "info", message: "Saved" }));
act(() => void notify({ level: "info", message: raw("Saved") }));
try {
const dismissToast = screen.getByTitle("notifications.dismiss");
// The panel's last control is no longer the trap's edge: Tab carries on (natively) to the toast.
@@ -234,7 +234,7 @@ describe("Modal focus trap (offsetParent stubbed 'visible')", () => {
<Body />
</Modal>,
);
act(() => void notify({ level: "error", message: "Nope" }));
act(() => void notify({ level: "error", message: raw("Nope") }));
const dismissToast = screen.getByTitle("notifications.dismiss");
dismissToast.focus();
expect(document.activeElement).toBe(dismissToast); // the scenario is actually being driven
@@ -311,6 +311,100 @@ describe("Modal focus trap (offsetParent stubbed 'visible')", () => {
document.body.removeChild(trigger);
});
it("hands its trigger to the dialog left standing above it, which restores it on close", () => {
// The standing upper dialog captured ITS restore target while the focus was inside the lower
// one — a control that is gone once the lower closes. So the lower skipping its own restore
// (above) handed the trigger to no one, and closing the upper left the focus on <body>.
// The arrangement keyboard help or the error dialog opened over a dialog actually has.
const trigger = document.createElement("button");
document.body.appendChild(trigger);
trigger.focus();
function Siblings() {
const [lower, setLower] = useState(true);
const [upper, setUpper] = useState(false);
return (
<>
{lower && (
<Modal initialFocus="close" title="lower" onClose={() => {}}>
<button onClick={() => setUpper(true)}>open upper</button>
<button onClick={() => setLower(false)}>close lower</button>
</Modal>
)}
{upper && (
<Modal initialFocus="close" title="upper" onClose={() => {}}>
<button onClick={() => setUpper(false)}>close upper</button>
</Modal>
)}
</>
);
}
render(<Siblings />);
screen.getByText("open upper").focus();
act(() => void screen.getByText("open upper").click()); // opened from inside the lower dialog
const inUpper = screen.getByText("close upper");
inUpper.focus();
// The lower closes on its own under the standing upper (`.click()` moves no focus).
act(() => void screen.getByText("close lower").click());
expect(screen.queryByText("close lower")).toBeNull();
expect(document.activeElement).toBe(inUpper);
act(() => void inUpper.click());
expect(document.activeElement).toBe(trigger);
document.body.removeChild(trigger);
});
it("restores the trigger when it closes with the focus in one of its menus", () => {
// The popover engine portals a menu out of the dialog's root (it carries no aria-modal), so a
// focus on a menu row read as "the user walked away": Back with a menu open inside a dialog
// left the focus on <body>. The portal marker is the one `ui/AnchoredPopover` reads too.
const trigger = document.createElement("button");
document.body.appendChild(trigger);
trigger.focus();
const portal = document.createElement("div");
portal.setAttribute("data-floating-ui-portal", "");
document.body.appendChild(portal);
const { unmount } = render(
<Modal initialFocus="close" title="t" onClose={() => {}}>
<Body />
{createPortal(<button>menu-row</button>, portal)}
</Modal>,
);
screen.getByText("menu-row").focus();
unmount();
expect(document.activeElement).toBe(trigger);
document.body.removeChild(trigger);
document.body.removeChild(portal);
});
it("does not pull the focus out of a menu that stays open when it closes on its own", () => {
// The other side of counting a menu row as held: a menu that is NOT this dialog's — a page's
// "…" left open while a dialog finishes in the background — stays standing, and a dialog that
// closes on its own (a write's success) must not take the focus from it to its trigger.
const trigger = document.createElement("button");
document.body.appendChild(trigger);
trigger.focus();
const portal = document.createElement("div");
portal.setAttribute("data-floating-ui-portal", "");
const row = document.createElement("button");
row.textContent = "page-menu-row";
portal.appendChild(row);
document.body.appendChild(portal);
function SelfClosing() {
const [open, setOpen] = useState(true);
return open ? (
<Modal initialFocus="close" title="t" onClose={() => {}}>
<button onClick={() => setOpen(false)}>finish</button>
</Modal>
) : null;
}
render(<SelfClosing />);
row.focus();
act(() => void screen.getByText("finish").click()); // `.click()` moves no focus
expect(screen.queryByText("finish")).toBeNull();
expect(document.activeElement).toBe(row);
document.body.removeChild(trigger);
document.body.removeChild(portal);
});
it("leaves the focus alone when the user moved it out of the dialog themselves", () => {
// The other half of the same rule, and the branch nothing drove: taking focus back from where
// the user deliberately put it is the rudest thing a closing dialog can do.
@@ -361,7 +455,7 @@ describe("Modal — toasts raised while it is open", () => {
<Body />
</Modal>,
);
act(() => void notify({ level: "error", message: "Couldn't share it." }));
act(() => void notify({ level: "error", message: raw("Couldn't share it.") }));
expect(screen.getByRole("dialog").textContent).toContain("Couldn't share it.");
});
});
@@ -17,6 +17,7 @@ vi.mock("react-i18next", async (importOriginal) => {
import PageScroller from "./PageScroller";
import BackToTop from "./BackToTop";
import { notifyNav } from "../../lib/history";
import { revealedNavItem } from "../../lib/listNav";
beforeAll(() => {
// useScrollFade wires a ResizeObserver (watch-the-children); jsdom ships none.
@@ -125,6 +126,38 @@ describe("PageScroller scroll memory", () => {
expect(main.scrollTop).toBe(450);
});
// Back focuses an item the restore did not expect — one that moved to another list while the page
// was away — and scrolls it into view: the restore's next frame put the old offset back over it.
it("gives the page up to an item brought into view while it restores", () => {
let frames: FrameRequestCallback[] = [];
vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => frames.push(cb));
const flush = (n: number) => {
for (let i = 0; i < n; i++) {
const run = frames;
frames = [];
for (const cb of run) cb(0);
}
};
setEntry(1);
const { container } = render(
<PageScroller scrollKey="e:k1">
<div id="moved" />
</PageScroller>,
);
const main = sizedMain(container);
main.scrollTop = 450;
fireEvent.scroll(main);
setEntry(2);
notifyNav();
flush(1);
setEntry(1);
notifyNav(); // Back: the restore to 450 waits for its first frame
main.scrollTop = 120; // the item scrolled into view
revealedNavItem(container.querySelector("#moved")!);
flush(5);
expect(main.scrollTop).toBe(120);
});
// Landing at the end shows Back to top, whose spacer then brings the spot within reach.
it("finishes the way to a spot the spacer brings within reach after it lands", () => {
let frames: FrameRequestCallback[] = [];
@@ -12,6 +12,7 @@ import { StickyTopProvider } from "./stickyTop";
import { useBackToTopShown, useCornerLift } from "./backToTopState";
import { CONTENT_ID } from "./landmarks";
import { ensureNavId, peekNavId, subscribeNav } from "../../lib/history";
import { NAV_REVEAL_EVENT } from "../../lib/listNav";
// Saved scroll offsets, keyed by `${historyEntryId}:${scrollKey}`. The entry id ties an offset to the
// exact history entry, so Back restores where you left it and a forward navigation (a new entry) opens
@@ -63,11 +64,15 @@ function restoreScroll(el: HTMLElement, top: number): () => void {
el.removeEventListener("wheel", stop);
el.removeEventListener("touchstart", stop);
el.removeEventListener("pointerdown", stop);
el.removeEventListener(NAV_REVEAL_EVENT, stop);
document.removeEventListener("keydown", stop, true);
};
el.addEventListener("wheel", stop, { passive: true });
el.addEventListener("touchstart", stop, { passive: true });
el.addEventListener("pointerdown", stop);
// So does an item the keyboard brought into view (`lib/listNav`): Back focusing one that is not
// where the restored offset shows.
el.addEventListener(NAV_REVEAL_EVENT, stop);
document.addEventListener("keydown", stop, true);
const tick = () => {
if (cancelled) return;
+17 -17
View File
@@ -14,7 +14,7 @@ vi.mock("react-i18next", async (importOriginal) => {
});
import Toaster from "./Toaster";
import { clearAll, notify } from "../../lib/notifications";
import { clearAll, notify, raw } from "../../lib/notifications";
import { dockUnderToasts } from "../../lib/toastDock";
afterEach(() => {
@@ -31,7 +31,7 @@ describe("Toaster — one host shows the toasts", () => {
<Toaster />
</div>,
);
act(() => void notify({ level: "error", message: "Couldn't remove the video." }));
act(() => void notify({ level: "error", message: raw("Couldn't remove the video.") }));
expect(screen.getByTestId("app").textContent).toContain("Couldn't remove the video.");
const overlay = render(
@@ -54,7 +54,7 @@ describe("Toaster — one host shows the toasts", () => {
it("lets a click through where there is no toast and nothing docked, and not where there is", () => {
const Docked = () => <button>dock</button>;
const stop = dockUnderToasts(Docked);
notify({ level: "info", message: "hello" });
notify({ level: "info", message: raw("hello") });
render(<Toaster />);
const region = document.querySelector<HTMLElement>("[data-toast-region]")!;
// The empty part of the region (beside and above a docked control) must not swallow the page's
@@ -75,7 +75,7 @@ describe("Toaster — one host shows the toasts", () => {
<Toaster overlay />
</div>,
);
act(() => void notify({ level: "error", message: "boom" }));
act(() => void notify({ level: "error", message: raw("boom") }));
fireEvent.click(screen.getByText("boom"));
expect(backdrop).not.toHaveBeenCalled();
});
@@ -86,11 +86,11 @@ describe("Toaster — handing the toasts to another host", () => {
// The new host gets them as NEW nodes: a screen reader read every one out again, an error
// assertively, each time a player (or any dialog) opened or closed.
render(<Toaster />);
act(() => void notify({ level: "error", message: "old news" }));
act(() => void notify({ level: "error", message: raw("old news") }));
render(<Toaster overlay />);
const old = screen.getByText("old news").closest(".toast-card")!;
expect(old.getAttribute("role")).toBeNull();
act(() => void notify({ level: "error", message: "fresh" }));
act(() => void notify({ level: "error", message: raw("fresh") }));
expect(screen.getByText("fresh").closest(".toast-card")!.getAttribute("role")).toBe("alert");
});
@@ -99,8 +99,8 @@ describe("Toaster — handing the toasts to another host", () => {
// host last showed — re-read on every render, this host's own "first" turned silent the moment
// "second" arrived (its own screen had become the handover by then).
render(<Toaster />);
act(() => void notify({ level: "error", message: "first" }));
act(() => void notify({ level: "error", message: "second" }));
act(() => void notify({ level: "error", message: raw("first") }));
act(() => void notify({ level: "error", message: raw("second") }));
const card = (text: string) => screen.getByText(text).closest(".toast-card")!;
expect(card("first").getAttribute("role")).toBe("alert");
expect(card("second").getAttribute("role")).toBe("alert");
@@ -111,7 +111,7 @@ describe("Toaster — handing the toasts to another host", () => {
// inserted announces them anyway. React flushes effects in the SAME task as that render, so the
// arming has to wait for the next one — a screen reader reads the region's state after the task.
render(<Toaster />);
act(() => void notify({ level: "info", message: "saved" }));
act(() => void notify({ level: "info", message: raw("saved") }));
const overlay = render(<Toaster overlay />);
const region = overlay.container.querySelector("[aria-live]")!;
expect(region.textContent).toContain("saved"); // it took the toast over…
@@ -135,7 +135,7 @@ describe("Toaster — handing the toasts to another host", () => {
<Toaster overlay />
<button
onClick={() => {
notify({ level: "success", message: "upload queued" });
notify({ level: "success", message: raw("upload queued") });
setOpen(false);
}}
>
@@ -147,7 +147,7 @@ describe("Toaster — handing the toasts to another host", () => {
);
}
render(<Stage />);
act(() => void notify({ level: "error", message: "old news" }));
act(() => void notify({ level: "error", message: raw("old news") }));
fireEvent.click(screen.getByText("upload"));
const card = (text: string) => screen.getByText(text).closest(".toast-card")!;
@@ -168,7 +168,7 @@ describe("Toaster — handing the toasts to another host", () => {
<Toaster overlay order={2} />
</div>,
);
act(() => void notify({ level: "error", message: "boom" }));
act(() => void notify({ level: "error", message: raw("boom") }));
expect(screen.getByTestId("dialog").textContent).toContain("boom");
// The player swaps its host (leaving true fullscreen) while the dialog stays open.
player.rerender(
@@ -188,7 +188,7 @@ describe("Toaster — handing the toasts to another host", () => {
const t0 = Date.now();
const now = vi.spyOn(Date, "now").mockReturnValue(t0);
render(<Toaster />);
act(() => void notify({ level: "info", message: "saved" }));
act(() => void notify({ level: "info", message: raw("saved") }));
now.mockReturnValue(t0 + 4000);
render(<Toaster overlay />);
const bar = screen.getByText("saved").closest(".toast-card")!.lastElementChild as HTMLElement;
@@ -199,7 +199,7 @@ describe("Toaster — handing the toasts to another host", () => {
// At the app's own spot (bottom-4) the stack sat on the Plex transport row and PlayerModal's
// docked bar, above them at z-overlay, and took their clicks for as long as a toast was up.
render(<Toaster overlay />);
act(() => void notify({ level: "error", message: "boom" }));
act(() => void notify({ level: "error", message: raw("boom") }));
const host = hosts()[0]!.closest("[data-toast-region]")!;
expect(host.className).toContain("bottom-24");
expect(host.className).toContain("pointer-events-none");
@@ -218,9 +218,9 @@ describe("Toaster — a toast's actions", () => {
() =>
void notify({
level: "success",
message: "Downloaded",
action: { label: "Copy link", onClick: copy },
secondaryAction: { label: "Open", onClick: open },
message: raw("Downloaded"),
action: { label: raw("Copy link"), onClick: copy },
secondaryAction: { label: raw("Open"), onClick: open },
}),
);
expect(screen.getByRole("button", { name: "Copy link" })).toBeTruthy();
@@ -0,0 +1,104 @@
// @vitest-environment jsdom
//
// A notice is said in the reader's language when it is shown, not in the one it was raised in. The
// text used to be translated by the caller and kept so: after a language switch every toast and every
// row of the notification center stayed in the old language, and so did the history after a reload.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, cleanup, render, screen } from "@testing-library/react";
import i18n from "../../i18n";
import Toaster from "./Toaster";
import { clearAll, msg, notify, raw, sayNotif } from "../../lib/notifications";
beforeEach(async () => {
await i18n.changeLanguage("en");
});
afterEach(async () => {
cleanup();
act(() => clearAll());
await i18n.changeLanguage("en");
});
describe("a notice speaks the reader's language of the moment", () => {
it("a toast raised in English reads Hungarian after the switch", async () => {
render(<Toaster />);
act(() => void notify({ level: "success", message: msg("common.copy.done") }));
const en = i18n.getFixedT("en")("common.copy.done");
const hu = i18n.getFixedT("hu")("common.copy.done");
expect(en).not.toBe(hu);
expect(screen.getByText(en)).toBeTruthy();
await act(async () => void (await i18n.changeLanguage("hu")));
expect(screen.getByText(hu)).toBeTruthy();
expect(screen.queryByText(en)).toBeNull();
});
it("its button is said again too, beside the text it acts on", async () => {
render(<Toaster />);
act(
() =>
void notify({
message: msg("common.copy.done"),
action: { label: msg("common.undo"), onClick: () => {} },
}),
);
expect(screen.getByRole("button", { name: "Undo" })).toBeTruthy();
await act(async () => void (await i18n.changeLanguage("hu")));
expect(screen.getByRole("button", { name: "Visszavonás" })).toBeTruthy();
expect(screen.queryByRole("button", { name: "Undo" })).toBeNull();
});
it("a server refusal is said again in the language of the moment", async () => {
render(<Toaster />);
act(
() =>
void notify({
level: "error",
message: { detail: { code: "not_found", reason: "view" } },
}),
);
expect(screen.getByText("This view doesn't exist any more")).toBeTruthy();
await act(async () => void (await i18n.changeLanguage("hu")));
expect(screen.getByText("Ez a nézet már nem létezik")).toBeTruthy();
});
it("a value inside the text is said again too, and a number is grouped the reader's way", async () => {
const text = msg("settings.takeout.read", {
name: raw("watch-history.json"),
count: 12345,
n: { number: 12345 },
});
const en = sayNotif(text);
await i18n.changeLanguage("hu");
const hu = sayNotif(text);
expect(en).toContain("12,345");
expect(hu).not.toContain("12,345");
expect(hu).toContain("watch-history.json");
expect(hu).not.toBe(en);
});
});
describe("the history kept from before", () => {
beforeEach(() => {
localStorage.clear();
vi.resetModules();
});
it("shows a notice stored as text as it was stored", async () => {
localStorage.setItem("siftlode.owner", "5");
localStorage.setItem(
"siftlode.notifications",
JSON.stringify([
{ id: 1, level: "info", title: "Old title", message: "Old text", ts: 1, read: true },
]),
);
const notifications = await import("../../lib/notifications");
const { claimLocalData } = await import("../../lib/storage");
claimLocalData(5);
const n = notifications.getNotifications()[0]!;
expect(n.message).toEqual(raw("Old text"));
expect(n.title).toEqual(raw("Old title"));
expect(notifications.sayNotif(n.message)).toBe("Old text");
});
});
@@ -23,7 +23,7 @@ vi.mock("react-i18next", async (importOriginal) => {
import Toaster from "./Toaster";
import { useOverlayOrder } from "./overlayOrder";
import { clearAll, notify } from "../../lib/notifications";
import { clearAll, notify, raw } from "../../lib/notifications";
afterEach(() => {
cleanup();
@@ -37,7 +37,7 @@ function Harness() {
<>
<button
onClick={() => {
notify({ level: "success", message: "Uploaded" });
notify({ level: "success", message: raw("Uploaded") });
setOpen(false); // …in the SAME handler: one batch, one commit
}}
>
+8 -6
View File
@@ -1,7 +1,7 @@
import { useEffect, useId, useLayoutEffect, useState, useSyncExternalStore } from "react";
import { useTranslation } from "react-i18next";
import { X } from "lucide-react";
import { dismiss, getActiveToasts, subscribe } from "../../lib/notifications";
import { dismiss, getActiveToasts, sayNotif, subscribe } from "../../lib/notifications";
import { createStore } from "../../lib/store";
import { useToastDock } from "../../lib/toastDock";
import { useOverlayOrder } from "./overlayOrder";
@@ -162,9 +162,11 @@ export default function Toaster({
>
<Icon className={`w-5 h-5 shrink-0 mt-0.5 ${color}`} />
<div className="min-w-0 flex-1">
{toast.title && <div className="text-sm font-semibold">{toast.title}</div>}
{toast.title && (
<div className="text-sm font-semibold">{sayNotif(toast.title, t)}</div>
)}
<div className="text-sm wrap-break-word">
{toast.message}
{sayNotif(toast.message, t)}
{toast.repeat > 1 && (
<span className="ml-1.5 align-middle text-2xs font-semibold text-muted tabular-nums">
×{toast.repeat}
@@ -174,10 +176,10 @@ export default function Toaster({
{(toast.action || toast.secondaryAction) && (
<div className="mt-1 flex flex-wrap gap-x-4">
{[toast.action, toast.secondaryAction].map(
(action) =>
(action, i) =>
action && (
<Button
key={action.label}
key={i}
variant="link"
onClick={() => {
action.onClick();
@@ -185,7 +187,7 @@ export default function Toaster({
}}
className="text-sm"
>
{action.label}
{sayNotif(action.label, t)}
</Button>
),
)}
+5 -5
View File
@@ -2,7 +2,7 @@ import { useTranslation } from "react-i18next";
import { Copy, Link2 } from "lucide-react";
import { copyText } from "../../lib/clipboard";
import { notify } from "../../lib/notifications";
import { msg, type NotifText, notify } from "../../lib/notifications";
import { type ActionMenuItem } from "./ActionMenu";
// "Copy title" and "Copy link", the two rows every object's menu ends its middle group with
@@ -17,12 +17,12 @@ type Named = "title" | "name";
export function useCopyItems() {
const { t } = useTranslation();
const copy = (text: string, done: string) => {
const copy = (text: string, done: NotifText) => {
void copyText(text).then((ok) =>
notify(
ok
? { level: "success", message: done }
: { level: "error", message: t("common.copy.failed") },
: { level: "error", message: msg("common.copy.failed") },
),
);
};
@@ -43,7 +43,7 @@ export function useCopyItems() {
label: t(named === "title" ? "common.copy.title" : "common.copy.name"),
icon: <Copy className="w-4 h-4" />,
inGesture: true,
onSelect: () => copy(text, t("common.copy.done")),
onSelect: () => copy(text, msg("common.copy.done")),
},
]
: []),
@@ -54,7 +54,7 @@ export function useCopyItems() {
label: linkLabel ?? t("common.copy.link"),
icon: <Link2 className="w-4 h-4" />,
inGesture: true,
onSelect: () => copy(link, t("common.copy.linkDone")),
onSelect: () => copy(link, msg("common.copy.linkDone")),
},
]
: []),
+33 -4
View File
@@ -75,6 +75,16 @@ export function lockBodyScroll(): () => void {
};
}
// A popover engine surface (`ui/AnchoredPopover`): portalled out of the dialog's root and carrying no
// aria-modal, so a focus on one of a dialog's menu rows is inside the dialog all the same.
const FLOATING = "[data-floating-ui-portal]";
// Restore targets handed to a dialog left standing above one that closed (see the cleanup below):
// that dialog's own target was captured while the focus was in the one that closed, and is gone.
// Keyed by the standing dialog's aria-modal element; a WeakMap, so a dialog that never closes
// through this hook leaves nothing behind.
const handedRestore = new WeakMap<Element, HTMLElement>();
/** The rest of the contract: lock the page behind, open focused, hand the focus back on close.
* `scopeRef` is the panel the focus should land in; `ownerRef`, when the dialog is bigger than its
* panel, is that whole dialog — it must be the SAME element `trapTab` is scoped to, or the two
@@ -128,6 +138,11 @@ export function useDialogChrome(
// Today that reads the same either way (a detached owner and a null one both fail every
// `contains` below), but the cleanup should not depend on a ref React has already let go of.
const ownerAtOpen = ownerRef?.current ?? scope;
// This dialog's aria-modal element, the key a closing dialog under it hands its target by.
const modalAtOpen =
ownerAtOpen?.closest('[aria-modal="true"]') ??
ownerAtOpen?.querySelector('[aria-modal="true"]') ??
null;
if (scope && !scope.contains(document.activeElement)) {
const back = focusedRef.current;
const target =
@@ -144,10 +159,12 @@ export function useDialogChrome(
// it — and while it held the focus we read that as the user walking away. Then both closing
// together left the focus on <body>: ours skipped the restore, and the child's own
// `restoreTo` was already detached (passive cleanups run parent-first). Measured in jsdom,
// and it is the ordinary case — an error dialog raised over a dialog.
// and it is the ordinary case — an error dialog raised over a dialog. And one of its own
// menus (see FLOATING): Back with a menu open closed the dialog with the focus on a menu row.
heldRef.current =
(owner?.contains(active) ?? false) ||
(active instanceof Element && active.closest('[aria-modal="true"]') != null);
(active instanceof Element &&
(active.closest('[aria-modal="true"]') != null || active.closest(FLOATING) != null));
// Where to put the focus back on a re-mount, though: the PANEL's own last focus. A toast is
// gone by then, so restoring onto it would strand the focus exactly as before.
if (scopeRef.current?.contains(document.activeElement))
@@ -172,16 +189,28 @@ export function useDialogChrome(
// this, a dialog closing on its own (an in-flight mutation's `onSuccess`) under a modal raised
// above it pulled the focus out of that standing dialog onto our own trigger, behind its
// scrim — where Enter still activated it.
//
// Skipped, the trigger is handed to that standing dialog instead: ITS target was captured
// while the focus was in ours, so it is gone now, and it falls back to the one handed to it.
// A popover still standing (a menu of a dialog that stays open) is not ours to take from
// either, though there is no dialog to hand the trigger to.
const owner = ownerAtOpen;
const active = document.activeElement;
const target = restoreTo?.isConnected
? restoreTo
: modalAtOpen
? handedRestore.get(modalAtOpen)
: undefined;
const above = active instanceof Element ? active.closest('[aria-modal="true"]') : null;
const standingAbove =
above !== null &&
above.isConnected &&
!above.contains(owner as Node | null) &&
!(owner?.contains(above) ?? false);
if (heldRef.current && !standingAbove && restoreTo && restoreTo.isConnected)
restoreTo.focus();
const standingMenu = active instanceof Element && active.closest(FLOATING)?.isConnected;
if (!heldRef.current || !target?.isConnected) return;
if (standingAbove) handedRestore.set(above, target);
else if (!standingMenu) target.focus();
};
}, [restoreTo, scopeRef, ownerRef, focusScope]);
}
@@ -38,6 +38,10 @@ const PLAIN: Record<string, [number, string]> = {
"shell/NavSidebar.tsx": [3, "the account trigger and its menu rows"],
"features/header/PaletteLauncher.tsx": [2, "launcher controls"],
"modules/playlists/PlaylistsRail.tsx": [1, "the load-error retry"],
"modules/notifications/NotificationsPanel.tsx": [
1,
"the Earlier (n) fold's heading (a disclosure: its text is its label)",
],
"modules/plex/PlexCollectionEditor.tsx": [1, "a checkbox row in a picker"],
"modules/plex/PlexInfo.tsx": [1, "the rating filter (a number)"],
"modules/plex/PlexPlayerSettings.tsx": [1, "the clock's date-style options"],
@@ -19,8 +19,9 @@ const PLATFORM_NAMESPACES = new Set([
]);
const files = readSources(__dirname, "./**/*.{ts,tsx}");
// `t("ns.…")` and `t(\`ns.…\`)` — the namespace is everything before the first dot.
const KEY = /\bt\(\s*["`]([A-Za-z]+)\./g;
// `t("ns.…")` and `t(\`ns.…\`)`, and a notice's `msg("ns.…")` (the same key, said when shown) — the
// namespace is everything before the first dot.
const KEY = /\b(?:t|msg)\(\s*["`]([A-Za-z]+)\./g;
describe("components/ui/ speaks only platform i18n namespaces", () => {
it("names no feature namespace", () => {
@@ -37,5 +38,6 @@ describe("components/ui/ speaks only platform i18n namespaces", () => {
it("sees a feature namespace when one is used", () => {
expect([...'t("plex.loading")'.matchAll(KEY)].map((m) => m[1])).toEqual(["plex"]);
expect([...'msg("plex.loading")'.matchAll(KEY)].map((m) => m[1])).toEqual(["plex"]);
});
});
@@ -11,8 +11,12 @@ vi.mock("react-i18next", async (importOriginal) => ({
useTranslation: () => ({ i18n: { language: "en" }, t: (key: string) => key }),
}));
vi.mock("../../lib/clipboard", () => ({ copyText: h.copyText }));
vi.mock("../../lib/notifications", () => ({ notify: h.notify }));
vi.mock("../../lib/notifications", async (importOriginal) => ({
...(await importOriginal<typeof import("../../lib/notifications")>()),
notify: h.notify,
}));
import { msg } from "../../lib/notifications";
import { useCopyFeedback } from "./useCopyFeedback";
afterEach(() => {
@@ -27,7 +31,7 @@ describe("useCopyFeedback", () => {
await act(() => result.current.copy("sfl_x"));
expect(h.copyText).toHaveBeenCalledWith("sfl_x");
expect(result.current.copied).toBe(true);
expect(h.notify).toHaveBeenCalledWith({ level: "success", message: "common.copy.done" });
expect(h.notify).toHaveBeenCalledWith({ level: "success", message: msg("common.copy.done") });
act(() => void vi.advanceTimersByTime(2000));
expect(result.current.copied).toBe(false);
});
@@ -49,6 +53,6 @@ describe("useCopyFeedback", () => {
const { result } = renderHook(() => useCopyFeedback());
await act(() => result.current.copy("sfl_x"));
expect(result.current.copied).toBe(false);
expect(h.notify).toHaveBeenCalledWith({ level: "warning", message: "common.copy.failed" });
expect(h.notify).toHaveBeenCalledWith({ level: "warning", message: msg("common.copy.failed") });
});
});
@@ -1,8 +1,7 @@
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { copyText } from "../../lib/clipboard";
import { notify } from "../../lib/notifications";
import { msg, notify } from "../../lib/notifications";
// How long a Copy button reads "Copied" after it worked.
const COPIED_MS = 2000;
@@ -15,7 +14,6 @@ const COPIED_MS = 2000;
*
* A feature file, not `ui/`: it speaks the app's notifications (as `copyItems` does). */
export function useCopyFeedback(): { copied: boolean; copy: (value: string) => Promise<void> } {
const { t } = useTranslation();
const [copied, setCopied] = useState(false);
// One reset at a time: a copy again within the moment starts it over, not cut short by the last.
const timer = useRef<number | undefined>(undefined);
@@ -23,12 +21,12 @@ export function useCopyFeedback(): { copied: boolean; copy: (value: string) => P
const copy = async (value: string) => {
if (await copyText(value)) {
setCopied(true);
notify({ level: "success", message: t("common.copy.done") });
notify({ level: "success", message: msg("common.copy.done") });
window.clearTimeout(timer.current);
timer.current = window.setTimeout(() => setCopied(false), COPIED_MS);
return;
}
notify({ level: "warning", message: t("common.copy.failed") });
notify({ level: "warning", message: msg("common.copy.failed") });
};
return { copied, copy };
}
@@ -1,4 +1,31 @@
[
{
"version": "0.114.2",
"date": "2026-10-30",
"new": [],
"fixed": [
"Security: a library the sign-in relies on is updated, closing two published vulnerabilities."
]
},
{
"version": "0.114.1",
"date": "2026-10-30",
"new": [],
"fixed": [
"Notifications follow a language switch: a notice on screen and every row in the notification center are said again in the new language, their buttons too."
]
},
{
"version": "0.114.0",
"date": "2026-10-29",
"new": [
"Notifications: what asks for you comes first, then everything else as one stream, newest first. Read notices older than a week fold under Earlier, and Back returns to the row you left."
],
"fixed": [
"Plex: a progress save sent just before an episode ends no longer leaves it unwatched, and a refused finish no longer sticks.",
"After a dialog closes, the keyboard focus returns to where it was, also inside a menu that stays open."
]
},
{
"version": "0.113.0",
"date": "2026-10-28",
@@ -1,4 +1,31 @@
[
{
"version": "0.114.2",
"date": "2026-10-30",
"new": [],
"fixed": [
"Biztonság: frissült egy könyvtár, amelyre a bejelentkezés épül, így két nyilvános sebezhetőség záródott be."
]
},
{
"version": "0.114.1",
"date": "2026-10-30",
"new": [],
"fixed": [
"Az értesítések követik a nyelvváltást: a képernyőn lévő értesítés és az értesítési központ minden sora az új nyelven szól, a gombjaik is."
]
},
{
"version": "0.114.0",
"date": "2026-10-29",
"new": [
"Értesítések: elöl az, ami rád vár, utána minden más egy folyamban, a legújabb elöl. Az egy hétnél régebbi olvasott értesítések a Korábbiak alá csukódnak, és a Vissza arra a sorra tér vissza, ahonnan elmentél."
],
"fixed": [
"Plex: az epizód vége előtt közvetlenül elküldött mentés már nem hagyja megnézetlenül, és egy elutasított befejezés sem ragad be.",
"Párbeszédablak bezárása után a billentyűfókusz oda tér vissza, ahol volt, egy nyitva maradó menüben is."
]
},
{
"version": "0.113.0",
"date": "2026-10-28",
+5 -5
View File
@@ -4,7 +4,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { qk } from "../../lib/queryKeys";
import { Plus, Trash2 } from "lucide-react";
import { api, type Tag } from "../../lib/api";
import { notify } from "../../lib/notifications";
import { msg, type NotifText, notify, raw } from "../../lib/notifications";
import { notifyActionError } from "../../lib/actionErrors";
import { useScrollFade } from "../../lib/useScrollFade";
import { Button } from "../../components/ui/Button";
@@ -137,22 +137,22 @@ export default function TagManager({
});
// A failure is said and the list refetched — a tag deleted in another tab used to fail in silence
// (a 404 `not_found/tag` gets no global dialog) and stay listed.
const onFailed = (e: unknown, fallback: string) => {
const onFailed = (e: unknown, fallback: NotifText) => {
notifyActionError(e, fallback);
invalidate();
};
const rename = useMutation({
mutationFn: (v: { id: number; name: string }) => api.updateTag(v.id, { name: v.name }),
onSuccess: invalidate,
onError: (e) => onFailed(e, t("tagManager.renameFailed")),
onError: (e) => onFailed(e, msg("tagManager.renameFailed")),
});
const del = useMutation({
mutationFn: (v: { id: number; name: string }) => api.deleteTag(v.id),
onSuccess: (_d, v) => {
invalidate();
notify({ level: "success", message: t("tagManager.deleted", { name: v.name }) });
notify({ level: "success", message: msg("tagManager.deleted", { name: raw(v.name) }) });
},
onError: (e) => onFailed(e, t("tagManager.deleteFailed")),
onError: (e) => onFailed(e, msg("tagManager.deleteFailed")),
});
// Picking a channel from a tag's popover: close the dialog, then focus it in the manager.
+13 -13
View File
@@ -6,7 +6,7 @@ import { Loader2, Pin, PinOff } from "lucide-react";
import { plexLang } from "../../i18n";
import { api, type DiscoverCard, type PinKind } from "../../lib/api";
import { notifyActionError } from "../../lib/actionErrors";
import { notify } from "../../lib/notifications";
import { msg, type NotifText, notify } from "../../lib/notifications";
import { qk } from "../../lib/queryKeys";
import { type ActionMenuItem } from "../../components/ui/ActionMenu";
@@ -81,9 +81,9 @@ export interface ComfortPin {
/** `pinIdOf` and `toggle` stay module-local until a second consumer needs them: publishing them
* invites the next surface to re-derive the three-state loading dance (pinned / not pinned /
* not known yet) that `item` already encapsulates, which is what this file exists to prevent. */
remove: (pinId: number, title: string) => void;
remove: (pinId: number, title: NotifText) => void;
/** The pin/unpin row, ready to spread into any `ActionMenuItem[]`. Empty for a null target. */
item: (target: PinTarget | null, title: string) => ActionMenuItem[];
item: (target: PinTarget | null, title: NotifText) => ActionMenuItem[];
}
/**
@@ -134,37 +134,37 @@ export function useComfortPin(open: boolean): ComfortPin {
}, [qc]);
const add = useCallback(
async (target: PinTarget, title: string) => {
async (target: PinTarget, title: NotifText) => {
try {
await api.addComfortPin(target.kind, target.ref);
// The shelf lives on another page, so without this the click has no visible consequence at
// all from the feed — the one place a toast genuinely carries the whole feedback.
notify({ level: "success", message: t("home.pin.added", { title }) });
notify({ level: "success", message: msg("home.pin.added", { title }) });
} catch (e) {
notifyActionError(e, t("home.pin.addFailed"));
notifyActionError(e, msg("home.pin.addFailed"));
}
invalidate();
},
[invalidate, t],
[invalidate],
);
const remove = useCallback(
(pinId: number, title: string) => {
(pinId: number, title: NotifText) => {
void (async () => {
try {
await api.removeComfortPin(pinId);
notify({ level: "success", message: t("home.pin.removed", { title }) });
notify({ level: "success", message: msg("home.pin.removed", { title }) });
} catch (e) {
notifyActionError(e, t("home.pin.removeFailed"));
notifyActionError(e, msg("home.pin.removeFailed"));
}
invalidate();
})();
},
[invalidate, t],
[invalidate],
);
const toggle = useCallback(
(target: PinTarget, title: string) => {
(target: PinTarget, title: NotifText) => {
const pinId = pinIdOf(target);
if (pinId == null) void add(target, title);
else remove(pinId, title);
@@ -173,7 +173,7 @@ export function useComfortPin(open: boolean): ComfortPin {
);
const item = useCallback(
(target: PinTarget | null, title: string): ActionMenuItem[] => {
(target: PinTarget | null, title: NotifText): ActionMenuItem[] => {
if (!target) return [];
const pinId = pinIdOf(target);
// Still loading: an honest "we don't know yet" row rather than a confident "Pin" over
@@ -18,7 +18,7 @@ import {
} from "../../lib/debugRecorder";
import { formatDateTimeSec, formatDuration } from "../../lib/format";
import { useLayer } from "../../lib/layerStack";
import { notify } from "../../lib/notifications";
import { msg, notify } from "../../lib/notifications";
import { qk } from "../../lib/queryKeys";
import { dockUnderToasts } from "../../lib/toastDock";
import { useMe } from "../../lib/useMe";
@@ -73,7 +73,7 @@ export default function DebugRecorder() {
if (!id || !until) return;
const wait = Math.min(Date.parse(until) - Date.now(), 2 ** 31 - 1);
const timer = window.setTimeout(() => {
notify({ level: "warning", message: t("recorder.pill.expired", { count: minutes ?? 0 }) });
notify({ level: "warning", message: msg("recorder.pill.expired", { count: minutes ?? 0 }) });
openComposer(id);
void qc.invalidateQueries({ queryKey: qk.me() });
}, wait);
@@ -6,7 +6,7 @@ import { ExternalLink, FileText, Plus, Server, X } from "lucide-react";
import { api, type DebugScreenshot, type DebugSession, type ServerLogRow } from "../../lib/api";
import { closeComposer } from "../../lib/debugRecorder";
import { formatBytes, formatDateTimeSec, formatUnit } from "../../lib/format";
import { notify } from "../../lib/notifications";
import { msg, notify } from "../../lib/notifications";
import { useMe } from "../../lib/useMe";
import {
draftSteps,
@@ -217,7 +217,7 @@ function ComposerBody({
meta: { failTitle: "recorder.composer.sendFailed" },
onSuccess: (r) => {
qc.setQueryData(qk.debugSession(session.id), r.session);
notify({ level: "success", message: t("recorder.composer.sentOk") });
notify({ level: "success", message: msg("recorder.composer.sentOk") });
},
// A failure half-way still created the issue: show what the server now has.
onSettled: async () => {
@@ -1,6 +1,5 @@
import { useCallback } from "react";
import { useIsMutating, useMutation, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { api, type DebugSession, type DebugSessionLevel } from "../../lib/api";
import { notifyActionError } from "../../lib/actionErrors";
@@ -14,7 +13,7 @@ import {
} from "../../lib/debugRecorder";
import { flushNow, liveSession } from "../../lib/diagnostics";
import { log } from "../../lib/log";
import { notify } from "../../lib/notifications";
import { msg, notify } from "../../lib/notifications";
import { qk } from "../../lib/queryKeys";
import { updateMeCache, useMe } from "../../lib/useMe";
@@ -22,7 +21,6 @@ import { updateMeCache, useMe } from "../../lib/useMe";
* Diagnosztika and the command palette. The session itself is `me.debug_session` — the server's
* answer, so a reload, another tab or the cap all show the same thing. */
export function useDebugRecording() {
const { t } = useTranslation();
const qc = useQueryClient();
const me = useMe();
const session = liveSession(me?.debug_session);
@@ -54,7 +52,7 @@ export function useDebugRecording() {
meta: { failTitle: "recorder.pill.stopFailed" },
onSuccess: (r) => {
setSession(null);
notify({ level: "success", message: t("recorder.pill.stopped") });
notify({ level: "success", message: msg("recorder.pill.stopped") });
openComposer(r.session.id);
},
});
@@ -78,7 +76,7 @@ export function useDebugRecording() {
);
// In the evidence too, where the step list is drafted from: which step it belongs to.
log.info("debug.screenshot", "screenshot taken", { screenshot: r.screenshot.id });
notify({ level: "success", message: t("recorder.pill.shotSaved") });
notify({ level: "success", message: msg("recorder.pill.shotSaved") });
},
});
@@ -95,9 +93,9 @@ export function useDebugRecording() {
const n = nextStep(sessionId);
const text = label.trim();
log.info("debug.step", text || `step ${n}`, { n, ...(text ? { label: text } : {}) });
notify({ level: "info", message: t("recorder.pill.stepMarked", { n }) });
notify({ level: "info", message: msg("recorder.pill.stepMarked", { n }) });
},
[sessionId, t],
[sessionId],
);
/** Capture this tab and attach it. `hide` runs around the capture so the pill is not in the
@@ -111,7 +109,7 @@ export function useDebugRecording() {
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
image = await captureTab();
} catch (err) {
notifyActionError(err, t("recorder.pill.shotFailed"));
notifyActionError(err, msg("recorder.pill.shotFailed"));
}
hide(false);
if (image) shoot.mutate({ id: session.id, image });
@@ -9,7 +9,7 @@ import { invalidateDownloads } from "../../lib/downloads";
// Lazy: the full profile editor only opens from the dialog's "manage presets" affordance.
const ProfileEditor = lazy(() => import("../../modules/settings/ProfileEditor"));
import { notify } from "../../lib/notifications";
import { msg, notify } from "../../lib/notifications";
import { notifyActionError } from "../../lib/actionErrors";
import { showInTray } from "../../lib/downloadQueue";
import { profileName } from "../../lib/vocab";
@@ -63,15 +63,15 @@ export default function DownloadDialog({
setLastProfile(chosen);
notify({
level: "success",
message: t("downloads.dialog.added"),
action: { label: t("downloads.dialog.view"), onClick: () => showInTray(job.id) },
message: msg("downloads.dialog.added"),
action: { label: msg("downloads.dialog.view"), onClick: () => showInTray(job.id) },
});
onClose();
} catch (e) {
// A quota or validation refusal (422/409) is already in the global error dialog, which the
// reporter skips; a 403/404/429 or a lost connection is not, and used to leave the dialog
// open with nothing said.
notifyActionError(e, t("errors.writeFailed.enqueueDownload"));
notifyActionError(e, msg("errors.writeFailed.enqueueDownload"));
setBusy(false);
}
};
@@ -70,6 +70,7 @@ vi.mock("../../lib/notifications", async (importOriginal) => ({
}));
import DownloadTray from "./DownloadTray";
import { msg, type NotifText, raw } from "../../lib/notifications";
import { noteQueue, resetQueueWatch, showInTray } from "../../lib/downloadQueue";
import { pushLayer, removeLayer } from "../../lib/layerStack";
import { qk } from "../../lib/queryKeys";
@@ -199,14 +200,15 @@ describe("DownloadTray", () => {
poll([job(1, "done")], view);
await waitFor(() => expect(h.notify).toHaveBeenCalledTimes(1));
const notice = h.notify.mock.calls[0]![0] as {
title: string;
message: string;
action: { label: string; onClick: () => void };
secondaryAction: { label: string; onClick: () => void };
title: NotifText;
message: NotifText;
action: { label: NotifText; onClick: () => void };
secondaryAction: { label: NotifText; onClick: () => void };
};
expect(notice.title).toBe("tray.done.title:Clip 1");
expect(notice.message).toBe("tray.done.phone");
expect(notice.action.label).toBe("tray.done.copyLink");
expect(notice.title).toEqual(msg("tray.done.title", { title: raw("Clip 1") }));
expect(notice.message).toEqual(msg("tray.done.phone"));
expect(notice.action.label).toEqual(msg("tray.done.copyLink"));
expect(notice.secondaryAction.label).toEqual(msg("tray.done.open"));
notice.action.onClick();
expect(h.share).toHaveBeenCalled();
notice.secondaryAction.onClick();
@@ -225,9 +227,9 @@ describe("DownloadTray", () => {
await waitFor(() => expect(h.notify).toHaveBeenCalledTimes(1));
poll([job(1, "done"), job(2, "done")], view);
await waitFor(() => expect(h.notify).toHaveBeenCalledTimes(2));
expect(h.notify.mock.calls.map((c) => (c[0] as { title: string }).title)).toEqual([
"tray.done.title:Clip 1",
"tray.done.title:Clip 2",
expect(h.notify.mock.calls.map((c) => (c[0] as { title: NotifText }).title)).toEqual([
msg("tray.done.title", { title: raw("Clip 1") }),
msg("tray.done.title", { title: raw("Clip 2") }),
]);
});
@@ -236,7 +238,9 @@ describe("DownloadTray", () => {
const view = mount();
poll([job(1, "done", { asset: { media_kind: "file" } as never })], view);
await waitFor(() =>
expect(h.notify).toHaveBeenCalledWith(expect.objectContaining({ message: "tray.done.file" })),
expect(h.notify).toHaveBeenCalledWith(
expect.objectContaining({ message: msg("tray.done.file") }),
),
);
});
@@ -257,7 +261,9 @@ describe("DownloadTray", () => {
poll([job(1, "done", { source_key: "upload" })], view);
await waitFor(() =>
expect(h.notify).toHaveBeenCalledWith(
expect.objectContaining({ title: "tray.done.uploadedTitle:Clip 1" }),
expect.objectContaining({
title: msg("tray.done.uploadedTitle", { title: raw("Clip 1") }),
}),
),
);
});
@@ -30,7 +30,7 @@ import {
} from "../../lib/downloadQueue";
import { anyBlockingLayer } from "../../lib/layerStack";
import { focusNavItem, navItemAttrs } from "../../lib/listNav";
import { notify } from "../../lib/notifications";
import { msg, notify, raw } from "../../lib/notifications";
import { qk } from "../../lib/queryKeys";
import { LS } from "../../lib/storage";
import { useLiveQuery } from "../../lib/useLiveQuery";
@@ -191,16 +191,16 @@ export default function DownloadTray() {
notify({
level: "success",
// An upload the worker had to convert first is done here too — it was never downloaded.
title: t(job.source_key === "upload" ? "tray.done.uploadedTitle" : "tray.done.title", {
title,
title: msg(job.source_key === "upload" ? "tray.done.uploadedTitle" : "tray.done.title", {
title: raw(title),
}),
message: t(job.asset?.media_kind === "file" ? "tray.done.file" : "tray.done.phone"),
message: msg(job.asset?.media_kind === "file" ? "tray.done.file" : "tray.done.phone"),
action: {
label: t("tray.done.copyLink"),
onClick: () => void quickShareLink(qc, { kind: "file", id: job.id }, t),
label: msg("tray.done.copyLink"),
onClick: () => void quickShareLink(qc, { kind: "file", id: job.id }),
},
secondaryAction: {
label: t("tray.done.open"),
label: msg("tray.done.open"),
onClick: () => openFile(job.id, job.folder_id),
},
});
@@ -298,7 +298,7 @@ export default function DownloadTray() {
id: "copyLink",
label: t("tray.done.copyLink"),
icon: <Link className="w-4 h-4" />,
onSelect: () => void quickShareLink(qc, { kind: "file", id: job.id }, t),
onSelect: () => void quickShareLink(qc, { kind: "file", id: job.id }),
},
]);
const menuItems = menu.target
@@ -9,6 +9,7 @@ import { useCopyItems } from "../../components/ui/copyItems";
import { jobTitle } from "./downloadTitle";
import { type ActionMenuItem } from "../../components/ui/ActionMenu";
import { menuGroups } from "../../components/ui/menuGroups";
import { msg } from "../../lib/notifications";
// A queued download's ONE action list: the row's "…", its right-click, long-press and Shift+F10. The
// row keeps its primary action (pause / resume / retry) and its state (the error and its details) in
@@ -56,7 +57,7 @@ export function useDownloadActions() {
? api.resumeDownload(id)
: api.cancelDownload(id),
onSuccess: () => invalidateDownloads(qc),
onError: (e, v) => notifyActionError(e, t(`errors.writeFailed.job.${v.op}`)),
onError: (e, v) => notifyActionError(e, msg(`errors.writeFailed.job.${v.op}`)),
});
const confirmThen = async (title: string, body: string, action: string, fn: () => void) => {
@@ -8,6 +8,7 @@ import { notifyActionError } from "../../lib/actionErrors";
import { ownKey } from "../../lib/storage";
import { useConfirm } from "../../components/ui/confirmContext";
import type { SavedViewsContext, ViewSource } from "./savedViewsContext";
import { msg, type NotifText } from "../../lib/notifications";
// Saved "smart views" serve two modules — the feed and Plex — and each owns one store of them in its
// own folder, built on this hook. The two never switch into each other: a visit to Plex leaves the
@@ -114,7 +115,7 @@ export function useSavedViewsStore(cfg: SavedViewsConfig): SavedViewsContext {
const isDirty = displayView != null && activeView == null;
const refresh = () => void qc.invalidateQueries({ queryKey: qk.savedViews(source) });
async function runViewMutation(fn: () => Promise<unknown>, failMsg: string): Promise<boolean> {
async function runViewMutation(fn: () => Promise<unknown>, failMsg: NotifText): Promise<boolean> {
let ok = true;
try {
await fn();
@@ -146,28 +147,28 @@ export function useSavedViewsStore(cfg: SavedViewsConfig): SavedViewsContext {
if (!clean) return false;
return runViewMutation(
() => api.createView(clean, cfg.stripForSave(cfg.filters), source),
t("views.actionFailed"),
msg("views.actionFailed"),
);
},
rename: async (id, name) => {
const next = name.trim();
if (!next) return false;
return runViewMutation(() => api.updateView(id, { name: next }), t("views.actionFailed"));
return runViewMutation(() => api.updateView(id, { name: next }), msg("views.actionFailed"));
},
updateFilters: (id, filters) =>
runViewMutation(() => api.updateView(id, { filters }), t("views.actionFailed")),
runViewMutation(() => api.updateView(id, { filters }), msg("views.actionFailed")),
saveCurrent: async (id) => {
const filters = cfg.stripForSave(cfg.filters);
const ok = await runViewMutation(
() => api.updateView(id, { filters }),
t("views.actionFailed"),
msg("views.actionFailed"),
);
return ok ? filters : null;
},
toggleDefault: (v) =>
runViewMutation(
() => api.updateView(v.id, { is_default: !v.is_default }),
t("views.actionFailed"),
msg("views.actionFailed"),
).then(() => undefined),
remove: async (v) => {
const ok = await confirm({
@@ -177,14 +178,14 @@ export function useSavedViewsStore(cfg: SavedViewsConfig): SavedViewsContext {
});
if (!ok) return false;
if (stickyId === v.id) setStickyId(null);
return runViewMutation(() => api.deleteView(v.id), t("views.actionFailed"));
return runViewMutation(() => api.deleteView(v.id), msg("views.actionFailed"));
},
reorder: async (nextIds) => {
qc.setQueryData<SavedView[]>(
qk.savedViews(source),
nextIds.map((id) => views.find((v) => v.id === id)!).filter(Boolean),
);
await runViewMutation(() => api.reorderViews(nextIds), t("views.reorderFailed"));
await runViewMutation(() => api.reorderViews(nextIds), msg("views.reorderFailed"));
},
};
}
@@ -30,10 +30,11 @@ vi.mock("../../lib/downloadQueue", () => ({ showInTray: (id: number) => showInTr
const notifyActionError = vi.fn();
vi.mock("../../lib/actionErrors", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../lib/actionErrors")>();
return { ...actual, notifyActionError: (e: unknown, t: string) => notifyActionError(e, t) };
return { ...actual, notifyActionError: (e: unknown, t: NotifText) => notifyActionError(e, t) };
});
import ClipPanel, { type ClipCrop, type ClipSource } from "./ClipPanel";
import { msg, type NotifText } from "../../lib/notifications";
afterEach(() => {
cleanup();
@@ -188,7 +189,7 @@ describe("ClipPanel", () => {
display_name: "The bit",
});
expect(notify).toHaveBeenCalledWith(
expect.objectContaining({ level: "success", message: "clip.queued" }),
expect.objectContaining({ level: "success", message: msg("clip.queued") }),
);
// Its View opens the tray on the new clip.
(
@@ -214,7 +215,7 @@ describe("ClipPanel", () => {
fireEvent.click(screen.getByRole("button", { name: "clip.cut" }));
await waitFor(() => expect(notifyActionError).toHaveBeenCalledTimes(1));
expect(notifyActionError.mock.calls[0]![0]).toBe(err);
expect(notifyActionError.mock.calls[0]![1]).toBe("clip.failed");
expect(notifyActionError.mock.calls[0]![1]).toEqual(msg("clip.failed"));
expect(onClose).not.toHaveBeenCalled();
expect((screen.getByRole("button", { name: "clip.cut" }) as HTMLButtonElement).disabled).toBe(
false,
+4 -4
View File
@@ -5,7 +5,7 @@ import { Crop, Crosshair, Maximize, Minus, Plus, Repeat, Scissors, X } from "luc
import { api, type ClipRequest } from "../../lib/api";
import { DISPLAY_NAME_MAX } from "../../lib/api/downloads";
import { invalidateDownloads } from "../../lib/downloads";
import { notify } from "../../lib/notifications";
import { msg, notify } from "../../lib/notifications";
import { notifyActionError } from "../../lib/actionErrors";
import { showInTray } from "../../lib/downloadQueue";
import { log } from "../../lib/log";
@@ -197,14 +197,14 @@ export default function ClipPanel({
log.info("clip.queued", "clip queued", { source: src.source, seconds: Math.round(length) });
notify({
level: "success",
message: t("clip.queued"),
action: { label: t("clip.view"), onClick: () => showInTray(job.id) },
message: msg("clip.queued"),
action: { label: msg("clip.view"), onClick: () => showInTray(job.id) },
});
onClose();
} catch (err) {
// A refusal the server explains (too long, quota) is already in the error dialog; a lost
// connection or a 404 is not, and must not leave the panel looking as if nothing happened.
notifyActionError(err, t("clip.failed"));
notifyActionError(err, msg("clip.failed"));
setBusy(false);
}
};
@@ -1,5 +1,4 @@
import { lazy, Suspense, useState } from "react";
import { useTranslation } from "react-i18next";
import { useQueryClient } from "@tanstack/react-query";
import { type Video, type VideoStatus } from "../../lib/api";
import { qk } from "../../lib/queryKeys";
@@ -7,6 +6,7 @@ import { notifyActionError } from "../../lib/actionErrors";
import { useStableCallback } from "../../lib/useLatest";
import { writeVideoState } from "../video/videoWrites";
import { useOpenLinkedPlayer } from "./useOpenLinkedPlayer";
import { msg } from "../../lib/notifications";
const PlayerModal = lazy(() => import("./PlayerModal"));
@@ -14,7 +14,6 @@ const PlayerModal = lazy(() => import("./PlayerModal"));
* channel manager, where the watched-videos dialog played it; any page a link names): the video
* alone. Decided at boot, once — `hosted` is whether the page on screen then opens it itself. */
export function LinkedPlayer({ hosted }: { hosted: boolean }) {
const { t } = useTranslation();
const qc = useQueryClient();
const [own] = useState(!hosted);
const [video, setVideo] = useState<Video | null>(null);
@@ -27,7 +26,7 @@ export function LinkedPlayer({ hosted }: { hosted: boolean }) {
const write = writeVideoState(qc, id, status);
write
.then(() => qc.invalidateQueries({ queryKey: qk.feed() }))
.catch((e: unknown) => notifyActionError(e, t("errors.writeFailed.videoState")));
.catch((e: unknown) => notifyActionError(e, msg("errors.writeFailed.videoState")));
return write;
});
const close = useStableCallback(() => setVideo(null));
@@ -40,7 +40,7 @@ import { closeKeyboardHelp, toggleKeyboardHelp } from "../../lib/keymap";
import { ConfirmProvider } from "../../components/ui/ConfirmProvider";
import { useConfirm } from "../../components/ui/confirmContext";
import { api, type Me, type Video, type VideoStatus } from "../../lib/api";
import { clearAll, notify } from "../../lib/notifications";
import { clearAll, notify, raw } from "../../lib/notifications";
import { qk } from "../../lib/queryKeys";
import { writeVideoState } from "../video/videoWrites";
@@ -314,7 +314,7 @@ describe("PlayerModal — toasts raised while it is open", () => {
onState={() => Promise.resolve()}
/>,
);
act(() => void notify({ level: "error", message: "Couldn't remove the video." }));
act(() => void notify({ level: "error", message: raw("Couldn't remove the video.") }));
expect(screen.getByTestId("player-modal").textContent).toContain("Couldn't remove the video.");
});
@@ -336,7 +336,7 @@ describe("PlayerModal — toasts raised while it is open", () => {
Object.defineProperty(document, "fullscreenElement", { configurable: true, get: () => stage });
try {
act(() => void document.dispatchEvent(new Event("fullscreenchange")));
act(() => void notify({ level: "error", message: "Connection lost" }));
act(() => void notify({ level: "error", message: raw("Connection lost") }));
await waitFor(() => expect(stage.textContent).toContain("Connection lost"));
} finally {
delete (document as { fullscreenElement?: unknown }).fullscreenElement;
@@ -26,6 +26,7 @@ import VideoCard from "../video/VideoCard";
import { VideoMenu } from "../video/VideoMenu";
import { announceVideoState } from "../video/stateNotice";
import { writeVideoState, writeWatchLater } from "../video/videoWrites";
import { msg } from "../../lib/notifications";
/** Which list YouTube fills in — on the video's topic or with what trends in Hungary — is a mood,
* decided at the list, and the account keeps the last one (the owner's pick). */
@@ -100,7 +101,7 @@ export function RelatedVideos({
announceVideoState(id, status, v, () => onState(id, "new"));
})
.catch((e: unknown) => {
notifyActionError(e, t("errors.writeFailed.videoState"));
notifyActionError(e, msg("errors.writeFailed.videoState"));
settle(seq);
});
};
@@ -118,7 +119,7 @@ export function RelatedVideos({
.catch((e: unknown) => {
notifyActionError(
e,
t(saved ? "errors.writeFailed.watchLaterAdd" : "errors.writeFailed.watchLaterRemove"),
msg(saved ? "errors.writeFailed.watchLaterAdd" : "errors.writeFailed.watchLaterRemove"),
);
settle(seq);
});
@@ -26,6 +26,7 @@ import { useOpenLinkedPlayer } from "./useOpenLinkedPlayer";
import { LinkedPlayer } from "./LinkedPlayer";
import { api, type Video } from "../../lib/api";
import { notifyActionError } from "../../lib/actionErrors";
import { msg } from "../../lib/notifications";
import { qk } from "../../lib/queryKeys";
const vid = (id: string) => ({ id, title: id }) as unknown as Video;
@@ -76,7 +77,7 @@ describe("useOpenLinkedPlayer", () => {
const open = vi.fn();
renderHook(() => useOpenLinkedPlayer(true, () => undefined, open), { wrapper });
await waitFor(() =>
expect(notifyActionError).toHaveBeenCalledWith(gone, "player.linkedFailed"),
expect(notifyActionError).toHaveBeenCalledWith(gone, msg("player.linkedFailed")),
);
expect(open).not.toHaveBeenCalled();
// Said once: the address no longer names it, so a reload or a copied link does not say it again.
@@ -1,11 +1,11 @@
import { useEffect, useEffectEvent, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useQuery } from "@tanstack/react-query";
import { api, type Video } from "../../lib/api";
import { qk } from "../../lib/queryKeys";
import { linkedPlayer, PLAYER_KEYS } from "../../lib/playerUrlState";
import { clearParams } from "../../lib/urlState";
import { notifyActionError } from "../../lib/actionErrors";
import { msg } from "../../lib/notifications";
/** Opens the player an address names (a reload, a bookmark — lib/playerUrlState) on the page that
* hosts it, once: read as the host mounts, opened once its list is in (`ready`). From the list when the
@@ -22,7 +22,6 @@ export function useOpenLinkedPlayer(
open: (video: Video, inList: boolean) => void,
scope: string | number | null = null,
): void {
const { t } = useTranslation();
const [linked] = useState(() => linkedPlayer()?.watch ?? null);
const [linkedScope] = useState(scope);
// Opened, said to be gone or given up, once. A ref for the effect (a StrictMode re-run of it must
@@ -40,7 +39,7 @@ export function useOpenLinkedPlayer(
});
const openIt = useEffectEvent(open);
const failed = useEffectEvent((err: unknown) => {
notifyActionError(err, t("player.linkedFailed"));
notifyActionError(err, msg("player.linkedFailed"));
clearParams(...PLAYER_KEYS);
});
useEffect(() => {
@@ -8,6 +8,7 @@ import { PopoverSurface } from "../../components/ui/AnchoredPopover";
import { useAnchoredPopover } from "../../components/ui/anchoredPopoverHooks";
import { MembershipMenu } from "../../components/ui/MembershipMenu";
import { SURFACE } from "../../components/ui/surfaces";
import { msg } from "../../lib/notifications";
// "Add to collection…" from a movie or show card's menu: a panel beside the card that ticks the title
// into or out of its library's manual collections, and names a new one — created with this title in
@@ -67,7 +68,7 @@ export default function PlexCollectionAddAt({
else await api.plexCollectionAddItem(c.id, ratingKey);
qc.setQueryData<typeof list.data>(forKey, (old) => flipContains(old, c.id));
} catch (e) {
notifyActionError(e, t("plex.collection.editFailed"));
notifyActionError(e, msg("plex.collection.editFailed"));
}
refresh();
setBusy(false);
@@ -83,7 +84,7 @@ export default function PlexCollectionAddAt({
made = true;
} catch (e) {
made = madeOnPlex(e);
notifyActionError(e, t("plex.collection.editFailed"));
notifyActionError(e, msg("plex.collection.editFailed"));
}
refresh();
setBusy(false);
@@ -12,6 +12,7 @@ import Modal from "../../components/ui/Modal";
import { Pressable } from "../../components/ui/Pressable";
import { Input } from "../../components/ui/Input";
import { SURFACE } from "../../components/ui/surfaces";
import { msg } from "../../lib/notifications";
// What we're adding: a single leaf (movie/episode) or a whole group (a season or an entire show, as a
// set of episode rating_keys). Groups get a tri-state (none / some / all) per playlist.
@@ -80,7 +81,7 @@ export default function PlexPlaylistAdd({
} catch (e) {
notifyActionError(
e,
t(allIn ? "plex.playlistAdd.removeFailed" : "plex.playlistAdd.addFailed"),
msg(allIn ? "plex.playlistAdd.removeFailed" : "plex.playlistAdd.addFailed"),
);
}
refresh();
@@ -104,7 +105,7 @@ export default function PlexPlaylistAdd({
} catch (e) {
// The create may already have landed when the add fails — `refresh()` in `finally` is what
// makes that half-done state visible instead of leaving a playlist the dialog never lists.
notifyActionError(e, t("plex.playlistAdd.createFailed"));
notifyActionError(e, msg("plex.playlistAdd.createFailed"));
}
refresh();
setBusy(false);
@@ -74,6 +74,8 @@ describe("a season's name", () => {
["Season 1", 1, "plex.seasonTitle(1)"],
["Specials", 0, "plex.specials"],
["Book One: Water", 1, "Book One: Water"],
["Season 4", null, "Season 4"],
["", null, "plex.playlist.seasonUnknown"],
])("Plex's %j (season %s) reads %j", (title, n, shown) => {
render(<SeasonCard se={season(title, n)} onOpen={vi.fn()} />);
expect(screen.getByText(shown)).toBeTruthy();
@@ -10,6 +10,7 @@ import { pinPlex, useComfortPin } from "../comfort/comfortPin";
import { useCopyItems } from "../../components/ui/copyItems";
import PlexPlaylistAdd, { type PlexAddTarget } from "./PlexPlaylistAdd";
import { type ActionMenuItem } from "../../components/ui/ActionMenu";
import { raw } from "../../lib/notifications";
// A Plex title's ONE action list (docs/DESIGN.md "Action placement"): the right-click, the
// long-press, Shift+F10 and the card's "…" open a menu built here, so every poster grid and rail
@@ -151,7 +152,7 @@ export function usePlexCardActions(handlers: PlexActionHandlers, open: boolean)
]
: []),
...copy(c.title, null).map((it) => ({ ...it, separatorBefore: !onInfo })),
...(withPin ? pin.item(pinPlex(c), c.title) : []),
...(withPin ? pin.item(pinPlex(c), raw(c.title)) : []),
];
};
@@ -13,6 +13,7 @@ import { useAnchoredPopover } from "../../components/ui/anchoredPopoverHooks";
import { IconButton } from "../../components/ui/IconButton";
import { MembershipMenu } from "../../components/ui/MembershipMenu";
import { SURFACE } from "../../components/ui/surfaces";
import { msg } from "../../lib/notifications";
// A small anchored menu for toggling a video's membership in the user's playlists, with inline
// "new playlist" creation. Its body is the shared MembershipMenu (as the channel tag editor's).
@@ -122,7 +123,7 @@ function MembershipPanel({
old?.map((p) => (p.id === pl.id ? { ...p, has_video: !p.has_video } : p)),
);
} catch (e) {
notifyActionError(e, t(pl.has_video ? "playlists.removeFailed" : "playlists.addFailed"));
notifyActionError(e, msg(pl.has_video ? "playlists.removeFailed" : "playlists.addFailed"));
}
refresh();
setBusy(false);
@@ -137,7 +138,7 @@ function MembershipPanel({
await addPlaylistItem(qc, pl, videoId);
made = true;
} catch (e) {
notifyActionError(e, t("playlists.addFailed"));
notifyActionError(e, msg("playlists.addFailed"));
}
refresh();
setBusy(false);
+4 -4
View File
@@ -1,5 +1,5 @@
import i18n from "../../i18n";
import { notify, resolveVideo } from "../../lib/notifications";
import { msg, notify, raw, resolveVideo } from "../../lib/notifications";
import type { Video, VideoStatus } from "../../lib/api";
/** What a notice names the video by; any of it may be unknown to the surface that marked it. */
@@ -24,9 +24,9 @@ export function announceVideoState(
const hidden = status === "hidden";
notify({
message: video?.title
? i18n.t(hidden ? "feed.hiddenNamed" : "feed.markedWatchedNamed", { title: video.title })
: i18n.t(hidden ? "feed.hidden" : "feed.markedWatched"),
action: { label: i18n.t(hidden ? "feed.undo" : "feed.unwatch"), onClick: undo },
? msg(hidden ? "feed.hiddenNamed" : "feed.markedWatchedNamed", { title: raw(video.title) })
: msg(hidden ? "feed.hidden" : "feed.markedWatched"),
action: { label: msg(hidden ? "feed.undo" : "feed.unwatch"), onClick: undo },
meta: {
kind: hidden ? "video-hidden" : "video-watched",
videoId: id,
@@ -27,6 +27,7 @@ import { useCopyItems } from "../../components/ui/copyItems";
import { useNavigationActions } from "../navigation/navigationContext";
import { type ActionMenuItem } from "../../components/ui/ActionMenu";
import { VideoFollowUp, type VideoFollowUpState } from "./VideoFollowUp";
import { raw } from "../../lib/notifications";
// A video's ONE action list (docs/DESIGN.md "Action placement"). The right-click, the
// long-press, Shift+F10 and the card's "…" all open a menu built here, so the feed, a channel page
@@ -198,7 +199,7 @@ export function useVideoActions(handlers: VideoActionHandlers, open: boolean) {
},
]
: []),
...(withPin ? pin.item(pinVideo(v.id), v.title ?? "") : []),
...(withPin ? pin.item(pinVideo(v.id), raw(v.title ?? "")) : []),
];
};
@@ -23,7 +23,7 @@ vi.mock("../../lib/onboarding", () => ({ beginGrant: vi.fn(), endOnboarding: vi.
import OnboardingWizard from "./OnboardingWizard";
import { api, type Me } from "../../lib/api";
import { clearAll, notify } from "../../lib/notifications";
import { clearAll, notify, raw } from "../../lib/notifications";
const me = { id: 1, email: "a@b", can_read: false, can_write: false } as unknown as Me;
@@ -110,7 +110,7 @@ describe("the onboarding wizard as a dialog", () => {
document.body.appendChild(outside);
outside.focus();
const { unmount } = mount();
act(() => void notify({ level: "error", message: "offline" }));
act(() => void notify({ level: "error", message: raw("offline") }));
const dismissToast = screen.getByTitle("notifications.dismiss");
dismissToast.focus();
expect(document.activeElement).toBe(dismissToast);
@@ -126,7 +126,7 @@ describe("the onboarding wizard as a dialog", () => {
// unreachable from the keyboard. `ui/Modal` spans the whole dialog for exactly this reason.
mount();
const panel = screen.getByRole("dialog");
act(() => void notify({ level: "error", message: "offline" }));
act(() => void notify({ level: "error", message: raw("offline") }));
try {
const dismissToast = screen.getByTitle("notifications.dismiss");
expect(panel.contains(dismissToast)).toBe(false);
@@ -89,7 +89,7 @@ const quotaActions = enumValues("quota.py");
// ruff wraps over several lines still matches.
const adminCodes = [
...(
readFileSync(new URL("../lib/api/core.ts", import.meta.url), "utf-8").match(
readFileSync(new URL("../lib/api/refusalText.ts", import.meta.url), "utf-8").match(
/ADMIN_REFUSALS = new Set\(\[([^\]]*)\]/,
)?.[1] ?? ""
).matchAll(/"(\w+)"/g),
+6 -2
View File
@@ -6,11 +6,15 @@
"clearConfirmTitle": "Clear every notification?",
"clearConfirmBody": "Every notification here leaves the list, read or not. They don't come back.",
"markRead": "Mark read",
"unreadName": "{{name}}, unread",
"dismiss": "Dismiss",
"rowMenu": "Notification actions",
"menu": "Notifications actions",
"sectionSystem": "System",
"sectionActivity": "Activity",
"sectionNeedsYou_one": "Needs you · {{count}}",
"sectionNeedsYou_other": "Needs you · {{count}}",
"sectionRecent": "Recent",
"earlier_one": "Earlier ({{count}})",
"earlier_other": "Earlier ({{count}})",
"andMore_one": "and {{count}} more",
"andMore_other": "and {{count}} more",
"openScheduler": "Open Scheduler",
+1
View File
@@ -344,6 +344,7 @@
"playShow": "Play the show",
"playSeason": "Play the season",
"removeFromPlaylist": "Remove from the playlist",
"seasonOfShow": "{{show}} · {{season}}",
"removedToast": "“{{title}}” removed from the playlist",
"undoRemoveFailed": "Couldn't put it back in the playlist"
},
+6 -2
View File
@@ -6,11 +6,15 @@
"clearConfirmTitle": "Törlöd az összes értesítést?",
"clearConfirmBody": "Minden itteni értesítés eltűnik a listából, olvasott és olvasatlan is. Nem hozhatók vissza.",
"markRead": "Olvasottnak jelöl",
"unreadName": "{{name}}, olvasatlan",
"dismiss": "Elrejtés",
"rowMenu": "Értesítés műveletei",
"menu": "Értesítések műveletei",
"sectionSystem": "Rendszer",
"sectionActivity": "Tevékenység",
"sectionNeedsYou_one": "Rád vár · {{count}}",
"sectionNeedsYou_other": "Rád vár · {{count}}",
"sectionRecent": "Legutóbbiak",
"earlier_one": "Korábbiak ({{count}})",
"earlier_other": "Korábbiak ({{count}})",
"andMore_one": "és még {{count}}",
"andMore_other": "és még {{count}}",
"openScheduler": "Ütemező megnyitása",
+1
View File
@@ -344,6 +344,7 @@
"playShow": "Sorozat lejátszása",
"playSeason": "Évad lejátszása",
"removeFromPlaylist": "Eltávolítás a listáról",
"seasonOfShow": "{{show}} · {{season}}",
"removedToast": "„{{title}}” eltávolítva a listáról",
"undoRemoveFailed": "Nem sikerült visszatenni a listára"
},
@@ -13,6 +13,7 @@ import {
reloadAfterRefusal,
} from "./entryUrl";
import { HttpError } from "./api/core";
import { raw } from "./notifications";
const notice = (search: string, hash = "") =>
accountSwitchNotice(new URLSearchParams(search), new URLSearchParams(hash));
@@ -27,13 +28,13 @@ describe("a switch of account, told to the account signed in", () => {
expect(notice("", "access=requested&email=b%2Btag%40example.com")).toEqual({
level: "info",
key: "header.account.switchRequested",
values: { email: "b+tag@example.com" },
values: { email: raw("b+tag@example.com") },
});
});
it("names the address the request lives under when that is another spelling", () => {
expect(notice("", "access=requested&email=bob%40x.com&linked=b.ob%40x.com")?.values).toEqual({
email: "b.ob@x.com",
email: raw("b.ob@x.com"),
});
});
+46 -24
View File
@@ -4,12 +4,16 @@
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) }));
vi.mock("./notifications", async (importOriginal) => ({
...(await importOriginal<typeof import("./notifications")>()),
notify: vi.fn(() => 0),
}));
vi.mock("./errorDialog", () => ({ reportError: vi.fn() }));
import { HttpError, api } from "./api";
import { alreadyReported } from "./api/core";
import { notify } from "./notifications";
import { msg, notify, raw, sayNotif } from "./notifications";
import { localizeDetail } from "./api/refusalText";
import { reportError } from "./errorDialog";
import { failureReason, notifyActionError, notifyKnownFailure } from "./actionErrors";
import i18n from "../i18n";
@@ -32,10 +36,19 @@ describe("a Plex collection name that is already taken", () => {
);
const err = await api.plexCreateCollection("1", "Marvel", "m1").catch((e: unknown) => e);
notifyMock.mockClear();
notifyActionError(err, "EDIT_FAILED");
notifyActionError(err, raw("EDIT_FAILED"));
expect(notifyMock.mock.calls).toEqual([
[{ level: "error", title: "EDIT_FAILED", message: i18n.t("plex.collection.nameTaken") }],
[
{
level: "error",
title: raw("EDIT_FAILED"),
message: { detail: { code: "collection", reason: "name_taken" } },
},
],
]);
expect(sayNotif(notifyMock.mock.calls[0]![0].message)).toBe(
i18n.t("plex.collection.nameTaken"),
);
expect(reportError).not.toHaveBeenCalled();
});
});
@@ -52,26 +65,26 @@ describe("notifyActionError", () => {
);
const err = await api.plexSetState("1", "watched").catch((e: unknown) => e);
notifyMock.mockClear();
notifyActionError(err, "FAILED");
notifyActionError(err, raw("FAILED"));
expect(notifyMock).not.toHaveBeenCalled();
});
it("does toast one the caller was left to handle", () => {
notifyActionError(new HttpError(404, "gone"), "FAILED");
notifyActionError(new HttpError(404, "gone"), raw("FAILED"));
expect(notifyMock).toHaveBeenCalled();
});
it("reports one failure once, however many callers try", () => {
// What keeps the app-wide mutation reporter quiet for a mutation whose own onError already spoke.
const err = new HttpError(404, "gone");
notifyActionError(err, "FIRST");
notifyActionError(err, "SECOND");
notifyActionError(err, raw("FIRST"));
notifyActionError(err, raw("SECOND"));
expect(notifyMock).toHaveBeenCalledTimes(1);
expect(alreadyReported(err)).toBe(true);
});
it("says nothing about an upload the user cancelled", () => {
notifyActionError(new HttpError(0, "aborted"), "FAILED");
notifyActionError(new HttpError(0, "aborted"), raw("FAILED"));
expect(notifyMock).not.toHaveBeenCalled();
});
@@ -79,27 +92,32 @@ describe("notifyActionError", () => {
// `reportFailure` leaves 403/404/429 to the caller, so for exactly those statuses whatever
// English a route hand-wrote went straight into the toast — "No user with that email." in an
// otherwise Hungarian UI, while the caller's translated fallback sat unused.
notifyActionError(new HttpError(404, "No user with that email."), "TRANSLATED");
notifyActionError(new HttpError(404, "No user with that email."), raw("TRANSLATED"));
expect(notifyMock).toHaveBeenCalledWith({
level: "error",
title: "TRANSLATED",
message: i18n.t("errors.notFound.generic"),
title: raw("TRANSLATED"),
message: msg("errors.notFound.generic"),
});
});
it("still prefers a STRUCTURED reason, which the request layer has already localized", () => {
// `req` runs `{code, reason}` through localizeDetail, so `detail` is in the reader's language
// AND more specific than anything a status can say — quota numbers, which row is gone.
it("still prefers a STRUCTURED reason, kept as the refusal and said in the reader's language", () => {
// The notice keeps `{code, reason}` and says it through localizeDetail when shown: in the
// reader's language AND more specific than anything a status can say — quota numbers, which row
// is gone.
const err = new HttpError(404, "Ez a csatorna már nincs a könyvtárban.", {
code: "not_found",
reason: "channel",
});
notifyActionError(err, "TRANSLATED FALLBACK");
notifyActionError(err, raw("TRANSLATED FALLBACK"));
expect(notifyMock).toHaveBeenCalledWith({
level: "error",
title: "TRANSLATED FALLBACK",
message: "Ez a csatorna már nincs a könyvtárban.",
title: raw("TRANSLATED FALLBACK"),
message: { detail: { code: "not_found", reason: "channel" } },
});
// Said at display through localizeDetail, in the language of that moment.
expect(sayNotif(notifyMock.mock.calls[0]![0].message)).toBe(
localizeDetail({ code: "not_found", reason: "channel" }),
);
});
});
@@ -114,17 +132,21 @@ describe("failureReason — why a write failed, when the server did not say", ()
[0, "errors.reason.unreachable"], // uploadReq's network failure
[500, "errors.generic"],
])("a %s reads %s", (status, key) => {
expect(failureReason(new HttpError(status, "hand-written English"))).toBe(i18n.t(key));
const reason = failureReason(new HttpError(status, "hand-written English"));
expect(reason).toEqual(msg(key));
expect(sayNotif(reason)).toBe(i18n.t(key));
});
it("a request that never reached the server reads as unreachable", () => {
expect(failureReason(new TypeError("Failed to fetch"))).toBe(
expect(sayNotif(failureReason(new TypeError("Failed to fetch")))).toBe(
i18n.t("errors.reason.unreachable"),
);
});
it("anything else is the app's own fault, not the server's", () => {
expect(failureReason(new Error("key import failed"))).toBe(i18n.t("errors.reason.unexpected"));
expect(sayNotif(failureReason(new Error("key import failed")))).toBe(
i18n.t("errors.reason.unexpected"),
);
});
it("every reason exists in both languages", () => {
@@ -142,9 +164,9 @@ describe("failureReason — why a write failed, when the server did not say", ()
describe("notifyKnownFailure", () => {
it("shows the caller's specific sentence and marks the failure reported", () => {
const err = new HttpError(409, "x", { code: "resend", reason: "email_off" });
notifyKnownFailure(err, "EMAIL IS OFF");
expect(notifyMock).toHaveBeenCalledWith({ level: "error", message: "EMAIL IS OFF" });
notifyActionError(err, "GENERIC");
notifyKnownFailure(err, raw("EMAIL IS OFF"));
expect(notifyMock).toHaveBeenCalledWith({ level: "error", message: raw("EMAIL IS OFF") });
notifyActionError(err, raw("GENERIC"));
expect(notifyMock).toHaveBeenCalledTimes(1);
});
});
+9 -11
View File
@@ -1,26 +1,24 @@
// From the core, not the `./api` barrel: these are pure (no request), and a test that mocks the
// barrel wholesale to stub its requests must not take the error path's own plumbing with it.
import { alreadyReported, HttpError, isAborted, markReported } from "./api/core";
import { notify } from "./notifications";
import i18n from "../i18n";
import { msg, notify, type NotifText } from "./notifications";
const GATEWAY = new Set([502, 503, 504]);
/**
* WHY a write failed, in the reader's language — the line under the toast's title.
* WHY a write failed — the line under the toast's title, said in the reader's language when shown.
*
* The server's own reason wins: `req` has already run the structured detail through
* `localizeDetail`, and it is the most specific thing there is to say (quota numbers, which row is
* The server's own reason wins: the structured detail, which `localizeDetail` says, and it is the most specific thing there is to say (quota numbers, which row is
* gone). Every refusal the app answers is structured now (backend `app/refusal.py`), and a string
* detail never reaches `HttpError.detail` (see there) — the hand-written English that once put
* "No user with that email." into a Hungarian UI has nowhere left to come from. `detailData` still
* gates it: status-0 errors carry a local sentinel in `detail`, not a reason. Otherwise the status
* says it.
*/
export function failureReason(err: unknown): string {
const t = i18n.t.bind(i18n);
export function failureReason(err: unknown): NotifText {
const t = msg;
if (err instanceof HttpError) {
if (err.detailData != null && err.detail) return err.detail;
if (err.detailData != null && err.detail) return { detail: err.detailData };
// 0: `uploadReq`'s network failure. 502–504: the gateway could not reach the app. Either way the
// change did not go through, which the connection notice alone does not say.
if (err.status === 0 || GATEWAY.has(err.status)) return t("errors.reason.unreachable");
@@ -36,13 +34,13 @@ export function failureReason(err: unknown): string {
/**
* Surface a failed write as an error toast instead of the click silently doing nothing: the title
* names WHAT failed (the caller's translated "Couldn't hide the channel"), the line under it says
* names WHAT failed (the caller's "Couldn't hide the channel"), the line under it says
* WHY (`failureReason`). Domain-agnostic — the one way every action failure is reported, including
* the app-wide mutation reporter in lib/queryClient.
*/
export function notifyActionError(
err: unknown,
title: string,
title: NotifText,
level: "error" | "warning" = "error",
): void {
// Already on screen (`req` opened the error dialog, the tab is being signed out, or a caller's own
@@ -57,7 +55,7 @@ export function notifyActionError(
* instance cannot send email", "someone registered on that address") — shown as it is, and marked,
* so the app-wide mutation reporter does not put a second, generic toast next to it.
*/
export function notifyKnownFailure(err: unknown, message: string): void {
export function notifyKnownFailure(err: unknown, message: NotifText): void {
if (alreadyReported(err)) return;
notify({ level: "error", message });
markReported(err);
+6 -1
View File
@@ -14,7 +14,12 @@ const { reportError, notify, remove, getActiveToasts } = vi.hoisted(() => ({
}));
vi.mock("../errorDialog", () => ({ reportError }));
vi.mock("../notifications", () => ({ notify, remove, getActiveToasts }));
vi.mock("../notifications", async (importOriginal) => ({
...(await importOriginal<typeof import("../notifications")>()),
notify,
remove,
getActiveToasts,
}));
vi.mock("../../i18n", () => ({ default: { t: (k: string) => k, language: "en" } }));
import { setDebugSession } from "../log";
+4 -119
View File
@@ -2,9 +2,9 @@
// error, structured-error localization, the connectivity toast, the identity headers,
// and the keepalive beacon. Domain modules import from here; nothing here imports a domain module,
// so the runtime graph is a clean barrel → domain → core with no cycles.
import { getActiveToasts, notify, remove } from "../notifications";
import { getActiveToasts, msg, notify, remove } from "../notifications";
import i18n from "../../i18n";
import { formatBytes, formatDecimal } from "../format";
import { localizeDetail } from "./refusalText";
import { reportError } from "../errorDialog";
import { logRequest, QUIET_HEADER, REQUEST_ID_HEADER, ROUTE_HEADER, send } from "../http";
import { currentDebugSession, newRequestId } from "../log";
@@ -71,121 +71,6 @@ export const refusalReason = (err: unknown): string | undefined =>
? (err.detailData as { reason?: string } | undefined)?.reason
: undefined;
// Localize a STRUCTURED server error ({ code, reason, ... }) into the user's language. FastAPI
// usually returns a plain string `detail`, but some routes (e.g. download quota/edit) return a
// structured object with an i18n reason key + numbers so HU/DE users don't get hardcoded English
// with a dot-decimal separator. Falls back to a generic message for unknown shapes.
// The refusal codes of the admin's account and invite actions (routes/admin.py). Their reasons share
// one namespace, `errors.admin.<reason>`, which i18n/backendIds.contract checks against the backend.
const ADMIN_REFUSALS = new Set(["activate", "resend", "invite_send"]);
function localizeDetail(d: unknown): string {
const t = i18n.t.bind(i18n);
// FastAPI request-validation errors: `detail` is an ARRAY of { loc, msg, type }. The UI sends
// well-formed requests, so this only fires on genuinely malformed input. The per-field `msg` and
// `loc` field names are English, snake_case internal tokens — surface a clean, fully localized
// "check your input" message rather than leaking them into a translated sentence.
if (Array.isArray(d)) return t("errors.validation");
const o = d as
| { code?: string; reason?: string; limit?: number; limit_s?: number; min?: number }
| null
| undefined;
if (o?.code === "quota") {
if (o.reason === "max_bytes") {
const gb = formatDecimal((o.limit || 0) / 1_073_741_824, i18n.language, 1, 0);
return t("downloads.errors.quota.max_bytes", { size: gb });
}
if (o.reason === "max_jobs") return t("downloads.errors.quota.max_jobs", { limit: o.limit });
return t("downloads.errors.quota.generic");
}
if (o?.code === "edit")
return t(`downloads.errors.edit.${o.reason}`, t("downloads.errors.edit.generic"));
// A clip the server will not cut (downloads/clip): the range, the frame, the source. An unknown
// reason reads as the plain failure rather than a key; a cap comes with the refusal (`limit_s`).
if (o?.code === "clip")
return t(`clip.errors.${o.reason}`, {
defaultValue: t("clip.failed"),
minutes: o.limit_s == null ? undefined : formatDecimal(o.limit_s / 60, i18n.language, 1, 0),
});
if (o?.code === "cookies")
return t(`downloads.errors.cookies.${o.reason}`, t("downloads.errors.cookies.generic"));
// Password sign-in refusals. The card renders the account-shaped ones as a full panel
// off `detailData`; this is the one-line form every other consumer (and the card's own fallback)
// gets. A reason this build doesn't know falls through to the generic message rather than showing
// a raw key, which is what an i18n miss would otherwise put on screen.
if (o?.code === "login")
return t(`welcome.auth.blocked.${o.reason}.short`, t("welcome.auth.genericError"));
// The two email-link confirmations. Their failure is one a user meets by simply clicking an old
// link, so it is the last place that may answer in hardcoded English under a bilingual UI.
if (o?.code === "verify") return t("welcome.auth.verifyInvalid");
if (o?.code === "reset")
return o.reason === "weak_password"
? t("welcome.auth.weakPassword", { min: o.limit ?? o.min })
: t("welcome.auth.deadLink.short");
// Local-upload ingest: the per-file cap carries the limit in bytes; empty is a bare code.
if (o?.code === "upload_too_large")
return t("downloads.errors.upload.tooLarge", { size: formatBytes(o.limit, i18n.language) });
if (o?.code === "upload_empty") return t("downloads.errors.upload.empty");
// The EDGE ceiling's refusal (`bodylimit.TOO_LARGE_BODY`), shared by every route and carrying no
// number. Not a theoretical case: on a DEFAULT install it is what an over-cap upload gets,
// because the ceiling and the route's own cap are the same figure and the edge answers first —
// the route's numbered `upload_too_large` above is the answer only once an admin has LOWERED the
// knob (see bodylimit.ROUTE_MAX_BODY_BYTES). Without this branch that refusal fell through to
// the generic "couldn't carry out that action", the one message no retry can fix.
//
// A BACKSTOP, not the plan. The middleware answers without draining, so whether the browser gets
// to read this at all depends on the client having finished sending: measured readable up to
// 200 MiB over loopback, and measured as a bare `onerror` through a dev proxy. That is why the
// upload dialog checks the file against `upload_max_bytes` BEFORE sending — this branch is what
// happens when that check could not run.
//
// `errors.tooLarge` stays PAYLOAD-NEUTRAL for that reason. It is also what a JSON form gets
// (`debug_sessions` refuses an over-size draft with the same bare code), so wording it as advice
// about a file told the issue composer's user to pick a smaller one — of nothing. The
// file-specific sentence is `downloads.errors.upload.tooLarge`, one branch up, which has a number.
if (o?.code === "too_large") return t("errors.tooLarge");
// SEC-12: a download URL the server refuses to fetch. The reasons are deliberately distinct —
// "we could not look that host up" is a different thing to tell someone than "we will not fetch
// from that address" — so an unknown reason falls back to the generic refusal rather than a key.
if (o?.code === "url_blocked")
return t(`downloads.errors.urlBlocked.${o.reason}`, t("downloads.errors.urlBlocked.generic"));
// The one-step "move to another list". Both reasons mean the same thing to a reader —
// the list moved under you — but they are different enough to be worth naming separately, and an
// unknown reason falls back to the plain failure rather than printing a key.
if (o?.code === "playlist_move")
return t(`playlists.moveErrors.${o.reason}`, t("playlists.moveFailed"));
// A Plex collection name already taken in that library (the one collection-edit refusal).
if (o?.code === "collection")
return o.reason === "name_taken"
? t("plex.collection.nameTaken")
: t("plex.collection.editFailed");
// An action YouTube refused (routes/_common.youtube_refusal): what the reader can do about it —
// wait for the budget, reconnect, or retry. An unknown reason reads as the retryable one.
if (o?.code === "youtube")
return t(`errors.youtube.${o.reason}`, t("errors.youtube.unavailable"));
// Every other refusal (backend `app/refusal.py`): one family, the reason naming the sentence and
// the rest of the detail its values (`{{min}}`, `{{name}}`, …). A reason this build does not know
// reads as the generic failure rather than a key.
if (o?.code === "refused")
return t(`errors.refused.${o.reason}`, { ...o, defaultValue: t("errors.generic") });
// A playlist action the server refused (Watch later can't be deleted, YouTube said no, …).
if (o?.code === "playlist") return t(`playlists.errors.${o.reason}`, t("errors.generic"));
// An admin action on an account or an invite that the server refused: activating an address that
// is no longer approved, a confirmation link or invitation with nothing to go to or no mail to go
// by. The toast's title already names the button; this is the WHY, and it used to be the generic
// "try again", which no retry could fix.
if (ADMIN_REFUSALS.has(o?.code ?? "")) return t(`errors.admin.${o?.reason}`, t("errors.generic"));
// Any row that is gone — deleted in another tab or device, or never the caller's (a stranger's
// row 404s the same way). `reason` names WHAT is gone; one this build doesn't know says it
// generically rather than printing a key.
if (o?.code === "not_found")
return t(`errors.notFound.${o.reason}`, t("errors.notFound.generic"));
// SEC-14: a manual sync trigger on cooldown. Not an error the user caused so much as a pace
// limit, and the copy says so.
if (o?.code === "sync_cooldown") return t("errors.syncCooldown");
return t("errors.generic");
}
// A single, self-resolving "connection lost" status: while the server is unreachable we
// show one sticky, transient (non-persisted) notice; the moment any request reaches the
// server again we remove it. This makes good on its "clears once it's back" copy and keeps
@@ -199,8 +84,8 @@ function markConnectivityLost(): void {
if (id !== null) remove(id);
connectivityNotifId = notify({
level: "error",
title: i18n.t("errors.offline.title"),
message: i18n.t("errors.offline.body"),
title: msg("errors.offline.title"),
message: msg("errors.offline.body"),
transient: true,
});
}
+2 -1
View File
@@ -7,7 +7,8 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("../errorDialog", () => ({ reportError: vi.fn() }));
vi.mock("../notifications", () => ({
vi.mock("../notifications", async (importOriginal) => ({
...(await importOriginal<typeof import("../notifications")>()),
notify: vi.fn(() => 1),
remove: vi.fn(),
getActiveToasts: vi.fn((): { id: number }[] => []),
+120
View File
@@ -0,0 +1,120 @@
// A server refusal ({ code, reason, … }) in the reader's language. Its own module, not the request
// core's: a notice keeps the refusal it reports and says it again at display, in the language of the
// moment, and the notice store cannot import the request core that imports it.
import i18n from "../../i18n";
import { formatBytes, formatDecimal } from "../format";
// Localize a STRUCTURED server error ({ code, reason, ... }) into the user's language. FastAPI
// usually returns a plain string `detail`, but some routes (e.g. download quota/edit) return a
// structured object with an i18n reason key + numbers so HU/DE users don't get hardcoded English
// with a dot-decimal separator. Falls back to a generic message for unknown shapes.
// The refusal codes of the admin's account and invite actions (routes/admin.py). Their reasons share
// one namespace, `errors.admin.<reason>`, which i18n/backendIds.contract checks against the backend.
const ADMIN_REFUSALS = new Set(["activate", "resend", "invite_send"]);
export function localizeDetail(d: unknown): string {
const t = i18n.t.bind(i18n);
// FastAPI request-validation errors: `detail` is an ARRAY of { loc, msg, type }. The UI sends
// well-formed requests, so this only fires on genuinely malformed input. The per-field `msg` and
// `loc` field names are English, snake_case internal tokens — surface a clean, fully localized
// "check your input" message rather than leaking them into a translated sentence.
if (Array.isArray(d)) return t("errors.validation");
const o = d as
| { code?: string; reason?: string; limit?: number; limit_s?: number; min?: number }
| null
| undefined;
if (o?.code === "quota") {
if (o.reason === "max_bytes") {
const gb = formatDecimal((o.limit || 0) / 1_073_741_824, i18n.language, 1, 0);
return t("downloads.errors.quota.max_bytes", { size: gb });
}
if (o.reason === "max_jobs") return t("downloads.errors.quota.max_jobs", { limit: o.limit });
return t("downloads.errors.quota.generic");
}
if (o?.code === "edit")
return t(`downloads.errors.edit.${o.reason}`, t("downloads.errors.edit.generic"));
// A clip the server will not cut (downloads/clip): the range, the frame, the source. An unknown
// reason reads as the plain failure rather than a key; a cap comes with the refusal (`limit_s`).
if (o?.code === "clip")
return t(`clip.errors.${o.reason}`, {
defaultValue: t("clip.failed"),
minutes: o.limit_s == null ? undefined : formatDecimal(o.limit_s / 60, i18n.language, 1, 0),
});
if (o?.code === "cookies")
return t(`downloads.errors.cookies.${o.reason}`, t("downloads.errors.cookies.generic"));
// Password sign-in refusals. The card renders the account-shaped ones as a full panel
// off `detailData`; this is the one-line form every other consumer (and the card's own fallback)
// gets. A reason this build doesn't know falls through to the generic message rather than showing
// a raw key, which is what an i18n miss would otherwise put on screen.
if (o?.code === "login")
return t(`welcome.auth.blocked.${o.reason}.short`, t("welcome.auth.genericError"));
// The two email-link confirmations. Their failure is one a user meets by simply clicking an old
// link, so it is the last place that may answer in hardcoded English under a bilingual UI.
if (o?.code === "verify") return t("welcome.auth.verifyInvalid");
if (o?.code === "reset")
return o.reason === "weak_password"
? t("welcome.auth.weakPassword", { min: o.limit ?? o.min })
: t("welcome.auth.deadLink.short");
// Local-upload ingest: the per-file cap carries the limit in bytes; empty is a bare code.
if (o?.code === "upload_too_large")
return t("downloads.errors.upload.tooLarge", { size: formatBytes(o.limit, i18n.language) });
if (o?.code === "upload_empty") return t("downloads.errors.upload.empty");
// The EDGE ceiling's refusal (`bodylimit.TOO_LARGE_BODY`), shared by every route and carrying no
// number. Not a theoretical case: on a DEFAULT install it is what an over-cap upload gets,
// because the ceiling and the route's own cap are the same figure and the edge answers first —
// the route's numbered `upload_too_large` above is the answer only once an admin has LOWERED the
// knob (see bodylimit.ROUTE_MAX_BODY_BYTES). Without this branch that refusal fell through to
// the generic "couldn't carry out that action", the one message no retry can fix.
//
// A BACKSTOP, not the plan. The middleware answers without draining, so whether the browser gets
// to read this at all depends on the client having finished sending: measured readable up to
// 200 MiB over loopback, and measured as a bare `onerror` through a dev proxy. That is why the
// upload dialog checks the file against `upload_max_bytes` BEFORE sending — this branch is what
// happens when that check could not run.
//
// `errors.tooLarge` stays PAYLOAD-NEUTRAL for that reason. It is also what a JSON form gets
// (`debug_sessions` refuses an over-size draft with the same bare code), so wording it as advice
// about a file told the issue composer's user to pick a smaller one — of nothing. The
// file-specific sentence is `downloads.errors.upload.tooLarge`, one branch up, which has a number.
if (o?.code === "too_large") return t("errors.tooLarge");
// SEC-12: a download URL the server refuses to fetch. The reasons are deliberately distinct —
// "we could not look that host up" is a different thing to tell someone than "we will not fetch
// from that address" — so an unknown reason falls back to the generic refusal rather than a key.
if (o?.code === "url_blocked")
return t(`downloads.errors.urlBlocked.${o.reason}`, t("downloads.errors.urlBlocked.generic"));
// The one-step "move to another list". Both reasons mean the same thing to a reader —
// the list moved under you — but they are different enough to be worth naming separately, and an
// unknown reason falls back to the plain failure rather than printing a key.
if (o?.code === "playlist_move")
return t(`playlists.moveErrors.${o.reason}`, t("playlists.moveFailed"));
// A Plex collection name already taken in that library (the one collection-edit refusal).
if (o?.code === "collection")
return o.reason === "name_taken"
? t("plex.collection.nameTaken")
: t("plex.collection.editFailed");
// An action YouTube refused (routes/_common.youtube_refusal): what the reader can do about it —
// wait for the budget, reconnect, or retry. An unknown reason reads as the retryable one.
if (o?.code === "youtube")
return t(`errors.youtube.${o.reason}`, t("errors.youtube.unavailable"));
// Every other refusal (backend `app/refusal.py`): one family, the reason naming the sentence and
// the rest of the detail its values (`{{min}}`, `{{name}}`, …). A reason this build does not know
// reads as the generic failure rather than a key.
if (o?.code === "refused")
return t(`errors.refused.${o.reason}`, { ...o, defaultValue: t("errors.generic") });
// A playlist action the server refused (Watch later can't be deleted, YouTube said no, …).
if (o?.code === "playlist") return t(`playlists.errors.${o.reason}`, t("errors.generic"));
// An admin action on an account or an invite that the server refused: activating an address that
// is no longer approved, a confirmation link or invitation with nothing to go to or no mail to go
// by. The toast's title already names the button; this is the WHY, and it used to be the generic
// "try again", which no retry could fix.
if (ADMIN_REFUSALS.has(o?.code ?? "")) return t(`errors.admin.${o?.reason}`, t("errors.generic"));
// Any row that is gone — deleted in another tab or device, or never the caller's (a stranger's
// row 404s the same way). `reason` names WHAT is gone; one this build doesn't know says it
// generically rather than printing a key.
if (o?.code === "not_found")
return t(`errors.notFound.${o.reason}`, t("errors.notFound.generic"));
// SEC-14: a manual sync trigger on cooldown. Not an error the user caused so much as a pace
// limit, and the copy says so.
if (o?.code === "sync_cooldown") return t("errors.syncCooldown");
return t("errors.generic");
}
@@ -9,13 +9,15 @@ import type { ReactNode } from "react";
import { act, renderHook, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
vi.mock("./notifications", () => ({ notify: vi.fn(() => 0) }));
vi.mock("./notifications", async (importOriginal) => ({
...(await importOriginal<typeof import("./notifications")>()),
notify: vi.fn(() => 0),
}));
import { api, HttpError } from "./api";
import { notify } from "./notifications";
import { msg, notify } from "./notifications";
import { useChannelTagActions } from "./useChannelTagActions";
import { useChannelPriority } from "./useChannelPriority";
import i18n from "../i18n";
const notifyMock = vi.mocked(notify);
beforeEach(() => notifyMock.mockClear());
@@ -37,8 +39,8 @@ describe("useChannelTagActions", () => {
await waitFor(() => expect(onError).toHaveBeenCalledWith("UC1"));
expect(notifyMock).toHaveBeenCalledWith({
level: "error",
title: i18n.t("channels.tags.updateFailed"),
message: i18n.t("errors.reason.forbidden"),
title: msg("channels.tags.updateFailed"),
message: msg("errors.reason.forbidden"),
});
});
@@ -52,8 +54,8 @@ describe("useChannelTagActions", () => {
await waitFor(() =>
expect(notifyMock).toHaveBeenCalledWith({
level: "error",
title: i18n.t("channels.tags.createFailed"),
message: i18n.t("errors.reason.rateLimited"),
title: msg("channels.tags.createFailed"),
message: msg("errors.reason.rateLimited"),
}),
);
});
@@ -76,8 +78,8 @@ describe("useChannelPriority", () => {
await waitFor(() => expect(onError).toHaveBeenCalledWith("UC1"));
expect(notifyMock).toHaveBeenCalledWith({
level: "error",
title: i18n.t("errors.writeFailed.channelPriority"),
message: i18n.t("errors.reason.unreachable"),
title: msg("errors.writeFailed.channelPriority"),
message: msg("errors.reason.unreachable"),
});
});
});
+10 -9
View File
@@ -5,11 +5,12 @@
// notification store used to keep its empty, nobody's history, and the tab's first notice replaced the
// account's stored history with it.
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { raw, sayNotif, type NotifText } from "./notifications";
const entry = (id: number, message: string) => ({
id,
level: "info",
message,
message: raw(message),
ts: 1,
read: true,
dismissed: true,
@@ -19,10 +20,10 @@ const entry = (id: number, message: string) => ({
});
const storedMessages = () =>
(
JSON.parse(localStorage.getItem("siftlode.notifications") ?? "[]") as {
message: string;
JSON.parse(localStorage.getItem("siftlode.notifHistory") ?? "[]") as {
message: NotifText;
}[]
).map((n) => n.message);
).map((n) => sayNotif(n.message));
// Warm the transform cache once (see verifyEntry.dom.test.tsx): the first cold import costs ~0.8 s
// alone and outran the 5 s case timeout under a full coverage run. Transformed code outlives
@@ -47,14 +48,14 @@ beforeEach(() => {
describe("stores read at import, before the account is known", { timeout: CASE_BUDGET }, () => {
it("keep the account's notification history and settings once it is claimed", async () => {
localStorage.setItem("siftlode.owner", "5");
localStorage.setItem("siftlode.notifications", JSON.stringify([entry(7, "older")]));
localStorage.setItem("siftlode.notifHistory", JSON.stringify([entry(7, "older")]));
localStorage.setItem("siftlode.notifSettings", JSON.stringify({ durationMs: 0 }));
const notifications = await import("./notifications");
const { claimLocalData } = await import("./storage");
notifications.notify({ message: "before the claim" }); // e.g. a failed boot probe
notifications.notify({ message: raw("before the claim") }); // e.g. a failed boot probe
claimLocalData(5);
notifications.notify({ message: "after the claim" });
notifications.notify({ message: raw("after the claim") });
expect(storedMessages()).toEqual(["older", "before the claim", "after the claim"]);
expect(notifications.getNotifSettings().durationMs).toBe(0);
@@ -75,12 +76,12 @@ describe("stores read at import, before the account is known", { timeout: CASE_B
describe("a browser that held another account's state", { timeout: CASE_BUDGET }, () => {
it("starts the new account's notices from an empty history", async () => {
localStorage.setItem("siftlode.owner", "4");
localStorage.setItem("siftlode.notifications", JSON.stringify([entry(7, "four's")]));
localStorage.setItem("siftlode.notifHistory", JSON.stringify([entry(7, "four's")]));
const notifications = await import("./notifications");
const { claimLocalData } = await import("./storage");
claimLocalData(5);
notifications.notify({ message: "as five" });
notifications.notify({ message: raw("as five") });
expect(storedMessages()).toEqual(["as five"]);
});
+19 -16
View File
@@ -21,18 +21,21 @@ vi.mock("./api", () => ({
api: { downloadLinks, createDownloadLink, folderLinks, createFolderLink },
}));
vi.mock("./clipboard", () => ({ copyText }));
vi.mock("./notifications", () => ({ notify }));
vi.mock("./notifications", async (importOriginal) => ({
...(await importOriginal<typeof import("./notifications")>()),
notify,
}));
import { quickShareLink } from "./downloads";
import { qk } from "./queryKeys";
import { msg, type NotifText } from "./notifications";
const t = (k: string) => k;
const origin = window.location.origin;
type ToastArg = {
level: string;
message: string;
action?: { label: string; onClick: () => void };
message: NotifText;
action?: { label: NotifText; onClick: () => void };
};
const firstToast = () => notify.mock.calls[0]![0] as ToastArg;
@@ -55,7 +58,7 @@ describe("quickShareLink", () => {
createDownloadLink.mockResolvedValue({ id: 7, url: "/watch/tok7" });
const openSpy = vi.spyOn(window, "open").mockReturnValue(null);
const url = await quickShareLink(qc(), { kind: "file", id: 42 }, t);
const url = await quickShareLink(qc(), { kind: "file", id: 42 });
expect(url).toBe(`${origin}/watch/tok7`);
// Minted with an EMPTY body so the backend default (allow_download=true) applies.
@@ -63,8 +66,8 @@ describe("quickShareLink", () => {
expect(copyText).toHaveBeenCalledWith(`${origin}/watch/tok7`);
const arg = firstToast();
expect(arg.level).toBe("success");
expect(arg.message).toBe("downloads.share.linkCopied");
expect(arg.action!.label).toBe("downloads.share.openNewTab");
expect(arg.message).toEqual(msg("downloads.share.linkCopied"));
expect(arg.action!.label).toEqual(msg("downloads.share.openNewTab"));
arg.action!.onClick();
expect(openSpy).toHaveBeenCalledWith(`${origin}/watch/tok7`, "_blank", "noopener,noreferrer");
});
@@ -72,7 +75,7 @@ describe("quickShareLink", () => {
it("reuses an existing link instead of minting a duplicate", async () => {
downloadLinks.mockResolvedValue([{ id: 3, url: "/watch/old" }]);
const url = await quickShareLink(qc(), { kind: "file", id: 1 }, t);
const url = await quickShareLink(qc(), { kind: "file", id: 1 });
expect(url).toBe(`${origin}/watch/old`);
expect(createDownloadLink).not.toHaveBeenCalled();
@@ -85,7 +88,7 @@ describe("quickShareLink", () => {
{ id: 3, url: "/watch/older" },
]);
const url = await quickShareLink(qc(), { kind: "file", id: 1 }, t);
const url = await quickShareLink(qc(), { kind: "file", id: 1 });
expect(url).toBe(`${origin}/watch/older`);
expect(createDownloadLink).not.toHaveBeenCalled();
@@ -97,7 +100,7 @@ describe("quickShareLink", () => {
createDownloadLink.mockResolvedValue({ id: 8, url: "/watch/new" });
const client = qc();
const url = await quickShareLink(client, { kind: "file", id: 6 }, t);
const url = await quickShareLink(client, { kind: "file", id: 6 });
expect(url).toBe(`${origin}/watch/new`);
expect(copyText).toHaveBeenCalledWith(`${origin}/watch/new`);
@@ -113,7 +116,7 @@ describe("quickShareLink", () => {
const client = qc();
client.setQueryData(qk.fileFolders(), []);
const url = await quickShareLink(client, { kind: "folder", id: 5 }, t);
const url = await quickShareLink(client, { kind: "folder", id: 5 });
expect(url).toBe(`${origin}/watch/fold`);
expect(folderLinks).toHaveBeenCalledWith(5);
@@ -129,26 +132,26 @@ describe("quickShareLink", () => {
downloadLinks.mockResolvedValue([{ id: 5, url: "/watch/x" }]);
copyText.mockResolvedValue(false);
const url = await quickShareLink(qc(), { kind: "file", id: 2 }, t);
const url = await quickShareLink(qc(), { kind: "file", id: 2 });
expect(url).toBe(`${origin}/watch/x`); // the link is never a dead end
const arg = firstToast();
expect(arg.level).toBe("warning");
expect(arg.message).toBe("downloads.share.copyFailed");
expect(arg.action!.label).toBe("downloads.share.openNewTab");
expect(arg.message).toEqual(msg("downloads.share.copyFailed"));
expect(arg.action!.label).toEqual(msg("downloads.share.openNewTab"));
});
it("reports an error and returns null when link creation fails", async () => {
downloadLinks.mockResolvedValue([]);
createDownloadLink.mockRejectedValue(new Error("boom"));
const url = await quickShareLink(qc(), { kind: "file", id: 9 }, t);
const url = await quickShareLink(qc(), { kind: "file", id: 9 });
expect(url).toBeNull();
expect(copyText).not.toHaveBeenCalled();
// Titled with what failed, and carrying why (notifyActionError), not a bare message.
expect(notify).toHaveBeenCalledWith(
expect.objectContaining({ level: "error", title: "downloads.share.linkFailed" }),
expect.objectContaining({ level: "error", title: msg("downloads.share.linkFailed") }),
);
});
});
+6 -10
View File
@@ -2,7 +2,7 @@ import type { QueryClient } from "@tanstack/react-query";
import { qk } from "./queryKeys";
import { api, type NewShareLink } from "./api";
import { copyText } from "./clipboard";
import { notify } from "./notifications";
import { msg, notify } from "./notifications";
import { notifyActionError } from "./actionErrors";
// The download queue, the per-card download index, the library (its listings and folder counts) and
@@ -44,11 +44,7 @@ export function invalidateLinks(qc: QueryClient, target: LinkTarget) {
// new tab" action. A copy failure still toasts (warning) WITH that action, so the link is never a dead
// end. Advanced controls (password, expiry, revoke, share-with-a-user) live in the Share dialog.
// Returns the shared URL, or null if link creation failed — so the caller/tests can react to the outcome.
export async function quickShareLink(
qc: QueryClient,
target: LinkTarget,
t: (key: string) => string,
): Promise<string | null> {
export async function quickShareLink(qc: QueryClient, target: LinkTarget): Promise<string | null> {
let path;
try {
const existing = await qc.fetchQuery({
@@ -65,18 +61,18 @@ export async function quickShareLink(
}
if (!path) throw new Error("the new link came back without its address");
} catch (e) {
notifyActionError(e, t("downloads.share.linkFailed"));
notifyActionError(e, msg("downloads.share.linkFailed"));
return null;
}
const url = window.location.origin + path;
const action = {
label: t("downloads.share.openNewTab"),
label: msg("downloads.share.openNewTab"),
onClick: () => window.open(url, "_blank", "noopener,noreferrer"),
};
notify(
(await copyText(url))
? { level: "success", message: t("downloads.share.linkCopied"), action }
: { level: "warning", message: t("downloads.share.copyFailed"), action },
? { level: "success", message: msg("downloads.share.linkCopied"), action }
: { level: "warning", message: msg("downloads.share.copyFailed"), action },
);
return url;
}
+3 -3
View File
@@ -14,7 +14,7 @@
// names) — an email in a query string lands in every reverse-proxy access log. Plain status flags
// with nothing personal in them (login, deleted, account) ride the query string.
import type { NotifLevel } from "./notifications";
import { raw, type NotifLevel, type NotifValue } from "./notifications";
import { refusalReason } from "./api/core";
import { hrefHere } from "./urlState";
@@ -88,7 +88,7 @@ export function clearEntryUrl(): void {
export interface AccountNotice {
level: NotifLevel;
key: string;
values?: Record<string, string>;
values?: Record<string, NotifValue>;
}
/** What the entry URL says to the account signed in here about a switch of account. A sign-in as
@@ -110,7 +110,7 @@ export function accountSwitchNotice(
const access = hash.get("access");
const email = hash.get("linked") || hash.get("email");
if (access === "requested" && email)
return { level: "info", key: "header.account.switchRequested", values: { email } };
return { level: "info", key: "header.account.switchRequested", values: { email: raw(email) } };
if (access === "requested" || access === "denied")
return { level: "warning", key: "header.account.switchNotApproved" };
return null;
+113
View File
@@ -0,0 +1,113 @@
import { describe, expect, it } from "vitest";
import { FOLD_AFTER_MS, entryKey, orderInbox, type InboxOrder } from "./inboxOrder";
import type { AppNotification } from "./api";
import type { Notification as ClientNotif } from "./notifications";
const NOW = Date.parse("2026-10-28T12:00:00Z");
const ago = (ms: number) => NOW - ms;
const H = 3600 * 1000;
const D = 24 * H;
const server = (id: number, at: number | null, read: boolean) =>
({
id,
type: "download_done",
title: "t",
body: null,
data: null,
read,
dismissed: false,
created_at: at === null ? null : new Date(at).toISOString(),
}) as AppNotification;
const client = (id: number, at: number, extra: Partial<ClientNotif> = {}) =>
({
id,
level: "info",
message: "m",
requiresInteraction: false,
ts: at,
read: true,
dismissed: true,
transient: false,
repeat: 1,
...extra,
}) as ClientNotif;
const keys = (o: InboxOrder) => ({
needsYou: o.needsYou.map(entryKey),
recent: o.recent.map(entryKey),
earlier: o.earlier.map(entryKey),
});
const order = (s: AppNotification[], c: ClientNotif[], held: ReadonlySet<string> = new Set()) =>
keys(orderInbox(s, c, { now: NOW, held }));
describe("orderInbox", () => {
it("puts a waiting access-requests nudge above every server row, read or not", () => {
const o = order(
[server(1, ago(1 * H), false), server(2, ago(1 * D), true)],
[client(1, ago(5 * 60 * 1000), { meta: { kind: "access-requests" } })],
);
expect(o.needsYou).toEqual(["client:1", "server:1"]);
expect(o.recent).toEqual(["server:2"]);
});
it("an old unread row still asks: it never folds or drifts below newer read ones", () => {
const o = order([server(1, ago(30 * D), false), server(2, ago(1 * H), true)], []);
expect(o.needsYou).toEqual(["server:1"]);
expect(o.recent).toEqual(["server:2"]);
expect(o.earlier).toEqual([]);
});
it("merges both sources into one stream, newest first", () => {
const o = order(
[server(1, ago(3 * H), true), server(2, ago(1 * H), true)],
[client(7, ago(2 * H)), client(8, ago(4 * H))],
);
expect(o.recent).toEqual(["server:2", "client:7", "server:1", "client:8"]);
});
it("folds read rows older than a week, and only those", () => {
const o = order(
[server(1, ago(FOLD_AFTER_MS + H), true), server(2, ago(FOLD_AFTER_MS - H), true)],
[client(3, ago(9 * D))],
);
expect(o.recent).toEqual(["server:2"]);
expect(o.earlier).toEqual(["server:1", "client:3"]);
});
it("keeps a row that asked earlier in the visit in its place once read, from either source", () => {
// Opening the center marks the client notices read: the band must not empty as it renders; and
// a server row marked read must not jump lists under the reader.
const o = order(
[server(1, ago(30 * D), true), server(2, ago(3 * H), true)],
[client(4, ago(2 * H)), client(5, ago(1 * H))],
new Set(["client:4", "server:1"]),
);
expect(o.needsYou).toEqual(["client:4", "server:1"]);
expect(o.recent).toEqual(["client:5", "server:2"]);
expect(o.earlier).toEqual([]);
});
it("an unread client notice (raised while the center is open) asks", () => {
const o = order([], [client(6, ago(H), { read: false })]);
expect(o.needsYou).toEqual(["client:6"]);
});
it("a notice waiting on an interaction asks until it is dealt with", () => {
const o = order(
[],
[
client(1, ago(H), { requiresInteraction: true, dismissed: false }),
client(2, ago(H), { requiresInteraction: true, dismissed: true }),
],
);
expect(o.needsYou).toEqual(["client:1"]);
expect(o.recent).toEqual(["client:2"]);
});
it("a server row of no known date sorts last and never folds", () => {
const o = order([server(1, null, true), server(2, ago(30 * D), true)], [client(3, ago(H))]);
expect(o.recent).toEqual(["client:3", "server:1"]);
expect(o.earlier).toEqual(["server:2"]);
});
});
+72
View File
@@ -0,0 +1,72 @@
import type { AppNotification } from "./api";
import type { Notification as ClientNotif } from "./notifications";
// The notification center's order, in ONE rule for both sources. It used to be two fixed sections,
// the server's above this browser's — so an admin's "access requests waiting" sat under up to a
// hundred server rows, week-old read "Download ready" notices among them. Now what asks for the
// reader comes first, then everything else as one time-ordered stream, and read rows older than a
// week fold away (kept, not deleted).
export type InboxEntry =
| { src: "server"; n: AppNotification; at: number }
| { src: "client"; n: ClientNotif; at: number };
export interface InboxOrder {
/** Unread, or waiting on an action: newest first. Never folded, whatever its age. */
needsYou: InboxEntry[];
/** The rest from the last week (or of no known date), newest first. */
recent: InboxEntry[];
/** Read rows older than `FOLD_AFTER_MS`, newest first — shown only on request. */
earlier: InboxEntry[];
}
export const FOLD_AFTER_MS = 7 * 24 * 3600 * 1000;
// A row of no known date (a server row stored without one) sorts last and never folds: its age is
// unknown, and hiding it on a guess is how a row goes missing.
const NO_DATE = Number.NEGATIVE_INFINITY;
function serverAt(n: AppNotification): number {
const at = n.created_at ? Date.parse(n.created_at) : NaN;
return Number.isNaN(at) ? NO_DATE : at;
}
/** A row's key across both sources: their ids are separate sequences, and they share one list now. */
export const keyOf = (src: InboxEntry["src"], id: number): string => `${src}:${id}`;
export const entryKey = (e: InboxEntry): string => keyOf(e.src, e.n.id);
/** Whether a client notice asks for the reader by what it is: an access-requests nudge for as long
* as it exists (its producer removes it when nothing is pending), a notice that waits on an
* interaction until it is dealt with. */
export const clientAsks = (n: ClientNotif): boolean =>
n.meta?.kind === "access-requests" || (n.requiresInteraction && !n.dismissed);
export function orderInbox(
server: readonly AppNotification[],
client: readonly ClientNotif[],
/** `held`: the rows that asked earlier in this visit (entry keys). They keep their place once read —
* a row marked read must not jump to another list under the reader, taking the keyboard focus to
* <body> as it remounts there — and they are how a client notice stays up at all: opening the
* center marks every client notice read, so "unread" alone would empty the band as it renders. */
{ now, held }: { now: number; held: ReadonlySet<string> },
): InboxOrder {
const out: InboxOrder = { needsYou: [], recent: [], earlier: [] };
const place = (e: InboxEntry, asks: boolean) => {
if (asks) out.needsYou.push(e);
else if (e.at !== NO_DATE && now - e.at > FOLD_AFTER_MS) out.earlier.push(e);
else out.recent.push(e);
};
for (const n of server)
place({ src: "server", n, at: serverAt(n) }, !n.read || held.has(keyOf("server", n.id)));
for (const n of client)
place(
{ src: "client", n, at: n.ts },
!n.read || held.has(keyOf("client", n.id)) || clientAsks(n),
);
// Newest first; equal times keep their source's own order (Array.sort is stable).
const newest = (a: InboxEntry, b: InboxEntry) => (b.at === a.at ? 0 : b.at > a.at ? 1 : -1);
out.needsYou.sort(newest);
out.recent.sort(newest);
out.earlier.sort(newest);
return out;
}
@@ -0,0 +1,206 @@
// @vitest-environment jsdom
//
// Back to a page whose item changed lists while it was away. The notification center holds a row
// that asked for the reader in "Needs you" for the visit; on the next visit the same row, read now,
// sits in the stream below. The restore matched the unnamed list by its place, missed the name there
// and fell back to the saved index — the focus landed on the neighbour, and Enter opened the wrong
// notification. Its own file: listNav keeps module-level state.
import { afterEach, describe, expect, it, vi } from "vitest";
import { act, cleanup, render } from "@testing-library/react";
import { useRef } from "react";
import {
NAV_REVEAL_EVENT,
navItemAttrs,
navPosition,
pendingNavName,
restoreNavPosition,
useListNav,
} from "./listNav";
afterEach(cleanup);
// `count`: a virtualized list's, which counts rows it has yet to render.
function List({ items, count }: { items: string[]; count?: number }) {
const ref = useRef<HTMLDivElement>(null);
useListNav(ref, { enabled: true, count });
return (
<div ref={ref}>
{items.map((t, i) => (
<div key={`${t}-${i}`} {...navItemAttrs(i, t)}>
<button data-nav-open="">{t}</button>
</div>
))}
</div>
);
}
// `tray`: a list of the app's own, outside the page (the running downloads), whose rows can share a
// name with the page's.
const Page = ({
lists,
tray,
counts = [],
}: {
lists: string[][];
tray?: string[];
counts?: number[];
}) => (
<>
<main>
{lists.map((items, i) => (
<List key={i} items={items} count={counts[i]} />
))}
</main>
{tray && <List items={tray} />}
</>
);
const button = (name: string) =>
[...document.querySelectorAll("button")].find((b) => b.textContent === name)!;
const focused = () => document.activeElement?.closest("[data-nav-index]")?.textContent;
const wait = (ms: number) => act(() => new Promise((r) => setTimeout(r, ms)));
async function leaveAndReturn(
before: string[][],
from: string,
after: string[][],
tray?: string[],
) {
const { unmount } = render(<Page lists={before} tray={tray} />);
button(from).focus();
const pos = navPosition()!;
unmount(); // the page switch
render(<Page lists={after} tray={tray} />); // Back
const cancel = restoreNavPosition(pos);
await wait(120);
cancel();
}
describe("Back to an item that moved to another list", () => {
it("finds it by its name in the list it moved to, not its neighbour at the old place", async () => {
await leaveAndReturn([["subscribed", "download"], ["older"]], "subscribed", [
["download"],
["subscribed", "older"],
]);
expect(focused()).toBe("subscribed");
});
it("finds it when the list it left is gone altogether", async () => {
await leaveAndReturn([["subscribed"], ["older"]], "subscribed", [["subscribed", "older"]]);
expect(focused()).toBe("subscribed");
});
it("does not guess between two items of that name elsewhere: the saved place", async () => {
await leaveAndReturn([["twin", "download"], ["older"]], "twin", [
["download"],
["twin", "older", "twin"],
]);
expect(focused()).toBe("download");
});
it("brings an item found in another list into view; one in its own list stays put", async () => {
// The scroll restore put the page where the item WAS: found elsewhere, it would hold the focus
// off screen.
const reveal = vi.fn();
Element.prototype.scrollIntoView = reveal;
// ...and tells the page's scroll restore, which would otherwise put the old offset back over it.
const told = vi.fn();
document.addEventListener(NAV_REVEAL_EVENT, told);
await leaveAndReturn([["subscribed", "download"], ["older"]], "subscribed", [
["download"],
["subscribed", "older"],
]);
expect(reveal).toHaveBeenCalledTimes(1);
expect(told).toHaveBeenCalledTimes(1);
cleanup();
reveal.mockClear();
told.mockClear();
await leaveAndReturn([["subscribed", "download"]], "download", [["download", "subscribed"]]);
expect(focused()).toBe("download");
expect(reveal).not.toHaveBeenCalled();
expect(told).not.toHaveBeenCalled();
document.removeEventListener(NAV_REVEAL_EVENT, told);
});
it("never takes it from a list outside the page: the download tray's row of that name", async () => {
// A feed card and its download in the tray are both named by the video's title.
await leaveAndReturn(
[["subscribed", "download"]],
"subscribed",
[["download"]],
["subscribed"],
);
expect(focused()).toBe("download");
});
it("waits for its own list to render the place before looking in another one", async () => {
// A virtualized list puts its rows back a frame after the page: the item is still to come at
// the first look, and the one of that name in the rail below is not it.
const { unmount } = render(<Page lists={[["older", "subscribed"], ["subscribed-2"]]} />);
button("subscribed").focus();
const pos = navPosition()!;
unmount();
const { rerender } = render(<Page lists={[["older"], ["subscribed"]]} counts={[2]} />);
const cancel = restoreNavPosition(pos);
await wait(60);
rerender(<Page lists={[["older", "subscribed"], ["subscribed"]]} counts={[2]} />);
await wait(120);
cancel();
expect(document.activeElement?.closest("main > div")).toBe(
document.querySelector("main")!.firstElementChild,
);
});
it("looks in another list at its last look when its own never renders the place it counts", async () => {
vi.useFakeTimers();
try {
const { unmount } = render(<Page lists={[["older", "subscribed"], ["subscribed-2"]]} />);
button("subscribed").focus();
const pos = navPosition()!;
unmount();
// Its own list still counts two, and keeps showing one; the item is in the next list.
render(<Page lists={[["older"], ["subscribed"]]} counts={[2]} />);
const cancel = restoreNavPosition(pos);
await act(async () => vi.advanceTimersByTimeAsync(1000));
expect(focused()).toBeUndefined(); // still giving its own list the chance
await act(async () => vi.advanceTimersByTimeAsync(1100));
expect(focused()).toBe("subscribed");
cancel();
} finally {
vi.useRealTimers();
}
});
});
describe("the name a Back is still looking for", () => {
const pos = { list: 0, lists: 1, index: 0, name: "subscribed" };
it("is there while it looks, and gone once cancelled", () => {
const cancel = restoreNavPosition(pos);
expect(pendingNavName()).toBe("subscribed");
cancel();
expect(pendingNavName()).toBeNull();
});
it("is gone once the focus went somewhere else", async () => {
render(<button>elsewhere</button>);
const cancel = restoreNavPosition(pos);
button("elsewhere").focus();
await wait(80);
expect(pendingNavName()).toBeNull();
cancel();
});
it("is gone once it gave up", async () => {
vi.useFakeTimers();
try {
const cancel = restoreNavPosition(pos);
await vi.advanceTimersByTimeAsync(2100);
expect(pendingNavName()).toBeNull();
cancel();
} finally {
vi.useRealTimers();
}
});
});
+97 -22
View File
@@ -42,6 +42,15 @@ const lists = new Map<HTMLElement, NavList>();
// The item we gave a tabindex to, so the next move can take it back — and whether we named it too.
let roved: HTMLElement | null = null;
let named = false;
// A named item's label follows its spoken state while it holds the focus: Mark read hands the
// focus to the row BEFORE the row reads read, and the label given then said "unread" until the
// keyboard moved on.
const relabel =
typeof MutationObserver === "undefined"
? null
: new MutationObserver(() => {
if (named && roved) roved.setAttribute("aria-label", spokenName(roved));
});
const INDEX = "data-nav-index";
const LIST = "data-nav-list";
@@ -56,14 +65,24 @@ const REVEAL_FRAMES = 10;
* `inset` draws the keyboard ring INSIDE the item. The ring sits 2 px outside by default, which a
* card with a gap round it has room for; an item flush against its neighbours (a log line) or against
* the edge of a clipping card (a Plex playlist show header) does not: the ring ran past the list, or
* was clipped away entirely. A table row needs no flag: index.css insets every `tr` by its tag. */
* was clipped away entirely. A table row needs no flag: index.css insets every `tr` by its tag.
*
* `label` is what is SPOKEN instead of `name`, when it carries a state the item can lose while the
* page is away (a notification's "unread"): `name` is also how Back finds the item again, and a
* name that changed with the state would miss it. */
export const navItemAttrs = (
index: number,
name?: string | null,
{ inset = false }: { inset?: boolean } = {},
): { "data-nav-index": number; "data-nav-name"?: string; "data-nav-inset"?: "" } => ({
{ inset = false, label }: { inset?: boolean; label?: string } = {},
): {
"data-nav-index": number;
"data-nav-name"?: string;
"data-nav-label"?: string;
"data-nav-inset"?: "";
} => ({
[INDEX]: index,
...(name ? { "data-nav-name": name } : null),
...(label ? { "data-nav-label": label } : null),
...(inset ? { "data-nav-inset": "" } : null),
});
@@ -204,11 +223,23 @@ function itemName(el: HTMLElement): string {
).trim();
}
/** What a screen reader hears while the item holds the focus: its `label` when it gives one. */
const spokenName = (el: HTMLElement): string => el.getAttribute("data-nav-label") || itemName(el);
/** The event an item brought into view sends up its page: a scroll restore still under way gives the
* page up to it (`ui/PageScroller`) — on Back, an item found in another list than the one it was
* left in is not where the restored offset shows, and the restore's next frame scrolled it away. */
export const NAV_REVEAL_EVENT = "navreveal";
export const revealedNavItem = (el: HTMLElement): void => {
el.dispatchEvent(new Event(NAV_REVEAL_EVENT, { bubbles: true }));
};
function focusItem(el: HTMLElement, reveal = true): void {
if (roved !== el) {
if (named) roved?.removeAttribute("role");
if (named) roved?.removeAttribute("aria-label");
roved?.removeAttribute("tabindex");
relabel?.disconnect();
roved = null;
named = false;
// An item that is a Tab stop in its own right (a native <button> tile, a role=button card with
@@ -219,7 +250,7 @@ function focusItem(el: HTMLElement, reveal = true): void {
// A card root is a plain <div>: focused, it has no role and no name, and a screen reader read it
// as an unlabelled run of text. While it holds the focus it is a group named after what Enter
// opens. A row (<tr>) keeps its own role.
const name = itemName(el);
const name = spokenName(el);
if (
el.tagName === "DIV" &&
name &&
@@ -229,13 +260,17 @@ function focusItem(el: HTMLElement, reveal = true): void {
el.setAttribute("role", "group");
el.setAttribute("aria-label", name);
named = true;
relabel?.observe(el, { attributeFilter: ["data-nav-label", "data-nav-name"] });
}
}
}
el.focus({ preventScroll: true });
// `nearest` + the item's scroll margins: a visible item does not move, an item under the floating
// header or past the bottom edge scrolls just far enough to clear it.
if (reveal) el.scrollIntoView?.({ block: "nearest" });
if (reveal) {
el.scrollIntoView?.({ block: "nearest" });
revealedNavItem(el);
}
}
function focusIndex(
@@ -459,18 +494,37 @@ export function navPosition(): NavPosition | null {
// look — on a timer, not animation frames: a hidden tab runs none.
const RESTORE_MS = 2000;
const RESTORE_POLL_MS = 50;
// The restore under way, while it looks for its item.
let pending: NavPosition | null = null;
/** The item in `list` to hand the focus back to: the saved place while it still holds the saved
/** The item to hand the focus back to: in `list`, the saved place while it still holds the saved
* name, else the nearest item of that name (names repeat — a log console's identical request
* lines), else the saved place. */
function restoreTarget(list: NavList, pos: NavPosition): HTMLElement | null {
const at = itemAt(list, pos.index);
if (!pos.name || (at && itemName(at) === pos.name)) return at;
const distance = (el: HTMLElement) => Math.abs(indexOf(el) - pos.index);
const named = ownItems(list.el)
.filter((it) => itemName(it) === pos.name)
.sort((a, b) => distance(a) - distance(b));
return named[0] ?? at;
* lines). Then the one item of that name in another of the page's lists: an item can change lists
* while the page was away (a notification asked for the reader on the visit it was opened from and
* sits in the stream on the next), and the place left behind is now its neighbour's. Only when the
* name is the page's alone — of two, either could be the one — and only the PAGE's lists: the
* running-downloads tray is the app's, on every page, and its row of a download is named like the
* feed card it came from. Not while `list` has yet to render a saved place it still counts (a
* virtualized list puts its rows back a frame after the page), until the `last` look: the item is
* likely still to come there. Else the saved place. */
function restoreTarget(
all: readonly NavList[],
list: NavList | undefined,
pos: NavPosition,
last: boolean,
): HTMLElement | null {
const at = list ? itemAt(list, pos.index) : null;
if (list && (!pos.name || (at && itemName(at) === pos.name))) return at;
if (!pos.name) return null;
const named = (l: NavList) => ownItems(l.el).filter((it) => itemName(it) === pos.name);
if (list) {
const distance = (el: HTMLElement) => Math.abs(indexOf(el) - pos.index);
const here = named(list).sort((a, b) => distance(a) - distance(b));
if (here[0]) return here[0];
}
if (list && !at && pos.index < list.count() && !last) return null;
const [only, ...more] = all.filter((l) => l.el.closest("main")).flatMap(named);
return only && more.length === 0 ? only : at;
}
/** Focus the item at `pos` once its list is on screen again. A NAMED list (a rail) is found by its
@@ -478,15 +532,21 @@ function restoreTarget(list: NavList, pos: NavPosition): HTMLElement | null {
* watched in between, after the page's cache expired — shifts every place after it, and matching by
* place landed the focus in the wrong rail. An unnamed list only by its
* place, and only once the page has exactly as many lists as when it was left: with more or fewer,
* list N may be another list, and <body> is the honest outcome. Gives up when the time is up, and at
* list N may be another list, and <body> is the honest outcome — unless the item's name is the
* page's alone (`restoreTarget`). Gives up when the time is up, and at
* once when focus is already somewhere (the reader moved on, a dialog took it). The item is focused
* where the scroll restore put it, never scrolled to. (A stand-in the focus keeper placed when the view
* where the scroll restore put it, never scrolled to — but one found in another list is brought into
* view. (A stand-in the focus keeper placed when the view
* it held was swapped away counts as untouched, like <body>.) Returns a cancel. */
export function restoreNavPosition(pos: NavPosition): () => void {
const deadline = Date.now() + RESTORE_MS;
let timer: ReturnType<typeof setTimeout> | undefined;
pending = pos;
const done = () => {
if (pending === pos) pending = null;
};
const attempt = () => {
if (!focusIsUnclaimed()) return;
if (!focusIsUnclaimed()) return done();
const all = ordered();
const list = pos.label
? all.find((l) => labelOf(l) === pos.label)
@@ -499,18 +559,33 @@ export function restoreNavPosition(pos: NavPosition): () => void {
// The saved place still holding the saved name wins, and among several items of that name the
// nearest one: names repeat (a log console's `http.request GET /api/feed 200` lines, two
// downloads of one video), and the first of them is rarely the one left behind.
const el = list ? restoreTarget(list, pos) : null;
if (el) return focusItem(el, false);
if (Date.now() < deadline) timer = setTimeout(attempt, RESTORE_POLL_MS);
const last = Date.now() + RESTORE_POLL_MS >= deadline;
const el = restoreTarget(all, list, pos, last);
// Where the scroll restore put it — unless it is found in another list than the one it was left
// in: the restored scroll is the old place's, and the item would hold the focus off screen.
if (el) {
done();
return focusItem(el, !list?.el.contains(el));
}
if (last) done();
else timer = setTimeout(attempt, RESTORE_POLL_MS);
};
// Not inside the popstate dispatch that landed: a sub-view's own listener (`useHistorySubview`)
// may run after ours and has not switched the view yet — focus still sits in the view being left,
// and "focus is already somewhere" would give up for good (measured in Chrome:
// Plex show → Back left focus on <body>). One task later the view has rendered.
timer = setTimeout(attempt, 0);
return () => clearTimeout(timer);
return () => {
clearTimeout(timer);
done();
};
}
/** The name of the item a Back is still looking for, while it looks: a page that keeps some of its
* rows folded away (the notification center's Earlier) opens the fold holding it — a list it does
* not render is one the restore cannot find the item in. Read it as the page mounts. */
export const pendingNavName = (): string | null => pending?.name ?? null;
/** Register `ref` as a keyboard-navigable list while `enabled`. A virtualized list must say how many
* items it has (`count` — the loaded ones, for an infinite list), how many per row (`cols`) and how
* to scroll an unmounted item into view (`reveal`). A list that mounts everything omits all three:
+72
View File
@@ -0,0 +1,72 @@
// @vitest-environment jsdom
//
// The notice history changed shape (a notice keeps what to say, not the said text). A build from
// before it reads the old key and renders its rows as text, so an object there crashed its
// Notifications page after a rollback. The history now lives under a key of its own: the old one is
// read once, while the new one does not exist yet, and never written.
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { msg, raw, sayNotif, type NotifText } from "./notifications";
const OLD_KEY = "siftlode.notifications";
// A row as the build before wrote it: its text already said.
const oldRow = (id: number, message: string) => ({
id,
level: "info",
message,
ts: 1,
read: true,
dismissed: true,
transient: false,
repeat: 1,
requiresInteraction: false,
});
const saidHistory = (key: string) =>
(JSON.parse(localStorage.getItem(key) ?? "[]") as { message: NotifText }[]).map((n) =>
sayNotif(n.message),
);
beforeAll(async () => {
await import("./notifications");
await import("./storage");
}, 60_000);
beforeEach(() => {
localStorage.clear();
sessionStorage.clear();
vi.resetModules(); // the store loads at import: each case needs a fresh one
});
describe("the notice history's own key", { timeout: 30_000 }, () => {
it("carries the old history forward and leaves the old key as the build before wrote it", async () => {
const old = JSON.stringify([oldRow(7, "older")]);
localStorage.setItem("siftlode.owner", "5");
localStorage.setItem(OLD_KEY, old);
const notifications = await import("./notifications");
const { claimLocalData, LS } = await import("./storage");
claimLocalData(5);
notifications.notify({ message: msg("common.copy.done") });
expect(LS.notifHistory).not.toBe(OLD_KEY);
expect(localStorage.getItem(OLD_KEY)).toBe(old);
expect(saidHistory(LS.notifHistory)).toEqual(["older", "Copied to the clipboard"]);
});
it("reads the old key only while the new one does not exist", async () => {
const { LS } = await import("./storage");
localStorage.setItem("siftlode.owner", "5");
localStorage.setItem(OLD_KEY, JSON.stringify([oldRow(7, "older")]));
localStorage.setItem(
LS.notifHistory,
JSON.stringify([{ ...oldRow(8, ""), message: raw("newer") }]),
);
const notifications = await import("./notifications");
const { claimLocalData } = await import("./storage");
claimLocalData(5);
expect(notifications.getNotifications().map((n) => sayNotif(n.message))).toEqual(["newer"]);
});
});
@@ -0,0 +1,73 @@
import { describe, expect, it } from "vitest";
import ts from "typescript";
import { readSources } from "./rawSources.testkit";
// A notice says WHAT to say (lib/notifications NotifText) and is said when shown; a plain `t()` says
// it now. tsc cannot tell the two apart where they meet — `msg`'s key is a string, `detail` is
// unknown, and i18next's options take any value — so the slips are caught here, on the syntax:
// - a NotifText handed to `t()` as a value (`t("k", { name: raw(x) })`) reads "[object Object]";
// - an already-said text kept in a notice (`msg(t("k"))`, `raw(t("k"))`, `{ detail: t("k") }`)
// stays in the language it was raised in.
const isT = (e: ts.Expression) =>
(ts.isIdentifier(e) && e.text === "t") ||
(ts.isPropertyAccessExpression(e) && e.name.text === "t");
const isCallTo = (e: ts.Node, names: string[]): e is ts.CallExpression =>
ts.isCallExpression(e) && ts.isIdentifier(e.expression) && names.includes(e.expression.text);
const isTCall = (e: ts.Node): e is ts.CallExpression => ts.isCallExpression(e) && isT(e.expression);
function mixedNotifText(name: string, src: string): string[] {
const file = ts.createSourceFile(name, src, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
const out: string[] = [];
const at = (n: ts.Node, what: string) =>
out.push(`${name}:${file.getLineAndCharacterOfPosition(n.getStart()).line + 1} ${what}`);
const visit = (n: ts.Node) => {
if (isTCall(n)) {
for (const arg of n.arguments.slice(1)) {
if (!ts.isObjectLiteralExpression(arg)) continue;
for (const p of arg.properties) {
if (ts.isPropertyAssignment(p) && isCallTo(p.initializer, ["raw", "msg"]))
at(p, "a notice value handed to t()");
}
}
}
if (isCallTo(n, ["msg", "raw"]) && n.arguments[0] && isTCall(n.arguments[0]))
at(n, "a said text kept in a notice");
if (
ts.isPropertyAssignment(n) &&
ts.isIdentifier(n.name) &&
n.name.text === "detail" &&
isTCall(n.initializer)
)
at(n, "a said text kept as a notice's detail");
ts.forEachChild(n, visit);
};
visit(file);
return out;
}
describe("a notice's text and a plain t() do not mix", () => {
it("holds across src", () => {
const files = readSources(__dirname, ["../**/*.ts", "../**/*.tsx", "!../**/*.test.*"]);
expect(Object.keys(files).length).toBeGreaterThan(100); // the glob matched the app
expect(Object.entries(files).flatMap(([p, src]) => mixedNotifText(p, src))).toEqual([]);
});
it.each([
['t("users.delete.confirm", { email: raw(u.email) })', "a notice value handed to t()"],
['i18n.t("k", { n: msg("x") })', "a notice value handed to t()"],
['notify({ message: msg(t("feed.saved")) })', "a said text kept in a notice"],
['notify({ message: raw(t("feed.saved")) })', "a said text kept in a notice"],
['notify({ message: { detail: t("x") } })', "a said text kept as a notice's detail"],
])("sees %s", (src, what) => {
expect(mixedNotifText("x.tsx", src)).toEqual([`x.tsx:1 ${what}`]);
});
it.each([
'msg("channels.discovery.subscribedBody", { name: raw(nameOf(c)) })',
't("channels.discovery.confirmSubscribe", { name: nameOf(c) })',
"sayNotif(msg(key), t)",
])("lets %s through", (src) => {
expect(mixedNotifText("x.tsx", src)).toEqual([]);
});
});
@@ -10,6 +10,7 @@ import {
configureNotifications,
getActiveToasts,
notify,
raw,
} from "./notifications";
beforeEach(() => vi.useFakeTimers());
@@ -19,14 +20,14 @@ afterEach(() => {
vi.useRealTimers();
});
const undo = { label: "Undo", onClick: () => {} };
const undo = { label: raw("Undo"), onClick: () => {} };
const shown = (id: number) => getActiveToasts().some((n) => n.id === id);
describe("an action toast's lifetime", () => {
it("stays for the floor when the reader's toasts last 3 s", () => {
configureNotifications({ durationMs: 3000 });
const plain = notify({ message: "plain" });
const withUndo = notify({ message: "removed", action: undo });
const plain = notify({ message: raw("plain") });
const withUndo = notify({ message: raw("removed"), action: undo });
vi.advanceTimersByTime(3001);
expect(shown(plain)).toBe(false);
expect(shown(withUndo)).toBe(true);
@@ -36,7 +37,7 @@ describe("an action toast's lifetime", () => {
it("keeps a longer setting", () => {
configureNotifications({ durationMs: 12000 });
const id = notify({ message: "removed", action: undo });
const id = notify({ message: raw("removed"), action: undo });
vi.advanceTimersByTime(ACTION_TTL_MIN + 1);
expect(shown(id)).toBe(true);
vi.advanceTimersByTime(12000 - ACTION_TTL_MIN);
@@ -45,7 +46,7 @@ describe("an action toast's lifetime", () => {
it("never dismisses when the reader chose never", () => {
configureNotifications({ durationMs: 0 });
const id = notify({ message: "removed", action: undo });
const id = notify({ message: raw("removed"), action: undo });
vi.advanceTimersByTime(60000);
expect(shown(id)).toBe(true);
});
@@ -0,0 +1,37 @@
// @vitest-environment jsdom
//
// A burst of the same notice is one entry with a count (×N), not a pile of copies. The texts are
// objects now (what to say, built afresh by every msg() call), so "the same" is the same key and
// values, never the same object: a compare by reference would let every repeat stack.
import { afterEach, describe, expect, it } from "vitest";
import { clearAll, getNotifications, msg, notify, raw } from "./notifications";
afterEach(() => clearAll());
describe("coalescing a repeated notice", () => {
it("folds the same key and values into one entry, counted", () => {
notify({
level: "error",
title: msg("errors.title"),
message: msg("errors.reason.unreachable"),
});
notify({
level: "error",
title: msg("errors.title"),
message: msg("errors.reason.unreachable"),
});
expect(getNotifications().map((n) => n.repeat)).toEqual([2]);
});
it("folds the same server refusal too", () => {
notify({ level: "error", message: { detail: { code: "not_found", reason: "view" } } });
notify({ level: "error", message: { detail: { code: "not_found", reason: "view" } } });
expect(getNotifications().map((n) => n.repeat)).toEqual([2]);
});
it("keeps two notices apart when only a value differs", () => {
notify({ message: msg("channels.notify.hidden", { name: raw("One") }) });
notify({ message: msg("channels.notify.hidden", { name: raw("Two") }) });
expect(getNotifications().map((n) => n.repeat)).toEqual([1, 1]);
});
});
+72 -12
View File
@@ -2,9 +2,61 @@
// the Notification Center shows (info events, actions awaiting interaction, and app
// errors). History is persisted to localStorage so it survives reloads; action
// callbacks are live-only and dropped on reload.
import type { TFunction } from "i18next";
import i18n from "../i18n";
import { localizeDetail } from "./api/refusalText";
import { formatBytes, formatNumber } from "./format";
import { LS, onLocalDataClaimed, readAccount, readAccountMerged, writeAccount } from "./storage";
import { log, type Level } from "./log";
// What a notice SAYS, kept as what to say rather than the said text: a key and its values, said in
// the reader's language each time it is shown. The text a caller translated at raise time was kept
// in the history, so a language switch left every notice in the language it was raised in. `raw` is
// text kept as it was fetched (a video's title, a name — a Plex title in the language it was fetched
// in, since the card holds only that one); `detail` is a server refusal, localized at display like
// the key. `notify` takes nothing else, and tsc refuses a plain string as a value (a name goes in as
// `raw`, a translated word as its own `msg`). What tsc cannot see — `msg(t(…))`, `raw(t(…))`,
// `{ detail: t(…) }`, or a `raw`/`msg` handed to a plain `t()` — notifText.contract.test refuses.
// A value is formatted when shown too: `{ number }` / `{ bytes }` group digits the reader's way.
export type NotifValue = number | NotifText | { number: number } | { bytes: number };
export type NotifText =
| { key: string; values?: Readonly<Record<string, NotifValue>> }
| { raw: string }
| { detail: unknown };
/** The notice text for `t(key, values)`, said when it is shown. */
export const msg = (key: string, values?: Record<string, NotifValue>): NotifText =>
values ? { key, values } : { key };
/** Text kept as it was fetched (a title, a name), shown as it is — never something `t()` said. */
export const raw = (text: string): NotifText => ({ raw: text });
/** A notice text in the language `t` speaks — the reader's, from a component's `useTranslation`. */
export function sayNotif(text: NotifText, t: TFunction = i18n.t.bind(i18n)): string {
if ("raw" in text) return text.raw;
if ("detail" in text) return localizeDetail(text.detail);
if (!text.values) return t(text.key);
const values = Object.fromEntries(
Object.entries(text.values).map(([k, v]) => [k, sayValue(v, t)]),
);
return t(text.key, values);
}
function sayValue(v: NotifValue, t: TFunction): string | number {
if (typeof v !== "object") return v;
// In the language `t` speaks: a fixed one (the English log) names it, a component's follows i18n.
const lang = (t as { lng?: string }).lng ?? i18n.language;
if ("number" in v) return formatNumber(v.number, lang);
if ("bytes" in v) return formatBytes(v.bytes, lang);
return sayNotif(v, t);
}
const sameText = (a: NotifText | undefined, b: NotifText | undefined): boolean =>
JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
// A notice the history kept from before it said keys: its text, in the language it was raised in.
const asText = (v: unknown): NotifText | undefined =>
typeof v === "string" ? raw(v) : (v as NotifText | undefined);
// A notification's level in the client log: what the user was TOLD is part of the story a log tells.
const LOG_LEVEL: Record<NotifLevel, Level> = {
info: "INFO",
@@ -17,7 +69,8 @@ const LOG_LEVEL: Record<NotifLevel, Level> = {
export type NotifLevel = "info" | "success" | "warning" | "error" | "fatal";
interface NotifAction {
label: string;
// Said when shown, like the notice's text: a language switch must not leave the button behind.
label: NotifText;
onClick: () => void;
}
@@ -56,8 +109,8 @@ export type NotifMeta =
export interface Notification {
id: number;
level: NotifLevel;
title?: string;
message: string;
title?: NotifText;
message: NotifText;
action?: NotifAction;
// A second, lesser action beside it (a done download's "Open" after its "Copy link").
secondaryAction?: NotifAction;
@@ -79,9 +132,9 @@ export interface Notification {
}
export interface NotifyInput {
message: string;
message: NotifText;
level?: NotifLevel;
title?: string;
title?: NotifText;
action?: NotifAction;
secondaryAction?: NotifAction;
meta?: NotifMeta;
@@ -175,17 +228,21 @@ let cachedUnread = 0;
function load(): Notification[] {
try {
const raw = readAccount<unknown>(HISTORY_KEY, []);
if (!Array.isArray(raw)) return [];
// The old key only until the new one exists: its rows carry forward, said text as `raw`.
const stored =
readAccount<unknown>(HISTORY_KEY, null) ?? readAccount<unknown>(LS.legacyNotifHistory, []);
if (!Array.isArray(stored)) return [];
// Restored entries are history only. Mark them dismissed so they don't
// resurrect as active toasts on reload — their auto-dismiss timers aren't
// re-armed across a reload, so otherwise they'd stick forever. Also drop the
// live `action` callback, which can't survive serialization.
return raw.map(
return stored.map(
(n) =>
({
repeat: 1,
...(n as object),
title: asText((n as { title?: unknown }).title),
message: asText((n as { message?: unknown }).message) ?? raw(""),
action: undefined,
secondaryAction: undefined,
dismissed: true,
@@ -261,9 +318,12 @@ const COALESCE_WINDOW_MS = 5000;
export function notify(input: NotifyInput): number {
const requiresInteraction = input.requiresInteraction ?? false;
const level = input.level ?? "info";
log.at(LOG_LEVEL[level], "ui.notify", input.message, {
// In English, whatever the reader's language (a server refusal aside: it says itself in the
// reader's), so one log reads the same for every reader.
const en = i18n.getFixedT("en");
log.at(LOG_LEVEL[level], "ui.notify", sayNotif(input.message, en), {
level,
...(input.title ? { title: input.title } : {}),
...(input.title ? { title: sayNotif(input.title, en) } : {}),
...(input.meta ? { kind: input.meta.kind } : {}),
});
const now = Date.now();
@@ -294,8 +354,8 @@ export function notify(input: NotifyInput): number {
!n.action &&
now - n.ts <= COALESCE_WINDOW_MS &&
n.level === level &&
(n.title ?? "") === (input.title ?? "") &&
n.message === input.message,
sameText(n.title, input.title) &&
sameText(n.message, input.message),
);
if (prior) {
// Re-surface the existing toast (undismiss + unread) and re-arm its auto-dismiss timer.
+6
View File
@@ -1,7 +1,13 @@
import type { TFunction } from "i18next";
import { msg, raw, type NotifText } from "./notifications";
/** Watch later is a built-in playlist whose stored `name` is not translatable — show the localized
* label instead. Was hand-inlined in Playlists, PlaylistsRail and AddToPlaylist; one copy now. */
export function playlistName(p: { kind: string; name: string }, t: TFunction): string {
return p.kind === "watch_later" ? t("playlists.watchLater") : p.name;
}
/** The same name in a notice, said in the reader's language when it is shown. */
export function playlistNameText(p: { kind: string; name: string }): NotifText {
return p.kind === "watch_later" ? msg("playlists.watchLater") : raw(p.name);
}
+13 -4
View File
@@ -3,6 +3,7 @@ import { REDUCED_MOTION, useMediaQuery } from "./useMediaQuery";
import { usePref, useSavePref } from "./usePref";
import type { TFunction } from "i18next";
import type { PlexCard, PlexTmdb } from "./api";
import { msg, raw, sayNotif, type NotifText } from "./notifications";
// The non-component half of the shared Plex "detail page" UI (see plexDetailUi): the per-user display
// prefs, the TMDB backdrop URL, the crossfading art backdrop hook and the episode label. Split out so
@@ -241,9 +242,17 @@ export function seasonTitle(
se: { title?: string | null; season_number?: number | null },
t: TFunction,
): string {
return sayNotif(seasonTitleText(se), t);
}
/** The season's name as a notice keeps it: said in the reader's language each time it is shown. */
export function seasonTitleText(se: {
title?: string | null;
season_number?: number | null;
}): NotifText {
const n = se.season_number;
if (se.title && !GENERIC_SEASON.test(se.title.trim())) return se.title;
if (n === 0) return t("plex.specials");
if (n != null) return t("plex.seasonTitle", { n });
return se.title || t("plex.playlist.seasonUnknown");
if (se.title && !GENERIC_SEASON.test(se.title.trim())) return raw(se.title);
if (n === 0) return msg("plex.specials");
if (n != null) return msg("plex.seasonTitle", { n });
return se.title ? raw(se.title) : msg("plex.playlist.seasonUnknown");
}
@@ -0,0 +1,119 @@
// @vitest-environment jsdom
//
// The finish latch's refusal path, against a real query cache.
//
// A refusal answers ONE finish: a replay begun while it was in flight starts a new watch, and a
// refusal that lands after it must not take back the finish that watch later makes — that left the
// cache saying unwatched, mid-episode, for an item the server had watched.
//
// And a refusal while the player is still on the item (the up-next card, the default end state) must
// not empty the detail that player observes: the card is gated on it, so it dropped, came back with its
// countdown restarted when the refetch landed, and never came back if that refetch failed too. The
// entry is dropped when the player leaves, where the next open has to fetch the truth.
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
import { act, cleanup, renderHook } from "@testing-library/react";
import { QueryClient, QueryClientProvider, useQuery } from "@tanstack/react-query";
import { type ReactNode } from "react";
import { usePlexProgress } from "./plexPlayerHooks";
import { api, type PlexItemDetail } from "./api";
import { qk } from "./queryKeys";
import i18n, { plexLang } from "../i18n";
const RK = "4242";
const key = () => qk.plexItem(RK, plexLang(i18n.language));
const detail = { id: RK, status: "new", position_seconds: 600 } as unknown as PlexItemDetail;
function deferred() {
let reject: (e: unknown) => void = () => {};
const promise = new Promise((_, rej) => {
reject = rej;
});
return { promise, reject };
}
const flush = () =>
act(async () => {
await Promise.resolve();
await Promise.resolve();
});
describe("usePlexProgress — a refused finish", () => {
let qc: QueryClient;
let video: HTMLVideoElement;
beforeEach(() => {
qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
video = document.createElement("video");
vi.spyOn(api, "plexProgress").mockResolvedValue({ ok: true } as never);
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
const setup = (observe: boolean) => {
const wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
);
// Stable, as the player's own refs are: the effect re-runs on a new ref object, and a re-run
// starts the item over.
const refs = {
videoRef: { current: video },
absRef: { current: 1500 },
durationRef: { current: 3000 },
};
return renderHook(
() => {
useQuery({
queryKey: key(),
queryFn: () => Promise.resolve(detail),
enabled: observe,
});
return usePlexProgress({
id: RK,
qc,
...refs,
});
},
{ wrapper },
);
};
it.each([
["before", true],
["after", false],
])(
"a refusal landing after a replay, %s its next finish, takes back neither",
async (_, early) => {
qc.setQueryData(key(), detail);
const { result } = setup(false);
const first = deferred();
act(() => result.current.finish(first.promise));
act(() => void video.dispatchEvent(new Event("play"))); // the viewer plays it again
if (early) {
first.reject(new Error("refused")); // only now is the first finish refused
await flush();
}
act(() => result.current.finish(Promise.resolve()));
await flush();
if (!early) {
first.reject(new Error("refused"));
await flush();
}
act(() => result.current.settle());
expect(qc.getQueryData(key())).toMatchObject({ status: "watched", position_seconds: 0 });
},
);
it("keeps the detail the still-open player observes, and drops it when the player leaves", async () => {
const { result, unmount } = setup(true);
await flush();
expect(qc.getQueryData(key())).toBeDefined();
const refused = deferred();
act(() => result.current.finish(refused.promise));
refused.reject(new Error("refused"));
await flush();
expect(qc.getQueryData(key())).toBeDefined();
unmount();
expect(qc.getQueryData(key())).toBeUndefined();
});
});
+28 -8
View File
@@ -214,6 +214,14 @@ export function usePlexProgress(opts: {
// write; this closes the other three. What the refusal does take back is the
// CLAIM: the cache must not say watched, and `settle` must not re-assert it on the way out.
let finishRefused = false;
// Whether the player has left the item (`settle` ran): what a refusal does to the cached detail
// depends on whether the player is still observing it.
let left = false;
// Which finish a refusal answers. A replay starts a new watch, so a refusal of an earlier finish
// that lands after it must not raise `finishRefused` for the finish that watch makes later — the
// leave then skipped its watched patch, and the cache said unwatched, mid-item, for an item the
// server had watched.
let finishGen = 0;
// `final` = a settled checkpoint (pause / page-hide / leaving the player) vs. the periodic tick.
// Only final checkpoints get mirrored to a linked Plex account (backend-side debounce), so Plex
// sees the resume position when the user actually stops — not a timeline call every 10 seconds.
@@ -261,6 +269,7 @@ export function usePlexProgress(opts: {
const onPlayAgain = () => {
finished = false;
finishRefused = false;
finishGen = finishGen + 1;
};
video?.addEventListener("play", onPlayAgain);
// Back (unmount) persists the position server-side via flush(), but reopening the same item in
@@ -273,12 +282,14 @@ export function usePlexProgress(opts: {
const settle = () => {
// Match the language-keyed detail query so the optimistic resume patch lands.
const key = qk.plexItem(id, plexLang(i18n.language));
left = true;
if (finished) {
// A refused finish claims nothing: the server has the item unwatched at the position the
// rollback restored, and the catch below has already dropped this entry, so the next open
// fetches the truth. Re-patching "watched / 0" here would put back the very lie the
// refusal handler removed.
if (!finishRefused)
// rollback restored. Re-patching "watched / 0" here would put back the very lie the refusal
// handler took back, and the entry the player kept observing (see the catch below) may
// still be the one from before the rollback — dropped, the next open fetches the truth.
if (finishRefused) qc.removeQueries({ queryKey: key });
else
qc.setQueryData(key, (old) =>
old ? { ...old, status: "watched", position_seconds: 0 } : old,
);
@@ -293,6 +304,9 @@ export function usePlexProgress(opts: {
settleRef.current = settle;
finishRef.current = (confirmed?: Promise<unknown>) => {
finished = true;
finishRefused = false;
finishGen = finishGen + 1;
const gen = finishGen;
// The latch has to go up BEFORE the media pauses, so it cannot wait for the server. If the
// watched write is then refused, what is taken back is the CLAIM, not the latch (see
// `finishRefused`): the cached detail said watched / position 0 while the server still had
@@ -306,6 +320,7 @@ export function usePlexProgress(opts: {
// position left an unwatched item with a ~99% bar that reopened in its last seconds.
const ended = video?.ended === true;
void confirmed?.catch(() => {
if (gen !== finishGen) return;
finishRefused = true;
// Restore the resume point the finish replaced — but ONLY where there is one to restore.
// On the play-to-the-end path `pos` IS the end of the media, and the backend reads a
@@ -316,10 +331,15 @@ export function usePlexProgress(opts: {
// credits-skip caller captures a mid-episode position and is unaffected.
if (!ended && pos > 0 && dur > 0 && pos <= dur - FINISH_MARGIN_S)
api.plexProgress(id, pos, dur, true).catch(() => {});
// `settle` may already have patched the detail to watched / 0 on the way out. Dropping that
// entry rather than invalidating it: an invalidated query with no observer keeps serving its
// stale data, and this one is a lie — no data is better, the next open fetches the truth.
qc.removeQueries({ queryKey: qk.plexItem(id, plexLang(i18n.language)) });
// Still on the item (the up-next card is the default end state), the player observes this
// entry: emptying it dropped the card — gated on the detail — and its countdown restarted
// when the refetch landed, or the card never came back if that failed too. So it is
// refetched under the player, and dropped when the player leaves (`settle`). Already left,
// `settle` may have patched it to watched / 0 on the way out, and an invalidated query with
// no observer keeps serving that lie: dropped now, the next open fetches the truth.
const key = qk.plexItem(id, plexLang(i18n.language));
if (left) qc.removeQueries({ queryKey: key });
else void qc.invalidateQueries({ queryKey: key });
});
};
return () => {
+2 -3
View File
@@ -1,6 +1,6 @@
import type { TFunction } from "i18next";
import { api, type PlexCard } from "./api";
import { notifyActionError } from "./actionErrors";
import { msg } from "./notifications";
// Persist a watch-state toggle from any Plex surface: flip via the caller's `optimistic` cache
// updater, call the API, surface a failure (the click used to look dead on 403/404/429), then
@@ -12,7 +12,6 @@ import { notifyActionError } from "./actionErrors";
// (react-query keeps the same `data`), or a cache whose refetch lands later than the click's answer.
export async function toggleWatchState(
card: PlexCard,
t: TFunction,
handlers: { optimistic: (next: string) => void; resync: () => void; revert?: () => void },
) {
const next = card.status === "watched" ? "new" : "watched";
@@ -21,7 +20,7 @@ export async function toggleWatchState(
await api.plexSetState(card.id, next);
} catch (e) {
handlers.revert?.();
notifyActionError(e, t("plex.watchedFailed"));
notifyActionError(e, msg("plex.watchedFailed"));
}
handlers.resync();
}
+12 -10
View File
@@ -5,16 +5,18 @@
// silence on a 403/404/429 or a lost connection — 34 of them.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("./notifications", () => ({ notify: vi.fn(() => 0) }));
vi.mock("./notifications", async (importOriginal) => ({
...(await importOriginal<typeof import("./notifications")>()),
notify: vi.fn(() => 0),
}));
vi.mock("./errorDialog", () => ({ reportError: vi.fn() }));
import { MutationObserver, type MutationObserverOptions } from "@tanstack/react-query";
import { createQueryClient } from "./queryClient";
import { HttpError, req } from "./api/core";
import { notify } from "./notifications";
import { msg, notify, raw } from "./notifications";
import { reportError } from "./errorDialog";
import { notifyActionError } from "./actionErrors";
import i18n from "../i18n";
const notifyMock = vi.mocked(notify);
beforeEach(() => {
@@ -39,7 +41,7 @@ describe("a failed mutation", () => {
meta: { failTitle: TITLE },
});
expect(notifyMock.mock.calls).toEqual([
[{ level: "error", title: i18n.t(TITLE), message: i18n.t("errors.notFound.generic") }],
[{ level: "error", title: msg(TITLE), message: msg("errors.notFound.generic") }],
]);
});
@@ -47,8 +49,8 @@ describe("a failed mutation", () => {
await fail({ mutationFn: () => Promise.reject(new HttpError(429, "slow down")) });
expect(notifyMock).toHaveBeenCalledWith({
level: "error",
title: i18n.t("errors.title"),
message: i18n.t("errors.reason.rateLimited"),
title: msg("errors.title"),
message: msg("errors.reason.rateLimited"),
});
});
@@ -58,7 +60,7 @@ describe("a failed mutation", () => {
meta: { failTitle: TITLE },
});
expect(notifyMock).toHaveBeenCalledWith(
expect.objectContaining({ message: i18n.t("errors.reason.unreachable") }),
expect.objectContaining({ message: msg("errors.reason.unreachable") }),
);
});
@@ -66,9 +68,9 @@ describe("a failed mutation", () => {
await fail({
mutationFn: () => Promise.reject(new HttpError(404, "gone")),
meta: { failTitle: TITLE },
onError: (e) => notifyActionError(e, "OWN TITLE"),
onError: (e) => notifyActionError(e, raw("OWN TITLE")),
});
expect(notifyMock.mock.calls.map(([n]) => n.title)).toEqual(["OWN TITLE"]);
expect(notifyMock.mock.calls.map(([n]) => n.title)).toEqual([raw("OWN TITLE")]);
});
it("is still reported when the mutation's onError only rolls back", async () => {
@@ -79,7 +81,7 @@ describe("a failed mutation", () => {
onError: rollback,
});
expect(rollback).toHaveBeenCalled();
expect(notifyMock.mock.calls.map(([n]) => n.title)).toEqual([i18n.t(TITLE)]);
expect(notifyMock.mock.calls.map(([n]) => n.title)).toEqual([msg(TITLE)]);
});
it("gets no toast when the page shows the failure itself", async () => {
+2 -2
View File
@@ -1,7 +1,7 @@
import { MutationCache, QueryCache, QueryClient } from "@tanstack/react-query";
import i18n from "../i18n";
import { notifyActionError } from "./actionErrors";
import { log } from "./log";
import { msg } from "./notifications";
/** A failed query or mutation, for the client log. Only the key's HEAD (the resource, `"videos"`):
* the rest of a key is filter state, which the base level does not keep. The status is what
@@ -42,7 +42,7 @@ type FailMeta = { failTitle?: string; errorShownInline?: boolean } | undefined;
*/
function reportMutationFailure(err: unknown, meta: FailMeta): void {
if (meta?.errorShownInline) return;
setTimeout(() => notifyActionError(err, i18n.t(meta?.failTitle ?? "errors.title")), 0);
setTimeout(() => notifyActionError(err, msg(meta?.failTitle ?? "errors.title")), 0);
}
export function createQueryClient(): QueryClient {
+6 -1
View File
@@ -100,7 +100,12 @@ const KEYS = {
// flag). A YT.Player instance is created per video and starts at full volume, so without this
// every next video is loud again (same idea as plexPlayerPrefs).
ytPlayerPrefs: { key: "siftlode.ytPlayerPrefs", kind: "device" },
notifHistory: { key: "siftlode.notifications", kind: "cache" },
// The notice history: what each notice says (lib/notifications NotifText), not the said text.
notifHistory: { key: "siftlode.notifHistory", kind: "cache" },
// DEPRECATED — the history as said text, which a build from before the change still reads (and
// renders as text: an object there crashed its Notifications page after a rollback). Read once while
// `notifHistory` does not exist yet, then inert. Do not write.
legacyNotifHistory: { key: "siftlode.notifications", kind: "cache" },
notifSettings: { key: "siftlode.notifSettings", kind: "pref" },
onboardingDismissed: { key: "siftlode.onboarding.dismissed", kind: "pref" },
seenVersion: { key: "siftlode.seenVersion", kind: "pref" },
@@ -28,6 +28,7 @@ vi.mock("./entryUrl", async (importOriginal) => {
return { ...actual, accountSwitchNotice: () => notice() };
});
const { useAccountSwitchNotice } = await import("./useAccountSwitchNotice");
const { msg, raw } = await import("./notifications");
afterEach(() => {
notifyMock.mockClear();
@@ -39,7 +40,7 @@ describe("the shell's word on a switch of account", () => {
notice.mockReturnValue({
level: "info",
key: "header.account.switchRequested",
values: { email: "b@example.com" },
values: { email: raw("b@example.com") },
});
const { rerender } = renderHook(({ signedIn }) => useAccountSwitchNotice(signedIn), {
initialProps: { signedIn: false },
@@ -53,7 +54,7 @@ describe("the shell's word on a switch of account", () => {
expect(notifyMock).toHaveBeenCalledTimes(1);
expect(notifyMock).toHaveBeenCalledWith({
level: "info",
message: 'header.account.switchRequested {"email":"b@example.com"}',
message: msg("header.account.switchRequested", { email: raw("b@example.com") }),
});
});
+2 -2
View File
@@ -1,7 +1,7 @@
import { useEffect, useRef } from "react";
import { useTranslation } from "react-i18next";
import { accountSwitchNotice } from "./entryUrl";
import { notify } from "./notifications";
import { msg, notify } from "./notifications";
/** Say, once per load, what the entry URL tells the account signed in here about a switch of
* account (`accountSwitchNotice`): a sign-in as someone else the server refused, or a tab that
@@ -14,6 +14,6 @@ export function useAccountSwitchNotice(signedIn: boolean): void {
if (!signedIn || said.current) return;
said.current = true;
const notice = accountSwitchNotice();
if (notice) notify({ level: notice.level, message: t(notice.key, notice.values) });
if (notice) notify({ level: notice.level, message: msg(notice.key, notice.values) });
}, [signedIn, t]);
}

Some files were not shown because too many files have changed in this diff Show More