Compare commits

..
51 Commits
Author SHA1 Message Date
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
peter 4084dd2f92 release: 0.113.0 (feature/e46-url-is-the-view) 2026-10-28 02:19:29 +01:00
peter 6aa6a6efdf chore(release): 0.113.0 2026-10-28 02:07:48 +01:00
peter 5686759bca fix(nav): a page settling keeps a reload's overlay entry; an old entry's sub-view goes to its own (review round 4)
- useUrlMirror: a change before the user has done anything since the load is the page settling (the
  rail stepping off a playlist deleted since, Files off a folder gone), not another view: the reload
  mark stays, and the player that reopens takes its entry over — one Back closes it, as before.
- useHistorySubview: an entry an older build wrote is a module's only when its _sub is the view the
  address names under that module's key (the card's push kept the season's `v` beside its `m`).
- Playlists: the rail's default selection and the page's "the rail moved there" read one helper.
- moduleUrl contract: writer literals in a choice's branches are scanned too; the sub-view census
  walks type arguments of any depth and refuses an aliased import.
- A stale comment on the nav press over a live search.
2026-10-28 01:39:26 +01:00
peter 99a33e7fd1 fix(history): a sub-view reads only its own module's from the entry it mounts on
The entry's _sub now names its owner (_subOf, the module's address key). Signed out on a Plex season,
the sign-in card took the season for one of its modes and rendered a card with an empty button; the
other way round, Plex took the card's register view for one of its own. An entry an older build wrote
is read as the module's only when its address names one of that module's views.
2026-10-28 00:29:40 +01:00
peter 8804bdde63 Merge branch 'main' into feature/e46-url-is-the-view 2026-10-28 00:11:30 +01:00
peter 02906d0acb fix(nav): the address keeps what the app rewrites around it (review round 3)
- The Feed press over a live search ends only the search: the feed's own view stays named.
- Leaving a search steps back only off an entry the app pushed (_ytOwn); one a link or a reload
  opened is cleared in place, also where the Navigation API is missing.
- useUrlMirror rewrites a changed view through replaceView, so a reload's overlay marks go; its
  first write and its landing writes keep them for an overlay that reopens.
- Files and Playlists: a box emptied and filled again settles at once, never naming the ended search.
- Playlists: a linked player is scoped to the playlist the store started with, and only the rail's
  own move to the first playlist is no pick.
- Contract: writer objects are scanned to their matching brace, a computed or shorthand key is
  refused, and every useHistorySubview call hands a named definition.
- Lint: playwright/no-skipped-test also refuses fixme (gate snapshot regenerated).
- FEED_DEFAULT_SORT at the three remaining sites; linkedNotListed says to search by name.
2026-10-27 23:33:22 +01:00
peter 66e427dfa4 fix(nav): a search the address opened marks its entry, so a channel over it keeps it
A search reached by a reload or a link had no _yt mark on its entry, so a channel opened from its
results carried none, and a landing back on that channel (an overlay over it closing) ended the
search under it.
2026-10-27 22:43:17 +01:00
peter e2b725dba7 test(e2e): the Files sort round trip starts from a known sort and puts the account's back
A header press cycles the sort (asc, desc, none), so the case depended on what an earlier run left
in the account: the third run in a row pressed it to none.
2026-10-27 16:34:17 +01:00
peter 6d57d62ac8 fix(nav): a linked player, file and search survive what the app does by itself (review round 2)
- Playlists: a link over a playlist the account does not hold plays alone; the rail's own
  fallback no longer counts as a pick that gives the player up.
- The linked opener opens or clears nothing once the address names another player, and stops
  looking (and fetching its row) once it settled.
- Files: a list the server's cap cut — the sorted or searched one too — keeps the link, with a
  notice of its own (EN/HU).
- History: an in-place view rewrite (search refine, count, channel to channel, sub-view)
  drops a reloaded entry's overlay marks (replaceView); a landing on a channel opened from a
  search keeps the search; the Feed press on a search opens the plain feed.
- Files/Playlists: an emptied search is written at once.
- Client log: plf/cf (column text filters) are typed keys; the shell has none of its own.
- Contract: writers outside lib/ take a literal or a lib/ function's result; every URL key is
  sorted typed or not; the writer regexes are held by a fixture; sub-views named; the bookmark
  net reads page tags.
- Feed defaults in one constant; one live-search queue helper; Scheduler writes through the
  Activity codec; notRun copy (EN/HU) names Forward and a plain "Search YouTube" button.
2026-10-27 16:08:52 +01:00
peter 0d5c6273f1 fix(nav): the address follows the view through overlays, reloads and Back (review round 1)
- A URL mirror names the view again on every entry Back or Forward lands on, so a change made
  under a dialog, a drawer or a sub-view is not undone by the next reload.
- A view pushed from the entry a reload under a dialog started from no longer inherits its overlay
  mark; a reload under two overlays closes onto the page in one step.
- The linked player reopens over a live search, gives itself up when its list moves first, and
  plays alone over an emptied playlist; a capped Files list keeps the link it cannot resolve.
- The Feed press over a channel ends the search; leaving a search ends its asked mark; Files and
  Playlists searches end with their page; the feed always names its view and debounces its search.
- Search-box keys are declared per module and kept out of the client log below TRACE.
- Writers outside lib/ go through codecs; the URL contract scans computed keys, overlay writers and
  two-line reads, and holds every page to a bookmark round trip (Config, Activity, Playlists and the
  Files sort and viewer added).
2026-10-27 13:52:42 +01:00
peter 7b4e007680 feat(nav): the live YouTube search and Home's budget are named in the address
A YouTube search is `?yt=<term>` (+ `ytn` for a non-default count), so a reload, a link
or Back/Forward reopens it. It runs by itself only where it costs no quota: the free page
scrape, a search asked for in this document, or results still cached. An API search
reopened any other way shows its term and waits for "Search YouTube again".

Home's time budget is `hb`; the question's draw stays on the history entry.

An e2e spec opens every page's bookmark in a fresh browser context; CLAUDE.md and
docs/development.md name the module URL codec as the place for view state.
2026-10-27 11:51:29 +01:00
peter 3d26ca0efa feat(player): a bookmark reopens every player as it was, once
The Plex player names the playlist it plays through (v=player:<id>:playlist:<pid>), so a bookmark
rebuilds the queue from the playlist's own order while the item is still in it.

Found in the browser and fixed:
- after a reload the shell's player opened a second one over Home and the feed: it read the catalog
  row the page had fetched (a disabled query still reads the shared cache); it now waits for ready.
- every reload under an open overlay left a dead history step: the first overlay to open takes over
  the entry the document was loaded on.
- the feed's address was implicit, the same as the start-page landing, so a reloaded or bookmarked
  feed opened as Home: the feed now names itself (page=feed) like every page.
- a linked file or video that is gone was said, but stayed in the address and was said again on
  every reload: its keys are taken off.

The overlay address is written in one place (the entry is named once it is current), and the player
keys only the codec reads are no longer exported.
2026-10-27 07:33:55 +01:00
peter fa893c73a3 feat(player): the YouTube player and the file viewer are named in the address
The player names what it plays in its own history entry (watch, wrel, wtab): a reload or a
bookmark reopens it on the page that hosts it, with that page's list as the queue, or alone
from the catalog (GET /api/videos/{id}/row); a page that hosts none gets the shell's. Back
lands on the page's address without it (useBackToClose's url; ui/Modal passes it on). The
Files viewer and player do the same (fview, fplay). The Plex and channel-manager views are
always written in full, as their fallback is remembered state.
2026-10-27 05:29:49 +01:00
peter d4f24de4ba feat(nav): tabs, list views and name searches are named in the address
Settings, Configuration and Users name their tab (stab/cfgtab/utab); a link's tab wins and a tab pre-selected
while its page is on screen now switches it. A playlist's sort, filters and grouping (plsort/plf/plgroup), the
Files sort (fsort, always written) and the name searches of Channels, Files, Playlists and Plex (cq/fq/plq/pq+pqf)
reopen from the address; the searches are written once the typing pauses.
2026-10-26 01:13:39 +01:00
peter 91d36b3357 fix(channel): the channel page's address names the channel and its own view, not the page under it
Opening a channel kept every key of the page under it (`?q=music&sort=oldest&chan=…`, a sort the
channel did not show), and the channel's own tab, sort and content types lived in its state alone, so a
reload or a bookmark lost them. Now the channel's address is `channelUrl`: the shell's keys and `chan`,
shared by `openChannel` and the channel link's href. The page writes its view (`chtab`, `chsort`,
`chtypes`, lib/channelPageUrlState) and reopens it; these are shell keys, cleared by a page switch and
by closing it in place. While a channel stands over a page, no module store writes its keys (`urlPage`);
Back finds them on the page's own entry. A link written by an older build opens without the page's keys.
2026-10-26 00:19:02 +01:00
peter 9bd7304bb9 fix(plex): a reload during a binge reopens the episode playing now
The player stepped to the next item in its own state while the history entry still held the item it
opened with, so F5 restarted the first episode. The player now says every item it moves on to, and the
page rewrites its entry with it (useHistorySubview's `follow`: no navigation, the overlays over the
player stay open, and the entry under an overlay is rewritten when a step lands back on it). The
player's URL is the item playing (`?v=player:<id>`), so a bookmark opens it; the list it plays from
stays in the history entry.
2026-10-25 23:24:29 +01:00
peter b30c60d1a5 feat(nav): each module declares its URL keys; a page switch clears them and a page opens without another's
The shell cleared a hand-kept key list on a page switch (it missed the Files and feed keys) and cleared
nothing when a page opened from a link carrying another page's keys. ModuleDef.urlKeys now names every
key a page's view writes, the shell derives both from the registry, and moduleUrl.contract.test holds the
declarations true: one owner per key, no shell key, exactly what each codec writes, and no undeclared
key literal in the app.
2026-10-25 20:48:14 +01:00
295 changed files with 9642 additions and 1434 deletions
+31
View File
@@ -7,6 +7,37 @@ 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.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
- The address names what you see: tabs, list views, sorts and searches (the live YouTube search too), the YouTube player and the file viewer. A reload, a bookmark or a shared link opens the same view.
- A link to a video reopens the player over its page once; Back closes it. A video the page no longer lists plays on its own and says so.
### Fixed
- Back and Forward step through the views as you saw them, across dialogs, players and reloads: a reload under an open player no longer leaves a Back step that does nothing.
- Signed out on a Plex page, the sign-in card could show a button with no text.
- The channel page's address names the channel and its own view, not the page under it.
- A reload during a Plex binge reopens the episode playing now, not the one it started from.
## 0.112.1 — 2026-10-27
### Fixed
+1
View File
@@ -115,6 +115,7 @@ CQRS, a repository/DI framework, a router library, a generic CRUD-router factory
| FE keyboard shortcut | a row in `lib/keymap.ts` + `dispatch()` | a private `keydown` listener with its own key literals |
| FE list/grid keys | `NavGrid`/`keyboardNav` + `navItemAttrs` + `data-nav-open` | a private arrow-key / roving-tabindex handler |
| FE view push (history) | `rememberNavPosition()` right before `history.pushState` | a push that forgets: Back strands focus on `<body>` |
| FE view state | the module's URL codec: its keys in `urlKeys` (`module.ts`), read and written by `lib/<id>UrlState.ts` through `useUrlMirror` (contract-held, `e2e/bookmark.spec.ts`) | `useState` / sessionStorage alone: a reload, a bookmark or a shared link loses the view |
| FE a page's header content | the page renders it into the floating header itself (`PageHeader`, `ui/PageShell`) | a `page === …` branch in the shell's header, or a bridge carrying a page's state up to it |
| FE module store | its definition's `Store` (`module.ts`), mounted at the root by `ModuleStores`; it reads no other module's store (contract-held) | a provider hand-nested in `main.tsx` |
| FE saved views | the module's own store from `features/filters` (`createSavedViewsStore` + `useSavedViewsStore`) | one store switched by the active page — every visit elsewhere reset it |
+1 -1
View File
@@ -1 +1 @@
0.112.1
0.114.1
+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
+15
View File
@@ -249,6 +249,21 @@ def set_video_progress(
return {"ok": True, "video_id": video_id, "position_seconds": position}
@router.get("/videos/{video_id}/row")
def get_video_row(
video_id: str = Path(max_length=32),
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
"""One catalog video as the feed lists it (its channel, this user's watch state and resume point,
saved): what a player opened from an address starts from. The catalog only, so it costs nothing;
every video a player can open (a list's row, a related one) is in it."""
rows = queries.feed_rows_by_ids(db, user, [video_id])
if not rows:
raise not_found("video")
return rows[0]
@router.get("/videos/{video_id}")
def get_video_detail(
video_id: str,
+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,
+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) == []
+47
View File
@@ -0,0 +1,47 @@
"""A catalog video as the feed lists it, the row a player opened from an address starts from. DB lane."""
from datetime import datetime, timezone
from app.catalog.models import Channel, Video
from app.reader.models import VideoState
def _video(db, vid="v1"):
db.add(Channel(id="UCo", title="Owner", handle="@owner"))
db.flush()
db.add(
Video(
id=vid,
title="A talk",
channel_id="UCo",
published_at=datetime.now(timezone.utc),
duration_seconds=3600,
)
)
db.commit()
def test_a_video_comes_as_the_feed_row_with_my_own_state(db, make_user, as_user):
me, other = make_user(), make_user()
_video(db)
db.add(VideoState(user_id=me.id, video_id="v1", status="new", position_seconds=600))
db.add(VideoState(user_id=other.id, video_id="v1", status="watched", position_seconds=90))
db.commit()
r = as_user(me).get("/api/videos/v1/row")
assert r.status_code == 200, r.text
row = r.json()
assert (row["id"], row["title"], row["channel_title"]) == ("v1", "A talk", "Owner")
assert (row["status"], row["position_seconds"]) == ("new", 600)
assert row["watch_url"] == "https://www.youtube.com/watch?v=v1"
def test_a_video_never_opened_has_no_state(db, make_user, as_user):
_video(db)
row = as_user(make_user()).get("/api/videos/v1/row").json()
assert (row["status"], row["position_seconds"], row["saved"]) == ("new", 0, False)
def test_a_video_outside_the_catalog_is_not_found(db, make_user, as_user):
r = as_user(make_user()).get("/api/videos/nope/row")
assert r.status_code == 404
assert r.json()["detail"]["code"] == "not_found"
+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 |
+16 -2
View File
@@ -45,7 +45,18 @@ A frontend page is one folder, `frontend/src/modules/<id>/` (the layering is in
page renders into the floating header itself through `PageHeader`; the shell knows no page.
2. `module.ts`: its `ModuleDef` (`lib/moduleDef.ts`): id, label keys, icon, an optional `gate`, the page as a
`lazyWithPreload(() => import(…))`, and optionally a rail and a `Store`. Of its own folder it imports statically
only its store (a `…Provider` / `…Store` file); the page and the rail load lazily.
only its store (a `…Provider` / `…Store` file); the page and the rail load lazily. `urlKeys` names every
address-bar key its view writes (the URL is the view: a bookmark reopens it), its list kept in `lib/` beside
its codec; the shell clears them on a page switch and opens a page without another page's keys.
Every setting the page shows — a tab, a sort, a filter, a search, the item open in it — goes there: a codec
in `lib/<id>UrlState.ts` (read the params into the view, write the view as updates, a default left out —
or always written, when an address naming none falls back to a view remembered elsewhere), written with
`useUrlMirror` (a sub-view through `useHistorySubview`'s `url`, an overlay through `useBackToClose`'s),
always as the codec's result: a writer outside `lib/` computes no key of its own. Read back first on mount
(a link wins over anything remembered). A key that carries typed text — a search box's, a column filter
map with a text filter — is also in `typedUrlKeys` (the client log keeps what was typed out). Add its round
trip to `e2e/bookmark.spec.ts`, tagged with the page (`{ tag: "@<id>" }`). Only what an address cannot promise to
reopen stays out, said in its codec (Home's draw, `lib/homeUrlState`).
3. `index.ts`, the module's public face: the definition, and only what other folders must read (its store's
hooks). Another folder reaches the module through this file or not at all.
4. The id in `lib/urlState`'s `PAGES`, and the definition in `features/navigation/moduleRegistry.ts`, in
@@ -58,7 +69,10 @@ A frontend page is one folder, `frontend/src/modules/<id>/` (the layering is in
returns to `ui/FilterRail`; tsc names each table still missing the new id.
What holds it: `moduleRegistry.contract.test.ts` (every id defined once, from its own folder, lazily),
`moduleStores.contract.test.tsx` (no store reads another module), the `local/layers` lint rule (imports only go
`moduleStores.contract.test.tsx` (no store reads another module), `moduleUrl.contract.test.ts` (each URL key
belongs to one module and matches what its codec writes; a writer outside `lib/` is handed a literal or a `lib/`
function's result, never a key of its own; no key literal written or read from the live query undeclared; every key
sorted as typed or not; every page with keys in a tagged bookmark round trip), the `local/layers` lint rule (imports only go
down, a module only through its face) and `chunkGuard` in the build (no lazy page on the boot path), plus the
i18n parity test.
+259
View File
@@ -0,0 +1,259 @@
import { test, expect, type Browser, type Page } from "@playwright/test";
import {
clickVisibleChannelLink,
expectContentType,
gotoPage,
openApp,
openFirstVideo,
toggleContentType,
} from "./helpers";
// The address is the view: a view set through the UI, its address opened as a bookmark — in a NEW
// browser context, so nothing this tab remembered (its history entries, sessionStorage, the view
// storage it wrote) can stand in for what the address says. One round trip per page that names its
// view, tagged with the page (moduleUrl.contract.test holds the tags to the module definitions),
// plus the overlays the shell opens over any page (the channel page, the player) and the live
// search. Not Plex: what it shows is the instance's own library, which a test stack may not have.
const STORAGE_STATE = "e2e/.auth/state.json";
/** The page's address, opened as a bookmark in a fresh context of the same account. */
async function bookmarked(browser: Browser, page: Page, prepare?: (p: Page) => Promise<void>) {
const url = page.url();
const ctx = await browser.newContext({ storageState: STORAGE_STATE, locale: "en-US" });
const fresh = await ctx.newPage();
await prepare?.(fresh);
await openApp(fresh, url);
return fresh;
}
const tab = (p: Page, name: string) => p.getByRole("tab", { name, exact: true });
const column = (p: Page, name: string) =>
p.getByRole("columnheader", { name: new RegExp(`^${name}`) });
async function gotoAdminPage(page: Page, id: string): Promise<void> {
await page.getByTestId("nav-admin").click();
await page.getByTestId(`nav-admin-${id}`).click();
}
test.describe("a bookmark reopens the view it was taken of", () => {
test("the feed's filters", { tag: "@feed" }, async ({ page, browser }) => {
await openApp(page, "/?page=feed");
await toggleContentType(page, "includeLive");
await expect(page).toHaveURL(/[?&]live=/);
const fresh = await bookmarked(browser, page);
await expectContentType(fresh, "includeLive", true);
await fresh.context().close();
});
test("Home's time budget", { tag: "@home" }, async ({ page, browser }) => {
await openApp(page, "/?page=home");
await page.getByRole("button", { name: "An evening", exact: true }).click();
await expect(page).toHaveURL(/[?&]hb=long/);
const fresh = await bookmarked(browser, page);
await expect(fresh.getByRole("button", { name: "An evening", exact: true })).toHaveAttribute(
"aria-pressed",
"true",
);
await fresh.context().close();
});
test("a Settings tab", { tag: "@settings" }, async ({ page, browser }) => {
await openApp(page, "/?page=feed");
await gotoPage(page, "settings");
await tab(page, "Notifications").click();
const fresh = await bookmarked(browser, page);
await expect(tab(fresh, "Notifications")).toHaveAttribute("aria-selected", "true");
await fresh.context().close();
});
test("an admin page's tab", { tag: "@users" }, async ({ page, browser }) => {
await openApp(page, "/?page=feed");
await gotoAdminPage(page, "users");
await tab(page, "Access requests").click();
const fresh = await bookmarked(browser, page);
await expect(tab(fresh, "Access requests")).toHaveAttribute("aria-selected", "true");
await fresh.context().close();
});
test("a Configuration group", { tag: "@config" }, async ({ page, browser }) => {
await openApp(page, "/?page=feed");
await gotoAdminPage(page, "config");
const second = page.getByRole("tab").nth(1);
const name = (await second.textContent()) ?? "";
await second.click();
await expect(page).toHaveURL(/[?&]cfgtab=/);
const fresh = await bookmarked(browser, page);
await expect(fresh.getByRole("tab", { name, exact: true })).toHaveAttribute(
"aria-selected",
"true",
);
await fresh.context().close();
});
test("the Activity tab and its time window", { tag: "@activity" }, async ({ page, browser }) => {
await openApp(page, "/?page=feed");
await gotoAdminPage(page, "activity");
await tab(page, "Server log").click();
await page.getByRole("button", { name: "30 days", exact: true }).click();
await expect(page).toHaveURL(/[?&]aw=30d/);
const fresh = await bookmarked(browser, page);
await expect(tab(fresh, "Server log")).toHaveAttribute("aria-selected", "true");
await expect(fresh.getByRole("button", { name: "30 days", exact: true })).toHaveAttribute(
"aria-pressed",
"true",
);
await fresh.context().close();
});
test("a playlist and its sort", { tag: "@playlists" }, async ({ page, browser }) => {
await openApp(page, "/?page=feed");
await gotoPage(page, "playlists");
await expect(column(page, "Title")).toBeVisible();
await column(page, "Title").getByRole("button").first().click();
await expect(page).toHaveURL(/[?&]plsort=/);
const sorted = await column(page, "Title").getAttribute("aria-sort");
const fresh = await bookmarked(browser, page);
await expect(column(fresh, "Title")).toHaveAttribute("aria-sort", sorted ?? "");
expect(new URL(fresh.url()).searchParams.get("pl")).toBe(
new URL(page.url()).searchParams.get("pl"),
);
await fresh.context().close();
});
test(
"the Channels manager's tab and name search",
{ tag: "@channels" },
async ({ page, browser }) => {
await openApp(page, "/?page=feed");
await gotoPage(page, "channels");
await tab(page, "Hidden").click();
await page.getByPlaceholder("Search channels…").fill("zz");
await expect(page).toHaveURL(/zz/); // written once the typing settles
const fresh = await bookmarked(browser, page);
await expect(tab(fresh, "Hidden")).toHaveAttribute("aria-selected", "true");
await expect(fresh.getByPlaceholder("Search channels…")).toHaveValue("zz");
await fresh.context().close();
},
);
test("the Files search", { tag: "@files" }, async ({ page, browser }) => {
await openApp(page, "/?page=feed");
await gotoPage(page, "files");
await page.getByPlaceholder("Search files…").fill("zz");
await expect(page).toHaveURL(/zz/);
const fresh = await bookmarked(browser, page);
await expect(fresh.getByPlaceholder("Search files…")).toHaveValue("zz");
await fresh.context().close();
});
// A file of the test's own, uploaded for it and deleted after, and the account's sort put back:
// the account is left as it was found.
test(
"the Files sort, and the file open in the viewer",
{ tag: "@files" },
async ({ page, browser }) => {
await openApp(page, "/?page=feed");
const NAME = "bookmark-note";
// One a run cut short left behind goes first.
const left = (await (await page.request.get("/api/files")).json()) as Record<
string,
unknown
>[];
for (const f of left.filter((f) => JSON.stringify(f).includes(`"${NAME}`)))
await page.request.delete(`/api/files/${String(f.id)}`);
const up = await page.request.post("/api/files/upload", {
headers: { "X-Upload-Filename": `${NAME}.txt` },
data: Buffer.from("a note the bookmark reopens\n"),
});
expect(up.ok(), await up.text()).toBeTruthy();
const { id } = (await up.json()) as { id: number };
// The account's own sort, put back after: a header press cycles it (asc, desc, none), so the
// case starts from none, as the address names it, whatever an earlier run left.
const me = (await (await page.request.get("/api/me")).json()) as {
preferences?: Record<string, unknown>;
};
const ownSort = me.preferences?.filesSort ?? null;
try {
await openApp(page, "/?page=files&fsort=none");
await column(page, "Name").getByRole("button").first().click();
await expect(page).toHaveURL(/[?&]fsort=name/);
const sorted = await column(page, "Name").getAttribute("aria-sort");
await page.getByText(NAME, { exact: true }).first().click();
await expect(page).toHaveURL(new RegExp(`[?&]fview=${id}`));
const fresh = await bookmarked(browser, page);
await expect(fresh.getByRole("dialog", { name: NAME })).toBeVisible();
await fresh.keyboard.press("Escape");
await expect(column(fresh, "Name")).toHaveAttribute("aria-sort", sorted ?? "");
await fresh.context().close();
} finally {
await page.request.delete(`/api/files/${id}`);
await page.request.put("/api/me/preferences", { data: { filesSort: ownSort } });
}
},
);
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();
await clickVisibleChannelLink(page);
const title = await page.getByTestId("channel-title").textContent();
await tab(page, "About").click();
const fresh = await bookmarked(browser, page);
await expect(fresh.getByTestId("channel-title")).toHaveText(title ?? "");
await expect(tab(fresh, "About")).toHaveAttribute("aria-selected", "true");
await fresh.context().close();
});
test("the player and the video it plays", async ({ page, browser }) => {
await openApp(page, "/?page=feed");
await openFirstVideo(page);
await expect(page).toHaveURL(/[?&]watch=/);
const watch = new URL(page.url()).searchParams.get("watch");
const fresh = await bookmarked(browser, page);
await expect(fresh.getByTestId("player-modal")).toBeVisible();
expect(new URL(fresh.url()).searchParams.get("watch")).toBe(watch);
await fresh.context().close();
});
// The search itself never reaches YouTube here: the answer is the server's shape, served by the
// test, and the request it answers is what the address named. The instance's search settings are
// pinned too (the free source, switched on): a stack left on the API source reopens the search
// waiting for its press, by design, and one with search off offers none.
test(
"a live YouTube search, its term and its count",
{ tag: "@feed" },
async ({ page, browser }) => {
const answer = async (p: Page, seen: string[]) => {
await p.route("**/api/me", async (route) => {
const me = (await (await route.fetch()).json()) as Record<string, unknown>;
return route.fulfill({ json: { ...me, search_source: "scrape", search_enabled: true } });
});
await p.route("**/api/search/youtube?**", (route) => {
seen.push(route.request().url());
return route.fulfill({ json: { items: [], next_cursor: null, source: "scrape" } });
});
};
await answer(page, []);
await openApp(page, "/?page=feed");
await page.getByPlaceholder("Search your subscriptions…").fill("lofi");
await page.getByRole("button", { name: "Search YouTube", exact: true }).click();
await expect(page.getByText("YouTube results for “lofi”")).toBeVisible();
await expect(page).toHaveURL(/[?&]yt=lofi/); // named as it opens, not only once the count is set
await page.getByRole("button", { name: "40", exact: true }).click();
await expect(page).toHaveURL(/[?&]ytn=40/);
const seen: string[] = [];
const fresh = await bookmarked(browser, page, (p) => answer(p, seen));
await expect(fresh.getByText("YouTube results for “lofi”")).toBeVisible();
await expect(fresh.getByRole("button", { name: "40", exact: true })).toHaveAttribute(
"aria-pressed",
"true",
);
await expect.poll(() => seen.length).toBeGreaterThan(0);
const asked = new URL(seen[0]!).searchParams;
expect([asked.get("q"), asked.get("limit")]).toEqual(["lofi", "40"]);
await fresh.context().close();
},
);
});
+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();
}
/**
+2 -1
View File
@@ -898,7 +898,8 @@ export default tseslint.config(
plugins: { playwright },
rules: {
"playwright/no-focused-test": "error",
"playwright/no-skipped-test": "error",
// fixme too: a test Playwright never runs, which the bookmark net would still count.
"playwright/no-skipped-test": ["error", { disallowFixme: true }],
},
},
{
@@ -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.");
});
});
+6 -1
View File
@@ -65,6 +65,7 @@ export default function Modal({
placement = "center",
initialFocus,
headerActions,
url,
}: {
title: ReactNode;
initialFocus: ModalFocus;
@@ -81,10 +82,14 @@ export default function Modal({
placement?: "center" | "drawer" | "drawer-start" | "top";
/** Controls beside the ✕ in the header (a side panel's pin and customize, C2). */
headerActions?: ReactNode;
/** What the dialog names in the address while it is open, when it is part of the view (a viewer, a
* player): written into its own history entry, so a reload or a bookmark reopens it and Back lands
* on the address without it (lib/history). */
url?: Record<string, string | null>;
}) {
const { t } = useTranslation();
// Browser/mouse Back closes the topmost modal instead of navigating away.
useBackToClose(onClose);
useBackToClose(onClose, url);
// ONE container element for the dialog's whole life, re-parented between <body> and the fullscreen
// element — and attached before the children commit, so an `autoFocus` child
// still gets the focus. Both rules live in the hook; `ui/AnchoredPopover` shares 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;
+3 -2
View File
@@ -10,8 +10,9 @@ import { MEDIA_CHOSEN } from "./mediaChrome";
// Below it a side rail leaves a phone too little width for the content beside it (measured at 390 px:
// the rail clipped its own labels and Settings → Account pushed its buttons over their text), so there
// it is a horizontal row that scrolls. A page whose sub-tab is worth keeping persists it with
// lib/storage's useTabPersistedState (per browser tab: Admin Users, Config, Settings, Stats):
// a reload keeps the user where they were, and a fresh tab opens on the first sub-tab. Not every
// lib/storage's useTabPersistedState (per browser tab: Admin Users, Config, Settings) and names it in
// the address: a reload keeps the user where they were, a link opens its tab, and a fresh tab opens on
// the first sub-tab. Not every
// caller: the channel manager's view is account-wide (ChannelsProvider), and Downloads, a channel page
// and the player settings start on their first tab each time.
//
+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,37 @@
[
{
"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",
"new": [
"The address names what you see: tabs, list views, sorts and searches (the live YouTube search too), the YouTube player and the file viewer. A reload, a bookmark or a shared link opens the same view.",
"A link to a video reopens the player over its page once; Back closes it. A video the page no longer lists plays on its own and says so."
],
"fixed": [
"Back and Forward step through the views as you saw them, across dialogs, players and reloads: a reload under an open player no longer leaves a Back step that does nothing.",
"Signed out on a Plex page, the sign-in card could show a button with no text.",
"The channel page's address names the channel and its own view, not the page under it.",
"A reload during a Plex binge reopens the episode playing now, not the one it started from."
]
},
{
"version": "0.112.1",
"date": "2026-10-27",
@@ -1,4 +1,37 @@
[
{
"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",
"new": [
"A cím megnevezi, amit látsz: a füleket, a listanézeteket, a rendezést és a kereséseket (az élő YouTube-keresést is), a YouTube-lejátszót és a fájlnézőt. Újratöltés, könyvjelző vagy megosztott link ugyanazt a nézetet nyitja meg.",
"Egy videóra mutató link egyszer újranyitja a lejátszót az oldala fölött, a Vissza bezárja. Ha az oldal már nem listázza a videót, a lejátszó csak azt az egyet játssza le, és ezt jelzi is."
],
"fixed": [
"A Vissza és az Előre úgy lép végig a nézeteken, ahogy láttad őket, párbeszédablakokon, lejátszókon és újratöltésen át: nyitott lejátszó alatti újratöltés után nem marad semmit sem csináló Vissza-lépés.",
"Plex-oldalon kijelentkezve a bejelentkező kártyán felirat nélküli gomb jelenhetett meg.",
"A csatornaoldal címe a csatornát és annak saját nézetét nevezi meg, nem az alatta lévő oldalt.",
"Plexben sorozatnézés közben az újratöltés az épp futó epizódot nyitja meg, nem azt, amelyikkel kezdted."
]
},
{
"version": "0.112.1",
"date": "2026-10-27",
+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"));
},
};
}
@@ -27,6 +27,8 @@ vi.mock("react-i18next", async (importOriginal) => {
import { NavigationProvider } from "./NavigationProvider";
import { useNavigation, useNavigationActions } from "./navigationContext";
import { urlPage } from "./urlPage";
import { channelUrl } from "../../lib/urlState";
import { useBackToClose } from "../../lib/history";
import { PageHeading } from "../../components/ui/Headings";
@@ -167,6 +169,19 @@ describe("NavigationProvider · setPage's same-page guard", () => {
expect(window.location.search).toBe("?page=plex&keep=1");
});
it("leaves the keys of the modules it does not list by hand behind too: Files and the feed's", () => {
// Each module declares its keys (`urlKeys`), and the switch clears the union of them: a key
// missing from a hand-kept list rode along onto the next page's entry.
window.history.replaceState(
{ sfPage: "files" },
"",
"/?page=files&folder=all&item=5&q=music&sort=oldest&keep=1",
);
const { result } = render();
act(() => result.current.setPage("plex"));
expect(window.location.search).toBe("?page=plex&keep=1");
});
it("pushes nothing when it is already that page", () => {
const { result } = render();
act(() => result.current.setPage("plex"));
@@ -195,6 +210,93 @@ describe("NavigationProvider · setPage's same-page guard", () => {
});
});
describe("NavigationProvider · a link carrying another page's keys", () => {
it("opens with only its own page's keys: what the page does not show, its address does not say", () => {
window.history.replaceState(
{},
"",
"/?page=activity&at=logs&folder=all&ctab=hidden&q=music&pscope=movie&pl=3&keep=1",
);
render();
expect(window.location.search).toBe("?page=activity&at=logs&keep=1");
});
it("keeps the channel page open over any page", () => {
window.history.replaceState({}, "", "/?page=stats&chan=UC1&folder=all");
const { result } = render();
expect(window.location.search).toBe("?page=stats&chan=UC1");
expect(result.current.channelView?.id).toBe("UC1");
});
it("opens a channel link with only the channel page's keys, even over the page they belong to", () => {
window.history.replaceState({}, "", "/?q=music&sort=oldest&chan=UC1&chtab=about&chtypes=ns");
render();
expect(window.location.search).toBe("?chan=UC1&chtab=about&chtypes=ns&page=feed");
});
});
// The channel page has an address of its own (the owner, 2026-10-05): it used to take the feed's
// filters along (`?q=music&sort=oldest&chan=…`), naming a sort the channel did not show.
describe("NavigationProvider · the channel page's address", () => {
it("names only the channel; the page under it keeps its own address for Back", async () => {
window.history.replaceState({ sfPage: "feed" }, "", "/?q=music&sort=oldest");
const { result } = render();
act(() => result.current.openChannel("UC1", "One"));
expect(window.location.search).toBe("?page=feed&chan=UC1");
act(() => window.history.back());
await vi.waitFor(() => expect(result.current.channelView).toBeNull());
expect(window.location.search).toBe("?q=music&sort=oldest&page=feed");
});
it("says no page owns the address while the channel stands over it", () => {
window.history.replaceState({ sfPage: "plex" }, "", "/?page=plex");
const { result } = render();
expect(result.current.page).toBe("plex");
expect(urlPage(result.current)).toBe("plex");
act(() => result.current.openChannel("UC1", "One"));
expect(urlPage(result.current)).toBeNull();
});
it("another channel opened from it starts fresh", () => {
const { result } = render();
act(() => result.current.openChannel("UC1", "One"));
window.history.replaceState(window.history.state, "", "/?chan=UC1&chtab=about&chsort=oldest");
act(() => result.current.openChannel("UC2", "Two"));
expect(window.location.search).toBe("?chan=UC2");
});
it("a page switch takes the channel page's keys with it", () => {
window.history.replaceState({}, "", "/?chan=UC1&chtab=about&chsort=oldest&chtypes=n");
const { result } = render();
act(() => result.current.setPage("stats"));
expect(window.location.search).toBe("?page=stats");
});
it("closing it in place takes its keys with it", () => {
window.history.replaceState({}, "", "/?chan=UC1&chtab=about&chtypes=n");
const { result } = render();
stubNavigation([false]);
act(() => result.current.closeChannel());
expect(window.location.search).toBe("?page=feed");
});
});
// The YouTube player opens over any page and names itself in its own entry (lib/playerUrlState).
describe("NavigationProvider · the player's address", () => {
it("a page switch takes a player the address still names with it", () => {
window.history.replaceState({}, "", "/?q=music&watch=v1&wrel=v2&wtab=related");
const { result } = render();
act(() => result.current.setPage("stats"));
expect(window.location.search).toBe("?page=stats");
});
it("a channel link names no player: opening the channel closes it", () => {
window.history.replaceState({}, "", "/?q=music&watch=v1&wrel=v2&wtab=related");
render();
expect(channelUrl("UC1")).toBe("/?page=feed&chan=UC1");
});
});
describe("NavigationProvider · Back", () => {
// A tab that had a since-removed module open, reloaded onto the new build: its older history
// entries still name that page, and Back lands on one of them.
@@ -208,7 +310,7 @@ describe("NavigationProvider · Back", () => {
expect(result.current.page).toBe("feed");
// …and the entry says so too, so the address bar and a later Feed click agree with the screen.
expect(window.history.state).toMatchObject({ sfPage: "feed" });
expect(window.location.search).toBe("?keep=1");
expect(window.location.search).toBe("?page=feed&keep=1");
});
it("lands where a retired page's content went, and the entry names that page", () => {
@@ -325,7 +427,7 @@ describe("NavigationProvider · a page the account cannot open", () => {
window.history.replaceState(null, "", "/?page=scheduler&keep=1");
const { result } = render();
expect(result.current.page).toBe("feed");
expect(window.location.search).toBe("?keep=1");
expect(window.location.search).toBe("?page=feed&keep=1");
expect(window.history.state).toMatchObject({ sfPage: "feed" });
});
@@ -361,7 +463,7 @@ describe("NavigationProvider · a page the account cannot open", () => {
qc.setQueryData(qk.me(), member);
});
await settle();
expect(window.location.search).toBe("");
expect(window.location.search).toBe("?page=feed");
expect(window.history.state).toMatchObject({ sfPage: "feed" });
expect(getAccountRaw(PAGE_KEY)).toBe("feed");
act(() => {
@@ -381,7 +483,7 @@ describe("NavigationProvider · a page the account cannot open", () => {
});
await settle();
expect(result.current.channelView).toBeNull();
expect(window.location.search).toBe("");
expect(window.location.search).toBe("?page=feed");
expect(window.history.state?._chan).toBeUndefined();
});
});
@@ -0,0 +1,57 @@
// @vitest-environment jsdom
//
// A reload under a dialog that does not reopen (About) loads on the dialog's entry, its overlay mark
// still on it. A view rewritten in place there (the search refined) is another view: the next overlay
// pushes its own entry over it rather than taking this one over — taken over, closing it stepped back
// onto the entry below, and the screen went back to the search before the refine. Its own file: the
// entry is read as the history module loads, so it is set up before the import.
import { describe, expect, it, vi } from "vitest";
import { act, renderHook } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ReactNode } from "react";
vi.mock("react-i18next", async (importOriginal) => ({
...(await importOriginal<typeof import("react-i18next")>()),
useTranslation: () => ({ t: (k: string) => k, i18n: { language: "en" } }),
}));
describe("a view rewritten in place on the entry a reload under a dialog started from", () => {
it("is where an overlay over it closes to", async () => {
window.history.replaceState({ sfPage: "feed", _yt: "dogs" }, "", "/?page=feed&yt=dogs");
window.history.pushState(
{ sfPage: "feed", _yt: "dogs", _ov: 1, _ovd: 1 },
"",
"/?page=feed&yt=dogs",
);
const { NavigationProvider } = await import("./NavigationProvider");
const { useNavigation, useNavigationActions } = await import("./navigationContext");
const { useBackToClose } = await import("../../lib/history");
const { qk } = await import("../../lib/queryKeys");
const { claimLocalData } = await import("../../lib/storage");
const qc = new QueryClient();
qc.setQueryData(qk.me(), { id: 1, role: "admin" });
claimLocalData(1);
const wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={qc}>
<NavigationProvider>{children}</NavigationProvider>
</QueryClientProvider>
);
const nav = renderHook(() => ({ ...useNavigation(), ...useNavigationActions() }), { wrapper });
act(() => nav.result.current.enterYtSearch("cats"));
const depth = window.history.length;
const overlay = renderHook(() => useBackToClose(() => {}));
await act(async () => {
await Promise.resolve();
});
expect(window.history.length).toBe(depth + 1);
await act(async () => {
const landed = new Promise((res) => window.addEventListener("popstate", res, { once: true }));
overlay.unmount();
await landed;
});
expect(new URLSearchParams(window.location.search).get("yt")).toBe("cats");
expect(nav.result.current.ytSearch).toBe("cats");
}, 30_000); // the provider's whole import graph loads inside the case (see the header)
});
@@ -2,8 +2,13 @@ import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react
import { useLatest } from "../../lib/useLatest";
import { useTranslation } from "react-i18next";
import { useMe } from "../../lib/useMe";
import { resolvePage, retiredParams, urlWithParams, type Page } from "../../lib/urlState";
import { ACTIVITY_KEYS } from "../../lib/activityUrlState";
import {
channelUrl,
resolvePage,
retiredParams,
urlWithParams,
type Page,
} from "../../lib/urlState";
import { useDocumentObjectTitle } from "../../lib/documentTitle";
import { getAccountRaw, setAccountRaw } from "../../lib/storage";
import {
@@ -12,8 +17,17 @@ import {
nextNavId,
notifyNav,
rememberNavPosition,
replaceView,
} from "../../lib/history";
import { pageTitleKey, reachablePage } from "./moduleRegistry";
import { foreignUrlKeys, moduleUrlKeys, pageTitleKey, reachablePage } from "./moduleRegistry";
import { CHANNEL_PAGE_KEYS } from "../../lib/channelPageUrlState";
import { PLAYER_KEYS } from "../../lib/playerUrlState";
import {
DEFAULT_YT_COUNT,
ytCountFrom,
ytSearchFrom,
ytSearchParams,
} from "../../lib/ytSearchUrlState";
import {
channelViewFrom,
loadInitialPage,
@@ -37,24 +51,35 @@ import {
//
// The contexts, their value types and the hooks live in navigationContext.ts.
/** The address bar of `page`'s root: the page (feed implicit) with every view-specific key cleared. */
/** The address bar of `page`'s root: the page — the feed too: a bare address is the landing (Home, or
* the remembered page), so a feed left unnamed reloaded or bookmarked as another page — no channel or
* player over it, and every module's own keys cleared — a page switch resets to that module's root,
* and its store mirrors what it holds back in. */
function pageRootParams(page: Page): Record<string, string | null> {
return {
page: page === "feed" ? null : page,
chan: null,
v: null,
pl: null,
pscope: null,
psort: null,
pshow: null,
pf: null,
ctab: null,
csort: null,
cf: null,
...Object.fromEntries(ACTIVITY_KEYS.map((k) => [k, null])),
page,
...closedChannelParams(),
...nulls(PLAYER_KEYS),
...nulls(moduleUrlKeys()),
};
}
/** The address of the page under a live search, the search ended: no search, player or channel. */
const searchEndParams = (): Record<string, string | null> => ({
...closedChannelParams(),
...nulls(PLAYER_KEYS),
...ytSearchParams(null, DEFAULT_YT_COUNT),
});
const nulls = (keys: readonly string[]) => Object.fromEntries(keys.map((k) => [k, null]));
/** No channel page: neither `chan` nor its view's keys, which the page itself writes (ChannelPage) —
* a closed channel leaves none behind. (An opened one gets its address from `channelUrl`.) */
const closedChannelParams = (): Record<string, string | null> => ({
chan: null,
...nulls(CHANNEL_PAGE_KEYS),
});
export function NavigationProvider({ children }: { children: ReactNode }) {
const { t, i18n } = useTranslation();
// Held here as well as in Settings: this is the instance mounted on every boot, so a value only
@@ -66,18 +91,30 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
// and the address bar (stamped from it below) agree.
const me = useMe();
const page = reachablePage(chosenPage, me);
// Ephemeral: not persisted and not in the URL, so a reload returns to the normal feed (a search
// spends quota, so we never auto-replay it) — UNLESS it was zero-quota scrape-sourced, which rides
// in history.state and is free to re-fetch. The popstate handler derives it from history.state so
// Back steps player → search → feed instead of the search vanishing without a history entry.
const [ytSearch, setYtSearch] = useState<string | null>(() => {
const st = window.history.state;
return st?._yt && st?._ytScrape ? (st._yt as string) : null;
});
// The live YouTube search: named in the address (`yt`, and `ytn` for its count, lib/ytSearchUrlState)
// so a reload or a link opens it again; its own history entry, so Back steps player → search → feed.
// Whether it RUNS by itself is not the address's to say: `ytAsked` is the term this document was
// asked to search for (sent from the search box, or "Search YouTube" pressed), on the entries
// it has stayed on since — leaving the search ends it. Anything else — a reload, a link, a Back or
// a Forward onto a search — runs only when it costs no quota (Feed decides).
const [ytSearch, setYtSearch] = useState<string | null>(() =>
page === "feed" ? ytSearchFrom(new URLSearchParams(window.location.search)) : null,
);
const [ytCount, setYtCountState] = useState(() =>
ytCountFrom(new URLSearchParams(window.location.search)),
);
const [ytAsked, setYtAsked] = useState<string | null>(null);
const ytSearchRef = useLatest(ytSearch);
const ytCountRef = useLatest(ytCount);
// The search ends: off the screen, and no longer asked for.
const endYtSearch = useCallback(() => {
setYtSearch(null);
setYtAsked(null);
}, []);
// The open channel page rides in history.state (_chan = id, _chanName = a name to show before the
// detail loads) AND is mirrored to the address bar as ?chan=<id>, so Back closes it, a reload restores
// it, and a bookmarked ?chan link opens it (unlike ytSearch, which is dropped). Reload of an in-app
// entry reads history.state._chan; a fresh bookmark has only the ?chan param.
// it, and a bookmarked ?chan link opens it. Reload of an in-app entry reads history.state._chan; a
// fresh bookmark has only the ?chan param.
const [channelView, setChannelView] = useState<ChannelView | null>(() =>
channelViewFrom(window.history.state, window.location.search),
);
@@ -107,31 +144,36 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
closeOverlaysThenNavigate(() => {
// A channel page overlays the content column, so a nav click must close it even when the
// underlying page is unchanged (e.g. "Feed" while a channel opened from the feed).
if (next === pageRef.current && !channelViewRef.current) {
// So must a live search on it, though that press ends only the search: the page's view stays.
const samePage = next === pageRef.current && !channelViewRef.current;
if (samePage && ytSearchRef.current == null) {
then?.();
return;
}
setChannelView(null); // leave any open channel page when navigating via the rail
// A live search under that channel ends too: the page opens at its root, which the address
// below names — the Feed press over a channel opened from a search included.
endYtSearch();
setPageState(next);
setAccountRaw(PAGE_KEY, next);
// Push an in-app history entry so browser/mouse Back steps through pages instead of leaving
// the app. A fresh { sfPage } (no carried-over _yt/_chan markers) so each page starts at its
// root, and the address bar reflects the page (feed is implicit) while clearing the
// view-specific params (chan/v/pl and the plex content keys pscope/psort/pshow/pf) — a page
// switch resets to that module's root. Feed filters (owned by FeedFiltersProvider) are left
// intact; the plex/channels keys re-mirror from PlexProvider / ChannelsProvider on return to
// their page. The item it was opened from is still focused: the state setters above have not
// re-rendered anything yet.
// root, and the address bar reflects the page (the feed too) while clearing `chan` and
// every module's own keys (pageRootParams) — a page switch resets to that module's root, and
// each module's store mirrors its state back in on its page. The item it was opened from is
// still focused: the state setters above have not re-rendered anything yet.
// The press on a search over its own page ends only the search: the page's view is the one
// on screen and stays named — its store, unchanged, writes nothing back in.
rememberNavPosition();
window.history.pushState(
{ sfPage: next, sfScroll: nextNavId() },
"",
urlWithParams(pageRootParams(next)),
urlWithParams(samePage ? searchEndParams() : pageRootParams(next)),
);
notifyNav();
then?.();
}),
[pageRef, channelViewRef],
[pageRef, channelViewRef, ytSearchRef, endYtSearch],
);
// A sub-view (channel / YT search) is an OVERLAY, not a page change. Like setPage it never lands
@@ -139,10 +181,12 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
// inside one.
const openChannel = useCallback((id: string, name?: string) => {
closeOverlaysThenNavigate(() => {
const url = urlWithParams({ chan: id });
// The channel's address names the channel alone: the page under it keeps its own keys on its
// own entry, where Back finds them, and writes none while the channel stands over it (urlPage).
const url = channelUrl(id);
if (window.history.state?._chan) {
const st = window.history.state;
window.history.replaceState({ ...st, _chan: id, _chanName: name ?? null }, "", url);
replaceView({ ...st, _chan: id, _chanName: name ?? null }, url);
} else {
const st = rememberNavPosition();
window.history.pushState({ ...st, _chan: id, _chanName: name ?? null }, "", url);
@@ -157,36 +201,67 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
// No owned history entry (e.g. opened straight from a ?chan bookmark), or none of this document
// below it to step back to (a full tab, see backStaysInDocument): clear it in place — the
// entry's channel marks and the ?chan param.
// A live search under it (the channel opened from its results) is on screen again, and named
// again: the channel's address carried none of it.
setChannelView(null);
const { _chan: _id, _chanName: _name, ...st } = window.history.state ?? {};
window.history.replaceState(st, "", urlWithParams({ chan: null }));
replaceView(
st,
urlWithParams({
...closedChannelParams(),
...ytSearchParams(ytSearchRef.current, ytCountRef.current),
}),
);
}
}, []);
}, [ytSearchRef, ytCountRef]);
const enterYtSearch = useCallback((q: string) => {
setYtSearch(q);
// Drop any prior _ytScrape marker — the new search's source isn't known yet; Feed re-stamps it
// once the results resolve, so a reload only restores a confirmed scrape-sourced search.
const { _ytScrape: _drop, ...st } = window.history.state || {};
if (st._yt)
window.history.replaceState({ ...st, _yt: q }, ""); // refine current search
else {
// New sub-view entry.
const { _ytScrape: _dropToo, ...left } = rememberNavPosition();
window.history.pushState({ ...left, sfPage: "feed", _yt: q }, "");
setYtAsked(q);
const st = window.history.state ?? {};
const here = new URLSearchParams(window.location.search);
if (st._yt || ytSearchFrom(here) != null) {
// Refine the search on screen, in place, at the count it shows (one a link opened too: it
// is the first entry of this document, so its new `_yt` mark has nothing of ours below it).
replaceView({ ...st, _yt: q }, urlWithParams(ytSearchParams(q, ytCountFrom(here))));
} else {
// New sub-view entry, at the default count — ours (`_ytOwn`): leaving it steps back off it.
setYtCountState(DEFAULT_YT_COUNT);
const left = rememberNavPosition();
window.history.pushState(
{ ...left, sfPage: "feed", _yt: q, _ytOwn: 1 },
"",
urlWithParams(ytSearchParams(q, DEFAULT_YT_COUNT)),
);
}
}, []);
// The press that runs the search on screen, where it did not run by itself.
const askYtSearch = useCallback(() => setYtAsked(ytSearchRef.current), [ytSearchRef]);
const setYtCount = useCallback(
(n: number) => {
setYtCountState(n);
replaceView(
window.history.state ?? {},
urlWithParams(ytSearchParams(ytSearchRef.current, n)),
);
},
[ytSearchRef],
);
const exitYtSearch = useCallback(() => {
// Pop our search entry (so Forward still works); fall back to a clear in place if we don't own
// one, or there is no entry of this document below it (a full tab, see backStaysInDocument).
if (window.history.state?._yt && backStaysInDocument()) window.history.back();
// one — a search a link or a reload opened is marked (`_yt`) but was pushed by nobody: below it is
// another site, or nothing — or there is no entry of this document below it (a full tab, see
// backStaysInDocument).
if (window.history.state?._ytOwn && backStaysInDocument()) window.history.back();
else {
setYtSearch(null);
const { _yt: _q, _ytScrape: _scrape, ...st } = window.history.state ?? {};
window.history.replaceState(st, "");
endYtSearch();
const { _yt: _q, _ytOwn: _own, ...st } = window.history.state ?? {};
replaceView(st, urlWithParams(ytSearchParams(null, DEFAULT_YT_COUNT)));
}
}, []);
}, [endYtSearch]);
// Leaving the feed ends any live YouTube search, so coming back shows the normal feed. Adjusted
// DURING the render that changes the page, against the page we were last on: as an effect it ran
@@ -196,7 +271,7 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
const [pageSearchWasOn, setPageSearchWasOn] = useState(page);
if (pageSearchWasOn !== page) {
setPageSearchWasOn(page);
if (page !== "feed") setYtSearch(null);
if (page !== "feed") endYtSearch();
}
// The browser tab names the place and the object in it (DESIGN § Composition, "Where am I"):
@@ -233,25 +308,33 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
// then sync state from history.state on popstate — the browser restores each entry's URL for us, so
// the popstate handler only needs to read history.state.
useEffect(() => {
// A reload drops a stale _yt EXCEPT when scrape-sourced (zero quota): then keep _yt + _ytScrape
// so it restores and re-fetches free (ytSearch above already initialised from it). _chan/_chanName
// are always kept so a channel page survives F5.
// The entry of a search the address names carries its `_yt` mark, whichever way it opened (sent
// from the box, a reload, a link): a channel opened from its results reads the search under it
// from that mark. A mark left without one (an older build's entry) goes. _chan/_chanName are
// always kept so a channel page survives F5.
const st = window.history.state || {};
// Mirror the resolved initial page to the address bar (feed implicit) so a page reached from the
// stored landing pref — not just from the URL — is itself bookmarkable. urlWithParams preserves any
// ?chan / ?v / ?pl / filter params the loaders already read.
// Mirror the resolved initial page to the address bar (the feed too) so a page reached from the
// stored landing pref — not just from the URL — is itself bookmarkable. urlWithParams keeps the
// shell's keys (?chan, a player's) and the page's own; other pages' go (below).
// A retired page's link — or the remembered last page, when that is what opened — also opens its
// part of the page that replaced it (an `at` tab).
const linked = new URLSearchParams(window.location.search).get("page");
const remembered = getAccountRaw(PAGE_KEY);
// A link can carry another page's keys (hand-edited, or written by an older build): the page
// opens without them, so its address names only what it shows — and a channel page open over it
// shows none of the page's own (an older build's channel links carried the feed's filters).
const shown = channelViewFrom(st, window.location.search);
const url = urlWithParams({
...nulls(shown ? moduleUrlKeys() : foreignUrlKeys(mountPage)),
...retiredParams(linked ?? (resolvePage(remembered) === mountPage ? remembered : null)),
page: mountPage === "feed" ? null : mountPage,
page: mountPage,
});
if (st._yt && st._ytScrape) {
window.history.replaceState({ ...st, sfPage: mountPage }, "", url);
const named =
mountPage === "feed" ? ytSearchFrom(new URLSearchParams(window.location.search)) : null;
if (named != null) {
window.history.replaceState({ ...st, _yt: named, sfPage: mountPage }, "", url);
} else {
const { _yt: _staleYt, _ytScrape: _staleScrape, ...rest } = st;
const { _yt: _staleYt, _ytScrape: _staleScrape, _ytOwn: _staleOwn, ...rest } = st;
window.history.replaceState({ ...rest, sfPage: mountPage }, "", url);
}
function onPop(e: PopStateEvent) {
@@ -267,13 +350,22 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
window.history.replaceState(
{ ...window.history.state, sfPage: p },
"",
urlWithParams({ ...retiredParams(sf), page: p === "feed" ? null : p }),
urlWithParams({ ...retiredParams(sf), page: p }),
);
setPageState(p);
setAccountRaw(PAGE_KEY, p);
// The YouTube-search sub-view and the channel page ride in history.state, so Back/Forward
// restores or clears them to match the entry we landed on.
setYtSearch((e.state?._yt as string) ?? null);
// The YouTube search is read from the address the browser restored, so Back/Forward opens or
// clears it to match the entry we landed on. One landed on without it has ended: a Forward back
// onto it is not asked for, and an API search waits for a press.
// A channel page opened from its results stands over it: its entry carries the search's mark
// (its address does not), and the search stays under it — closing a player over that channel
// lands there, and is no leaving of the search.
const here = new URLSearchParams(window.location.search);
const under = e.state?._chan && typeof e.state._yt === "string" ? e.state._yt : null;
const landed = p === "feed" ? (ytSearchFrom(here) ?? under) : null;
setYtSearch(landed);
if (landed == null) setYtAsked(null);
setYtCountState(ytCountFrom(here));
// The channel page rides in history.state._chan, with a ?chan URL fallback for a fresh bookmark
// entry (URL-only, no _chan) — reading e.state alone would wrongly close the channel when a
// popstate lands here (e.g. after an overlay's programmatic history.back()). channelViewFrom
@@ -285,12 +377,26 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
}, [mountPage]);
const state = useMemo<NavigationState>(
() => ({ page, channelView, ytSearch }),
[page, channelView, ytSearch],
() => ({
page,
channelView,
ytSearch,
ytCount,
ytSearchAsked: ytSearch != null && ytAsked === ytSearch,
}),
[page, channelView, ytSearch, ytCount, ytAsked],
);
const actions = useMemo<NavigationActions>(
() => ({ setPage, openChannel, closeChannel, enterYtSearch, exitYtSearch }),
[setPage, openChannel, closeChannel, enterYtSearch, exitYtSearch],
() => ({
setPage,
openChannel,
closeChannel,
enterYtSearch,
exitYtSearch,
askYtSearch,
setYtCount,
}),
[setPage, openChannel, closeChannel, enterYtSearch, exitYtSearch, askYtSearch, setYtCount],
);
return (
@@ -0,0 +1,320 @@
// @vitest-environment jsdom
//
// The live YouTube search in the address: sent from the search box it gets its own entry naming the
// term (and its count), a reload or a link opens it again, Back/Forward read it from the address the
// browser restored — and only a search asked for in this document counts as asked (an API search
// reopened any other way waits for a press, Feed's part).
import { describe, it, expect, afterEach, beforeEach, vi } from "vitest";
import { renderHook, act, cleanup } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ReactNode } from "react";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return {
...actual,
useTranslation: () => ({ t: (k: string) => k, i18n: { language: "en" } }),
};
});
import { NavigationProvider } from "./NavigationProvider";
import { useNavigation, useNavigationActions } from "./navigationContext";
import { qk } from "../../lib/queryKeys";
import { claimLocalData } from "../../lib/storage";
let qc = new QueryClient();
const wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={qc}>
<NavigationProvider>{children}</NavigationProvider>
</QueryClientProvider>
);
const render = () =>
renderHook(() => ({ ...useNavigation(), ...useNavigationActions() }), { wrapper });
const search = () => new URLSearchParams(window.location.search);
/** No entry of this document under the current one (a bookmark opened in a new tab). */
function stubNoEntryBelow() {
Object.defineProperty(window, "navigation", {
configurable: true,
value: {
currentEntry: { index: 1 },
entries: () => [{ sameDocument: false }, { sameDocument: true }],
},
});
}
beforeEach(() => {
qc = new QueryClient();
qc.setQueryData(qk.me(), { id: 1, role: "admin", plex_enabled: true });
localStorage.clear();
claimLocalData(1);
window.history.replaceState({ sfPage: "feed" }, "", "/?page=feed&q=lofi");
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
Reflect.deleteProperty(window, "navigation");
});
describe("NavigationProvider · the YouTube search in the address", () => {
it("names a search sent from the box on an entry of its own, over the feed's", () => {
const { result } = render();
const depth = window.history.length;
act(() => result.current.enterYtSearch("lofi"));
expect(window.history.length).toBe(depth + 1);
expect(search().get("yt")).toBe("lofi");
expect(search().get("q")).toBe("lofi"); // the feed's own keys stay
expect(search().get("ytn")).toBeNull(); // the default count is left out
expect(result.current.ytSearch).toBe("lofi");
expect(result.current.ytSearchAsked).toBe(true);
});
it("refines the search on screen in place, at the count it shows", () => {
const { result } = render();
act(() => result.current.enterYtSearch("lofi"));
act(() => result.current.setYtCount(60));
expect(search().get("ytn")).toBe("60");
const depth = window.history.length;
act(() => result.current.enterYtSearch("lofi beats"));
expect(window.history.length).toBe(depth);
expect(search().get("yt")).toBe("lofi beats");
expect(search().get("ytn")).toBe("60");
expect(result.current.ytCount).toBe(60);
});
it("a new search starts at the default count", () => {
const { result } = render();
act(() => result.current.enterYtSearch("lofi"));
act(() => result.current.setYtCount(40));
stubNoEntryBelow(); // left in place, so the next search is a new entry
vi.spyOn(window.history, "back").mockImplementation(() => {});
act(() => result.current.exitYtSearch());
act(() => result.current.enterYtSearch("jazz"));
expect(result.current.ytCount).toBe(20);
expect(search().get("ytn")).toBeNull();
});
it("writes the count, and clears it back at the default", () => {
const { result } = render();
act(() => result.current.enterYtSearch("lofi"));
act(() => result.current.setYtCount(100));
expect(search().get("ytn")).toBe("100");
act(() => result.current.setYtCount(20));
expect(search().get("ytn")).toBeNull();
});
it("reopens the search a reload or a link names, not as asked", () => {
window.history.replaceState(null, "", "/?page=feed&yt=lofi&ytn=40");
const { result } = render();
expect(result.current.ytSearch).toBe("lofi");
expect(result.current.ytCount).toBe(40);
expect(result.current.ytSearchAsked).toBe(false);
expect(search().get("yt")).toBe("lofi"); // the mount's own stamp keeps it
act(() => result.current.askYtSearch());
expect(result.current.ytSearchAsked).toBe(true);
});
it("refines a search a link opened in place too, at the count it shows", () => {
window.history.replaceState(null, "", "/?page=feed&yt=lofi&ytn=40");
const { result } = render();
const depth = window.history.length;
act(() => result.current.enterYtSearch("jazz"));
expect(window.history.length).toBe(depth);
expect([search().get("yt"), search().get("ytn")]).toEqual(["jazz", "40"]);
});
it("Back onto an entry whose address alone names the search reopens it", () => {
const { result } = render();
window.history.replaceState({ sfPage: "feed" }, "", "/?page=feed&yt=lofi");
act(() => {
window.dispatchEvent(new PopStateEvent("popstate", { state: window.history.state }));
});
expect(result.current.ytSearch).toBe("lofi");
});
it("keeps the search mark of an entry the reload started from", () => {
window.history.replaceState({ sfPage: "feed", _yt: "lofi" }, "", "/?page=feed&yt=lofi");
render();
expect(window.history.state?._yt).toBe("lofi");
});
it("marks the entry of a search the address opened (a reload, a link)", () => {
window.history.replaceState({ sfPage: "feed" }, "", "/?page=feed&yt=lofi");
render();
expect(window.history.state?._yt).toBe("lofi");
});
it("drops an older build's search mark the address does not name", () => {
window.history.replaceState(
{ sfPage: "feed", _yt: "lofi", _ytScrape: true },
"",
"/?page=feed",
);
const { result } = render();
expect(result.current.ytSearch).toBeNull();
expect(window.history.state?._yt).toBeUndefined();
expect(window.history.state?._ytScrape).toBeUndefined();
});
it("names no search under another page", () => {
window.history.replaceState(null, "", "/?page=plex&yt=lofi");
const { result } = render();
expect(result.current.page).toBe("plex");
expect(result.current.ytSearch).toBeNull();
expect(search().get("yt")).toBeNull();
});
it("Back/Forward read the search from the address the browser restored, not as asked", () => {
const { result } = render();
act(() => result.current.enterYtSearch("lofi"));
window.history.replaceState({ sfPage: "feed", _yt: "jazz" }, "", "/?page=feed&yt=jazz&ytn=60");
act(() => {
window.dispatchEvent(new PopStateEvent("popstate", { state: window.history.state }));
});
expect(result.current.ytSearch).toBe("jazz");
expect(result.current.ytCount).toBe(60);
expect(result.current.ytSearchAsked).toBe(false);
window.history.replaceState({ sfPage: "feed" }, "", "/?page=feed&q=lofi");
act(() => {
window.dispatchEvent(new PopStateEvent("popstate", { state: window.history.state }));
});
expect(result.current.ytSearch).toBeNull();
});
it("leaving a search with no entry of this document below clears it from the address", () => {
window.history.replaceState(null, "", "/?page=feed&yt=lofi&ytn=40");
const { result } = render();
stubNoEntryBelow();
const back = vi.spyOn(window.history, "back").mockImplementation(() => {});
act(() => result.current.exitYtSearch());
expect(back).not.toHaveBeenCalled();
expect(result.current.ytSearch).toBeNull();
expect(search().get("yt")).toBeNull();
expect(search().get("ytn")).toBeNull();
});
it("a search left by Back and reached again by Forward is not asked for", () => {
const { result } = render();
act(() => result.current.enterYtSearch("lofi"));
expect(result.current.ytSearchAsked).toBe(true);
window.history.replaceState({ sfPage: "feed" }, "", "/?page=feed&q=lofi");
act(() => {
window.dispatchEvent(new PopStateEvent("popstate", { state: window.history.state }));
});
window.history.replaceState({ sfPage: "feed", _yt: "lofi" }, "", "/?page=feed&yt=lofi");
act(() => {
window.dispatchEvent(new PopStateEvent("popstate", { state: window.history.state }));
});
expect(result.current.ytSearch).toBe("lofi");
expect(result.current.ytSearchAsked).toBe(false);
});
it("a search left in place is not asked for when it is opened again by a link", () => {
window.history.replaceState(null, "", "/?page=feed&yt=lofi");
const { result } = render();
act(() => result.current.askYtSearch());
stubNoEntryBelow();
act(() => result.current.exitYtSearch());
window.history.replaceState({ sfPage: "feed" }, "", "/?page=feed&yt=lofi");
act(() => {
window.dispatchEvent(new PopStateEvent("popstate", { state: window.history.state }));
});
expect(result.current.ytSearchAsked).toBe(false);
});
it("the Feed press over a channel opened from the search opens the plain feed", () => {
const { result } = render();
act(() => result.current.enterYtSearch("lofi"));
act(() => result.current.openChannel("UC1"));
act(() => result.current.setPage("feed"));
expect(result.current.channelView).toBeNull();
expect(result.current.ytSearch).toBeNull();
expect(search().get("yt")).toBeNull();
});
it("the Feed press on the search itself opens the plain feed, as over a channel", () => {
const { result } = render();
act(() => result.current.enterYtSearch("lofi"));
const depth = window.history.length;
act(() => result.current.setPage("feed"));
expect(result.current.ytSearch).toBeNull();
expect(search().get("yt")).toBeNull();
expect(window.history.length).toBe(depth + 1); // its own entry: Back returns to the search
});
it("the Feed press on the search keeps the feed's own view in the address", () => {
window.history.replaceState({ sfPage: "feed" }, "", "/?page=feed&show=all&sort=oldest");
const { result } = render();
act(() => result.current.enterYtSearch("lofi"));
act(() => result.current.setPage("feed"));
expect(search().get("yt")).toBeNull();
expect([search().get("show"), search().get("sort")]).toEqual(["all", "oldest"]);
});
it("a search sent from the box is left by a step back off its own entry", () => {
const { result } = render();
act(() => result.current.enterYtSearch("lofi"));
const back = vi.spyOn(window.history, "back").mockImplementation(() => {});
act(() => result.current.exitYtSearch());
expect(back).toHaveBeenCalledTimes(1);
});
it("a search a link opened is left in place, not by a step back off the document", () => {
window.history.replaceState(null, "", "/?page=feed&yt=lofi");
const { result } = render();
const back = vi.spyOn(window.history, "back");
act(() => result.current.exitYtSearch());
expect(back).not.toHaveBeenCalled();
expect(result.current.ytSearch).toBeNull();
expect(search().get("yt")).toBeNull();
});
it("a landing on a channel opened from the search (an overlay over it closing) keeps the search", () => {
const { result } = render();
act(() => result.current.enterYtSearch("lofi"));
act(() => result.current.openChannel("UC1"));
// A player over the channel closed: the step back lands on the channel's own entry.
act(() => {
window.dispatchEvent(new PopStateEvent("popstate", { state: window.history.state }));
});
expect(result.current.ytSearch).toBe("lofi");
expect(result.current.ytSearchAsked).toBe(true);
// Back off the channel: the search it stood over is still the one asked for.
window.history.replaceState({ sfPage: "feed", _yt: "lofi" }, "", "/?page=feed&yt=lofi");
act(() => {
window.dispatchEvent(new PopStateEvent("popstate", { state: window.history.state }));
});
expect(result.current.ytSearchAsked).toBe(true);
});
it("a landing on a channel opened from a search the address opened keeps the search", () => {
window.history.replaceState({ sfPage: "feed" }, "", "/?page=feed&yt=lofi");
const { result } = render();
act(() => result.current.openChannel("UC1"));
act(() => {
window.dispatchEvent(new PopStateEvent("popstate", { state: window.history.state }));
});
expect(result.current.ytSearch).toBe("lofi");
});
it("a channel closed in place over the search names the search again", () => {
const { result } = render();
act(() => result.current.enterYtSearch("lofi"));
act(() => result.current.setYtCount(40));
act(() => result.current.openChannel("UC1"));
expect(search().get("yt")).toBeNull();
stubNoEntryBelow();
act(() => result.current.closeChannel());
expect(result.current.ytSearch).toBe("lofi");
expect([search().get("yt"), search().get("ytn")]).toEqual(["lofi", "40"]);
});
it("a page switch takes the search off the address", () => {
const { result } = render();
act(() => result.current.enterYtSearch("lofi"));
act(() => result.current.setPage("home"));
expect(result.current.ytSearch).toBeNull();
expect(search().get("yt")).toBeNull();
});
});
@@ -0,0 +1,111 @@
// @vitest-environment jsdom
//
// The channel page opens over any page, and its address names the channel alone (the owner, 2026-10-05:
// it used to carry the feed's filters, naming a sort the channel did not show). The page under it is
// still mounted — its store lives at the root — and a change in it while the channel stands over it
// (a background refetch, the header search) must not write its keys onto the channel's address. Closing
// the channel brings them back. The real provider and the real stores; only the network is absent.
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { act, cleanup, render, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useEffect } from "react";
vi.mock("react-i18next", async (importOriginal) => ({
...(await importOriginal<typeof import("react-i18next")>()),
useTranslation: () => ({ t: (k: string) => k, i18n: { language: "en" } }),
}));
import { NavigationProvider } from "./NavigationProvider";
import { ModuleStores } from "./ModuleStores";
import { useNavigationActions, type NavigationActions } from "./navigationContext";
import { useFeedFilters, useFeedFiltersActions } from "../../modules/feed";
import { usePlexActions } from "../../modules/plex";
import { useChannelsActions } from "../../modules/channels";
import { useFilesActions } from "../../modules/files";
import { usePlaylistsActions } from "../../modules/playlists";
import { claimLocalData } from "../../lib/storage";
type Probe = {
nav: NavigationActions;
change: Record<string, () => void>;
};
const probe: { current: Probe | null } = { current: null };
function Probe() {
const nav = useNavigationActions();
const filters = useFeedFilters();
const { setFilters } = useFeedFiltersActions();
const plex = usePlexActions();
const channels = useChannelsActions();
const files = useFilesActions();
const playlists = usePlaylistsActions();
// Handed out after each render (an effect, not the render itself, may write outside it).
useEffect(() => {
probe.current = {
nav,
change: {
feed: () => setFilters({ ...filters, sort: "oldest" }),
plex: () => plex.setScope("movie"),
channels: () => channels.setView("hidden"),
files: () => files.setView({ kind: "folder", id: 3 }),
playlists: () => playlists.setSelectedId(5),
"plex search": () => plex.setQ("poirot"),
"channels search": () => channels.setSearch("tom"),
"files search": () => files.setSearch("notes"),
"playlists search": () => playlists.setSearch("jazz"),
},
};
});
return null;
}
// What each change above writes, once its page is what the address shows: [page, key, value]. A
// search is written once the typing pauses.
const WRITTEN: Record<string, [string, string, string]> = {
feed: ["feed", "sort", "oldest"],
plex: ["plex", "pscope", "movie"],
channels: ["channels", "ctab", "hidden"],
files: ["files", "folder", "3"],
playlists: ["playlists", "pl", "5"],
"plex search": ["plex", "pq", "poirot"],
"channels search": ["channels", "cq", "tom"],
"files search": ["files", "fq", "notes"],
"playlists search": ["playlists", "plq", "jazz"],
};
const PAUSE = () => new Promise((r) => setTimeout(r, 400));
beforeEach(() => {
localStorage.clear();
claimLocalData(3);
});
afterEach(() => {
cleanup();
window.history.replaceState(null, "", "/");
});
const search = () => new URLSearchParams(window.location.search);
describe.each(Object.keys(WRITTEN))("a channel page over %s", (change) => {
it("keeps the page's keys off its address, and they come back when it closes", async () => {
const [page, key, value] = WRITTEN[change]!;
// The page named even for the feed: a bare address opens on Home (the account's default).
window.history.replaceState({ sfPage: page }, "", `/?page=${page}`);
render(
<QueryClientProvider client={new QueryClient()}>
<NavigationProvider>
<ModuleStores>
<Probe />
</ModuleStores>
</NavigationProvider>
</QueryClientProvider>,
);
act(() => probe.current!.nav.openChannel("UC1", "One"));
act(() => probe.current!.change[change]!());
await act(PAUSE);
expect(search().get(key)).toBeNull();
expect(search().get("chan")).toBe("UC1");
act(() => probe.current!.nav.closeChannel());
await waitFor(() => expect(search().get("chan")).toBeNull());
await waitFor(() => expect(search().get(key)).toBe(value));
});
});
@@ -48,6 +48,21 @@ export const REGISTERED_PAGES: readonly Page[] = ALL.map((m) => m.id);
const BY_ID = Object.fromEntries(ALL.map((m) => [m.id, m])) as Record<Page, ModuleDef>;
// Every module's address-bar keys, and those of every module but `page`: what a page switch clears and
// what a page opens without (NavigationProvider). Derived from the definitions, so a new module's keys
// are cleared the day it declares them.
const ALL_URL_KEYS: readonly string[] = ALL.flatMap((m) => m.urlKeys);
export function moduleUrlKeys(): readonly string[] {
return ALL_URL_KEYS;
}
/** Every module's address-bar keys that carry typed text — what the client log keeps out (main.tsx). */
export function typedUrlKeys(): readonly string[] {
return ALL.flatMap((m) => m.typedUrlKeys ?? []);
}
export function foreignUrlKeys(page: Page): readonly string[] {
return ALL.filter((m) => m.id !== page).flatMap((m) => m.urlKeys);
}
// Look up a module's definition by page id (every Page is covered, incl. settings).
export function moduleDef(id: Page): ModuleDef {
return BY_ID[id];
@@ -1,5 +1,6 @@
import { createContext, useContext } from "react";
import type { Page } from "../../lib/urlState";
import { DEFAULT_YT_COUNT } from "../../lib/ytSearchUrlState";
import type { ChannelView } from "./navigationInit";
// The navigation contexts + their hooks, split out of NavigationProvider so that file exports only
@@ -15,8 +16,13 @@ export interface NavigationState {
page: Page;
/** The open channel page (null = none). Overlays `page`; rides in history.state._chan. */
channelView: ChannelView | null;
/** Live YouTube search term (null = normal feed). A feed sub-view; rides in history.state._yt. */
/** Live YouTube search term (null = normal feed). A feed sub-view, named in the address (`yt`). */
ytSearch: string | null;
/** How many results the search gathers (`ytn`). */
ytCount: number;
/** The search on screen was asked for in this document (sent from the search box, or run again by
* a press), not only reopened by a reload, a link or Back. An API search runs only when asked. */
ytSearchAsked: boolean;
}
export interface NavigationActions {
@@ -33,12 +39,17 @@ export interface NavigationActions {
closeChannel: () => void;
enterYtSearch: (q: string) => void;
exitYtSearch: () => void;
/** Run the search on screen: the "Search YouTube" press. */
askYtSearch: () => void;
setYtCount: (n: number) => void;
}
export const NavigationStateContext = createContext<NavigationState>({
page: "feed",
channelView: null,
ytSearch: null,
ytCount: DEFAULT_YT_COUNT,
ytSearchAsked: false,
});
export const NavigationActionsContext = createContext<NavigationActions>({
setPage: () => {},
@@ -46,6 +57,8 @@ export const NavigationActionsContext = createContext<NavigationActions>({
closeChannel: () => {},
enterYtSearch: () => {},
exitYtSearch: () => {},
askYtSearch: () => {},
setYtCount: () => {},
});
export function useNavigation(): NavigationState {
@@ -0,0 +1,9 @@
import type { Page } from "../../lib/urlState";
import type { NavigationState } from "./navigationContext";
/** The page whose own keys the address bar carries: none while the channel page stands over it, so
* every module store writes its keys (`useUrlMirror`) only while its page is what the screen shows.
* A plain function beside the context, not a hook in it: the stores' tests mock that module. */
export function urlPage(nav: Pick<NavigationState, "page" | "channelView">): Page | null {
return nav.channelView ? null : nav.page;
}
@@ -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);
}
};
@@ -0,0 +1,39 @@
import { lazy, Suspense, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { type Video, type VideoStatus } from "../../lib/api";
import { qk } from "../../lib/queryKeys";
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"));
/** The shell's player, for an address that names one over a page that hosts none of its own (the
* 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 qc = useQueryClient();
const [own] = useState(!hosted);
const [video, setVideo] = useState<Video | null>(null);
useOpenLinkedPlayer(
own,
() => undefined,
(v) => setVideo(v),
);
const onState = useStableCallback((id: string, status: VideoStatus) => {
const write = writeVideoState(qc, id, status);
write
.then(() => qc.invalidateQueries({ queryKey: qk.feed() }))
.catch((e: unknown) => notifyActionError(e, msg("errors.writeFailed.videoState")));
return write;
});
const close = useStableCallback(() => setVideo(null));
if (!video) return null;
return (
<Suspense fallback={null}>
<PlayerModal video={video} startAt={null} onClose={close} onState={onState} />
</Suspense>
);
}
@@ -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";
@@ -111,6 +111,7 @@ function renderModal(ui: ReactNode) {
afterEach(() => {
cleanup();
vi.restoreAllMocks();
window.history.replaceState(null, "", "/");
started.length = 0;
loaded.length = 0;
seeks.length = 0;
@@ -313,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.");
});
@@ -335,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;
+32 -3
View File
@@ -68,6 +68,7 @@ import {
import { canFullscreenElements, fullscreenElement } from "../../lib/fullscreen";
import { dispatch, isActivationTarget, setScopeCoveredProbe, useKeyScope } from "../../lib/keymap";
import { useBackToClose } from "../../lib/history";
import { linkedPlayer, playerParamUpdates, type PlayerPanelTab } from "../../lib/playerUrlState";
import ChannelNameLink from "../video/ChannelNameLink";
import Toaster from "../../components/ui/Toaster";
import { useOverlayOrder } from "../../components/ui/overlayOrder";
@@ -222,8 +223,6 @@ export default function PlayerModal({
const { openChannel } = useNavigationActions();
const qc = useQueryClient();
const { ref: descFadeRef, style: descFadeStyle } = useScrollFade();
// Browser/mouse Back closes the player instead of leaving the page.
useBackToClose(onClose);
// Freeze the launch queue: the player steps through the SAME filtered + sorted list it was
// opened with, for its whole session. The live feed refetches and reorders/drops items under
// us — e.g. auto-marking each video watched near its end removes it from a filtered feed — which
@@ -277,6 +276,25 @@ export default function PlayerModal({
setRelatedOf(active.id);
setRelated(null);
}
// An address that names the item this player opens on (a reload, a bookmark) names its view too: the
// related video playing in place over it and the panel's tab come back with it. Read once.
const [linked] = useState(() => {
const here = linkedPlayer();
return here && here.watch === video.id ? here : null;
});
const linkedRelated = useQuery({
queryKey: qk.videoRow(linked?.related ?? ""),
queryFn: () => api.videoRow(linked!.related!),
enabled: !!linked?.related,
retry: false,
});
const [relatedAdopted, setRelatedAdopted] = useState(false);
if (!relatedAdopted && linkedRelated.data) {
setRelatedAdopted(true);
// Only over the item it was named with: a step of the queue before it arrived left that one.
if (active.id === linked?.watch && !related)
setRelated({ video: linkedRelated.data, startAt: null });
}
const subject = related?.video ?? active;
const subjectResume = related
? (related.startAt ?? related.video.position_seconds ?? 0)
@@ -753,7 +771,7 @@ export default function PlayerModal({
// take the side panel's place of the description, a tab beside it; stacked, a drawer over the video
// opened from a button beside the ⓘ. The list is the OPENED video's: one played from it leaves the
// list in place, to try the next one (the owner's pick).
const [panelTab, setPanelTab] = useState<"description" | "related">("description");
const [panelTab, setPanelTab] = useState<PlayerPanelTab>(linked?.panel ?? "description");
const [relatedOpen, setRelatedOpen] = useState(false);
// The drawer is the stacked layout's: going wide while it is open, the list moves into the panel's
// tab instead. Left open, it reopened unasked when the window narrowed again.
@@ -762,6 +780,17 @@ export default function PlayerModal({
setPanelTab("related");
}
const panelTabs = useTabPanel();
// Browser/mouse Back closes the player instead of leaving the page. Its entry's address names what
// it plays (the queue's item, a related one over it) and the wide panel's tab, so a reload or a
// bookmark reopens it as it is; the page's own entry under it never carries them.
useBackToClose(
onClose,
playerParamUpdates({
watch: active.id,
related: related?.video.id ?? null,
panel: wide ? panelTab : "description",
}),
);
// Turned off on this instance (a daily limit of 0): no tab, no button — nothing that could only
// be refused.
const relatedOn = useMe().related_enabled;
@@ -0,0 +1,196 @@
// @vitest-environment jsdom
//
// The URL is the view: the YouTube player names what it plays in its own history entry — the item of
// the queue, a related video played in place over it, the wide panel's tab — so a reload or a bookmark
// reopens it as it was, and Back lands on the page's address without it. Its own file: the overlay
// history is module state, and these read the address the closed players of other tests step back over.
//
// `window.YT` is a fake installed before the player's API loader first runs (see PlayerModal.dom.test).
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
vi.mock("react-i18next", async (importOriginal) => ({
...(await importOriginal<typeof import("react-i18next")>()),
useTranslation: () => ({ t: (k: string) => k, i18n: { language: "en" } }),
}));
vi.mock("../navigation/navigationContext", () => ({
useNavigationActions: () => ({ openChannel: vi.fn() }),
}));
vi.mock("../video/AddToPlaylist", () => ({ default: () => null }));
vi.mock("../downloads/DownloadButton", () => ({ default: () => null }));
import PlayerModal from "./PlayerModal";
import { api, type Me, type Video } from "../../lib/api";
import { qk } from "../../lib/queryKeys";
beforeAll(() => {
class Stub {
observe() {}
unobserve() {}
disconnect() {}
takeRecords() {
return [];
}
}
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
});
const started: { videoId: string; start: number | undefined }[] = [];
let playerEvents: { onReady?: () => void } = {};
const fakePlayer = new Proxy(
{ getCurrentTime: () => 0, getDuration: () => 3000, getVideoData: () => null },
{ get: (t, k) => (k in t ? t[k as keyof typeof t] : k === "then" ? undefined : () => undefined) },
);
window.YT = {
Player: function (
_el: unknown,
opts: { videoId: string; playerVars: { start?: number }; events?: typeof playerEvents },
) {
started.push({ videoId: opts.videoId, start: opts.playerVars.start });
playerEvents = opts.events ?? {};
return fakePlayer;
} as never,
};
const lastStart = () => started[started.length - 1];
const vid = (id: string, position_seconds = 0): Video =>
({
id,
title: `Video ${id}`,
channel_id: "UCx",
channel_title: "Channel",
status: "new",
position_seconds,
duration_seconds: 3000,
watch_url: `https://www.youtube.com/watch?v=${id}`,
}) as unknown as Video;
const related = (id: string, position_seconds = 0) => ({
...vid(id, position_seconds),
followed: false,
});
// The wide layout (the side panel with its tabs): jsdom has no matchMedia, so one is put in.
function wideWindow() {
window.matchMedia = ((q: string) => ({
matches: q === "(min-width: 1280px)",
media: q,
addEventListener: () => {},
removeEventListener: () => {},
})) as unknown as typeof window.matchMedia;
}
function renderModal(video: Video, queue: Video[]) {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
qc.setQueryData(qk.me(), { related_enabled: true } as Me);
return render(
<QueryClientProvider client={qc}>
<PlayerModal
video={video}
queue={queue}
onClose={() => {}}
onState={() => Promise.resolve()}
/>
</QueryClientProvider>,
);
}
// A closed player steps back over its entry a task later (jsdom's back(), like a browser's).
async function closeAndLand(unmount: () => void) {
const landed = new Promise((r) => window.addEventListener("popstate", r, { once: true }));
unmount();
await act(() => landed);
}
const here = () => new URLSearchParams(window.location.search);
beforeEach(() => {
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
vi.spyOn(api, "related").mockResolvedValue({
items: [related("rel1"), related("rel2", 120)],
has_more: false,
next_cursor: null,
});
});
afterEach(() => {
vi.restoreAllMocks();
delete (window as { matchMedia?: unknown }).matchMedia;
started.length = 0;
});
describe("the player's address", () => {
it("names the item playing now in its own entry, a step of the queue too", async () => {
window.history.replaceState({ sfPage: "feed" }, "", "/?q=talks");
const r = renderModal(vid("A"), [vid("A"), vid("B")]);
await waitFor(() => expect(here().get("watch")).toBe("A"));
expect(here().get("q")).toBe("talks");
fireEvent.click(screen.getAllByRole("button", { name: "player.next" })[0]!);
await waitFor(() => expect(here().get("watch")).toBe("B"));
await closeAndLand(r.unmount);
expect(window.location.search).toBe("?q=talks");
});
it("wide: names the related video playing over it and the panel's tab, and Back drops it", async () => {
wideWindow();
window.history.replaceState({ sfPage: "feed" }, "", "/");
const r = renderModal(vid("A", 40), [vid("A", 40)]);
act(() => playerEvents.onReady?.());
fireEvent.click(screen.getByRole("tab", { name: "player.related.title" }));
await waitFor(() => expect(here().get("wtab")).toBe("related"));
const rows = await screen.findAllByRole("link", { name: "Video rel2" });
fireEvent.click(rows.find((a) => a.hasAttribute("data-nav-open"))!);
await waitFor(() => expect(here().get("wrel")).toBe("rel2"));
expect(here().get("watch")).toBe("A");
fireEvent.click(screen.getByRole("button", { name: "player.back" }));
await waitFor(() => expect(here().has("wrel")).toBe(false));
expect(here().get("watch")).toBe("A");
await closeAndLand(r.unmount);
expect(window.location.search).toBe("");
});
it("wide: a link's related video and panel tab come back over the item it names", async () => {
wideWindow();
window.history.replaceState({ sfPage: "feed" }, "", "/?watch=A&wrel=rel2&wtab=related");
const row = vi.spyOn(api, "videoRow").mockResolvedValue(vid("rel2", 120));
const r = renderModal(vid("A", 40), [vid("A", 40)]);
await waitFor(() => expect(lastStart()).toEqual({ videoId: "rel2", start: 120 }));
expect(row).toHaveBeenCalledWith("rel2");
const tab = screen.getByRole("tab", { name: "player.related.title" });
expect(tab.getAttribute("aria-selected")).toBe("true");
expect(screen.getByRole("button", { name: "player.back" })).toBeTruthy();
expect(here().get("wrel")).toBe("rel2");
await closeAndLand(r.unmount);
// The entry the link opened at named the player; once the player is gone, so is its name.
expect(window.location.search).toBe("");
});
it("a link's related video that arrives after the queue stepped on stays off the next item", async () => {
window.history.replaceState({ sfPage: "feed" }, "", "/?watch=A&wrel=rel2");
let arrive: (v: Video) => void = () => {};
vi.spyOn(api, "videoRow").mockReturnValue(new Promise<Video>((r) => (arrive = r)));
const r = renderModal(vid("A"), [vid("A"), vid("B")]);
fireEvent.click(screen.getAllByRole("button", { name: "player.next" })[0]!);
await waitFor(() => expect(here().get("watch")).toBe("B"));
await act(async () => {
arrive(vid("rel2", 120));
await new Promise((r) => setTimeout(r, 50)); // the query hands its answer on a tick later
});
expect(screen.getByRole("heading", { name: "Video B" })).toBeTruthy();
expect(here().has("wrel")).toBe(false);
await closeAndLand(r.unmount);
});
it("wide: a link naming another item brings none of its view", async () => {
wideWindow();
window.history.replaceState({ sfPage: "feed" }, "", "/?watch=Z&wrel=rel2&wtab=related");
const row = vi.spyOn(api, "videoRow").mockResolvedValue(vid("rel2", 120));
const r = renderModal(vid("A", 40), [vid("A", 40)]);
await waitFor(() => expect(here().get("watch")).toBe("A"));
expect(row).not.toHaveBeenCalled();
expect(here().has("wrel")).toBe(false);
expect(here().has("wtab")).toBe(false);
expect(lastStart()?.videoId).toBe("A");
await closeAndLand(r.unmount);
});
});
@@ -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);
});
@@ -0,0 +1,182 @@
// @vitest-environment jsdom
//
// A reload or a bookmark of the YouTube player reopens it: the page that hosts it opens it once its list
// is in — from the list, so it plays on through it, or alone from the catalog — and a page that hosts
// none gets the shell's, the video alone. A video the catalog no longer has is said, not swallowed.
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, render, renderHook, screen, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ReactNode } from "react";
vi.mock("react-i18next", async (importOriginal) => ({
...(await importOriginal<typeof import("react-i18next")>()),
useTranslation: () => ({ t: (k: string) => k, i18n: { language: "en" } }),
}));
vi.mock("../../lib/actionErrors", () => ({ notifyActionError: vi.fn() }));
// The player itself is not what is under test: it shows what it was opened on.
vi.mock("./PlayerModal", () => ({
default: ({ video, queue }: { video: Video; queue?: Video[] }) => (
<p>
playing {video.id} {queue ? "in a list" : "alone"}
</p>
),
}));
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;
function wrapper({ children }: { children: ReactNode }) {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return <QueryClientProvider client={qc}>{children}</QueryClientProvider>;
}
afterEach(() => {
cleanup();
vi.restoreAllMocks();
vi.mocked(notifyActionError).mockClear();
window.history.replaceState(null, "", "/");
});
describe("useOpenLinkedPlayer", () => {
it("opens the item from the host's list once the list is in, and only once", async () => {
window.history.replaceState(null, "", "/?watch=B");
const row = vi.spyOn(api, "videoRow");
const open = vi.fn();
const list = [vid("A"), vid("B")];
const { rerender } = renderHook(
({ ready, rows }) => useOpenLinkedPlayer(ready, (id) => rows.find((v) => v.id === id), open),
{ wrapper, initialProps: { ready: false, rows: list } },
);
expect(open).not.toHaveBeenCalled();
rerender({ ready: true, rows: list });
await waitFor(() => expect(open).toHaveBeenCalledWith(list[1], true));
// The list refetched (new rows for the same videos): the player it opened is not opened again.
rerender({ ready: true, rows: [vid("A"), vid("B")] });
expect(open).toHaveBeenCalledTimes(1);
expect(row).not.toHaveBeenCalled();
});
it("opens an item the list does not hold alone, its row from the catalog", async () => {
window.history.replaceState(null, "", "/?watch=Z");
vi.spyOn(api, "videoRow").mockResolvedValue(vid("Z"));
const open = vi.fn();
renderHook(() => useOpenLinkedPlayer(true, () => undefined, open), { wrapper });
await waitFor(() => expect(open).toHaveBeenCalledWith(vid("Z"), false));
});
it("says so when the catalog no longer has it", async () => {
window.history.replaceState(null, "", "/?watch=Z");
const gone = new Error("not found");
vi.spyOn(api, "videoRow").mockRejectedValue(gone);
const open = vi.fn();
renderHook(() => useOpenLinkedPlayer(true, () => undefined, open), { wrapper });
await waitFor(() =>
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.
expect(new URLSearchParams(window.location.search).has("watch")).toBe(false);
});
it("leaves a player opened while the row was on its way alone, and its address", async () => {
window.history.replaceState(null, "", "/?watch=Z");
let answer: (v: Video) => void = () => {};
vi.spyOn(api, "videoRow").mockReturnValue(new Promise((r) => (answer = r)));
const open = vi.fn();
renderHook(() => useOpenLinkedPlayer(true, () => undefined, open), { wrapper });
// A card clicked meanwhile: its player names itself in the address.
window.history.replaceState(null, "", "/?watch=Y");
answer(vid("Z"));
await new Promise((r) => setTimeout(r, 20));
expect(open).not.toHaveBeenCalled();
expect(new URLSearchParams(window.location.search).get("watch")).toBe("Y");
});
it("does not clear the address of a player opened since when its catalog read fails", async () => {
window.history.replaceState(null, "", "/?watch=Z");
let refuse: (e: Error) => void = () => {};
vi.spyOn(api, "videoRow").mockReturnValue(new Promise((_, r) => (refuse = r)));
renderHook(() => useOpenLinkedPlayer(true, () => undefined, vi.fn()), { wrapper });
window.history.replaceState(null, "", "/?watch=Y");
refuse(new Error("not found"));
await new Promise((r) => setTimeout(r, 20));
expect(new URLSearchParams(window.location.search).get("watch")).toBe("Y");
});
it("given up over a moved list, keeps the address of a player opened since", async () => {
window.history.replaceState(null, "", "/?watch=Z");
const { rerender } = renderHook(
({ scope }) => useOpenLinkedPlayer(false, () => undefined, vi.fn(), scope),
{ wrapper, initialProps: { scope: 1 } },
);
window.history.replaceState(null, "", "/?watch=Y");
rerender({ scope: 2 });
await new Promise((r) => setTimeout(r, 20));
expect(new URLSearchParams(window.location.search).get("watch")).toBe("Y");
});
it("stops looking once it opened: the video leaving the list asks the catalog for nothing", async () => {
window.history.replaceState(null, "", "/?watch=B");
const row = vi.spyOn(api, "videoRow").mockResolvedValue(vid("B"));
const find = vi.fn((id: string) => (id === "B" ? vid("B") : undefined));
const { rerender } = renderHook(
({ rows }) => useOpenLinkedPlayer(true, rows ? find : () => undefined, vi.fn()),
{ wrapper, initialProps: { rows: true } },
);
await waitFor(() => expect(find).toHaveBeenCalled());
find.mockClear();
rerender({ rows: false });
rerender({ rows: true });
await new Promise((r) => setTimeout(r, 20));
expect(find).not.toHaveBeenCalled();
expect(row).not.toHaveBeenCalled();
});
it("opens nothing, and asks for nothing, for an address that names no player", async () => {
window.history.replaceState(null, "", "/?q=talks");
const row = vi.spyOn(api, "videoRow");
const open = vi.fn();
renderHook(() => useOpenLinkedPlayer(true, () => undefined, open), { wrapper });
await new Promise((r) => setTimeout(r, 20));
expect(open).not.toHaveBeenCalled();
expect(row).not.toHaveBeenCalled();
});
});
describe("the shell's LinkedPlayer", () => {
it("plays the video alone over a page that hosts no player", async () => {
window.history.replaceState(null, "", "/?page=channels&watch=Z");
vi.spyOn(api, "videoRow").mockResolvedValue(vid("Z"));
render(<LinkedPlayer hosted={false} />, { wrapper });
expect(await screen.findByText("playing Z alone")).toBeTruthy();
});
it("leaves it to a page that hosts its own, even once that page has fetched the row", async () => {
// The page plays it alone, from the catalog row it asked for: the same cache entry this one reads.
window.history.replaceState(null, "", "/?page=home&watch=Z");
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
qc.setQueryData(qk.videoRow("Z"), vid("Z"));
render(
<QueryClientProvider client={qc}>
<LinkedPlayer hosted />
</QueryClientProvider>,
);
await new Promise((r) => setTimeout(r, 20));
expect(screen.queryByText(/playing/)).toBeNull();
});
it("leaves it to a page that hosts its own", async () => {
window.history.replaceState(null, "", "/?watch=Z");
const row = vi.spyOn(api, "videoRow").mockResolvedValue(vid("Z"));
render(<LinkedPlayer hosted />, { wrapper });
await new Promise((r) => setTimeout(r, 20));
expect(row).not.toHaveBeenCalled();
expect(screen.queryByText(/playing/)).toBeNull();
});
});
@@ -0,0 +1,70 @@
import { useEffect, useEffectEvent, useRef, useState } from "react";
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
* item is in it (`find`), so the player plays on through the list; else alone, its row fetched from
* the catalog. A row the catalog no longer has is said, not swallowed.
*
* `scope` names the list the address put it over (a playlist, a live search): when the host moves to
* another before it opened, the address under it named something else, and the player is given up —
* opened later, it would start over a list nobody linked, unasked. So it is when a player opened
* meanwhile (a card clicked while the row was on its way): the address names that one now. */
export function useOpenLinkedPlayer(
ready: boolean,
find: (id: string) => Video | undefined,
open: (video: Video, inList: boolean) => void,
scope: string | number | null = null,
): void {
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
// not open it twice); the state stops the lookup and the row fetch on screen after it.
const done = useRef(linked == null);
const [settled, setSettled] = useState(linked == null);
const moved = scope !== linkedScope;
const waiting = ready && !moved && !settled && linked != null;
const listed = waiting ? find(linked) : undefined;
const row = useQuery({
queryKey: qk.videoRow(linked ?? ""),
queryFn: () => api.videoRow(linked!),
enabled: waiting && !listed,
retry: false,
});
const openIt = useEffectEvent(open);
const failed = useEffectEvent((err: unknown) => {
notifyActionError(err, msg("player.linkedFailed"));
clearParams(...PLAYER_KEYS);
});
useEffect(() => {
if (done.current) return;
const settle = () => {
done.current = true;
// State, not derived: a one-way decision taken as the list or the row arrives. Derived from
// `listed`, the lookup and the row fetch would come back whenever the video left the list.
setSettled(true);
};
// A player opened since names its own: this one is not wanted any more.
if (linkedPlayer()?.watch !== linked) return settle();
if (moved) {
// The address goes on naming it under the new list (a reload would open it there).
clearParams(...PLAYER_KEYS);
return settle();
}
// Not before the host is ready: the row's cache entry is shared, so another opener's fetch fills
// it here too (a disabled query still reads the cache) — the shell's player over a page that
// plays its own would open a second one.
if (!ready) return;
if (listed) openIt(listed, true);
else if (row.data) openIt(row.data, false);
else if (row.isError) failed(row.error);
else return;
settle();
}, [linked, moved, ready, listed, row.data, row.isError, row.error]);
}
@@ -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);
@@ -48,8 +48,9 @@ describe("ChannelNameLink", () => {
it("is an anchor whose href opens this channel on the current page", () => {
const { link } = renderLink();
expect(link.tagName).toBe("A");
// The same URL openChannel pushes: the current page's query plus ?chan=<id>.
expect(link.getAttribute("href")).toBe(`/?page=feed&show=unwatched&chan=${CH}`);
// The same URL openChannel pushes: the current page plus ?chan=<id> — not the page's own keys,
// which named a view the channel does not show (`show=unwatched` here).
expect(link.getAttribute("href")).toBe(`/?page=feed&chan=${CH}`);
});
it("takes the in-app path on a plain left click, and suppresses the browser navigation", () => {
@@ -73,11 +74,16 @@ describe("ChannelNameLink", () => {
expect(onOpenInApp).not.toHaveBeenCalled();
});
it("does not lose the rest of the address bar", () => {
window.history.replaceState({}, "", "/?page=channels&q=abc");
// The address names only what the screen shows: the channel over the page, not the page's filters
// (a link's new tab once opened the channel under the feed's search and sort).
it("keeps the page and the shell's keys, and leaves the page's and another channel's behind", () => {
window.history.replaceState(
{},
"",
"/?page=channels&q=abc&ctab=hidden&chtab=about&m=signup#x=1",
);
const { link } = renderLink();
expect(link.getAttribute("href")).toContain("page=channels");
expect(link.getAttribute("href")).toContain("q=abc");
expect(link.getAttribute("href")).toBe(`/?page=channels&chan=${CH}&m=signup#x=1`);
});
it("gives a channel with no title yet an accessible name", () => {
@@ -1,15 +1,15 @@
import { forwardRef, type AnchorHTMLAttributes } from "react";
import { useTranslation } from "react-i18next";
import { isPlainLeftClick } from "../../lib/nav";
import { urlWithParams } from "../../lib/urlState";
import { channelUrl } from "../../lib/urlState";
// A channel name that is a REAL link. The player showed it as a `<button>`, so the browser's own
// gestures did nothing on it: middle-click and Ctrl/Cmd-click opened no tab, on a thing that looks
// exactly like a link (the YouTube icon right next to it IS an anchor and does open one). The video
// card's channel name had the same `<button>`, so it renders through here too.
//
// The href is the same URL `openChannel` pushes — `?chan=<id>` on the current page — so a new tab
// lands where the in-app navigation would have. A plain left click is still handled in-app, because
// The href is the same URL `openChannel` pushes (`channelUrl`: the channel over the current page,
// without the page's own keys) — so a new tab lands where the in-app navigation would have. A plain left click is still handled in-app, because
// the SPA path does more than navigate (the player has to close first and reclaim its own
// Back-to-close history entry; see lib/history.closeOverlaysThenNavigate).
type Props = Omit<AnchorHTMLAttributes<HTMLAnchorElement>, "href" | "onClick" | "title"> & {
@@ -33,7 +33,7 @@ const ChannelNameLink = forwardRef<HTMLAnchorElement, Props>(function ChannelNam
<a
{...rest}
ref={ref}
href={urlWithParams({ chan: channelId })}
href={channelUrl(channelId)}
title={tooltip}
onClick={(e) => {
if (stopPropagation) e.stopPropagation();
+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),
+4 -1
View File
@@ -75,6 +75,9 @@
"clearNow": "Clear now",
"cleared_one": "Cleared {{count}} result",
"cleared_other": "Cleared {{count}} results",
"showHint": "How many results to fetch at a time — “Load more” pulls another batch of this size."
"showHint": "How many results to fetch at a time — “Load more” pulls another batch of this size.",
"notRunTitle": "Not searched yet",
"notRunBody": "A YouTube API search spends 100 units of the shared daily quota, so a reload, a link, Back or Forward doesn't start one by itself.",
"runAgain": "Search YouTube"
}
}
+2
View File
@@ -1,4 +1,6 @@
{
"linkedGone": "The file in the address is no longer in this list",
"linkedNotListed": "The file in the link isn't among the files listed here — search for it by name",
"navLabel": "Files",
"searchPlaceholder": "Search files…",
"count_one": "{{count}} item",
+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
@@ -1,5 +1,6 @@
{
"loading": "Loading…",
"linkedFailed": "The video in the address could not be opened",
"backToOriginal": "Back to the original video",
"back": "Back",
"previous": "Previous",
+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"
},
+4 -1
View File
@@ -75,6 +75,9 @@
"clearNow": "Törlés most",
"cleared_one": "{{count}} találat törölve",
"cleared_other": "{{count}} találat törölve",
"showHint": "Hány találatot szerezzen egyszerre — a „Továbbiak betöltése” ennyivel tölt tovább."
"showHint": "Hány találatot szerezzen egyszerre — a „Továbbiak betöltése” ennyivel tölt tovább.",
"notRunTitle": "Ez a keresés még nem futott le",
"notRunBody": "Egy YouTube API-keresés 100 egységet fogyaszt a közös napi kvótából, ezért újratöltéskor, linkről, vissza- vagy előrelépéskor nem indul el magától.",
"runAgain": "Keresés a YouTube-on"
}
}
+2
View File
@@ -1,4 +1,6 @@
{
"linkedGone": "A linkben szereplő fájl már nincs ebben a listában",
"linkedNotListed": "A linkben szereplő fájl nincs a listázottak között — keress rá a nevére",
"navLabel": "Fájlok",
"searchPlaceholder": "Keresés a fájlok között…",
"count_one": "{{count}} elem",
+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
@@ -1,5 +1,6 @@
{
"loading": "Betöltés…",
"linkedFailed": "A linkben szereplő videó nem nyitható meg",
"backToOriginal": "Vissza az eredeti videóhoz",
"back": "Vissza",
"previous": "Előző",
+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);
+8 -1
View File
@@ -2,12 +2,19 @@ import { LS, setTabRaw } from "./storage";
// The admin Users page's persisted-tab key + the Access-requests tab id, plus a helper to
// pre-select that tab before navigating there (a notification's "Review" link uses it —
// useTabPersistedState reads the key on mount, and the page mounts fresh on navigation).
// useTabPersistedState reads the key on mount, and the page on screen hears it).
//
// Kept out of AdminUsers.tsx so callers (App, NotificationsPanel) can pre-select the tab
// WITHOUT statically importing the — now lazy-loaded — admin page, which would otherwise pull
// it back into the main bundle and defeat the code-split.
export const ADMIN_USERS_TAB_KEY = LS.adminUsersTab;
// The address-bar key the page mirrors its tab to.
export const USERS_TAB_URL_KEY = "utab";
/** The address-bar delta for the Users tab shown. */
export function usersTabParams(tab: string | null): Record<string, string | null> {
return { [USERS_TAB_URL_KEY]: tab };
}
const ADMIN_USERS_ACCESS_TAB = "access";
export function focusAccessRequestsTab(): void {
+1 -1
View File
@@ -20,7 +20,7 @@ import { setupApi } from "./api/setup";
export type * from "./api/setup";
import { feedApi } from "./api/feed";
export type * from "./api/feed";
export { TAKEOUT_JOB } from "./api/feed";
export { FEED_DEFAULT_SHOW, FEED_DEFAULT_SORT, TAKEOUT_JOB } from "./api/feed";
import { discoverApi } from "./api/discover";
export type * from "./api/discover";
export { DISCOVER_BUDGETS, HERO_TIERS, discoverCardKey } from "./api/discover";
+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,
});
}

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