Compare commits

..
78 Commits
Author SHA1 Message Date
peter 242f00671d release: 0.115.0 (improvement/e50-queue-skips) 2026-11-01 19:13:09 +01:00
peter 7f8d7cbe20 chore(release): 0.115.0 2026-11-01 19:01:45 +01:00
peter 8dd2f0c015 fix: review round 2 — Clear watch progress clears only the resume point, big view focus by the way it arrived, dialog name, reason tooltip, backfill locks 2026-11-01 18:26:33 +01:00
peter b3402d158f fix(player): the card under the big view is out of the Tab order, and the focus leaves it and comes back 2026-11-01 17:12:05 +01:00
peter c910cf2f20 fix: review round 1 — row press after a rename, related reset, dead items on removal, honest Prev/Next
- A press that begins while a field in the row is edited only ends the editing (rowOpen latches it
  at pointerdown); the card layout's row press is now tested.
- A related video reset in the player forgets the session's stored position.
- Removing the playing video passes over known-dead items and steps the way it went.
- Prev/Next off only because of dead items stay focusable and say why (HU/EN).
- The docked bar's scroller leaves room for focus rings.
- A focus-opened About card is not closed by its own scroll into view.
- EN "Clear watch progress" for the video reset, distinct from a page's "Reset view".
- The signed-out title read goes to the page yt-dlp read; the retitle writes are conditional.
- Tests: the cap of three errors, the bar's own Fullscreen, the group glyph, the backfill's reads.
2026-11-01 05:36:59 +01:00
peter 245c0a2019 test(e2e): the channel chrome's fixed-toolbar check holds by the watch-state pill
A channel page no longer shows a count pill; the toolbar's watch-state pill
rides in the same fixed chrome.
2026-11-01 04:44:37 +01:00
peter add7f7d167 feat(channel): on a tablet the header's words give their room to the tags
Below xl the action row drops "Prio" (the stepper's tooltip names it) and
the Subscribed button's word (a check glyph; its name stays in the
aria-label). On an iPad at 820 px with touch-sized targets the tag lane
grows from 29 px, a sliver of its control, to 130 px.
2026-11-01 04:22:20 +01:00
peter a29afd916d feat(feed): toolbar pills named by a glyph, Sort and View on the filters' row
The watch-state, content-type and sort pills on the feed, a channel page and
Plex Browse show a glyph where their group's word was; the word moves to the
tooltip and stays in the accessible name. Sort + View end the toolbar's row
instead of a row of their own, the chip row shows only with a chip, and a
channel page drops the count pill its header already carries.

Also logs the docked bar's glyph-only decision in DESIGN.md.
2026-11-01 04:18:01 +01:00
peter e9ce989998 feat(player): the big view's bar is glyphs only, and its way out stays in sight
On an iPad the bar's labelled row outgrew the screen and its last control,
leaving the big view, scrolled off the right. Every control is its glyph
now, named by its label and tooltip as in the windowed player; the controls
scroll on a narrow window while fullscreen and leave stay pinned right.
2026-11-01 03:59:17 +01:00
peter 7299e82c22 fix(downloads): a Facebook download titled only "Facebook" takes the post's text
Since 2026-10-28 a signed-in read of a reel gives the placeholder title and
no description, so the adapter had nothing to fall back on and every such
download (and its name) read "Facebook". A placeholder with nothing behind
it now gets one signed-out, metadata-only read of the page. The backfill
retitles the downloads already made, and every name that only repeated the
placeholder; a name its owner gave stays.
2026-11-01 03:52:16 +01:00
peter 41052eb4ea feat(files): a press anywhere on a file's row plays it
A table row (and its narrow card) can carry its object's default action:
a click or tap on the row runs it, a press on a control inside the row,
the click that ends a text selection and a drag do not.
2026-11-01 03:30:49 +01:00
peter 85fd9b6704 feat(channels): an Added column, and About cards that wait for a resting pointer
The channel manager lists when each follow reached the list (the
subscription import that first saw it, or a subscribe made here) as a
sortable Added date after Last upload. The About cell's card opens after the
pointer rests on it for half a second instead of at once, and a scroll puts
an open one away.
2026-11-01 01:30:30 +01:00
peter e06c0688d9 feat(video): Reset view on every YouTube video menu
The row that sets a video back to fresh (its state row and resume point both
go) was only on the feed's cards. It is now on Home's shelves, a Playlists
row's menu and the player's related list too, and is named "Reset view" /
"Megtekintés visszaállítása".
2026-11-01 01:20:20 +01:00
peter 15a3603513 fix(player): a step onto an unplayable video carries on the way it went
Previous onto an embed-blocked or removed video skipped forward again (the
auto-advance direction), straight back to the item just left. The player now
remembers the direction of the step that reached the item and the videos
YouTube refused this session: an error carries on that way, Previous / Next
pass over known-dead items without loading them, and go dark when only those
are left. A step of your own skips with auto-advance off too.
2026-11-01 01:14:50 +01:00
peter c430558ab3 release: 0.114.3 (improvement/e49-refusal-contract) 2026-11-01 00:30:51 +01:00
peter be38d652a5 chore(release): 0.114.3 notes 2026-11-01 00:18:59 +01:00
peter 31a75f4985 fix(i18n): write each refusal where it is sent, and read only that form
A refusal's detail now stands where it is sent (HTTPException's detail,
a JSONResponse's "detail") or is returned by a factory that only ever is,
with any spread before its code and reason, so no spread can replace
them. The scan follows no variable and no kwargs any more; what it cannot
read in a reason (a lambda's or comprehension's name, a nonlocal or
global, a local that reaches itself, a constant or map changed elsewhere,
an app import it cannot resolve) is reported. The detail guard reads the
same slots and fails on anything but a code dict or a factory's call.
2026-10-31 23:54:55 +01:00
peter f8a8e905da fix(i18n): read refusals along a closed list of shapes, report the rest
The scan followed many shapes and called the rest unread, but each shape it learned opened a silent
branch: a parameter rebound in the body, a second visit of a followed helper, an `import app.x`, a name
bound twice or under an `if`, a spread or a store that replaces the code, a detail rebuilt from its
variable, a None reason. It now reads only what the app writes and reports everything else: a
branch the app never takes is gone (an `or`, a default, a base class's attribute, string and union
annotations, a bare module constant), a spread is clean only from an untouched `**kwargs` no call puts
a `code` in, and the detail's variable may only grow by `|=` or `["reason"]`. The real tree reads
exactly as before; every exit and every kept branch has a self-test that a mutant of it fails.

The detail test also fails a dict without a `code`, a JSONResponse sentence and a detail-less
HTTPException; the two that are no refusal (the setup gate's 503, an unmatched api path's 404) are
listed with why. The contract's assertion that compared the reason map with itself is gone.
2026-10-31 18:45:02 +01:00
peter 5e8589bbf8 fix(i18n): read every refusal from the backend's syntax tree, not by regex
The contract read Python with regexes, and each guard added for a missed shape had shapes of its own
it missed: a reason after another key, a status passed by name, a second `e.reason` handler with the
same text, a pin naming a type nothing checked.

backend/tests/refusal_sites.py (stdlib `ast`, run by the contract on the host's Python) reads every
`{"code": ...}` detail and follows each reason to its values: literals, both arms of a conditional,
module constants, the `Literal` on the parameter or attribute it travels in (on the class an `except`
catches), every call of a `str` helper with its defaults, a map's values with its keys held to the
index's type. What it cannot follow fails the contract, unless no screen shows that code; no pins.

The reason attributes are typed with their Literal, so a subclass or an assignment cannot widen
them; InvalidSetting takes a SettingReason, and the settings map is held to it (a reason the map
lacked was a 500). The per-family reverse checks are one check by prefix (the admin actions
included), and the leaf-key filter applies only to the suffixed sign-in family.
2026-10-31 15:07:38 +01:00
peter ceadf69e43 fix(i18n): hold every refusal reason by type, and refuse what the scan cannot read
Review round 1 of the refusal contract: it claimed more than it held.

- The other reason carriers get their Literal too (BlockedUrl, ClipError,
  FolderError, the playlist and sign-in refusal helpers), so the type
  checker refuses a reason passed by variable or an unknown literal, and
  the contract reads one shape.
- A literal reason in a detail dict built anywhere counts as a reason of
  its code; a code the scan cannot read, a refused() whose reason it
  cannot read, and a new reason passed by variable each fail the contract
  until listed with what holds them.
- A sign-in refusal key the backend no longer sends is caught too.
- localizeDetail's numbers (download cap in GB, upload cap in bytes) and
  the shared-sentence fallbacks are tested in both languages.
- One Literal parser in the contract; a stale comment fixed.
2026-10-31 01:00:26 +01:00
peter 4379f76747 feat(i18n): every server refusal is said by a record the contract holds
A refusal code or reason with no sentence fell through to the generic
"couldn't carry out that action" unnoticed: the parity test is green when
EN and HU miss the same key. One did (an edit refused for a file that is
not video or audio).

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

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

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

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

Stopping, replacing, culling or reaping a session waited up to 3 s for ffmpeg and deleted a whole
segmented film under _lock, which every segment request and checkpoint takes. Teardown now signals
the process and moves its directory aside under the lock, and waits/deletes after it. A new session
on the same path still waits for the dying ffmpeg first (it writes by path), and the orphan sweep
recognises a moved-aside directory left by a crash.
2026-10-28 18:48:21 +01:00
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 35266fd7e8 release: 0.112.1 (bug/authlib-cve) 2026-10-28 00:08:40 +01:00
peter 6211e15611 chore(release): 0.112.1 2026-10-27 23:59:09 +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 011dea8eb7 fix(deps): Authlib 1.7.2 -> 1.9.1 for CVE-2026-96760 and CVE-2026-114616
One pin bumped in place; pip check passes and pip-audit finds nothing.
2026-10-27 21:46:16 +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
370 changed files with 13991 additions and 2069 deletions
+64
View File
@@ -7,6 +7,70 @@ 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). Upgrading, pinning a version and rolling back: [docs/self-hosting.md](docs/self-hosting.md#upgrading).
## 0.115.0 — 2026-11-01
### New
- Clear watch progress is on every YouTube video's menu, on Home, in playlists and in the player's related videos. It forgets where you left a started video and changes nothing else.
- Channels have an Added column, and an About card opens only when the pointer rests on it.
- In Files, a press anywhere on a row plays the file.
- The feed's toolbar pills are named by a glyph, with Sort and View on the filters' row.
### Fixed
- In a play queue, Previous and Next pass over a video that can't play, in the direction you were going, and say why when only such videos are left.
- A Facebook download titled only “Facebook” now takes the post's text as its name.
- The player's big view keeps its way out in sight on a tablet, and Tab no longer lands on controls hidden under it.
## 0.114.3 — 2026-11-01
### Fixed
- Editing a file that is neither video nor audio now says why it can't be done, instead of a generic error.
## 0.114.2 — 2026-10-30
### Fixed
- Security: a library the sign-in relies on is updated, closing two published vulnerabilities.
## 0.114.1 — 2026-10-30
### Fixed
- Notifications follow a language switch: a notice on screen and every row in the notification center are said again in the new language, their buttons too.
## 0.114.0 — 2026-10-29
### New
- Notifications: what asks for you comes first, then everything else as one stream, newest first. Read notices older than a week fold under Earlier, and Back returns to the row you left.
### Fixed
- Plex: a progress save sent just before an episode ends no longer leaves it unwatched, and a refused finish no longer sticks.
- After a dialog closes, the keyboard focus returns to where it was, also inside a menu that stays open.
## 0.113.0 — 2026-10-28
### New
- 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
- Security: the sign-in library is updated, closing two published vulnerabilities.
## 0.112.0 — 2026-10-25 ## 0.112.0 — 2026-10-25
### New ### New
+2 -1
View File
@@ -105,7 +105,7 @@ CQRS, a repository/DI framework, a router library, a generic CRUD-router factory
| Ownership check | `userscope.owned_by` (a filter) / `owned_row` (a row by id): a unit's `queries.own_x` returns None and its route answers `not_found`; a core route `owned_or_404`. A composite rule (own or shared, built-in or own) says so in its `queries` | an inline owner filter — `Model.user_id == …`, an alias's, `.in_()`, `filter_by(user_id=…)` (fence-held, every module) — or a `db.get` and a compare where `owned_row` fits | | Ownership check | `userscope.owned_by` (a filter) / `owned_row` (a row by id): a unit's `queries.own_x` returns None and its route answers `not_found`; a core route `owned_or_404`. A composite rule (own or shared, built-in or own) says so in its `queries` | an inline owner filter — `Model.user_id == …`, an alias's, `.in_()`, `filter_by(user_id=…)` (fence-held, every module) — or a `db.get` and a compare where `owned_row` fits |
| Admin gate import | `app.auth` | the `app.routes.admin` re-export | | Admin gate import | `app.auth` | the `app.routes.admin` re-export |
| Upstream failure status | `422` with JSON detail | `502` (reserve 502 for raw byte-proxies) | | Upstream failure status | `422` with JSON detail | `502` (reserve 502 for raw byte-proxies) |
| User-facing error | structured `detail` (`{code, reason}`) | a bare English string under a bilingual UI | | User-facing error | structured `detail` (`{code, reason}`), its sentence named in `lib/api/refusalCodes.ts` (contract-held) | a bare English string under a bilingual UI |
| Mutation response | `{"ok": true, …}` (`{"deleted": id}`), test-held | a bare `{"unsubscribed"}`/`{"cleared": n}`, no `ok` | | Mutation response | `{"ok": true, …}` (`{"deleted": id}`), test-held | a bare `{"unsubscribed"}`/`{"cleared": n}`, no `ok` |
| Transaction commit | the handler commits; a unit's `service` and every helper `flush` (fence-held, every module) | a helper that commits — only a listed one (what owns its session, or a core step that must land whatever its caller does next), each with its reason: the fence's `UNIT_COMMITS` / `CORE_COMMITS`, a route's `COMMITTING_HELPERS` (`test_route_conventions`) | | Transaction commit | the handler commits; a unit's `service` and every helper `flush` (fence-held, every module) | a helper that commits — only a listed one (what owns its session, or a core step that must land whatever its caller does next), each with its reason: the fence's `UNIT_COMMITS` / `CORE_COMMITS`, a route's `COMMITTING_HELPERS` (`test_route_conventions`) |
| Admin knob storage | `sysconfig` (values) | env / `app_state` / a table of its own (the job intervals had one) | | Admin knob storage | `sysconfig` (values) | env / `app_state` / a table of its own (the job intervals had one) |
@@ -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 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 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 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 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 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 | | 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.0 0.115.0
+12 -1
View File
@@ -66,6 +66,11 @@ class SourceAdapter:
"""The download's raw title, before `titles.normalize_title` cleans it. Generic: yt-dlp's.""" """The download's raw title, before `titles.normalize_title` cleans it. Generic: yt-dlp's."""
return info.get("title") return info.get("title")
def is_placeholder_title(self, title: str | None) -> bool:
"""Whether `title` is a stand-in the source gives in place of the real one (worth one more,
signed-out read of the page). Generic: never."""
return False
def uploader_url(self, info: dict) -> str | None: def uploader_url(self, info: dict) -> str | None:
"""The channel/uploader page URL. Generic: whatever yt-dlp exposes directly (YouTube gives """The channel/uploader page URL. Generic: whatever yt-dlp exposes directly (YouTube gives
`channel_url`), else None (reddit / a raw media URL have no derivable channel page).""" `channel_url`), else None (reddit / a raw media URL have no derivable channel page)."""
@@ -201,11 +206,17 @@ class FacebookAdapter(SourceAdapter):
# Measured (2026-09-14): a SIGNED-IN read gives the placeholder "Video" (plain and as Chrome), and an # Measured (2026-09-14): a SIGNED-IN read gives the placeholder "Video" (plain and as Chrome), and an
# 18+ reel only readable as Chrome gives "Facebook"; the post's text is still in `description`. A # 18+ reel only readable as Chrome gives "Facebook"; the post's text is still in `description`. A
# signed-out read titles the page with that same text behind the engagement counts, cut short. # signed-out read titles the page with that same text behind the engagement counts, cut short.
# Measured (2026-11-01, yt-dlp 2026.08.19): since 2026-10-28 every signed-in reel read gives
# "Facebook" and NO description, while a signed-out read of the same reel still carries the text —
# so a placeholder left with nothing behind it gets one signed-out read (`backfill.public_title_info`).
_PLACEHOLDER_TITLES = frozenset({"video", "facebook"}) _PLACEHOLDER_TITLES = frozenset({"video", "facebook"})
def is_placeholder_title(self, title: str | None) -> bool:
return (title or "").strip().lower() in self._PLACEHOLDER_TITLES
def title(self, info: dict) -> str | None: def title(self, info: dict) -> str | None:
raw = info.get("title") raw = info.get("title")
if (raw or "").strip().lower() not in self._PLACEHOLDER_TITLES: if not self.is_placeholder_title(raw):
return raw return raw
line = next( line = next(
(ln.strip() for ln in (info.get("description") or "").splitlines() if ln.strip()), "" (ln.strip() for ln in (info.get("description") or "").splitlines() if ln.strip()), ""
+56 -6
View File
@@ -5,7 +5,10 @@ auto-detected channel becomes a clickable link) and `poster_path` (an ffmpeg fra
thumbnail-less sources, so the card / watch page / link preview still shows an image). Best-effort thumbnail-less sources, so the card / watch page / link preview still shows an image). Best-effort
and safe to re-run: it only touches assets still missing the field, and only re-fetches YouTube / and safe to re-run: it only touches assets still missing the field, and only re-fetches YouTube /
Facebook sources (a direct media URL like a reddit HLS manifest carries no channel and its signed Facebook sources (a direct media URL like a reddit HLS manifest carries no channel and its signed
URL may have expired — those just get a locally-generated poster instead). URL may have expired — those just get a locally-generated poster instead). A Facebook download
titled with the source's placeholder ("Facebook", "Video") is retitled from a signed-out read of its
page, and so is each download name that only repeated the placeholder. That page read
(`public_title_info`) also titles every new download, from the worker.
Run once per environment: Run once per environment:
docker exec siftlode-worker-1 python -c "from app.downloads import backfill; print(backfill.run())" docker exec siftlode-worker-1 python -c "from app.downloads import backfill; print(backfill.run())"
@@ -14,7 +17,8 @@ Run once per environment:
from urllib.parse import urlsplit from urllib.parse import urlsplit
import yt_dlp import yt_dlp
from sqlalchemy import select from sqlalchemy import select, update
from sqlalchemy.orm import Session
from app import logs from app import logs
from app.config import settings from app.config import settings
@@ -22,12 +26,14 @@ from app.db import SessionLocal
from app.downloads import adapters from app.downloads import adapters
from app.downloads import edit as editmod from app.downloads import edit as editmod
from app.downloads import formats, service from app.downloads import formats, service
from app.downloads.models import MediaAsset from app.downloads.models import DownloadJob, MediaAsset
from app.models import DISPLAY_TEXT_MAX
from app.titles import normalize_title
log = logs.get_logger(__name__) log = logs.get_logger(__name__)
def _extract_meta(url: str, adapter: adapters.SourceAdapter, asset_id: int) -> dict | None: def extract_meta(url: str, adapter: adapters.SourceAdapter, asset_id: int) -> dict | None:
"""Metadata-only yt-dlp extraction, using the source adapter's client sets + options (YouTube """Metadata-only yt-dlp extraction, using the source adapter's client sets + options (YouTube
needs the POT/player-client strategy even just to extract).""" needs the POT/player-client strategy even just to extract)."""
with formats.hiding(url): with formats.hiding(url):
@@ -62,8 +68,43 @@ def _extract_meta_scoped(url: str, adapter: adapters.SourceAdapter, asset_id: in
return None return None
def public_title_info(info: dict, url: str, adapter: adapters.SourceAdapter, asset_id: int) -> dict:
"""`info` to title a download from: itself, unless its title is a source's placeholder with
nothing behind it (a signed-in Facebook read: "Facebook", no description). Then one signed-out,
metadata-only read of the page lends its text — the description, else its title — as the
description the adapter takes a placeholder's title from. Nothing better there: `info` as it was."""
if not adapter.is_placeholder_title(adapter.title(info)):
return info
public = extract_meta(url, adapter, asset_id)
text = public and (public.get("description") or public.get("title"))
return {**info, "description": text} if text else info
def _retitle(db: Session, a: MediaAsset, adapter: adapters.SourceAdapter, url: str) -> bool:
"""Swap a placeholder title kept from an earlier download for the page's text, and the download
names that only repeated it. A name its owner gave (anything else) stays."""
if not adapter.is_placeholder_title(a.title):
return False
info = public_title_info({"title": a.title, "description": None}, url, adapter, a.id)
named = normalize_title(adapter.title(info))
if not named or adapter.is_placeholder_title(named):
return False
# One conditional write, not a read and a compare: a name its owner gives while the page is read
# is not overwritten by what was read before it. The asset first, as every other writer of the
# two locks them (`service.lock_own_job`): the session does not flush by itself.
placeholder = a.title
a.title = named
db.flush()
db.execute(
update(DownloadJob)
.where(DownloadJob.asset_id == a.id, DownloadJob.display_name == placeholder)
.values(display_name=named[:DISPLAY_TEXT_MAX])
)
return True
def run() -> dict: def run() -> dict:
filled_url = filled_poster = 0 filled_url = filled_poster = retitled = 0
with SessionLocal() as db: with SessionLocal() as db:
assets = ( assets = (
db.execute( db.execute(
@@ -78,7 +119,7 @@ def run() -> dict:
adapter = adapters.resolve_adapter(a.source_kind, a.source_ref) adapter = adapters.resolve_adapter(a.source_kind, a.source_ref)
if a.uploader_url is None and adapter.wants_metadata_backfill(): if a.uploader_url is None and adapter.wants_metadata_backfill():
url = a.source_webpage_url or service.source_url(a.source_kind, a.source_ref) url = a.source_webpage_url or service.source_url(a.source_kind, a.source_ref)
info = _extract_meta(url, adapter, a.id) info = extract_meta(url, adapter, a.id)
uu = adapter.uploader_url(info) if info else None uu = adapter.uploader_url(info) if info else None
if uu: if uu:
a.uploader_url = uu a.uploader_url = uu
@@ -86,6 +127,14 @@ def run() -> dict:
log.info( log.info(
"asset %s uploader_url filled", a.id "asset %s uploader_url filled", a.id
) # the URL names what they downloaded ) # the URL names what they downloaded
if adapter.wants_metadata_backfill():
url = a.source_webpage_url or service.source_url(a.source_kind, a.source_ref)
if _retitle(db, a, adapter, url):
# Before the poster step: it can cut a frame for 30 s, and these locks held
# across it block a rename and deadlock a delete.
db.commit()
retitled += 1
log.info("asset %s retitled", a.id) # the title names what they downloaded
if a.poster_path is None: if a.poster_path is None:
# Record a self-hosted poster for every download (the reliable og:image source): # Record a self-hosted poster for every download (the reliable og:image source):
# ensure_poster returns the existing <base>.jpg thumbnail sidecar, or cuts a frame. # ensure_poster returns the existing <base>.jpg thumbnail sidecar, or cuts a frame.
@@ -99,6 +148,7 @@ def run() -> dict:
"assets": len(assets), "assets": len(assets),
"filled_uploader_url": filled_url, "filled_uploader_url": filled_url,
"filled_poster": filled_poster, "filled_poster": filled_poster,
"retitled": retitled,
} }
log.info("backfill done: %s", result) log.info("backfill done: %s", result)
return result return result
+18 -2
View File
@@ -22,6 +22,7 @@ import json
import math import math
import re import re
from pathlib import Path from pathlib import Path
from typing import Literal
from app.downloads.edit import AAC_ARGS, EVEN_FRAME, INPUT_GUARD, X264_ARGS from app.downloads.edit import AAC_ARGS, EVEN_FRAME, INPUT_GUARD, X264_ARGS
from app.plex.sync import HDR_TRANSFERS from app.plex.sync import HDR_TRANSFERS
@@ -57,12 +58,27 @@ _CROP_ASPECTS = (0.25, 4.0)
_EDGE_SLACK = 1e-3 _EDGE_SLACK = 1e-3
# Every reason a clip is refused; the UI names each one (`clip.errors.<reason>`, held by the frontend's
# backendIds contract, which reads this Literal).
ClipReason = Literal[
"bad_source",
"bad_range",
"too_short",
"too_long",
"bad_audio",
"bad_crop",
"not_found",
"media_missing",
"past_end",
]
class ClipError(Exception): class ClipError(Exception):
"""A clip request that cannot be cut; `reason` is an i18n-mappable key, `params` the numbers its """A clip request that cannot be cut; `reason` is an i18n-mappable key, `params` the numbers its
message names (the cap itself, so the client never says a stale one).""" message names (the cap itself, so the client never says a stale one)."""
def __init__(self, reason: str, **params: float): def __init__(self, reason: ClipReason, **params: float):
self.reason = reason self.reason: ClipReason = reason
self.params = params self.params = params
super().__init__(reason) super().__init__(reason)
+10
View File
@@ -25,6 +25,7 @@ Security controls enforced here:
import os import os
from pathlib import Path from pathlib import Path
from typing import Literal
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -163,9 +164,18 @@ def get_credential(db: Session, user_id: int, host_key: str) -> DownloadCredenti
).scalar_one_or_none() ).scalar_one_or_none()
# Every reason a pasted cookies.txt is refused; the UI names each one (`downloads.errors.cookies.<reason>`,
# held by the frontend's backendIds contract, which reads this Literal).
CookieReason = Literal["empty", "format"]
class InvalidCookies(ValueError): class InvalidCookies(ValueError):
"""The pasted cookies aren't a usable Netscape cookies.txt — surfaced as a 400 by the route.""" """The pasted cookies aren't a usable Netscape cookies.txt — surfaced as a 400 by the route."""
def __init__(self, reason: CookieReason):
super().__init__(reason)
self.reason: CookieReason = reason
def validate_cookies(raw: str) -> str: def validate_cookies(raw: str) -> str:
"""Sanity-check a pasted cookies.txt (Netscape format) and return the cleaned text. yt-dlp reads """Sanity-check a pasted cookies.txt (Netscape format) and return the cleaned text. yt-dlp reads
+13 -2
View File
@@ -24,6 +24,7 @@ kept it — and its `ready_at`, the moment it finished (`stamp_done`).
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from enum import StrEnum from enum import StrEnum
from typing import Literal
from urllib.parse import urlsplit from urllib.parse import urlsplit
from sqlalchemy import select from sqlalchemy import select
@@ -181,12 +182,22 @@ def stamp_done(db: Session, job: DownloadJob) -> None:
job.expiry_warned = False job.expiry_warned = False
# Every folder change the rules refuse, answered as a generic refusal; the UI names each one
# (`errors.refused.<reason>`, held by the frontend's backendIds contract, which reads this Literal).
FolderReason = Literal[
"file_folder_gone",
"file_folder_name_taken",
"file_folder_not_its_source",
"file_folder_not_own",
]
class FolderError(Exception): class FolderError(Exception):
"""A folder change the rules refuse; `reason` is the refusal the route answers with.""" """A folder change the rules refuse; `reason` is the refusal the route answers with."""
def __init__(self, reason: str) -> None: def __init__(self, reason: FolderReason) -> None:
super().__init__(reason) super().__init__(reason)
self.reason = reason self.reason: FolderReason = reason
def own_folder(db: Session, user_id: int, folder_id: int, *, hold: bool = True) -> FileFolder: def own_folder(db: Session, user_id: int, folder_id: int, *, hold: bool = True) -> FileFolder:
+17 -4
View File
@@ -7,6 +7,7 @@ from datetime import date, datetime, timezone
from pathlib import Path from pathlib import Path
import yt_dlp import yt_dlp
from sqlalchemy import update
from yt_dlp.utils import download_range_func from yt_dlp.utils import download_range_func
from app import logs, sysconfig from app import logs, sysconfig
@@ -16,6 +17,7 @@ from app.downloads import clip as clipmod
from app.downloads import edit as editmod from app.downloads import edit as editmod
from app.downloads import errors as dlerrors from app.downloads import errors as dlerrors
from app.downloads import adapters, credentials, formats, mediakind, playable, probe, quota from app.downloads import adapters, credentials, formats, mediakind, playable, probe, quota
from app.downloads import backfill
from app.downloads import service, storage from app.downloads import service, storage
from app.downloads import urlguard from app.downloads import urlguard
from app.downloads.jobdb import ( from app.downloads.jobdb import (
@@ -585,9 +587,14 @@ def _download(
# missing duration hid the trim action. Probed before the sidecars so the .nfo runtime has it # missing duration hid the trim action. Probed before the sidecars so the .nfo runtime has it
# too. Same probe the upload ingest uses. # too. Same probe the upload ingest uses.
vcodec, acodec, width, height, duration_s = probe.resolve_media_facts(produced, info) vcodec, acodec, width, height, duration_s = probe.resolve_media_facts(produced, info)
# A placeholder title with nothing behind it ("Facebook" from a signed-in read) borrows the
# page's text from one signed-out read; the download itself stays the signed-in one. The page
# yt-dlp read, not the link queued: a group post's link is rewritten to its video, and
# signed out the post itself is the login wall.
titled = backfill.public_title_info(info, info.get("webpage_url") or url, adapter, asset_id)
meta = storage.MediaMeta( meta = storage.MediaMeta(
video_id=info.get("id") or source_ref, video_id=info.get("id") or source_ref,
title=normalize_title(adapter.title(info)) or source_ref, title=normalize_title(adapter.title(titled)) or source_ref,
uploader=info.get("uploader") or info.get("channel") or "Unknown Channel", uploader=info.get("uploader") or info.get("channel") or "Unknown Channel",
upload_date=_parse_upload_date(info.get("upload_date")), upload_date=_parse_upload_date(info.get("upload_date")),
uniqueid_type=adapter.nfo_uniqueid_type(), uniqueid_type=adapter.nfo_uniqueid_type(),
@@ -1116,14 +1123,20 @@ def _process_clip(job_id: int, asset_id: int) -> None:
fields: dict = {"status": JobStatus.DONE, "progress": 100, "phase": None} fields: dict = {"status": JobStatus.DONE, "progress": 100, "phase": None}
# A video outside the catalog was queued as "Clip (clip)": the fetch has its title now. # A video outside the catalog was queued as "Clip (clip)": the fetch has its title now.
placeholder = service.clip_title(None, None) placeholder = service.clip_title(None, None)
named = None
if asset and fetched_title and asset.title == placeholder: if asset and fetched_title and asset.title == placeholder:
named = service.clip_title(fetched_title, None) named = service.clip_title(fetched_title, None)
asset.title = named asset.title = named
if job_row := db.get(DownloadJob, job_id):
if job_row.display_name == placeholder:
fields["display_name"] = named[:DISPLAY_TEXT_MAX]
title = asset.title if asset else None title = asset.title if asset else None
finalized = service.finalize_if_running(db, job_id, **fields) finalized = service.finalize_if_running(db, job_id, **fields)
if finalized and named:
# Only a name that still repeats the placeholder, in one conditional write: one its
# owner gave meanwhile stays.
db.execute(
update(DownloadJob)
.where(DownloadJob.id == job_id, DownloadJob.display_name == placeholder)
.values(display_name=named[:DISPLAY_TEXT_MAX])
)
db.commit() db.commit()
if finalized: if finalized:
_notify_download_done(db, user_id, title, job_id, kind="edit") _notify_download_done(db, user_id, title, job_id, kind="edit")
+10 -4
View File
@@ -20,6 +20,7 @@ their output's real size before it is placed. Edit jobs do not consult it at all
""" """
from dataclasses import dataclass from dataclasses import dataclass
from typing import Literal
from sqlalchemy import and_, func, or_, select from sqlalchemy import and_, func, or_, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -50,11 +51,16 @@ class QuotaLimits:
unlimited: bool unlimited: bool
class QuotaExceeded(Exception): # Every cap a download can hit; the UI names each one (`downloads.errors.quota.<reason>`, held by the
"""Raised by check_enqueue; `reason` is one of max_jobs|max_bytes (i18n key on the client).""" # frontend's backendIds contract, which reads this Literal).
QuotaReason = Literal["max_jobs", "max_bytes"]
def __init__(self, reason: str, limit: int, current: int):
self.reason = reason class QuotaExceeded(Exception):
"""Raised by check_enqueue; `reason` names the cap it hit (an i18n key on the client)."""
def __init__(self, reason: QuotaReason, limit: int, current: int):
self.reason: QuotaReason = reason
self.limit = limit self.limit = limit
self.current = current self.current = current
super().__init__(f"download quota exceeded: {reason} ({current}/{limit})") super().__init__(f"download quota exceeded: {reason} ({current}/{limit})")
+10 -4
View File
@@ -10,6 +10,7 @@ handler); the folder, link and quota writes are `folders`, `links` and `quota`.
import uuid import uuid
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Literal
from sqlalchemy import delete as delete_rows from sqlalchemy import delete as delete_rows
from sqlalchemy import func, select, update from sqlalchemy import func, select, update
@@ -39,11 +40,16 @@ from app.plex import paths as plex_paths
from app.userscope import owned_by, owned_row from app.userscope import owned_by, owned_row
class EditError(Exception): # Every reason an edit is refused; the UI names each one (`downloads.errors.edit.<reason>`, held by
"""Bad edit request (empty spec / source not ready); `reason` is an i18n-mappable key.""" # the frontend's backendIds contract, which reads this Literal).
EditReason = Literal["source_not_ready", "not_media", "empty_edit"]
def __init__(self, reason: str):
self.reason = reason class EditError(Exception):
"""Bad edit request (empty spec / source not ready / not media); `reason` names the sentence."""
def __init__(self, reason: EditReason):
self.reason: EditReason = reason
super().__init__(reason) super().__init__(reason)
+17 -8
View File
@@ -33,24 +33,33 @@ user-controlled destination to guard.
import ipaddress import ipaddress
import socket import socket
from typing import Literal
from urllib.parse import urlparse from urllib.parse import urlparse
# Reason codes travel to the client inside the structured 422 detail, so they are part of the API # Reason codes travel to the client inside the structured 422 detail, so they are part of the API
# vocabulary (frontend `localizeDetail`) — keep them stable. # vocabulary — keep them stable. The UI names each one (`downloads.errors.urlBlocked.<reason>`, held by
REASON_SCHEME = "scheme" # not http(s) # the frontend's backendIds contract, which reads this Literal).
REASON_NO_HOST = "no_host" # URL carries no hostname UrlBlockedReason = Literal["scheme", "no_host", "malformed", "unresolvable", "private"]
REASON_MALFORMED = "malformed" # unparseable authority or port; urlparse raises late, so guard it REASON_SCHEME: UrlBlockedReason = "scheme" # not http(s)
REASON_UNRESOLVABLE = "unresolvable" # DNS said nothing (honest: not the same as "blocked") REASON_NO_HOST: UrlBlockedReason = "no_host" # URL carries no hostname
REASON_PRIVATE = "private" # resolves into a network the server must not fetch from REASON_MALFORMED: UrlBlockedReason = (
"malformed" # unparseable authority or port; urlparse raises late
)
REASON_UNRESOLVABLE: UrlBlockedReason = (
"unresolvable" # DNS said nothing (not the same as "blocked")
)
REASON_PRIVATE: UrlBlockedReason = (
"private" # resolves into a network the server must not fetch from
)
class BlockedUrl(Exception): class BlockedUrl(Exception):
"""A user-supplied URL that must not be fetched server-side. ``reason`` is one of the """A user-supplied URL that must not be fetched server-side. ``reason`` is one of the
REASON_* codes above.""" REASON_* codes above."""
def __init__(self, reason: str): def __init__(self, reason: UrlBlockedReason):
super().__init__(reason) super().__init__(reason)
self.reason = reason self.reason: UrlBlockedReason = reason
def _is_forbidden(ip: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool: def _is_forbidden(ip: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool:
+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. 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.dialects.postgresql import insert as pg_insert
from sqlalchemy.orm import Session 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 -------------------------------------------------------------------- # --- 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: 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 """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, now: datetime,
*, *,
dirty: bool, dirty: bool,
) -> PlexState: ) -> bool:
"""Record a resume position. `dirty` marks it not yet mirrored to Plex — set before a push, so """Record a resume position, and say whether it was recorded. `dirty` marks it not yet mirrored
the reconcile re-pushes it if the push fails and the push can settle it once Plex confirms.""" 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 = state_row(db, user, item_id, st)
st.position_seconds = position values: dict[str, object] = {"position_seconds": position, "progress_updated_at": now}
st.progress_updated_at = now
if dirty: 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() db.flush()
return st return isinstance(result, CursorResult) and result.rowcount > 0
def forget_state(db: Session, st: PlexState) -> None: def forget_state(db: Session, st: PlexState) -> None:
+95 -18
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 # 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 # 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 # 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 # 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 # 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 # 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 # fail. Covered by test_plex_stream.py — this regex is the only thing standing between the sweep
# and someone else's data. # and someone else's data.
_TOMB = ".dead-"
_SESSION_DIR_RE = re.compile( _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 # 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 # 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() _lock = threading.Lock()
# Keyed by VIEWER and item, (user_id, rating_key). Keyed by the item alone, a second account opening # 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 # 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.) # account on two devices still shares a session; that case ends in the player's bounded reclaim.)
_sessions: dict[tuple[int, str], "HlsSession"] = {} _sessions: dict[tuple[int, str], "HlsSession"] = {}
@@ -641,29 +645,82 @@ def _probe_first_pts(seg: Path) -> float | None:
return 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: try:
s.proc.terminate()
try: try:
s.proc.wait(timeout=3) proc.wait(timeout=3)
# silent-ok: the escalation IS the handling. A transcode that ignores TERM for three # silent-ok: the escalation IS the handling. A transcode that ignores TERM for three
# seconds gets KILL, which is exactly what this branch does. # seconds gets KILL, which is exactly what this branch does.
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
s.proc.kill() proc.kill()
except Exception as exc: # noqa: BLE001 — teardown finishes whatever the process does 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 # 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 # 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. # `_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( log.info(
"could not stop the transcode process", "could not stop the transcode process",
extra=logs.event("plex.stream.kill_failed", key=s.key, reason=type(exc).__name__), extra=logs.event("plex.stream.kill_failed", key=s.key, reason=type(exc).__name__),
) )
_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) 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: def _finish(doomed: "list[tuple[HlsSession, Path | None]]") -> None:
"""Caller holds _lock. Three bounds, applied after a session is registered, each dropping the """The slow half of a teardown, called WITHOUT `_lock`."""
least recently accessed sessions over it: 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 * this account's own sessions (`_MAX_SESSIONS_PER_USER`) — first, so a single account filling the
box evicts itself before anyone else; 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 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.""" 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: def cull(pool: list[tuple[tuple[int, str], HlsSession]], cap: int, bound: str) -> None:
if len(pool) <= cap: if len(pool) <= cap:
return return
@@ -690,12 +749,13 @@ def _enforce_caps(user_id: int, max_transcodes: int, max_sessions: int) -> None:
sessions=len(pool), sessions=len(pool),
), ),
) )
_kill(sess)
_sessions.pop(reg, None) _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[0][0] == user_id], _MAX_SESSIONS_PER_USER, "user")
cull([kv for kv in _sessions.items() if kv[1].transcode], max_transcodes, "transcode") cull([kv for kv in _sessions.items() if kv[1].transcode], max_transcodes, "transcode")
cull(list(_sessions.items()), max_sessions, "total") cull(list(_sessions.items()), max_sessions, "total")
return doomed
def start_session( def start_session(
@@ -759,12 +819,21 @@ def start_session(
# can't starve the co-located API/playback. preexec_fn runs in the forked child (POSIX); on a # 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. # 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 preexec = _sw_child_preexec if (transcode and not hw and os.name == "posix") else None
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: with _lock:
if replace_only is not None and _sessions.get(reg) is not replace_only: 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 return None # superseded/culled while we waited out of lock — don't clobber the newer one
old = _sessions.pop(reg, None) old = _sessions.pop(reg, None)
if old is not None: if old is not None:
_kill(old) 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) shutil.rmtree(directory, ignore_errors=True)
directory.mkdir(parents=True, exist_ok=True) directory.mkdir(parents=True, exist_ok=True)
proc = subprocess.Popen( proc = subprocess.Popen(
@@ -816,11 +885,13 @@ def start_session(
) )
# Admin-configurable caps (Configuration → Plex); at least 1 each so playback can't be # Admin-configurable caps (Configuration → Plex); at least 1 each so playback can't be
# capped to zero. # capped to zero.
_enforce_caps( doomed += _enforce_caps(
user_id, user_id,
max(1, sysconfig.get_int(db, "plex_max_transcodes")), max(1, sysconfig.get_int(db, "plex_max_transcodes")),
max(1, sysconfig.get_int(db, "plex_max_sessions")), max(1, sysconfig.get_int(db, "plex_max_sessions")),
) )
finally:
_finish(doomed)
return s return s
s = _spawn(*_select_encode(db, item, transcode, src)) 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. # session, absent for a single-audio one.
if variant not in (s.variants or (None,)): if variant not in (s.variants or (None,)):
return False 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: if seq == 0:
return True return True
try: 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 `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.""" one a whole segmented copy of the film on a volume shared with the download center."""
now = time.time() now = time.time()
dropped = 0 doomed: list[tuple[HlsSession, Path | None]] = []
with _lock: with _lock:
for reg, s in list(_sessions.items()): for reg, s in list(_sessions.items()):
done = s.proc.poll() is not None done = s.proc.poll() is not None
idle = now - s.last_access idle = now - s.last_access
if idle > _SESSION_IDLE_S or (done and idle > finished_idle_s): if idle > _SESSION_IDLE_S or (done and idle > finished_idle_s):
_kill(s)
_sessions.pop(reg, None) _sessions.pop(reg, None)
dropped += 1 doomed.append(_detach(s))
return dropped _finish(doomed)
return len(doomed)
def stop_session(user_id: int, key: str, session_id: str) -> bool: 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)) s = _sessions.get((user_id, key))
if s is None or s.id != session_id: if s is None or s.id != session_id:
return False return False
_kill(s)
_sessions.pop((user_id, key), None) _sessions.pop((user_id, key), None)
doomed = [_detach(s)]
_finish(doomed)
return True return True
+8 -14
View File
@@ -9,7 +9,7 @@ unique key, a row lock taken before a presence check. The comfort shelf's writes
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import cast from typing import Literal, cast
from sqlalchemy import Table, bindparam, delete, exists, func, select, text, update from sqlalchemy import Table, bindparam, delete, exists, func, select, text, update
from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.dialects.postgresql import insert as pg_insert
@@ -255,17 +255,6 @@ def mark(db: Session, user: User, video_id: str, status: str) -> None:
) )
def clear_state(db: Session, user: User, video_id: str) -> None:
"""The video back to pristine for this user: status, resume point and `watched_at` all go.
Playlist membership lives elsewhere and stays."""
lock_takeout(db, user) # as `unmark`
row = queries.video_state(db, user, video_id)
if row is not None:
db.delete(row)
_remember_unmark(db, user, video_id)
db.flush()
def set_progress(db: Session, user: User, video_id: str, position: int) -> None: def set_progress(db: Session, user: User, video_id: str, position: int) -> None:
"""The player's resume point. 0 clears it: a row holding nothing else goes, one with a status """The player's resume point. 0 clears it: a row holding nothing else goes, one with a status
(watched, hidden) keeps it with the position zeroed. The status itself is never changed here. (watched, hidden) keeps it with the position zeroed. The status itself is never changed here.
@@ -301,12 +290,17 @@ def set_progress(db: Session, user: User, video_id: str, position: int) -> None:
# --- playlists ------------------------------------------------------------------------------- # --- playlists -------------------------------------------------------------------------------
# Every reason a move is refused; the UI names each one (`playlists.moveErrors.<reason>`, held by the
# frontend's backendIds contract, which reads this Literal).
MoveReason = Literal["same_playlist", "not_in_playlist"]
class MoveRefused(Exception): class MoveRefused(Exception):
"""A move between playlists that cannot happen: `reason` is the code the UI translates.""" """A move between playlists that cannot happen: `reason` is the code the UI translates."""
def __init__(self, reason: str): def __init__(self, reason: MoveReason):
super().__init__(reason) super().__init__(reason)
self.reason = reason self.reason: MoveReason = reason
def touch(pl: Playlist) -> None: def touch(pl: Playlist) -> None:
+20 -10
View File
@@ -1,29 +1,39 @@
"""A request the app refuses, said in a way the UI can put in the reader's language. """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 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 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 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 (`errors.refused.<reason>`, the params interpolated into the sentence); the older specific codes
(`not_found`, `youtube`, `playlist`, the admin refusals, …) stay, since each is already translated. (`not_found`, `youtube`, `playlist`, the admin refusals, …) stay, since each is already translated.
A test (`test_http_details.py`) reads every `HTTPException` in the app and fails on a string detail, A test (`test_http_details.py`) reads every place an `HTTPException` or a `JSONResponse` sends a detail
and the frontend's contract test requires every `refused(…, "<reason>")` to be named in both and fails on one the client cannot say: a sentence, a dict without a `code`, a variable, none at all.
languages — so the rule is enforced, not described. `tests/refusal_sites.py` reads every `{"code": …}` detail from the syntax tree, written where it is sent
(or returned by a factory that only ever is), with any `**` spread before its `code` and `reason`, and
follows each reason along a closed list of shapes: a literal, a `Literal` type on the parameter or
attribute it travels in, every call of a helper like `refused` below, a map's values; anything else is
reported, never guessed. The frontend's contract test
(`i18n/backendIds.contract.test.ts`) runs it and requires each code in one record of
`lib/api/refusalCodes.ts`, and each reason's sentence in both languages; a code or reason it cannot
follow fails it, unless no screen shows that code. So the rule is enforced, not described.
""" """
from fastapi import HTTPException from fastapi import HTTPException
from app.sysconfig import InvalidSetting, SettingReason
def refused(status: int, reason: str, **params: object) -> HTTPException: def refused(status: int, reason: str, **params: object) -> HTTPException:
"""The refusal to raise: `raise refused(409, "view_name_taken", name=name)`. `params` travel in """The refusal to raise: `raise refused(409, "view_name_taken", name=name)`. `params` travel in
the detail for the sentence to interpolate (`{{name}}`); keep them to values the reader may see.""" the detail for the sentence to interpolate (`{{name}}`); keep them to values the reader may see.
return HTTPException(status_code=status, detail={"code": "refused", "reason": reason, **params}) They are spread first, so a `code` or `reason` among them could never replace these two."""
return HTTPException(status_code=status, detail={**params, "code": "refused", "reason": reason})
# `sysconfig.InvalidSetting.reason` → the refusal it answers as. A dict of literals, so the frontend's # `sysconfig.InvalidSetting.reason` → the refusal it answers as. A dict of literals, so the refusal
# contract test can read every reason this can produce. # scan reads every reason this can produce, and holds its keys to `SettingReason`.
_SETTING_REASONS = { _SETTING_REASONS: dict[SettingReason, str] = {
"not_a_number": "setting_not_a_number", "not_a_number": "setting_not_a_number",
"too_low": "setting_too_low", "too_low": "setting_too_low",
"too_high": "setting_too_high", "too_high": "setting_too_high",
@@ -32,6 +42,6 @@ _SETTING_REASONS = {
} }
def setting_refused(exc) -> HTTPException: def setting_refused(exc: InvalidSetting) -> HTTPException:
"""A value the admin entered that `sysconfig.set_value` refused (`exc`: its `InvalidSetting`).""" """A value the admin entered that `sysconfig.set_value` refused (`exc`: its `InvalidSetting`)."""
return refused(400, _SETTING_REASONS[exc.reason], key=exc.key, **exc.params) return refused(400, _SETTING_REASONS[exc.reason], key=exc.key, **exc.params)
+19 -10
View File
@@ -976,7 +976,16 @@ def _resend_verify_if_eligible(email: str) -> None:
send_verification_link(db, user) send_verification_link(db, user)
def _login_refused(status: int, reason: str, user: User | None = None, **extra) -> HTTPException: # Every refusal a password sign-in answers with; the UI names each one (`welcome.auth.blocked.<reason>`,
# held by the frontend's backendIds contract, which reads this Literal).
LoginRefusal = Literal[
"rate_limited", "invalid", "suspended", "email_unverified", "pending_approval"
]
def _login_refused(
status: int, reason: LoginRefusal, user: User | None = None, **extra
) -> HTTPException:
"""Every refusal password_login can answer with, in the codebase's structured `{code, reason}` """Every refusal password_login can answer with, in the codebase's structured `{code, reason}`
shape so the SPA can say it in the user's own language (the old bare English strings were shape so the SPA can say it in the user's own language (the old bare English strings were
unreadable under a bilingual UI, and unusable as anything but a one-line error). unreadable under a bilingual UI, and unusable as anything but a one-line error).
@@ -992,15 +1001,15 @@ def _login_refused(status: int, reason: str, user: User | None = None, **extra)
`email_verified` is false, and vice versa — the SPA renders whichever is actually outstanding `email_verified` is false, and vice versa — the SPA renders whichever is actually outstanding
rather than assuming a sequence. rather than assuming a sequence.
""" """
detail: dict = {"code": "login", "reason": reason} account = (
if user is not None: {"email": user.email, "email_verified": user.email_verified, "approved": user.is_active}
detail |= { if user is not None
"email": user.email, else {}
"email_verified": user.email_verified, )
"approved": user.is_active, given = {k: v for k, v in extra.items() if v is not None}
} return HTTPException(
detail |= {k: v for k, v in extra.items() if v is not None} status_code=status, detail={**account, **given, "code": "login", "reason": reason}
return HTTPException(status_code=status, detail=detail) )
@router.post("/password-login") @router.post("/password-login")
+4
View File
@@ -76,6 +76,10 @@ def list_channels(user: User = Depends(current_user), db: Session = Depends(get_
"details_synced": ch.details_synced_at is not None, "details_synced": ch.details_synced_at is not None,
"recent_synced": ch.recent_synced_at is not None, "recent_synced": ch.recent_synced_at is not None,
"backfill_done": ch.backfill_done, "backfill_done": ch.backfill_done,
# When the follow reached this list: written by the subscription import the run that
# first saw it, or by a subscribe made here — not the channel row's own date, which an
# explored or discovered channel had before it was followed.
"added_at": iso(sub.created_at),
} }
for sub, ch in rows for sub, ch in rows
] ]
+4 -3
View File
@@ -208,7 +208,7 @@ def set_credential(
try: try:
cleaned = dlcreds.validate_cookies(body.cookies) cleaned = dlcreds.validate_cookies(body.cookies)
except dlcreds.InvalidCookies as e: except dlcreds.InvalidCookies as e:
raise HTTPException(status_code=400, detail={"code": "cookies", "reason": str(e)}) from e raise HTTPException(status_code=400, detail={"code": "cookies", "reason": e.reason}) from e
dlcreds.upsert_credential(db, user.id, host_key, cleaned) dlcreds.upsert_credential(db, user.id, host_key, cleaned)
audit.record( audit.record(
db, db,
@@ -359,8 +359,9 @@ def enqueue_clip(
job = service.enqueue_clip(db, user.id, spec, display_name=display_name) job = service.enqueue_clip(db, user.id, spec, display_name=display_name)
except clipmod.ClipError as e: except clipmod.ClipError as e:
status = 404 if e.reason == "not_found" else 422 status = 404 if e.reason == "not_found" else 422
detail = {"code": "clip", "reason": e.reason, **e.params} raise HTTPException(
raise HTTPException(status_code=status, detail=detail) from e status_code=status, detail={**e.params, "code": "clip", "reason": e.reason}
) from e
except quota.QuotaExceeded as e: except quota.QuotaExceeded as e:
raise HTTPException(status_code=422, detail=quota_detail(e)) from e raise HTTPException(status_code=422, detail=quota_detail(e)) from e
db.commit() db.commit()
+22 -8
View File
@@ -204,22 +204,21 @@ def set_video_state(
return {"ok": True, "video_id": video_id, "status": status} return {"ok": True, "video_id": video_id, "status": status}
@router.delete("/videos/{video_id}/state") @router.delete("/videos/{video_id}/progress")
def clear_video_state( def clear_video_progress(
video_id: str, video_id: str,
user: User = Depends(current_user), user: User = Depends(current_user),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> dict: ) -> dict:
"""Reset a video to pristine for this user — drop the whole VideoState row (status, """Forget where the user left a video: its resume point goes, its status (watched, hidden) stays.
watch position and watched_at), as if it had never been opened. Saved/playlist membership Saved/playlist membership lives elsewhere and is untouched."""
lives elsewhere and is untouched."""
try: try:
service.clear_state(db, user, video_id) service.set_progress(db, user, video_id, 0)
db.commit() db.commit()
# silent-ok: the row went under the delete, which is the outcome the delete wanted. # silent-ok: the row went under the write, which is the outcome the write wanted.
except StaleDataError: except StaleDataError:
db.rollback() db.rollback()
return {"ok": True, "video_id": video_id, "status": "new", "position_seconds": 0} return {"ok": True, "video_id": video_id, "position_seconds": 0}
@router.post("/videos/{video_id}/progress") @router.post("/videos/{video_id}/progress")
@@ -249,6 +248,21 @@ def set_video_progress(
return {"ok": True, "video_id": video_id, "position_seconds": position} 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}") @router.get("/videos/{video_id}")
def get_video_detail( def get_video_detail(
video_id: str, 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: def quota_detail(e: quota.QuotaExceeded) -> dict:
"""Structured error the trilingual client localizes (reason key + numbers), instead of a """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} return {"code": "quota", "reason": e.reason, "limit": e.limit, "current": e.current}
+17 -2
View File
@@ -3,7 +3,7 @@ CRUD (create / rename / delete, add / remove / reorder items) plus YouTube two-w
mirror the user's own YouTube playlists in (`/sync-youtube`) and push/revert local edits back mirror the user's own YouTube playlists in (`/sync-youtube`) and push/revert local edits back
out (`/{id}/push-plan`, `/{id}/revert-youtube`), behind the optional write scope.""" out (`/{id}/push-plan`, `/{id}/revert-youtube`), behind the optional write scope."""
from typing import TYPE_CHECKING from typing import TYPE_CHECKING, Literal
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@@ -53,7 +53,22 @@ def _check_pull_cooldown(user: User) -> None:
raise HTTPException(status_code=429, detail={"code": "sync_cooldown"}) raise HTTPException(status_code=429, detail={"code": "sync_cooldown"})
def _refused(status: int, reason: str) -> HTTPException: # Every playlist action the server refuses; the UI names each one (`playlists.errors.<reason>`, held by
# the frontend's backendIds contract, which reads this Literal).
PlaylistRefusal = Literal[
"name_required",
"not_on_youtube",
"quota_exceeded",
"sync_all_busy",
"sync_busy",
"watch_later_not_pushable",
"watch_later_undeletable",
"youtube_editing_off",
"youtube_read_scope",
]
def _refused(status: int, reason: PlaylistRefusal) -> HTTPException:
"""A playlist action the server refuses, as a reason the UI translates """A playlist action the server refuses, as a reason the UI translates
(`playlists.errors.<reason>`) — a bare English detail reached the Hungarian UI verbatim.""" (`playlists.errors.<reason>`) — a bare English detail reached the Hungarian UI verbatim."""
return HTTPException(status_code=status, detail={"code": "playlist", "reason": reason}) return HTTPException(status_code=status, detail={"code": "playlist", "reason": reason})
+25 -10
View File
@@ -234,6 +234,15 @@ def person_image(u: str, request: Request) -> Response:
return imgcache.response(data, ct) 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( def _push_resume_guarded(
user_id: int, user_id: int,
item_id: int, item_id: int,
@@ -329,12 +338,7 @@ def item_progress(
near_end = duration > 0 and position > duration - _FINISH_MARGIN_S near_end = duration > 0 and position > duration - _FINISH_MARGIN_S
if near_end: if near_end:
flipped = service.mark_finished(db, user.id, it.id, now) 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 _supersede_resume_pushes(user.id, it.id, now)
# 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
db.commit() db.commit()
# Only the checkpoint that flipped the row scrobbles: a later checkpoint of the same finished # 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. # 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) 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 # 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. # `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() db.commit()
if push_now: if push_now:
_push_watch( _push_watch(
@@ -408,6 +416,8 @@ def item_state(
_push_watch(background, db, user.id, it.id, rating_key, "unwatched") _push_watch(background, db, user.id, it.id, rating_key, "unwatched")
return {"ok": True, "status": "new"} return {"ok": True, "status": "new"}
service.set_status(db, user, it.id, st, status) 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() db.commit()
if status == "watched" and prev_status != "watched": if status == "watched" and prev_status != "watched":
_push_watch(background, db, user.id, it.id, rating_key, "watched") _push_watch(background, db, user.id, it.id, rating_key, "watched")
@@ -427,6 +437,10 @@ def _bulk_state(
always reads the committed marks.""" always reads the committed marks."""
pushable = plex_watch.link_for_push(db, user_id) is not None pushable = plex_watch.link_for_push(db, user_id) is not None
to_push = service.bulk_state(db, user_id, eps, watched) 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: if pushable and to_push:
# One coalesced background task for the whole batch (reuses a single session + Plex client), # 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. # not one task per episode — a 200-ep show mark is 1 task, not 200.
@@ -632,9 +646,10 @@ def stream_hls_file(
try: try:
content = path.read_bytes() content = path.read_bytes()
except OSError: except OSError:
# The session was torn down between the wait and the read — `_kill` rmtrees the whole # The session was torn down between the wait and the read — teardown moves the whole
# directory, and nothing holds `_lock` across those two steps. It has to present as the same # directory aside and deletes it, and nothing holds `_lock` across those two steps. It has
# 404 a gone session does: the player's recovery is keyed on the STATUS (`code === 404`), so # 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 # 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 # 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 # 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() db.commit()
except IntegrityError: except IntegrityError:
db.rollback() 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) return _serialize(view)
+2 -1
View File
@@ -107,7 +107,8 @@ def update_tag(
db.commit() db.commit()
except IntegrityError: except IntegrityError:
db.rollback() 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 { return {
"ok": True, "ok": True,
"id": tag.id, "id": tag.id,
+7 -2
View File
@@ -443,15 +443,20 @@ def _default(s: ConfigSpec):
return getattr(settings, s.default_attr) return getattr(settings, s.default_attr)
# Every reason `set_value` refuses with. `refusal.setting_refused` looks each one up in its map, which
# the refusal scan holds to exactly these keys: a reason the map lacks would answer a 500.
SettingReason = Literal["not_a_number", "too_low", "too_high", "not_a_choice", "no_encryption_key"]
class InvalidSetting(ValueError): class InvalidSetting(ValueError):
"""A value `set_value` refuses, as a reason the UI translates (`errors.refused.setting_<reason>`) """A value `set_value` refuses, as a reason the UI translates (`errors.refused.setting_<reason>`)
plus what the sentence needs — the English message is for logs only. A ValueError still, so a plus what the sentence needs — the English message is for logs only. A ValueError still, so a
caller that only needs "refused or not" keeps working.""" caller that only needs "refused or not" keeps working."""
def __init__(self, key: str, reason: str, **params: object): def __init__(self, key: str, reason: SettingReason, **params: object):
super().__init__(f"{key}: {reason} {params}") super().__init__(f"{key}: {reason} {params}")
self.key = key self.key = key
self.reason = reason self.reason: SettingReason = reason
self.params = params self.params = params
+4 -3
View File
@@ -6,9 +6,10 @@
# bumped in place instead of a full regeneration, which would move every other package too — then # bumped in place instead of a full regeneration, which would move every other package too — then
# `pip install -r requirements.lock && pip check` must pass. In place so far: urllib3 2.7.0 -> 2.8.0 (PYSEC-2026-4175/4176/4177); yt-dlp 2026.7.4 -> 2026.8.19 # `pip install -r requirements.lock && pip check` must pass. In place so far: urllib3 2.7.0 -> 2.8.0 (PYSEC-2026-4175/4176/4177); yt-dlp 2026.7.4 -> 2026.8.19
# (it marks the clients that need a stream PO token, so the downloader stops fetching into a 403); # (it marks the clients that need a stream PO token, so the downloader stops fetching into a 403);
# certifi 2026.7.22 -> 2026.10.19 (GHSA-6q48-6c7w-9gw2); brotli 1.2.0 -> 1.2.1 (PYSEC-2026-4322). # certifi 2026.7.22 -> 2026.10.19 (GHSA-6q48-6c7w-9gw2); brotli 1.2.0 -> 1.2.1 (PYSEC-2026-4322);
# Authlib 1.7.2 -> 1.9.1 (CVE-2026-96760, CVE-2026-114616); joserfc 1.7.4 -> 1.7.6 (CVE-2026-119283, CVE-2026-119284).
APScheduler==3.11.3 APScheduler==3.11.3
Authlib==1.7.2 Authlib==1.9.1
Mako==1.4.1 Mako==1.4.1
MarkupSafe==3.0.3 MarkupSafe==3.0.3
PyYAML==6.0.3 PyYAML==6.0.3
@@ -38,7 +39,7 @@ httptools==0.8.0
httpx==0.28.1 httpx==0.28.1
idna==3.18 idna==3.18
itsdangerous==2.2.0 itsdangerous==2.2.0
joserfc==1.7.4 joserfc==1.7.6
markdown-it-py==4.2.0 markdown-it-py==4.2.0
mdurl==0.1.2 mdurl==0.1.2
mutagen==1.48.1 mutagen==1.48.1
+738
View File
@@ -0,0 +1,738 @@
"""Every refusal the backend can answer, read from its syntax tree: each `{"code": …, "reason": …}` detail,
with every reason its code can be sent with.
The frontend's contract (`i18n/backendIds.contract.test.ts`) runs this and requires a sentence for each
code and reason in both languages. A scan that tries to follow every shape opens a silent gap with each one
it learns, and following how a value flows through Python (variables, scopes, aliases, a dict changed in
place) never closes. So the CODE keeps to a narrow form, and this reads a CLOSED list of it, reporting
everything else, never guessing:
- where a detail stands: where it is sent — `HTTPException`'s second argument or `detail=`, the `"detail"`
value of a `JSONResponse`'s content dict — through the arms of `a if c else b`, or as what a module-level
factory returns when every return of it is a detail and every reference to it is a call sent as one. A
detail held in a variable is not followed;
- the code: one literal. A `**` spread, or a key that is not a literal, stands BEFORE a literal `code` and
`reason`, both of which the detail then carries: the later key wins, so no spread can change them;
- a reason: a string literal (`None` sends none: the site counts as reasonless); both arms of
`a if c else b`; a function's local bound only by plain `x = …`; a module constant reached as `mod.NAME`;
a call's `Literal` return annotation;
- a parameter typed with a `Literal` (basedpyright holds every value passed in), or a `str` one of a
module-level helper (`refused`, `not_found`), followed to every call, each of which must pass it by
position or by name; a parameter its body binds again is unread;
- an attribute: `e.x` of the exception an `except C as e` catches, or of a parameter typed `C`, when a
method of `C` declares `self.x: <Literal>`;
- a map lookup (`_SETTING_REASONS[exc.reason]`): the map's values, and its keys must be exactly what the
index can be.
A name bound twice at module level, inside a module-level `if`/`try`/loop, named by a `global`, or imported
from two places is not looked up; nor is a local a lambda or a comprehension binds, or one a `nonlocal` or
`global` names. A module constant or map that anything stores into, deletes from or (a map) uses but by
`map[k]` or a read method is unread. A reference named like a followed helper that the scan cannot resolve
(`import app.x`, a relative import, an app module that does not bind it) is unread. Anything else is UNREAD,
or an unread code, and the contract fails on it unless no screen shows that code. Stdlib only: it runs on
the host, beside the frontend's tests (`python -I backend/tests/refusal_sites.py` → JSON).
"""
import ast
import json
import pathlib
import sys
from collections.abc import Iterator
from dataclasses import dataclass, field
APP = pathlib.Path(__file__).resolve().parents[1] / "app"
# Files whose `"code"` dicts are data, not refusals: a subtitle track's status payload, TMDB country codes.
NOT_REFUSALS = {
"plex/subtitles.py": "a subtitle track's status payload (its own `code`)",
"plex/tmdb.py": "a production country's ISO code",
}
# What a map may be used by besides `map[k]`: a method that only reads it.
READS = {"get", "items", "keys", "values"}
Node = ast.AST
FUNCS = (ast.FunctionDef, ast.AsyncFunctionDef)
SCOPES = (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda, ast.ClassDef)
COMPREHENSIONS = (ast.ListComp, ast.SetComp, ast.DictComp, ast.GeneratorExp)
COMPOUND = (
ast.If,
ast.Try,
ast.TryStar,
ast.For,
ast.AsyncFor,
ast.While,
ast.With,
ast.AsyncWith,
ast.Match,
)
@dataclass
class Res:
values: set[str] = field(default_factory=set)
unread: list[str] = field(default_factory=list)
none: bool = False # an arm sends no reason
def __or__(self, other: "Res") -> "Res":
return Res(self.values | other.values, self.unread + other.unread, self.none or other.none)
@dataclass
class Module:
path: str # relative to app/, posix
name: str # dotted, `app.…`
tree: ast.Module
parents: dict[Node, Node]
# local name → (module, symbol or None for the module)
imports: dict[str, tuple[str, str | None]]
top: dict[str, Node] # module-level bindings: def, class, or the assignment
ambiguous: set[
str
] # bound more than once, conditionally, by a `global`, or imported from two places
slots: set[int] # the ids of the nodes sent as a detail
def _own_scope(node: Node) -> Iterator[Node]:
"""`node` and what it holds in its own scope: a nested def, class or lambda is itself, not its body."""
stack = [node]
while stack:
n = stack.pop()
yield n
if n is not node and isinstance(n, SCOPES):
continue
stack.extend(ast.iter_child_nodes(n))
def _stored(target: Node) -> set[str]:
"""The names an assignment target binds, not the ones it reads (`LABELS[k] = v` binds none)."""
return {
n.id for n in ast.walk(target) if isinstance(n, ast.Name) and isinstance(n.ctx, ast.Store)
}
def _binds(node: Node) -> set[str]:
"""The names one node binds in the scope it sits in."""
if isinstance(node, ast.Assign):
return set().union(*(_stored(t) for t in node.targets))
if isinstance(node, (ast.AnnAssign, ast.AugAssign, ast.For, ast.AsyncFor, ast.NamedExpr)):
return _stored(node.target)
if isinstance(node, (ast.With, ast.AsyncWith)):
return set().union(*(_stored(i.optional_vars) for i in node.items if i.optional_vars))
if isinstance(node, (ast.Import, ast.ImportFrom)):
return {(a.asname or a.name).split(".")[0] for a in node.names}
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
return {node.name}
if isinstance(node, (ast.MatchAs, ast.MatchStar, ast.ExceptHandler)) and node.name:
return {node.name}
return set()
def _sender(call: ast.Call) -> str | None:
return getattr(call.func, "id", None) or getattr(call.func, "attr", None)
def detail_slots(tree: Node) -> Iterator[tuple[ast.Call, Node | None]]:
"""Every place a detail is sent from, with what it holds: `HTTPException`'s second argument or
`detail=` (None: sent without one), and the `"detail"` value of a `JSONResponse`'s content dict (a
content without one is an answer, not a refusal)."""
for node in ast.walk(tree):
if not isinstance(node, ast.Call):
continue
kw = {k.arg: k.value for k in node.keywords}
if _sender(node) == "HTTPException":
yield node, kw.get("detail", node.args[1] if len(node.args) > 1 else None)
elif _sender(node) == "JSONResponse":
content = kw.get("content", node.args[0] if node.args else None)
if isinstance(content, ast.Dict):
for k, v in zip(content.keys, content.values, strict=True):
if isinstance(k, ast.Constant) and k.value == "detail":
yield node, v
def _code_dict(node: Node) -> bool:
"""A `{"code": …}` dict, or a `dict(code=…)` call."""
if isinstance(node, ast.Dict):
return any(isinstance(k, ast.Constant) and k.value == "code" for k in node.keys)
return (
isinstance(node, ast.Call)
and getattr(node.func, "id", None) == "dict"
and any(k.arg == "code" for k in node.keywords)
)
def _module_name(path: str) -> str:
parts = path.removesuffix(".py").split("/")
if parts[-1] == "__init__":
parts = parts[:-1]
return ".".join(["app", *parts])
def _load(sources: dict[str, str]) -> dict[str, Module]:
mods: dict[str, Module] = {}
for path, src in sources.items():
tree = ast.parse(src)
parents = {child: node for node in ast.walk(tree) for child in ast.iter_child_nodes(node)}
origins: dict[str, set[tuple[str, str | None]]] = {}
ambiguous: set[str] = set()
for node in ast.walk(tree):
if isinstance(node, ast.ImportFrom) and node.module and node.level == 0:
for a in node.names:
origins.setdefault(a.asname or a.name, set()).add((node.module, a.name))
elif isinstance(node, ast.Import):
for a in node.names:
if a.asname:
origins.setdefault(a.asname, set()).add((a.name, None))
elif isinstance(node, ast.Global):
ambiguous |= set(node.names)
top: dict[str, Node] = {}
count: dict[str, int] = {}
ambiguous |= {name for name, o in origins.items() if len(o) > 1}
for stmt in tree.body:
if isinstance(stmt, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
top[stmt.name] = stmt
elif isinstance(stmt, ast.Assign):
for t in stmt.targets:
if isinstance(t, ast.Name):
top[t.id] = stmt
elif isinstance(stmt, ast.AnnAssign) and isinstance(stmt.target, ast.Name):
top[stmt.target.id] = stmt
for n in [stmt] if isinstance(stmt, SCOPES) else _own_scope(stmt):
for name in _binds(n):
count[name] = count.get(name, 0) + 1
if isinstance(stmt, COMPOUND):
ambiguous.add(name)
ambiguous |= {name for name, c in count.items() if c > 1}
imports = {name: next(iter(o)) for name, o in origins.items() if name not in ambiguous}
slots = {id(d) for _, d in detail_slots(tree) if d is not None}
name = _module_name(path)
mods[name] = Module(path, name, tree, parents, imports, top, ambiguous, slots)
return mods
class Scan:
def __init__(self, sources: dict[str, str]):
self.mods = _load(sources)
self.errors: list[str] = []
self._following: set[tuple[str, str, str]] = set()
self._followed: dict[tuple[str, str, str], Res] = {}
self._resolving: set[int] = set()
self._factory: dict[int, bool] = {}
self._changed: dict[int, bool] = {}
# --- names ---------------------------------------------------------------------------------------
def lookup(
self, m: Module, name: str, seen: frozenset[tuple[str, str]] = frozenset()
) -> tuple[Module, Node] | tuple[str, None] | None:
"""What a module-level name is: (its module, its binding), or (a module's name, None)."""
if name in m.ambiguous:
return None
if name in m.top:
return m, m.top[name]
if name in m.imports:
mod, sym = m.imports[name]
if sym is None:
return (mod, None) if mod in self.mods else None
if f"{mod}.{sym}" in self.mods:
return f"{mod}.{sym}", None
if mod in self.mods and (mod, sym) not in seen:
return self.lookup(self.mods[mod], sym, seen | {(mod, sym)})
return None
def lookup_expr(self, m: Module, e: Node) -> tuple[Module, Node] | tuple[str, None] | None:
if isinstance(e, ast.Name):
return None if self.binder(m, e) is not None else self.lookup(m, e.id)
if isinstance(e, ast.Attribute):
base = self.lookup_expr(m, e.value)
if base and base[1] is None and isinstance(base[0], str):
return self.lookup(self.mods[base[0]], e.attr)
return None
def binder(self, m: Module, e: ast.Name) -> Node | None:
"""The innermost scope `e` is bound in: a function (a parameter, or a binding in its own body), a
lambda (a parameter), a comprehension (its target); None: a module-level name."""
node = m.parents.get(e)
while node is not None:
if isinstance(node, (*FUNCS, ast.Lambda)):
a = node.args
params = [*a.posonlyargs, *a.args, *a.kwonlyargs, a.vararg, a.kwarg]
if e.id in {p.arg for p in params if p}:
return node
if isinstance(node, FUNCS) and self.assignments(node, e.id):
return node
elif isinstance(node, COMPREHENSIONS) and any(
e.id in _stored(g.target) for g in node.generators
):
return node
node = m.parents.get(node)
return None
def at(self, m: Module, e: Node) -> str:
return f"{m.path}:{getattr(e, 'lineno', '?')}: {ast.unparse(e)}"
def enclosing(self, m: Module, e: Node, kind: type | tuple[type, ...]) -> Node | None:
node = m.parents.get(e)
while node is not None and not isinstance(node, kind):
node = m.parents.get(node)
return node
def assignments(self, fn: Node, name: str) -> list[Node]:
"""What binds `name` in this function's own body (not in a nested one)."""
return [n for n in _own_scope(fn) if n is not fn and name in _binds(n)]
def caught_as(self, m: Module, e: Node, name: str) -> ast.ExceptHandler | None:
node = m.parents.get(e)
while node is not None and not isinstance(node, FUNCS):
if isinstance(node, ast.ExceptHandler) and node.name == name:
return node
node = m.parents.get(node)
return None
def references(
self, m: Module, target: Node
) -> Iterator[tuple[Module, Node, ast.Call | None, bool]]:
"""Every reference to a module-level def or class: the call it makes (None: not a call), and
whether it resolved. A reference named like it that the scan cannot resolve is yielded too."""
name = getattr(target, "name", None)
for cm in self.mods.values():
for node in ast.walk(cm.tree):
if not isinstance(node, (ast.Name, ast.Attribute)) or not isinstance(
node.ctx, ast.Load
):
continue
if (node.id if isinstance(node, ast.Name) else node.attr) != name:
continue
parent = cm.parents.get(node)
if isinstance(parent, ast.Attribute) and parent.value is node:
continue
found = self.lookup_expr(cm, node)
if found:
if not (found[0] is m and found[1] is target):
continue
elif not self.unresolved(cm, node):
continue
call = parent if isinstance(parent, ast.Call) and parent.func is node else None
yield cm, node, call, found is not None
def unresolved(self, m: Module, node: Node) -> bool:
"""A name, or a dotted path, the app could mean that the scan cannot resolve: not a local, nor an
outside module's (an attribute of an object the function holds is not one)."""
root = node
while isinstance(root, ast.Attribute):
root = root.value
if not isinstance(root, ast.Name) or self.binder(m, root) is not None:
return False
if root.id in m.ambiguous or (root.id not in m.top and root.id not in m.imports):
return True
if root.id in m.top:
return False
if root is node: # imported from an app module that does not bind it where the scan can see
mod = m.imports[root.id][0]
return (mod == "app" or mod.startswith("app.")) and self.lookup(m, root.id) is None
found = self.lookup(m, root.id)
return (
found is not None and found[1] is None
) # an app module whose dotted path went nowhere
def changed(self, m: Module, stmt: Node, name: str, *, only_read: bool) -> bool:
"""Something in the app stores into, deletes or rebinds the module-level `name` that `stmt` binds,
or (`only_read`, a map) uses it but by `map[k]` or a method that only reads it."""
key = id(stmt) * 2 + only_read
if key in self._changed:
return self._changed[key]
own = stmt.targets if isinstance(stmt, ast.Assign) else [getattr(stmt, "target", None)]
out = False
for cm in self.mods.values():
for node in ast.walk(cm.tree):
if out:
break
if not (
isinstance(node, ast.Name)
and node.id == name
or isinstance(node, ast.Attribute)
and node.attr == name
) or (cm is m and node in own):
continue
found = self.lookup_expr(cm, node)
if not (found and found[0] is m and found[1] is stmt):
continue
if not isinstance(node.ctx, ast.Load):
out = True
elif only_read:
p = cm.parents.get(node)
out = not (
isinstance(p, ast.Subscript)
and p.value is node
and isinstance(p.ctx, ast.Load)
or isinstance(p, ast.Attribute)
and p.attr in READS
)
self._changed[key] = out
return out
# --- types ---------------------------------------------------------------------------------------
def ann_values(self, m: Module, ann: Node | None, depth: int = 0) -> set[str] | None:
"""The values a `Literal[…]` annotation, or an alias of one, allows; None when it is not one."""
if isinstance(ann, ast.Subscript) and getattr(ann.value, "id", None) == "Literal":
items = ann.slice.elts if isinstance(ann.slice, ast.Tuple) else [ann.slice]
if all(isinstance(i, ast.Constant) and isinstance(i.value, str) for i in items):
return {i.value for i in items}
return None
if isinstance(ann, (ast.Name, ast.Attribute)) and depth < 8:
found = self.lookup_expr(m, ann)
if found and isinstance(found[0], Module) and isinstance(found[1], ast.Assign):
return self.ann_values(found[0], found[1].value, depth + 1)
return None
def classes(self, m: Module, e: Node | None) -> list[tuple[Module, ast.ClassDef]] | None:
found = self.lookup_expr(m, e) if e is not None else None
if found and isinstance(found[0], Module) and isinstance(found[1], ast.ClassDef):
return [(found[0], found[1])]
return None
def typed(self, m: Module, at: Node, name: str) -> list[tuple[Module, ast.ClassDef]] | None:
"""The class `name` holds where `at` sits: the one an `except C as name` catches, or a parameter's
annotation (a parameter its body binds again holds no known class)."""
handler = self.caught_as(m, at, name)
if handler is not None:
return self.classes(m, handler.type)
fn = self.enclosing(m, at, FUNCS)
if fn is None:
return None
params = {a.arg: a for a in [*fn.args.posonlyargs, *fn.args.args, *fn.args.kwonlyargs]}
if name in params and not self.assignments(fn, name):
return self.classes(m, params[name].annotation)
return None
def attr_values(self, cm: Module, cls: ast.ClassDef, attr: str) -> set[str] | None:
"""An attribute's `Literal` values, from `self.attr: T` in a method of the class."""
for node in ast.walk(cls):
if (
isinstance(node, ast.AnnAssign)
and isinstance(node.target, ast.Attribute)
and node.target.attr == attr
and getattr(node.target.value, "id", None) == "self"
):
return self.ann_values(cm, node.annotation)
return None
# --- reasons -------------------------------------------------------------------------------------
def resolve(self, m: Module, e: Node) -> Res:
if isinstance(e, ast.Constant):
if e.value is None:
return Res(none=True)
if isinstance(e.value, str):
return Res({e.value})
if isinstance(e, ast.IfExp):
return self.resolve(m, e.body) | self.resolve(m, e.orelse)
if isinstance(e, ast.Name):
return self.resolve_name(m, e)
if isinstance(e, ast.Attribute):
return self.resolve_attr(m, e)
if isinstance(e, ast.Call):
found = self.lookup_expr(m, e.func)
if found and isinstance(found[0], Module) and isinstance(found[1], FUNCS):
v = self.ann_values(found[0], found[1].returns)
if v is not None:
return Res(v)
if isinstance(e, ast.Subscript):
return self.resolve_lookup(m, e)
return Res(unread=[self.at(m, e)])
def resolve_name(self, m: Module, e: ast.Name) -> Res:
if self.caught_as(m, e, e.id) is not None:
return Res(unread=[self.at(m, e)]) # the exception itself, not a reason
fn = self.binder(m, e)
if fn is None:
return Res(unread=[self.at(m, e)])
if not isinstance(fn, FUNCS):
return Res(unread=[f"{self.at(m, e)} (bound by a lambda or a comprehension)"])
if any(isinstance(n, (ast.Nonlocal, ast.Global)) and e.id in n.names for n in ast.walk(fn)):
return Res(unread=[f"{self.at(m, e)} (named by a nonlocal or a global)"])
a = fn.args
params = {p.arg: p for p in [*a.posonlyargs, *a.args, *a.kwonlyargs]}
assigned = self.assignments(fn, e.id)
if e.id in params:
if assigned:
return Res(unread=[f"{self.at(m, e)} (a parameter its body binds again)"])
return self.resolve_param(m, fn, params[e.id], e)
out = Res(unread=[] if assigned else [self.at(m, e)]) # `*args`, `**kwargs`
for s in assigned:
if not (isinstance(s, ast.Assign) and all(isinstance(t, ast.Name) for t in s.targets)):
out = out | Res(unread=[self.at(m, e)])
elif id(s) in self._resolving:
out = out | Res(unread=[f"{self.at(m, e)} (reached again while it is resolved)"])
else:
self._resolving.add(id(s))
out = out | self.resolve(m, s.value)
self._resolving.discard(id(s))
return out
def resolve_param(self, m: Module, fn: Node, param: ast.arg, e: Node) -> Res:
v = self.ann_values(m, param.annotation)
if v is not None:
return Res(v)
if param.annotation is not None and getattr(param.annotation, "id", None) != "str":
return Res(unread=[self.at(m, e)])
return self.follow(m, fn, param)
def follow(self, m: Module, fn: Node, param: ast.arg) -> Res:
"""A `str` parameter of a helper: what every call of the helper passes it."""
assert isinstance(fn, FUNCS)
key = (m.name, fn.name, param.arg)
if key in self._followed:
return self._followed[key]
where = f"{m.path}:{fn.lineno}: {fn.name}({param.arg})"
if key in self._following:
return Res(unread=[f"{where} reached again while it is followed"])
if m.top.get(fn.name) is not fn or fn.name in m.ambiguous:
return Res(unread=[f"{where} is not a module-level helper"])
self._following.add(key)
positional = [a.arg for a in [*fn.args.posonlyargs, *fn.args.args]]
by_name = param not in fn.args.posonlyargs
out = Res()
for cm, node, call, resolved in self.references(m, fn):
if not resolved:
out = out | Res(
unread=[f"{self.at(cm, node)} (a reference the scan cannot resolve)"]
)
continue
if call is None:
out = out | Res(unread=[f"{self.at(cm, node)} (the helper used as a value)"])
continue
kw = {k.arg: k.value for k in call.keywords}
given = param.arg in positional and positional.index(param.arg) < len(call.args)
if by_name and param.arg in kw:
out = out | self.resolve(cm, kw[param.arg])
elif any(isinstance(a, ast.Starred) for a in call.args):
out = out | Res(unread=[self.at(cm, call)])
elif given:
out = out | self.resolve(cm, call.args[positional.index(param.arg)])
else:
out = out | Res(unread=[f"{self.at(cm, call)} (does not pass {param.arg})"])
self._followed[key] = out # once followed, its key answers from here: never reached again
return out
def resolve_attr(self, m: Module, e: ast.Attribute) -> Res:
base = e.value
if isinstance(base, ast.Name):
typed = self.typed(m, e, base.id)
if typed is not None:
out = Res()
for cm, cls in typed:
v = self.attr_values(cm, cls, e.attr)
out = out | (
Res(v)
if v is not None
else Res(
unread=[f"{self.at(m, e)} ({cls.name}.{e.attr} has no Literal type)"]
)
)
return out
found = self.lookup_expr(m, e)
if (
found
and isinstance(found[0], Module)
and isinstance(found[1], (ast.Assign, ast.AnnAssign))
and isinstance(found[1].value, ast.Constant)
):
if self.changed(found[0], found[1], e.attr, only_read=False):
return Res(unread=[f"{self.at(m, e)} (bound again or deleted elsewhere)"])
return self.resolve(found[0], found[1].value)
return Res(unread=[self.at(m, e)])
def resolve_lookup(self, m: Module, e: ast.Subscript) -> Res:
found = self.lookup_expr(m, e.value)
if not (
found
and isinstance(found[0], Module)
and isinstance(found[1], (ast.Assign, ast.AnnAssign))
):
return Res(unread=[self.at(m, e)])
table = found[1].value
if not isinstance(table, ast.Dict) or not all(
isinstance(k, ast.Constant) and isinstance(k.value, str) for k in table.keys
):
return Res(unread=[self.at(m, e)])
name = e.value.attr if isinstance(e.value, ast.Attribute) else getattr(e.value, "id", "")
if self.changed(found[0], found[1], name, only_read=True):
return Res(unread=[f"{self.at(m, e)} (the map is changed after it is built)"])
out = Res()
for v in table.values:
out = out | self.resolve(found[0], v)
index = self.resolve(m, e.slice)
keys = {k.value for k in table.keys if isinstance(k, ast.Constant)}
if index.unread or index.none or index.values != keys:
self.errors.append(
f"{self.at(m, e)}: the map's keys {sorted(keys)} are not what its index can be "
f"{sorted(index.values)}{' + unread ' + str(index.unread) if index.unread else ''}"
)
return out
# --- sites ---------------------------------------------------------------------------------------
def is_detail(self, node: Node | None) -> bool:
if isinstance(node, ast.IfExp):
return self.is_detail(node.body) and self.is_detail(node.orelse)
return _code_dict(node)
def factory(self, m: Module, fn: Node) -> bool:
"""A module-level function each return of which is a detail, and each reference to which is a
call sent as one (`quota_detail`)."""
assert isinstance(fn, FUNCS)
if id(fn) in self._factory:
return self._factory[id(fn)]
returns = [n for n in _own_scope(fn) if isinstance(n, ast.Return)]
ok = (
m.top.get(fn.name) is fn # a name bound twice: its calls are unresolved below
and bool(returns)
and all(self.is_detail(r.value) for r in returns)
)
for cm, _, call, resolved in self.references(m, fn) if ok else []:
ok = resolved and id(call) in cm.slots # a bare reference is no call: never sent
if not ok:
break
self._factory[id(fn)] = ok
return ok
def placed(self, m: Module, d: Node) -> bool:
"""`d` is sent as a detail: from a sender's slot, through the arms of `a if c else b`, or as what a
factory returns."""
holder, p = d, m.parents.get(d)
while isinstance(p, ast.IfExp) and holder is not p.test:
holder, p = p, m.parents.get(p)
if id(holder) in m.slots:
return True
fn = self.enclosing(m, p, FUNCS) if isinstance(p, ast.Return) else None
return fn is not None and self.factory(m, fn)
def sayable(self, m: Module, d: Node | None) -> bool:
"""What a slot holds is a detail the client can say: a code dict, or a factory's call."""
if isinstance(d, ast.IfExp):
return self.sayable(m, d.body) and self.sayable(m, d.orelse)
if _code_dict(d):
return True
found = self.lookup_expr(m, d.func) if isinstance(d, ast.Call) else None
return bool(
found
and isinstance(found[0], Module)
and isinstance(found[1], FUNCS)
and self.factory(found[0], found[1])
)
def entries(self, d: Node) -> list[tuple[str | None, Node]]:
"""A dict's (key, value) pairs: None for a `**` spread, "?" for a key that is not a literal."""
if isinstance(d, ast.Dict):
return [
(
k.value
if isinstance(k, ast.Constant) and isinstance(k.value, str)
else None
if k is None
else "?",
v,
)
for k, v in zip(d.keys, d.values, strict=True)
]
assert isinstance(d, ast.Call)
return [(None, a) for a in d.args] + [(k.arg, k.value) for k in d.keywords]
def site(self, m: Module, d: Node, out: dict) -> None:
items = self.entries(d)
code = Res()
for v in (v for k, v in items if k == "code"):
code = code | self.resolve(m, v)
problems = [
f"{self.at(m, v)} (a spread in dict())"
for k, v in items
if k is None and isinstance(d, ast.Call)
] + [
f"{self.at(m, v)} (a spread or a computed key the code and the reason do not follow)"
for i, (k, v) in enumerate(items)
if k in (None, "?") and not {"code", "reason"} <= {k2 for k2, _ in items[i + 1 :]}
]
if not self.placed(m, d):
problems.append(f"{self.at(m, d)} (a detail put where the scan cannot follow it)")
if code.unread or code.none or len(code.values) != 1 or problems:
out["unreadCodes"] += problems or [self.at(m, d)]
return
entry = out["codes"].setdefault(
next(iter(code.values)), {"reasons": set(), "unread": [], "sites": 0, "reasonless": []}
)
entry["sites"] += 1
res, has_reason = Res(), False
for k, v in items:
if k == "reason":
res, has_reason = res | self.resolve(m, v), True
if not has_reason or res.none:
entry["reasonless"].append(f"{m.path}:{d.lineno}")
entry["reasons"] |= res.values
entry["unread"] += res.unread
def other_details(self) -> list[tuple[str, int, str | None]]:
"""Each slot that sends what the client cannot say — nothing, or anything but a code dict or a
factory's call: (its file, its line, the function it is sent from)."""
out = []
for m in self.mods.values():
for call, d in detail_slots(m.tree):
if self.sayable(m, d):
continue
fn = self.enclosing(m, call, FUNCS)
out.append((m.path, call.lineno, getattr(fn, "name", None)))
return sorted(out)
def run(self) -> dict:
out: dict = {
"codes": {},
"unreadCodes": [],
"errors": self.errors,
"excluded": {},
"aliases": {},
"notRefusals": sorted(NOT_REFUSALS),
}
for m in self.mods.values():
for name, stmt in m.top.items():
if (
isinstance(stmt, ast.Assign)
and isinstance(stmt.value, ast.Subscript)
and (v := self.ann_values(m, stmt.value))
):
out["aliases"].setdefault(m.path, {})[name] = sorted(v)
for node in ast.walk(m.tree):
if not _code_dict(node):
continue
if m.path in NOT_REFUSALS:
out["excluded"][m.path] = out["excluded"].get(m.path, 0) + 1
continue
self.site(m, node, out)
for entry in out["codes"].values():
entry["reasons"] = sorted(entry["reasons"])
return out
def scan(sources: dict[str, str]) -> dict:
return Scan(sources).run()
def app_sources(app: pathlib.Path = APP) -> dict[str, str]:
return {
p.relative_to(app).as_posix(): p.read_text(encoding="utf-8")
for p in sorted(app.rglob("*.py"))
if "__pycache__" not in p.parts
}
def scan_app(app: pathlib.Path = APP) -> dict:
return scan(app_sources(app))
if __name__ == "__main__":
json.dump(
scan_app(pathlib.Path(sys.argv[1]) if len(sys.argv) > 1 else APP),
sys.stdout,
indent=1,
sort_keys=True,
)
+20
View File
@@ -72,3 +72,23 @@ def test_the_sync_status_counts_the_callers_subscriptions(db, make_user, as_user
assert (body["subscriptions"], body["channels_total"]) == (1, 1) assert (body["subscriptions"], body["channels_total"]) == (1, 1)
mine = as_user(user).get("/api/sync/my-status").json() mine = as_user(user).get("/api/sync/my-status").json()
assert (mine["channels_total"], mine["channels_recent_pending"]) == (1, 1) assert (mine["channels_total"], mine["channels_recent_pending"]) == (1, 1)
def test_a_channel_lists_when_it_entered_this_users_list_not_when_the_channel_did(
db, make_user, as_user
):
from datetime import datetime, timezone
user = make_user()
db.add(Channel(id="UCa", title="A", created_at=datetime(2026, 6, 10, tzinfo=timezone.utc)))
db.flush()
db.add(
Subscription(
user_id=user.id,
channel_id="UCa",
created_at=datetime(2026, 10, 12, 8, 30, tzinfo=timezone.utc),
)
)
db.commit()
listed = as_user(user).get("/api/channels").json()
assert [(c["id"], c["added_at"]) for c in listed] == [("UCa", "2026-10-12T08:30:00+00:00")]
+299
View File
@@ -0,0 +1,299 @@
"""A Facebook download's title when the signed-in read gives only a placeholder.
Measured on prod (2026-11-01): every Facebook download since 2026-10-28 is titled "Facebook" — the
signed-in read (the owner's cookie) now gives that placeholder and NO description, so the adapter had
no post text to fall back on, and the download name copied the placeholder too. A signed-out read of
the same reel (yt-dlp 2026.08.19, as on prod) still carries the post's text in its description.
"""
from sqlalchemy import select, update
from app.config import settings
from app.db import SessionLocal
from app.downloads import adapters, backfill, pipeline
from app.downloads.models import DownloadJob, MediaAsset
_URL = "https://www.facebook.com/reel/1860682468422402/"
# The page yt-dlp says it read for it (`webpage_url`); a group post's link resolves the same way.
_PAGE = "https://www.facebook.com/watch/?v=1860682468422402"
# The measured signed-out read of that reel.
_PUBLIC = {
"title": "163K reactions · 14K shares | sorry not helping #workhumor #officelife | Work dot exe",
"description": "sorry not helping\n\n#workhumor #officelife",
"uploader": "Work dot exe",
}
def _fake_ydl(monkeypatch, outcome):
"""yt-dlp faked at its `YoutubeDL` boundary: records each read (its options and its `url`),
answers `outcome`."""
seen: list[dict] = []
class FakeYDL:
def __init__(self, opts):
self.opts = opts
def __enter__(self):
return self
def __exit__(self, *exc):
return False
def extract_info(self, url, download):
seen.append({**self.opts, "url": url})
return outcome
monkeypatch.setattr(backfill.yt_dlp, "YoutubeDL", FakeYDL)
return seen
def test_a_placeholder_with_nothing_behind_it_takes_the_signed_out_pages_text(monkeypatch):
seen = _fake_ydl(monkeypatch, _PUBLIC)
fb = adapters.FacebookAdapter()
info = backfill.public_title_info({"title": "Facebook", "description": None}, _URL, fb, 7)
assert fb.title(info) == "sorry not helping"
# One metadata-only read, and signed out: no cookie goes with it.
assert len(seen) == 1
assert seen[0]["skip_download"] is True
assert not seen[0].get("cookiefile")
def test_a_real_title_reads_nothing_more(monkeypatch):
seen = _fake_ydl(monkeypatch, _PUBLIC)
info = {"title": "Funny Dark Humor Dad Joke!", "description": None}
assert backfill.public_title_info(info, _URL, adapters.FacebookAdapter(), 7) is info
assert seen == []
def test_a_placeholder_with_the_posts_text_behind_it_reads_nothing_more(monkeypatch):
seen = _fake_ydl(monkeypatch, _PUBLIC)
info = {"title": "Video", "description": "Office jokes, part two"}
assert backfill.public_title_info(info, _URL, adapters.FacebookAdapter(), 7) is info
assert seen == []
def test_a_placeholder_the_signed_out_page_cannot_better_stays(monkeypatch):
_fake_ydl(monkeypatch, {"title": "Facebook", "description": None})
fb = adapters.FacebookAdapter()
info = backfill.public_title_info({"title": "Facebook", "description": None}, _URL, fb, 7)
assert fb.title(info) == "Facebook"
def test_another_source_is_never_read_again(monkeypatch):
seen = _fake_ydl(monkeypatch, _PUBLIC)
info = {"title": "Video", "description": None}
assert backfill.public_title_info(info, _URL, adapters.SourceAdapter(), 7) is info
assert seen == []
def test_the_backfill_retitles_a_placeholder_download_and_the_names_that_repeated_it(
db, make_user, monkeypatch
):
seen = _fake_ydl(monkeypatch, _PUBLIC)
owner, other = make_user(), make_user()
asset = MediaAsset(
source_kind="url",
source_ref=_URL,
format_sig="fb",
status="ready",
rel_path="facebook/x.mp4",
poster_path="facebook/x.jpg",
uploader_url="https://www.facebook.com/1",
title="Facebook",
ref_count=2,
)
db.add(asset)
db.flush()
copied = DownloadJob(
user_id=owner.id,
asset_id=asset.id,
source_kind="url",
source_ref=_URL,
status="done",
queue_pos=0,
profile_snapshot={},
display_name="Facebook",
)
renamed = DownloadJob(
user_id=other.id,
asset_id=asset.id,
source_kind="url",
source_ref=_URL,
status="done",
queue_pos=0,
profile_snapshot={},
display_name="Office jokes",
)
db.add_all([copied, renamed])
db.commit()
assert backfill.run()["retitled"] == 1
assert len(seen) == 1
db.expire_all()
assert db.get(MediaAsset, asset.id).title == "sorry not helping"
assert db.get(DownloadJob, copied.id).display_name == "sorry not helping"
assert db.get(DownloadJob, renamed.id).display_name == "Office jokes"
# Run again: nothing left to retitle, and no page read for it.
assert backfill.run()["retitled"] == 0
assert len(seen) == 1
def test_a_name_given_while_the_backfill_runs_stays(db, make_user, monkeypatch):
# The owner renames the download while the backfill reads the page: the owner's name wins, it is
# not overwritten by what was read before it.
_fake_ydl(monkeypatch, _PUBLIC)
owner = make_user()
asset = MediaAsset(
source_kind="url",
source_ref=_URL,
format_sig="fb",
status="ready",
rel_path="facebook/x.mp4",
uploader_url="https://www.facebook.com/1",
title="Facebook",
ref_count=1,
)
db.add(asset)
db.flush()
job = DownloadJob(
user_id=owner.id,
asset_id=asset.id,
source_kind="url",
source_ref=_URL,
status="done",
queue_pos=0,
profile_snapshot={},
display_name="Facebook",
)
db.add(job)
db.commit()
read_page = backfill.public_title_info
def renamed_while_reading(*args):
with SessionLocal() as other:
other.execute(
update(DownloadJob)
.where(DownloadJob.id == job.id)
.values(display_name="Office jokes")
)
other.commit()
return read_page(*args)
monkeypatch.setattr(backfill, "public_title_info", renamed_while_reading)
monkeypatch.setattr(backfill.editmod, "ensure_poster", lambda a, root: None)
assert backfill.run()["retitled"] == 1
db.expire_all()
assert db.get(DownloadJob, job.id).display_name == "Office jokes"
def test_the_poster_step_holds_no_row_lock(db, make_user, monkeypatch):
# It can cut a frame for 30 s. A delete locks the asset and then the job: with the job held here
# and the asset still to write, that is a deadlock, and a rename waits out the whole cut.
_fake_ydl(monkeypatch, _PUBLIC)
owner = make_user()
asset = MediaAsset(
source_kind="url",
source_ref=_URL,
format_sig="fb",
status="ready",
rel_path="facebook/x.mp4",
uploader_url="https://www.facebook.com/1",
title="Facebook",
ref_count=1,
)
db.add(asset)
db.flush()
job = DownloadJob(
user_id=owner.id,
asset_id=asset.id,
source_kind="url",
source_ref=_URL,
status="done",
queue_pos=0,
profile_snapshot={},
display_name="Facebook",
)
db.add(job)
db.commit()
asset_id, job_id = asset.id, job.id
def poster_finds_rows_free(a, root):
with SessionLocal() as other:
other.execute(
select(MediaAsset).where(MediaAsset.id == asset_id).with_for_update(nowait=True)
)
other.execute(
select(DownloadJob).where(DownloadJob.id == job_id).with_for_update(nowait=True)
)
other.rollback()
return None
monkeypatch.setattr(backfill.editmod, "ensure_poster", poster_finds_rows_free)
assert backfill.run()["retitled"] == 1
def test_a_download_titled_facebook_is_named_from_the_signed_out_page(
db, make_user, monkeypatch, tmp_path
):
# The whole download path with yt-dlp and the media tools faked: the signed-in read gives only
# the placeholder, and the file and the download's name get the post's text.
seen = _fake_ydl(monkeypatch, _PUBLIC)
monkeypatch.setattr(settings, "files_root", str(tmp_path))
monkeypatch.setattr(pipeline.urlguard, "check_url", lambda url: None)
monkeypatch.setattr(pipeline, "_ensure_phone_safe", lambda _j, produced, *_a: produced)
monkeypatch.setattr(
pipeline.probe, "resolve_media_facts", lambda produced, info: ("h264", "aac", 720, 1280, 9)
)
monkeypatch.setattr(pipeline.editmod, "thumbnail_to_jpg", lambda *a: None)
monkeypatch.setattr(pipeline.editmod, "ensure_poster", lambda *a: None)
owner = make_user()
asset = MediaAsset(
source_kind="url", source_ref=_URL, format_sig="fb", status="downloading", ref_count=1
)
db.add(asset)
db.flush()
job = DownloadJob(
user_id=owner.id,
asset_id=asset.id,
source_kind="url",
source_ref=_URL,
status="running",
queue_pos=0,
profile_snapshot={},
)
db.add(job)
db.commit()
def signed_in_read(job_id, asset_id, url, spec, staging, adapter, **kw):
f = staging / "1860682468422402.mp4"
f.write_bytes(b"v" * 100)
return {
"id": "1860682468422402",
"title": "Facebook",
"uploader": "Work dot exe",
"webpage_url": _PAGE,
"requested_downloads": [{"filepath": str(f)}],
}
monkeypatch.setattr(pipeline, "_extract_with_fallback", signed_in_read)
pipeline._download(job.id, asset.id, "url", _URL, {}, owner.id)
# The signed-out read goes to the page yt-dlp read, not the link queued: a group post's link
# resolves to its video, and signed out the post itself is the login wall.
assert [r["url"] for r in seen] == [_PAGE]
db.expire_all()
assert db.get(DownloadJob, job.id).status == "done"
assert db.get(MediaAsset, asset.id).title == "sorry not helping"
assert db.get(DownloadJob, job.id).display_name == "sorry not helping"
+2 -2
View File
@@ -368,7 +368,7 @@ def test_the_metadata_backfill_names_the_asset_and_not_the_download(sink, monkey
) )
monkeypatch.setattr(backfill.yt_dlp, "YoutubeDL", _Refusing) monkeypatch.setattr(backfill.yt_dlp, "YoutubeDL", _Refusing)
assert backfill._extract_meta(url, adapters.YouTubeAdapter(), 11) is None assert backfill.extract_meta(url, adapters.YouTubeAdapter(), 11) is None
lines = [o for o in sink.objects() if "meta extract failed" in o["msg"]] lines = [o for o in sink.objects() if "meta extract failed" in o["msg"]]
assert lines and all("dQw4w9WgXcQ" not in json.dumps(o) for o in lines) assert lines and all("dQw4w9WgXcQ" not in json.dumps(o) for o in lines)
@@ -393,7 +393,7 @@ def test_the_metadata_backfill_takes_out_a_generic_name_whatever_it_holds(sink,
raise yt_dlp.utils.DownloadError("ERROR: [generic] Our Trip: Day 1: Unable to download") raise yt_dlp.utils.DownloadError("ERROR: [generic] Our Trip: Day 1: Unable to download")
monkeypatch.setattr(backfill.yt_dlp, "YoutubeDL", _Refusing) monkeypatch.setattr(backfill.yt_dlp, "YoutubeDL", _Refusing)
assert backfill._extract_meta(url, adapters.SourceAdapter(), 11) is None assert backfill.extract_meta(url, adapters.SourceAdapter(), 11) is None
lines = [o for o in sink.objects() if "meta extract failed" in o["msg"]] lines = [o for o in sink.objects() if "meta extract failed" in o["msg"]]
assert lines and all("Day 1" not in json.dumps(o) for o in lines) assert lines and all("Day 1" not in json.dumps(o) for o in lines)
+1
View File
@@ -83,6 +83,7 @@ class _Proc:
class _Session: class _Session:
def __init__(self, tmp_path, name: str, finished: bool, idle_s: float): def __init__(self, tmp_path, name: str, finished: bool, idle_s: float):
self.key = name
self.dir = tmp_path / name self.dir = tmp_path / name
self.dir.mkdir() self.dir.mkdir()
self.proc = _Proc(finished) 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()
+72 -37
View File
@@ -1,53 +1,88 @@
"""No refusal answers in a hand-written English sentence. """No refusal answers in a way the client cannot say.
The UI is bilingual and translates a structured `detail` (`{code, reason, …}`), but a string detail it The UI is bilingual and translates a structured `detail` (`{code, reason, …}`), but a string detail it
can only print as it came — so ~150 of them reached the Hungarian UI verbatim, in the blocking error can only print as it came — so ~150 of them reached the Hungarian UI verbatim, in the blocking error
dialog and on every inline surface that shows `HttpError.detail`. `app/refusal.py` is the family they dialog and on every inline surface that shows `HttpError.detail`. `app/refusal.py` is the family they
became; this test is what keeps a new one from being written the old way. Pure: it reads the sources. became; this test is what keeps a new one from being written the old way. It reads the same slots the
refusal scan does (`refusal_sites.detail_slots`), so the two cannot disagree on where a detail is sent.
Pure: it reads the sources.
""" """
import ast import textwrap
import pathlib
APP = pathlib.Path(__file__).resolve().parents[1] / "app" from tests.refusal_sites import Scan, app_sources
# Answers that are no refusal of the app's, by the function they are sent from: the client shows a
# detail only when it is an object (`lib/api/core.ts`), so these read as its generic line, and no screen
# needs more from them.
NO_REFUSAL = {
("main.py", "setup_gate"): "the 503 before setup: the setup screen is what the user sees",
("main.py", "spa_fallback"): "a bare 404 for an api path no route matches: no screen calls one",
("main.py", "_validation_error"): "FastAPI's own 422 list: a body the client itself got wrong",
("routes/diagnostics.py", "_parsed_events"): "the client-events beacon's 422: nothing shows it",
}
def _is_text(node: ast.AST) -> bool: def _offenders(sources: dict[str, str]) -> list[str]:
"""A detail that is a sentence: a literal, an f-string, `str(exc)`, or `"…" + x`.""" return [
if isinstance(node, ast.Constant) and isinstance(node.value, str): f"{path}:{line}"
return True for path, line, fn in Scan(sources).other_details()
if isinstance(node, ast.JoinedStr): if (path, fn) not in NO_REFUSAL
return True ]
if isinstance(node, ast.Call) and getattr(node.func, "id", "") == "str":
return True
if isinstance(node, ast.BoolOp):
return any(_is_text(v) for v in node.values)
if isinstance(node, ast.BinOp):
return _is_text(node.left) or _is_text(node.right)
return False
def test_no_http_exception_carries_a_sentence(): def test_every_detail_is_one_the_client_can_say():
offenders = [] offenders = _offenders(app_sources())
for path in sorted(APP.rglob("*.py")):
for node in ast.walk(ast.parse(path.read_text(encoding="utf-8"))):
if not (
isinstance(node, ast.Call)
and getattr(node.func, "id", getattr(node.func, "attr", "")) == "HTTPException"
):
continue
kw = {k.arg: k.value for k in node.keywords}
detail = kw.get("detail", node.args[1] if len(node.args) > 1 else None)
if detail is not None and _is_text(detail):
offenders.append(f"{path.relative_to(APP).as_posix()}:{node.lineno}")
assert offenders == [], ( assert offenders == [], (
"a refusal with an English sentence for a detail — raise `refused(status, reason)` " "a refusal the client can only answer with its generic line — send `{code, reason}` "
f"(app/refusal.py) and name the reason in both locales: {offenders}" f"(`refused(status, reason)`), or list it in NO_REFUSAL with why: {offenders}"
) )
def test_the_scan_sees_a_sentence_when_there_is_one(): def test_the_detail_scan_sees_each_shape_the_client_cannot_say():
"""A guard that matches nothing is green over nothing.""" """A guard that matches nothing is green over nothing."""
sample = ast.parse('HTTPException(status_code=400, detail=f"bad {x}")').body[0].value src = textwrap.dedent(
assert _is_text(sample.keywords[1].value) """
assert not _is_text(ast.parse('{"code": "refused", "reason": "x"}').body[0].value) def f(x):
raise HTTPException(409, detail={"reason": "view_name_taken"})
def g(x):
raise HTTPException(409, dict(reason="taken"))
def h(x):
raise HTTPException(status_code=404)
def i(x):
return JSONResponse({"detail": "This instance isn't set up yet."}, status_code=503)
def j(x):
return JSONResponse(content={"detail": {"reason": "x"}}, status_code=409)
def k(x):
raise HTTPException(400, detail=f"bad {x}")
def l(x):
raise HTTPException(404, None)
def n(x, msg):
raise HTTPException(400, detail=msg)
def o(x):
raise HTTPException(400, detail=["a sentence"])
def p(x):
raise HTTPException(400, detail=describe(x))
def q(x):
raise HTTPException(400, detail={"code": "a"} if x else "text")
def r(x):
raise HTTPException(400, detail=nothing(x))
def nothing(x):
pass
def ok(x):
raise HTTPException(409, {"code": "refused", "reason": "x"})
raise HTTPException(409, detail=quota(x))
raise HTTPException(409, detail={"code": "a"} if x else {"code": "b"})
return JSONResponse({"detail": {"code": "too_large"}}, status_code=413)
return JSONResponse({"ok": True})
def quota(x):
return {"code": "quota", "reason": "files"}
def setup_gate(x):
return JSONResponse({"detail": "listed"}, status_code=503)
"""
)
lines = (3, 5, 7, 9, 11, 13, 15, 17, 19, 21, 23, 25)
assert _offenders({"main.py": src}) == [f"main.py:{n}" for n in lines]
# A file whose code dicts are data still answers its refusals in a way the client can say.
data = 'def f():\n raise HTTPException(400, "text")\n'
assert _offenders({"plex/tmdb.py": data}) == ["plex/tmdb.py:2"]
+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"}
+1 -1
View File
@@ -181,7 +181,7 @@ def test_clearing_a_video_leaves_another_users_state(db, make_user, as_user):
_videos(db, "v1") _videos(db, "v1")
db.add(VideoState(user_id=other.id, video_id="v1", status="hidden", position_seconds=90)) db.add(VideoState(user_id=other.id, video_id="v1", status="hidden", position_seconds=90))
db.commit() db.commit()
assert as_user(me).delete("/api/videos/v1/state").status_code == 200 assert as_user(me).delete("/api/videos/v1/progress").status_code == 200
assert _video_state(db, other) == ("hidden", 90) assert _video_state(db, other) == ("hidden", 90)
+148 -1
View File
@@ -13,7 +13,7 @@ from datetime import datetime, timedelta, timezone
from sqlalchemy import select from sqlalchemy import select
from app.db import SessionLocal 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.plex import service as plex_service
from app.routes.plex import playback 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 r.json() == {"ok": True, "status": "watched", "position_seconds": 0}
assert playback._resume_push_superseded(user.id, item.id, earlier) is True 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" 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]) @pytest.mark.parametrize("digits", [100, 5000])
def test_an_absurd_segment_index_is_a_404_not_a_500(client, tmp_path, sessions, digits): 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 """`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( def test_a_session_torn_down_during_the_read_is_a_404_not_a_500(
client, monkeypatch, tmp_path, sessions 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 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 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".""" 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() user = make_user()
lib = _lib(db) lib = _lib(db)
bare, hid = _movie(db, lib, "bare"), _movie(db, lib, "hid") 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) assert (st.position_seconds, st.synced_to_plex) == (120, False)
_state(db, user, hid, status="hidden", position_seconds=40) _state(db, user, hid, status="hidden", position_seconds=40)
service.clear_progress(db, queries.state(db, user, bare.id), NOW) 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() user = make_user()
it = _movie(db, _lib(db), "m-race") it = _movie(db, _lib(db), "m-race")
_state(db, user, it, position_seconds=10) # the request that won, committed _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) assert (st.position_seconds, st.synced_to_plex) == (42, False)
st = service.set_status(db, user, it.id, None, "hidden") st = service.set_status(db, user, it.id, None, "hidden")
assert st.status == "hidden" assert st.status == "hidden"
+1
View File
@@ -39,6 +39,7 @@ class _Proc:
class _Session: class _Session:
def __init__(self, session_id: str, directory): def __init__(self, session_id: str, directory):
self.id = session_id self.id = session_id
self.key = "m1"
self.dir = directory self.dir = directory
self.proc = _Proc() self.proc = _Proc()
self.last_access = time.time() 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_0_2_+0.00", # current shape, explicit audio_ord
"12345_842_multi_-1.25", # multi-audio, negative offset "12345_842_multi_-1.25", # multi-audio, negative offset
"12345_842_multi_+12.50", "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): 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 "snapshot_2024_backup_+1.00", # session-ish tail, but the key isn't a rating_key
"backup_2024_multi_+0.00", "backup_2024_multi_+0.00",
".config", ".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): def test_foreign_directories_are_left_alone(name):
@@ -159,7 +165,7 @@ class TestSweepOrphans:
class _FakeProc: 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): def poll(self):
return None return None
@@ -1052,3 +1058,63 @@ class TestSessionsPerViewer:
self._start(monkeypatch, user_id=uid) self._start(monkeypatch, user_id=uid)
assert len(stream._sessions) == 3 and (1, "12345") not in stream._sessions assert len(stream._sessions) == 3 and (1, "12345") not in stream._sessions
assert oldest.proc.killed and not oldest.transcode 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) == []
-3
View File
@@ -190,9 +190,6 @@ def test_a_cleared_resume_point_drops_a_bare_row_and_zeroes_one_with_a_status(db
db.commit() db.commit()
assert _state(db, user, "v1") is None assert _state(db, user, "v1") is None
assert (_state(db, user, "v2").status, _state(db, user, "v2").position_seconds) == ("hidden", 0) assert (_state(db, user, "v2").status, _state(db, user, "v2").position_seconds) == ("hidden", 0)
service.clear_state(db, user, "v2")
db.commit()
assert _state(db, user, "v2") is None
# --- playlists ------------------------------------------------------------------------------- # --- playlists -------------------------------------------------------------------------------
+770
View File
@@ -0,0 +1,770 @@
"""The refusal scan (`refusal_sites.py`) on small sources: each shape on its closed list is followed to its
values, and each exit from that list is reported, unread or as an unread code. A scan that silently drops a
shape is a guard green over nothing, so every exit is pinned here, not only the paths the app takes. Pure:
no app import, no database."""
import textwrap
from tests.refusal_sites import scan, scan_app
def _scan(**files: str) -> dict:
return scan(
{f"{name.replace('__', '/')}.py": textwrap.dedent(src) for name, src in files.items()}
)
def _code(result: dict, code: str) -> dict:
return result["codes"][code]
def _unread(result: dict, code: str) -> list[str]:
return [u.split(": ", 1)[1] for u in _code(result, code)["unread"]]
REFUSAL = """
from fastapi import HTTPException
def refused(status: int, reason: str, **params: object) -> HTTPException:
return HTTPException(status_code=status, detail={**params, "code": "refused", "reason": reason})
"""
def test_a_literal_wherever_it_sits_in_the_dict():
r = _scan(
a="""
def f(x):
raise HTTPException(409, {"code": "clip", "limit_s": 5, "reason": "too_long"})
def g(x):
raise HTTPException(409, {"reason": "before", "code": "clip"})
def h(x):
raise HTTPException(409, {
"code": "clip", # a comment between
"reason": "after_comment",
})
def i(x):
raise HTTPException(409, detail=dict(code="clip", reason="by_call"))
"""
)
assert _code(r, "clip")["reasons"] == ["after_comment", "before", "by_call", "too_long"]
assert _code(r, "clip")["unread"] == []
def test_both_arms_of_a_conditional_and_a_none_arm_makes_the_site_reasonless():
r = _scan(
a="""
def f(x):
raise HTTPException(409, {"code": "edit", "reason": "a" if x else "b"})
def g(x):
raise HTTPException(409, {"code": "playlist", "reason": "lit" if x else None})
def h(x):
raise HTTPException(409, {"code": "busy", "reason": None})
"""
)
assert _code(r, "edit")["reasons"] == ["a", "b"] and _code(r, "edit")["reasonless"] == []
assert _code(r, "playlist")["reasons"] == ["lit"]
assert _code(r, "playlist")["reasonless"] == ["a.py:5"]
assert _code(r, "busy")["reasonless"] == ["a.py:7"]
def test_what_the_list_does_not_hold_is_unread():
r = _scan(
a="""
def f(x, svc):
raise HTTPException(409, {"code": "edit", "reason": x.why or "c"})
def g(svc):
raise HTTPException(409, {"code": "edit", "reason": svc.reason()})
def h():
raise HTTPException(409, {"code": "edit", "reason": 5})
def i(e):
raise HTTPException(409, {"code": "edit", "reason": f"x{e}"})
"""
)
assert _unread(r, "edit") == ["x.why or 'c'", "svc.reason()", "5", "f'x{e}'"]
assert _code(r, "edit")["reasons"] == []
def test_a_helper_is_followed_to_every_call_however_it_is_written():
r = _scan(
refusal=REFUSAL,
routes__a="""
from http import HTTPStatus
from app import refusal
from app.refusal import refused
def f(status):
raise refused(status, "by_name_status")
def g():
raise refused(HTTPStatus.CONFLICT, reason="by_keyword")
def h():
raise refusal.refused(409, "qualified")
def i(x):
raise refused(409, "a" if x else "second_arm")
""",
routes__b="""
from other.lib import refused
def f():
refused("an outside library's, not the app's")
""",
)
assert _code(r, "refused")["reasons"] == [
"a",
"by_keyword",
"by_name_status",
"qualified",
"second_arm",
]
assert _code(r, "refused")["unread"] == []
def test_a_call_the_helper_cannot_be_followed_through_is_unread():
r = _scan(
refusal=REFUSAL,
routes__a="""
from app.refusal import refused
def f(args, kw):
pick = refused
raise refused(*args)
def g(kw):
raise refused(409, **kw)
def h():
raise refused(409)
""",
routes__b="""
import app.refusal
def f():
raise app.refusal.refused(409, "dotted")
""",
routes__c="""
from ..refusal import refused
def f():
raise refused(409, "relative")
""",
)
unread = _unread(r, "refused")
assert len(unread) == 6
assert sum("the helper used as a value" in u for u in unread) == 1
assert sum("a reference the scan cannot resolve" in u for u in unread) == 2
assert sum("does not pass reason" in u for u in unread) == 2 # `**kw`, and nothing at all
assert any(u == "refused(*args)" for u in unread)
assert _code(r, "refused")["reasons"] == []
def test_a_default_is_not_followed_a_call_must_pass_the_reason():
r = _scan(
routes___common="""
def not_found(what: str = "generic"):
return HTTPException(404, {"code": "not_found", "reason": what})
def get_or_404(db, id_, *, what: str = "generic"):
raise not_found(what)
""",
routes__a="""
from app.routes._common import get_or_404, not_found
def f(db):
get_or_404(db, 1, what="user")
get_or_404(db, 2)
raise not_found("debug_session")
def g():
raise not_found()
""",
)
assert _code(r, "not_found")["reasons"] == ["debug_session", "user"]
assert [u.endswith("(does not pass what)") for u in _unread(r, "not_found")] == [True, True]
def test_a_positional_only_reason_passed_by_name_is_not_bound_to_it():
r = _scan(
a="""
def deny(reason: str, /, **params):
return HTTPException(409, {"code": "playlist", "reason": reason})
def f():
raise deny("by_position")
def g():
raise deny(reason="lands_in_params")
"""
)
assert _code(r, "playlist")["reasons"] == ["by_position"]
assert _unread(r, "playlist") == ["deny(reason='lands_in_params') (does not pass reason)"]
def test_a_helper_reaching_two_sites_gives_both_its_reasons_and_one_reached_again_is_unread():
r = _scan(
a="""
def deny(reason: str, admin):
if admin:
return HTTPException(409, {"code": "activate", "reason": reason})
return HTTPException(409, {"code": "playlist", "reason": reason})
def again(reason: str, n):
if n:
return again(reason, n - 1)
return HTTPException(409, {"code": "edit", "reason": reason})
def f():
raise deny("foo", True)
def g():
raise again("bar", 3)
"""
)
assert _code(r, "activate")["reasons"] == ["foo"]
assert _code(r, "playlist")["reasons"] == ["foo"]
assert _code(r, "edit")["reasons"] == ["bar"]
assert any("reached again while it is followed" in u for u in _unread(r, "edit"))
def test_a_parameter_its_body_binds_again_is_unread():
r = _scan(
a="""
from typing import Literal
def refused(status, reason: str = ""):
reason = reason or "fallback"
return HTTPException(status, {"code": "refused", "reason": reason})
def typed(reason: Literal["a"]):
if not reason:
reason = "b"
return HTTPException(409, {"code": "edit", "reason": reason})
def f():
raise refused(409, "x")
"""
)
assert _code(r, "refused")["reasons"] == []
assert _unread(r, "refused") == ["reason (a parameter its body binds again)"]
assert _unread(r, "edit") == ["reason (a parameter its body binds again)"]
def test_a_local_is_followed_only_through_plain_assignments():
r = _scan(
a="""
from typing import Literal
def f(x):
why = "first"
if x:
why = "second" if x > 1 else "third"
raise HTTPException(409, {"code": "edit", "reason": why})
def g(svc):
hit, why = svc.fetch()
raise HTTPException(409, {"code": "playlist", "reason": why})
def h(items):
for why in items:
pass
raise HTTPException(409, {"code": "playlist", "reason": why})
def i():
why: Literal["typed"] = pick()
raise HTTPException(409, {"code": "playlist", "reason": why})
def j(items):
LABELS[why] = "NotAReason"
raise HTTPException(409, {"code": "playlist", "reason": why})
def k(*why):
raise HTTPException(409, {"code": "playlist", "reason": why})
"""
)
assert _code(r, "edit")["reasons"] == ["first", "second", "third"]
assert _code(r, "playlist")["reasons"] == []
assert _unread(r, "playlist") == [
"why",
"why",
"why",
"why",
"why",
] # unpacked, a loop, annotated, unbound, `*args`
def test_a_call_answers_with_its_literal_return_type():
r = _scan(
a="""
from typing import Literal
def why(x) -> Literal["quota", "gone"]:
return "quota"
def how(x) -> str:
return "x"
def f(x):
raise HTTPException(409, {"code": "youtube", "reason": why(x)})
def g(x):
raise HTTPException(409, {"code": "youtube", "reason": how(x)})
"""
)
assert _code(r, "youtube")["reasons"] == ["gone", "quota"]
assert _unread(r, "youtube") == ["how(x)"]
def test_a_module_constant_is_read_only_as_mod_name_and_only_when_bound_once():
r = _scan(
urlguard="""
REASON_MALFORMED = "malformed"
TWICE = "a"
TWICE = "b"
if FLAG:
LATER = "c"
""",
routes__a="""
from app import urlguard
BARE = "bare"
def f():
raise HTTPException(409, {"code": "url_blocked", "reason": urlguard.REASON_MALFORMED})
def g():
raise HTTPException(409, {"code": "url_blocked", "reason": urlguard.TWICE})
def h():
raise HTTPException(409, {"code": "url_blocked", "reason": urlguard.LATER})
def i():
raise HTTPException(409, {"code": "url_blocked", "reason": BARE})
""",
)
assert _code(r, "url_blocked")["reasons"] == ["malformed"]
assert _unread(r, "url_blocked") == ["urlguard.TWICE", "urlguard.LATER", "BARE"]
def test_a_name_imported_from_two_places_or_under_an_if_is_not_looked_up():
r = _scan(
m1='R = Literal["one"]',
m2='R = Literal["two"]',
a="""
def f():
from app.m1 import R
def g():
from app.m2 import R
def h(reason: R):
return HTTPException(409, {"code": "edit", "reason": reason})
""",
b="""
if FLAG:
from app.m1 import R
def h(reason: R):
return HTTPException(409, {"code": "playlist", "reason": reason})
""",
c="""
from app.m1 import R
def h(reason: R):
return HTTPException(409, {"code": "clip", "reason": reason})
""",
)
assert _code(r, "edit")["reasons"] == [] and _unread(r, "edit") == ["reason"]
assert _code(r, "playlist")["reasons"] == [] and _unread(r, "playlist") == ["reason"]
assert _code(r, "clip")["reasons"] == ["one"]
def test_a_caught_exception_answers_with_the_literal_its_class_declares_on_self():
r = _scan(
errors="""
from typing import Literal
ClipReason = Literal["bad_range", "too_short"]
EditReason = Literal["not_media"]
class ClipError(Exception):
def __init__(self, reason: ClipReason, **params: float):
self.reason: ClipReason = reason
self.params = params
class EditError(Exception):
reason: EditReason
class Untyped(Exception):
def __init__(self, reason: str):
self.reason = reason
class Narrower(ClipError):
pass
""",
routes__a="""
from app import errors
def f():
try:
pass
except errors.ClipError as e:
raise HTTPException(422, {"code": "clip", "reason": e.reason})
except (errors.ClipError, errors.EditError) as e:
raise HTTPException(422, {"code": "clip", "reason": e.reason})
except errors.EditError as e:
raise HTTPException(422, {"code": "clip", "reason": e.reason})
except errors.Untyped as e:
raise HTTPException(422, {"code": "clip", "reason": e.reason})
except errors.Narrower as e:
raise HTTPException(422, {"code": "clip", "reason": e.reason})
except errors.ClipError as e:
raise HTTPException(422, {"code": "clip", "reason": e})
""",
)
assert _code(r, "clip")["reasons"] == ["bad_range", "too_short"]
assert _unread(r, "clip") == [
"e.reason", # a tuple of classes
"e.reason (EditError.reason has no Literal type)", # declared on the class body only
"e.reason (Untyped.reason has no Literal type)",
"e.reason (Narrower.reason has no Literal type)", # a subclass: not looked up the bases
"e", # the exception itself
]
def test_a_parameter_answers_with_its_literal_or_its_class_and_any_other_type_is_unread():
r = _scan(
a="""
from typing import Literal
Why = Literal["one", "two"]
class Err(Exception):
def __init__(self, reason: Literal["three"]):
self.reason: Literal["three"] = reason
def typed(reason: Why):
return HTTPException(409, {"code": "playlist", "reason": reason})
def inline(reason: Literal["four"]):
return HTTPException(409, {"code": "playlist", "reason": reason})
def by_class(exc: Err):
return HTTPException(409, {"code": "playlist", "reason": exc.reason})
def rebound(exc: Err):
exc = other()
return HTTPException(409, {"code": "playlist", "reason": exc.reason})
def optional(reason: Literal["five"] | None = None):
return HTTPException(409, {"code": "playlist", "reason": reason})
def quoted(reason: "Why"):
return HTTPException(409, {"code": "playlist", "reason": reason})
def number(reason: int):
return HTTPException(409, {"code": "playlist", "reason": reason})
class C:
def method(self, reason: str):
return HTTPException(409, {"code": "playlist", "reason": reason})
"""
)
assert _code(r, "playlist")["reasons"] == ["four", "one", "three", "two"]
unread = _unread(r, "playlist")
# a parameter bound again, `| None`, a quoted annotation, `int`
assert unread[:4] == ["exc.reason", "reason", "reason", "reason"]
assert "method(reason) is not a module-level helper" in unread[4]
assert len(unread) == 5
def test_a_str_helper_nobody_calls_sends_nothing():
r = _scan(
a="""
def helper(reason: str):
return HTTPException(409, {"code": "playlist", "reason": reason})
"""
)
assert _code(r, "playlist") == {"reasons": [], "unread": [], "sites": 1, "reasonless": []}
def test_a_map_lookup_is_its_values_and_its_keys_must_be_exactly_what_the_index_can_be():
src = """
from typing import Literal
SettingReason = Literal["too_low", "too_high"]
class InvalidSetting(ValueError):
def __init__(self, key: str, reason: SettingReason, **params: object):
self.reason: SettingReason = reason
self.params = params
_MAP: dict[SettingReason, str] = {{{keys}}}
def refused(status: int, reason: str, **params: object):
return HTTPException(status, {{**params, "code": "refused", "reason": reason}})
def setting_refused(exc: InvalidSetting):
return refused(400, _MAP[{index}], **exc.params)
"""
both = '"too_low": "setting_too_low", "too_high": "setting_too_high"'
whole = _scan(a=src.format(keys=both, index="exc.reason"))
assert _code(whole, "refused")["reasons"] == ["setting_too_high", "setting_too_low"]
assert whole["errors"] == [] and whole["unreadCodes"] == []
for keys, index in [
('"too_low": "setting_too_low"', "exc.reason"), # a key short
(both + ', "gone": "setting_gone"', "exc.reason"), # a key no index can be
(both, "exc.key"), # an index the scan cannot read
]:
r = _scan(a=src.format(keys=keys, index=index))
assert len(r["errors"]) == 1 and "not what its index can be" in r["errors"][0]
def test_a_lookup_in_anything_but_a_module_dict_of_literal_keys_is_unread():
r = _scan(
a="""
BUILT = make()
NUMBERED = {1: "one"}
def f(table, k):
raise HTTPException(409, {"code": "edit", "reason": table[k]})
def g(k):
raise HTTPException(409, {"code": "edit", "reason": BUILT[k]})
def h(k):
raise HTTPException(409, {"code": "edit", "reason": NUMBERED[k]})
"""
)
assert _unread(r, "edit") == ["table[k]", "BUILT[k]", "NUMBERED[k]"]
assert r["errors"] == []
def test_a_code_must_be_one_literal():
r = _scan(
a="""
BUSY = "busy"
def f(code):
raise HTTPException(409, {"code": "busy"})
def g(code):
raise HTTPException(409, {"code": code})
def h():
raise HTTPException(409, {"code": BUSY})
def i(x):
raise HTTPException(409, {"code": "a" if x else "b"})
def j(x):
raise HTTPException(409, {"code": "a" if x else None})
def k(key):
raise HTTPException(409, {"code": "edit", key: "late"})
"""
)
assert r["codes"]["busy"]["reasonless"] == ["a.py:4"]
assert set(r["codes"]) == {"busy"}
assert len(r["unreadCodes"]) == 5
assert r["unreadCodes"][-1].endswith(
"(a spread or a computed key the code and the reason do not follow)"
)
def test_a_spread_or_a_computed_key_stands_before_both_the_code_and_the_reason():
def unread_codes(detail: str) -> list[str]:
return _scan(a=f"def f(x, more, key):\n raise HTTPException(409, {detail})\n")[
"unreadCodes"
]
assert unread_codes('{**more, "code": "refused", "reason": "a"}') == []
assert unread_codes('{key: 1, **more, "reason": "a", "code": "refused"}') == []
for late in [
'{"code": "refused", "reason": "a", **more}', # the spread may replace both
'{"code": "refused", **more, "reason": "a"}', # … the code
'{"reason": "a", **more, "code": "refused"}', # … the reason
'{**more, "code": "refused"}', # no reason after it: the spread may carry one
'{"code": "refused", "reason": "a", key: "x"}', # a key that may be either
'dict(**more, code="refused", reason="a")', # dict() raises on a repeated key
'dict(more, code="refused", reason="a")',
]:
assert unread_codes(late), late
def test_a_detail_stands_only_where_it_is_sent():
def result(body: str, extra: str = "") -> dict:
return _scan(a=f"def f(x, cached, fix):\n{textwrap.indent(body, ' ')}\n{extra}")
for body in [
'detail = {"code": "login", "reason": "a"}\nraise HTTPException(401, detail)',
'if (detail := {"code": "login", "reason": "a"}):\n raise HTTPException(401, detail)',
'raise HTTPException(401, {"code": "login", "reason": "a"}.copy())',
'raise HTTPException(401, fix({"code": "login", "reason": "a"}))',
'raise HTTPException(401, cached or {"code": "login", "reason": "a"})',
'return JSONResponse({"code": "login", "reason": "a"}, status_code=401)', # the whole body
'raise HTTPException(401, None, {"code": "login", "reason": "a"})', # the headers
'raise HTTPException(401, headers={"code": "login", "reason": "a"})',
'raise HTTPException(401, "a" if {"code": "login", "reason": "a"} else "b")',
]:
r = result(body)
assert r["unreadCodes"] and "login" not in r["codes"], body
assert "(a detail put where the scan cannot follow it)" in r["unreadCodes"][0], body
held = _scan(a='class C:\n detail = {"code": "login"}\n')
assert "(a detail put where the scan cannot follow it)" in held["unreadCodes"][0]
sent = result(
'raise HTTPException(401, {"code": "login", "reason": "a"} if x else {"code": "login"})\n'
'raise HTTPException(401, detail=dict(code="login", reason="b"))\n'
'return JSONResponse(content={"detail": {"code": "login", "reason": "c"}})'
)
assert sent["unreadCodes"] == [] and _code(sent, "login")["reasons"] == ["a", "b", "c"]
def test_a_factory_is_a_detail_only_when_each_return_is_one_and_each_call_is_sent():
factory = """
def quota(e) -> dict:
return {"code": "quota", "reason": "files"}
"""
sent = "def f(e):\n raise HTTPException(422, detail=quota(e))\n"
assert _scan(a=factory + sent)["codes"]["quota"]["reasons"] == ["files"]
for change in [
"def g(e):\n d = quota(e)\n d['code'] = 'other'\n raise HTTPException(422, d)\n",
"def g(e):\n raise HTTPException(422, detail=quota(e).copy())\n",
"pick = quota\n",
]:
r = _scan(a=factory + sent + change)
assert r["unreadCodes"] and "quota" not in r["codes"], change
twice = _scan(a="quota = None\n" + factory + sent) # the def is the last of two bindings
assert twice["unreadCodes"] and "quota" not in twice["codes"]
for other in [
" if e:\n return None\n",
" if e:\n return cached\n",
" if e:\n return {'code': 'quota', 'reason': 'x'} if e > 1 else None\n",
" log({'code': 'quota', 'reason': 'logged'})\n", # a detail it does not return
]:
bent = factory.replace("-> dict:\n", "-> dict:\n" + other)
assert _scan(a=bent + sent)["unreadCodes"], other
method = "class C:\n def quota(self):\n return {'code': 'quota', 'reason': 'm'}\n"
assert _scan(a=method)["unreadCodes"]
dotted = _scan(
a=factory,
routes__b="import app.a\n\ndef g(e):\n raise HTTPException(422, detail=app.a.quota(e))\n",
)
assert dotted["unreadCodes"] and "quota" not in dotted["codes"]
def test_a_name_a_lambda_a_comprehension_a_nonlocal_or_a_global_binds_is_unread():
r = _scan(
a="""
def f(svc):
why = "invalid"
make = lambda why: HTTPException(401, {"code": "login", "reason": why})
raise make(svc.state)
def g(svc):
why = "invalid"
raise [HTTPException(401, {"code": "login", "reason": why}) for why in svc.all][0]
def h(svc):
why = "invalid"
def inner():
nonlocal why
why = svc.reason
inner()
raise HTTPException(401, {"code": "login", "reason": why})
def i(svc):
global why
why = "invalid"
raise HTTPException(401, {"code": "login", "reason": why})
"""
)
assert _code(r, "login")["reasons"] == []
assert sorted(_unread(r, "login")) == [
"why (bound by a lambda or a comprehension)",
"why (bound by a lambda or a comprehension)",
"why (named by a nonlocal or a global)",
"why (named by a nonlocal or a global)",
]
def test_a_local_that_reaches_itself_is_unread():
r = _scan(
a="""
def f(x):
why = "a"
if x:
why = why if x > 1 else "b"
raise HTTPException(409, {"code": "edit", "reason": why})
def g(x):
a = "z"
b = a
a = b
raise HTTPException(409, {"code": "playlist", "reason": a})
def h(x):
why = "c"
if x:
raise HTTPException(409, {"code": "busy", "reason": why})
raise HTTPException(409, {"code": "busy", "reason": why})
"""
)
assert _code(r, "busy")["reasons"] == ["c"] and _code(r, "busy")["unread"] == []
assert _code(r, "edit")["reasons"] == ["a", "b"]
assert _unread(r, "edit") == ["why (reached again while it is resolved)"]
assert _code(r, "playlist")["reasons"] == ["z"]
assert any("reached again while it is resolved" in u for u in _unread(r, "playlist"))
def test_a_module_constant_or_a_map_changed_elsewhere_is_unread():
def reasons(urlguard: str, route: str = "", **more: str) -> tuple[list[str], list[str]]:
r = _scan(
**more,
urlguard="REASON = 'malformed'\n_MAP = {'a': 'map_a'}\n" + urlguard,
routes__a="from app import urlguard\n"
+ route
+ "\ndef f(k: Literal['a']):\n"
+ " raise HTTPException(409, {'code': 'url_blocked', 'reason': urlguard.REASON})\n"
+ " raise HTTPException(409, {'code': 'url_blocked', 'reason': urlguard._MAP[k]})\n",
)
return _code(r, "url_blocked")["reasons"], _unread(r, "url_blocked")
assert reasons("def get(k):\n return _MAP.get(k)\n") == (["malformed", "map_a"], [])
other = "from app import other\ndef g():\n other.REASON = 'x'\n" # another module's REASON
assert reasons("", other, other="REASON = 'y'\n") == (["malformed", "map_a"], [])
shadow = (
"def h(*urlguard):\n"
" raise HTTPException(409, {'code': 'url_blocked', 'reason': urlguard.REASON})\n"
)
assert reasons("", shadow)[1] == ["urlguard.REASON"]
for urlguard, route in [
("def reset():\n global REASON\n REASON = 'x'\n", ""),
("", "def g():\n urlguard.REASON = 'x'\n"),
("", "def g():\n del urlguard.REASON\n"),
]:
assert reasons(urlguard, route)[0] == ["map_a"], (urlguard, route)
for urlguard in [
"_MAP['a'] = compute()\n",
"def init(x):\n _MAP['a'] = x\n",
"_MAP.update(a='other')\n",
"def drop():\n del _MAP['a']\n",
"register(_MAP)\n",
]:
got, unread = reasons(urlguard)
assert got == ["malformed"] and "(the map is changed after it is built)" in unread[0]
def test_a_helper_bound_twice_is_not_followed():
r = _scan(
refusal="refused = None\n" + textwrap.dedent(REFUSAL),
routes__a="from app.refusal import refused\n\ndef f():\n raise refused(409, 'lost')\n",
)
assert _code(r, "refused")["reasons"] == []
assert _unread(r, "refused")[0].endswith("refused(reason) is not a module-level helper")
def test_a_dotted_path_through_an_app_module_that_goes_nowhere_is_unread():
r = scan(
{
"__init__.py": "",
"refusal.py": textwrap.dedent(REFUSAL),
"routes/a.py": "import app as a\n\ndef f():\n raise a.refusal.refused(409, 'dotted')\n",
}
)
assert _unread(r, "refused") == ["a.refusal.refused (a reference the scan cannot resolve)"]
def test_a_helper_reached_through_an_app_module_that_does_not_bind_it_is_unread():
for shim in [
"from app.refusal import *\n",
"try:\n from app.refusal import refused\nexcept ImportError:\n refused = None\n",
]:
r = _scan(
refusal=REFUSAL,
shim=shim,
routes__a="from app.shim import refused\n\ndef f():\n raise refused(409, 'hidden')\n",
)
assert _unread(r, "refused") == ["refused (a reference the scan cannot resolve)"], shim
package = scan(
{
"pkg/refusal.py": textwrap.dedent(REFUSAL),
"pkg/__init__.py": "from .refusal import refused\n",
"routes/a.py": "from app.pkg import refused\n\ndef f():\n raise refused(409, 'hidden')\n",
}
)
assert _unread(package, "refused") == ["refused (a reference the scan cannot resolve)"]
def test_the_data_files_are_excluded_by_name_only():
r = _scan(
plex__tmdb='def f():\n return {"code": "HU", "name": "Hungary"}',
plex__other='def f():\n return {"code": "HU"}',
)
assert r["excluded"] == {"plex/tmdb.py": 1}
assert "HU" in r["codes"]
def test_the_app_scans():
"""The real tree, on the interpreter the backend runs: every refusal it holds is read (the
frontend's contract judges what is read)."""
result = scan_app()
assert {"refused", "not_found", "login", "clip"} <= set(result["codes"])
assert result["errors"] == [] and result["unreadCodes"] == []
+4 -2
View File
@@ -101,8 +101,10 @@ def test_a_video_state_cleared_through_the_route_is_gone(db, make_user, as_user)
_video(db) _video(db)
db.add(VideoState(user_id=user.id, video_id="v1", status="hidden", position_seconds=90)) db.add(VideoState(user_id=user.id, video_id="v1", status="hidden", position_seconds=90))
db.commit() db.commit()
assert as_user(user).delete("/api/videos/v1/state").status_code == 200 assert as_user(user).delete("/api/videos/v1/progress").status_code == 200
assert _state(db, user) is None db.expire_all()
row = _state(db, user)
assert (row.status, row.position_seconds) == ("hidden", 0)
# --- saved views ------------------------------------------------------------------------------ # --- saved views ------------------------------------------------------------------------------
+5 -7
View File
@@ -292,17 +292,16 @@ def test_a_known_video_is_marked_watched_at_its_takeout_time_and_the_rest_wait_o
assert (states["older000000"].status, states["older000000"].position_seconds) == ("watched", 40) assert (states["older000000"].status, states["older000000"].position_seconds) == ("watched", 40)
@pytest.mark.parametrize("undo", ["unmark", "clear_state"]) def test_a_video_the_user_un_marked_here_is_not_marked_again_by_an_older_view(db, make_user):
def test_a_video_the_user_un_marked_here_is_not_marked_again_by_an_older_view(db, make_user, undo): """Un-marking a video leaves no state row, and the import inserted a watched one over the user's
"""Un-marking (or resetting) a video leaves no state row, and the import inserted a watched one own choice. A view on YouTube after the un-mark is a new view, and counts."""
over the user's own choice. A view on YouTube after the un-mark is a new view, and counts.""" user = make_user("unmark@example.com")
user = make_user(f"{undo}@example.com")
for vid in ("undone00000", "again000000"): for vid in ("undone00000", "again000000"):
_video(db, vid) _video(db, vid)
db.commit() db.commit()
for vid in ("undone00000", "again000000"): for vid in ("undone00000", "again000000"):
service.mark(db, user, vid, "watched") service.mark(db, user, vid, "watched")
getattr(service, undo)(db, user, vid) service.unmark(db, user, vid)
db.commit() db.commit()
assert _states(db, user) == {} assert _states(db, user) == {}
later = datetime.now(timezone.utc) + timedelta(minutes=5) later = datetime.now(timezone.utc) + timedelta(minutes=5)
@@ -603,7 +602,6 @@ def test_staging_and_settling_take_the_users_one_lock(db, make_user):
), ),
# Between the import's read of the un-marks and its marking, one would be overwritten. # Between the import's read of the un-marks and its marking, one would be overwritten.
"unmark": lambda s, u: service.unmark(s, u, "aaaaaaaaaaa"), "unmark": lambda s, u: service.unmark(s, u, "aaaaaaaaaaa"),
"reset": lambda s, u: service.clear_state(s, u, "aaaaaaaaaaa"),
} }
with SessionLocal() as holder: with SessionLocal() as holder:
service.lock_takeout(holder, holder.get(User, user.id)) service.lock_takeout(holder, holder.get(User, user.id))
+2 -1
View File
@@ -579,7 +579,7 @@ def _display_write_sites() -> dict[tuple[str, str], set[str]]:
# enqueue_clip slices the source's title to the same width. # enqueue_clip slices the source's title to the same width.
# files.ingest_spooled passes through `spool_upload`'s value, bounded there. # files.ingest_spooled passes through `spool_upload`'s value, bounded there.
# routes enqueue_clip the `display_name=` keyword passes on the name bound above it. # routes enqueue_clip the `display_name=` keyword passes on the name bound above it.
# pipeline._download / _process_clip / worker._finish_from_cache # pipeline._download / _process_clip / worker._finish_from_cache / backfill._retitle
# slice a fetched or stored title to the column's width. # slice a fetched or stored title to the column's width.
# #
# Held as EXACT EQUALITY rather than as a list of offenders. The offender form could only fail # Held as EXACT EQUALITY rather than as a list of offenders. The offender form could only fail
@@ -596,6 +596,7 @@ DISPLAY_WRITE_SITES = {
("downloads/service.py", "enqueue_clip"): {"display_name or asset.title[:DISPLAY_TEXT_MAX]"}, ("downloads/service.py", "enqueue_clip"): {"display_name or asset.title[:DISPLAY_TEXT_MAX]"},
("downloads/pipeline.py", "_download"): {"meta.title[:DISPLAY_TEXT_MAX]"}, ("downloads/pipeline.py", "_download"): {"meta.title[:DISPLAY_TEXT_MAX]"},
("downloads/pipeline.py", "_process_clip"): {"named[:DISPLAY_TEXT_MAX]"}, ("downloads/pipeline.py", "_process_clip"): {"named[:DISPLAY_TEXT_MAX]"},
("downloads/backfill.py", "_retitle"): {"named[:DISPLAY_TEXT_MAX]"},
("worker.py", "_finish_from_cache"): {"asset.title[:DISPLAY_TEXT_MAX]"}, ("worker.py", "_finish_from_cache"): {"asset.title[:DISPLAY_TEXT_MAX]"},
("downloads/upload.py", "ingest_upload"): {"display_text(display_name)"}, ("downloads/upload.py", "ingest_upload"): {"display_text(display_name)"},
("routes/auth.py", "callback"): {"display_text(userinfo.get('name'))"}, ("routes/auth.py", "callback"): {"display_text(userinfo.get('name'))"},
+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"
+12 -2
View File
@@ -256,7 +256,7 @@ Also in force:
- **A glyph means one thing.** Play (▷) starts playback, or resumes a paused transfer; a retry is the - **A glyph means one thing.** Play (▷) starts playback, or resumes a paused transfer; a retry is the
refresh arrow (`RefreshCw`), and so is doing a download again; a reset or revert is the refresh arrow (`RefreshCw`), and so is doing a download again; a reset or revert is the
counter-clockwise arrow (`RotateCcw`), and so is playing from the beginning, on the card and in its counter-clockwise arrow (`RotateCcw`), and so is playing from the beginning, on the card and in its
menu alike; clearing a video's progress and status is the eraser (`Eraser`). A failed download's Retry menu alike; clearing a video's watch progress is the eraser (`Eraser`). A failed download's Retry
had worn the Play glyph, which promised a video. The skip arrows (⏮ ⏭) are Previous / had worn the Play glyph, which promised a video. The skip arrows (⏮ ⏭) are Previous /
Next; the auto-advance mode beside them wears list glyphs (`ListEnd` next, `ListStart` previous, Next; the auto-advance mode beside them wears list glyphs (`ListEnd` next, `ListStart` previous,
`Shuffle` random; off is `ListEnd`, not lit) — a mode in the skip arrows put two identical ⏭ on the `Shuffle` random; off is `ListEnd`, not lit) — a mode in the skip arrows put two identical ⏭ on the
@@ -378,7 +378,11 @@ Which actions are visible and which live in a menu.
managing the shares, re-download, the copies, cancel / delete / remove 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 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 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 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 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 poll passes `useContextMenu({ keyOf })` (its id), so the "…" still closes its own menu. A menu's
@@ -824,3 +828,9 @@ 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 | 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 | 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-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 |
| 2026-11-01 | Docked bar glyphs (owner D8 a) | The maximised view's docked bar is glyph-only too (tooltips carry the names and keys): transport, auto-advance, loop, Mark watched (✓ / ✓✓); its controls scroll on a narrow window while Fullscreen and Exit stay pinned at its right end | Labels from `xl` up | On a tablet the labelled bar ran past the right edge and took Exit with it; the side panel had already lost its labels |
| 2026-11-01 | Toolbar group glyphs (owner D9 a, D10 a, D11 a) | The feed's, channel page's and Plex Browse's filter pills name their group by a glyph (`Eye` watch state, `Shapes` content types, `ArrowDownUp` sort), the word in the tooltip and the accessible name; Sort + View end the toolbar's row, the chip row shows only with a chip; a channel page has no count pill | The value alone; Sort + View on a row of their own | "Watch state:" and "Sort:" cost a tablet row its room; the value says enough once the glyph names the group; the channel header already counts the videos |
| 2026-11-01 | Channel header on a tablet (owner E2) | Below `xl` the action row drops its two words: "Prio" (the stepper's tooltip names it) and "Subscribed" (a ✓ glyph; its name in the aria-label, Unsubscribe in the tooltip); the tags keep the row's rest, folding into "+N" | The tag lane on a full line of its own below ~12rem; the tags under the channel name | On an iPad (820 px, 44 px targets) the lane got 29 px, a sliver; now 130 px, and every tag shows at 1180 |
| 2026-11-01 | Clear watch progress (owner E a) | A video menu's Reset reads "Clear watch progress" in English (HU "Megtekintés visszaállítása" stays); "Reset view" is only a page's view reset (sort, filters, grouping) | "Reset view" for both; the playlist page's view reset renamed instead | On a playlist page the two stood one right-click apart under one name, and one of them erases the video's progress with no undo |
| 2026-11-01 | Clear watch progress clears only that (owner A) | The row shows only on a video with a resume point and clears that alone: a hidden video stays hidden, a watched one watched. Un-hiding and un-watching have rows of their own |
+18 -3
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. 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 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 `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 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. 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 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. 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), 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 down, a module only through its face) and `chunkGuard` in the build (no lazy page on the boot path), plus the
i18n parity test. i18n parity test.
@@ -130,7 +144,8 @@ npm run typecheck # tsc x3 (app / node / e2e)
npm run lint # eslint (typed rules included; warnings fail) npm run lint # eslint (typed rules included; warnings fail)
npm run format:check # prettier npm run format:check # prettier
npm run knip # unused files/exports/deps npm run knip # unused files/exports/deps
npm run test # vitest (incl. the compiler and gate-register contracts) npm run test # vitest (incl. the compiler and gate-register contracts; the refusal contract runs
# the host's `python` on backend/tests/refusal_sites.py, stdlib only)
npx vite build # the production bundle (devOnlyGuard fails it on dev-only code, chunkGuard on a lazy page loaded at boot) npx vite build # the production bundle (devOnlyGuard fails it on dev-only code, chunkGuard on a lazy page loaded at boot)
npm audit --omit=dev --audit-level=high # what ships to the browser, at high or above npm audit --omit=dev --audit-level=high # what ships to the browser, at high or above
``` ```
+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();
},
);
});
+4 -2
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- // `.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 // 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.) // 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 expect(page.getByTestId("channel-title")).toBeVisible();
await page.getByTestId("channel-back").click(); await page.getByTestId("channel-back").click();
await expect(page.getByTestId("video-card").first()).toBeVisible(); await expect(page.getByTestId("video-card").first()).toBeVisible();
@@ -114,7 +114,9 @@ test.describe("channel view", () => {
}) => { }) => {
await openApp(page); await openApp(page);
await page.getByTestId("video-card-channel").first().click(); await page.getByTestId("video-card-channel").first().click();
const count = page.getByTestId("item-count"); // A pill of the feed's toolbar, which rides in the fixed chrome (the page has no count pill: its
// header counts the videos).
const count = page.getByTestId("feed-show");
await expect(count).toBeVisible(); await expect(count).toBeVisible();
await expect(page.getByTestId("video-card").first()).toBeVisible(); await expect(page.getByTestId("video-card").first()).toBeVisible();
await expect(page.getByTestId("channel-folded")).toHaveCount(0); await expect(page.getByTestId("channel-folded")).toHaveCount(0);
+27 -9
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 * 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 * 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). * 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 links = page.getByTestId("video-card-channel").filter({ visible: true });
let index = -1;
const findHittable = async () => {
const count = await links.count(); const count = await links.count();
for (let i = 0; i < count; i++) { for (let i = 0; i < count; i++) {
const hittable = await links.nth(i).evaluate((el) => { const hittable = await links
.nth(i)
.evaluate(
(el) => {
const r = el.getBoundingClientRect(); const r = el.getBoundingClientRect();
const hit = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2); const hit = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2);
return hit !== null && el.contains(hit); return hit !== null && el.contains(hit);
}); },
if (hittable) { undefined,
await links.nth(i).click(); { timeout: 1000 },
return true; )
.catch(() => false);
if (hittable) return i;
} }
} return -1;
return false; };
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 }, plugins: { playwright },
rules: { rules: {
"playwright/no-focused-test": "error", "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 })); vi.mock("./components/ui/confirmContext", () => ({ useConfirm: () => async () => true }));
const { notify } = vi.hoisted(() => ({ 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) => ({ vi.mock("./lib/notifications", async (importOriginal) => ({
...(await importOriginal<typeof import("./lib/notifications")>()), ...(await importOriginal<typeof import("./lib/notifications")>()),
@@ -35,6 +35,7 @@ import PlexPlaylistAdd from "./features/plexCards/PlexPlaylistAdd";
import TagManager from "./features/channel/TagManager"; import TagManager from "./features/channel/TagManager";
import { api, HttpError, type PlexCard } from "./lib/api"; import { api, HttpError, type PlexCard } from "./lib/api";
import { qk } from "./lib/queryKeys"; import { qk } from "./lib/queryKeys";
import type { NotifText } from "./lib/notifications";
beforeAll(() => { beforeAll(() => {
class Stub { class Stub {
@@ -98,7 +99,7 @@ describe("the Plex playlist page", () => {
() => fireEvent.contextMenu(screen.getByText("Movie 1").closest("[data-nav-index]")!), () => fireEvent.contextMenu(screen.getByText("Movie 1").closest("[data-nav-index]")!),
"plex.playlist.removeFromPlaylist", "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)))); await act(async () => void (await new Promise((r) => setTimeout(r, 20))));
expect(screen.getByText("Movie 1")).toBeTruthy(); expect(screen.getByText("Movie 1")).toBeTruthy();
}); });
@@ -119,7 +120,7 @@ describe("the Plex playlist page", () => {
const field = await screen.findByDisplayValue("Films"); const field = await screen.findByDisplayValue("Films");
fireEvent.change(field, { target: { value: "Movies" } }); fireEvent.change(field, { target: { value: "Movies" } });
fireEvent.keyDown(field, { key: "Enter" }); 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 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(screen.getByLabelText("playlists.addToPlaylist"));
fireEvent.click(await screen.findByRole("menuitemcheckbox", { hidden: true })); 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)); 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")); fireEvent.click(await screen.findByText("Gone"));
// The server's own localized reason wins over the caller's fallback when the detail is // 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. // 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)); await waitFor(() => expect(lists.mock.calls.length).toBeGreaterThan(1));
}); });
}); });
@@ -300,7 +301,7 @@ describe("the tag manager", () => {
</QueryClientProvider>, </QueryClientProvider>,
); );
fireEvent.click(await screen.findByLabelText("tagManager.delete")); 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)); 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 })); vi.mock("./components/ui/confirmContext", () => ({ useConfirm: () => async () => true }));
const { notify } = vi.hoisted(() => ({ 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) => ({ vi.mock("./lib/notifications", async (importOriginal) => ({
...(await importOriginal<typeof import("./lib/notifications")>()), ...(await importOriginal<typeof import("./lib/notifications")>()),
@@ -30,6 +30,7 @@ import PlexCollectionEditor from "./modules/plex/PlexCollectionEditor";
import UploadDialog from "./modules/files/UploadDialog"; import UploadDialog from "./modules/files/UploadDialog";
import DownloadDialog from "./features/downloads/DownloadDialog"; import DownloadDialog from "./features/downloads/DownloadDialog";
import { api, HttpError } from "./lib/api"; import { api, HttpError } from "./lib/api";
import { msg, type NotifText } from "./lib/notifications";
beforeAll(() => { beforeAll(() => {
class Stub { class Stub {
@@ -87,7 +88,7 @@ describe("the Plex collection editor", () => {
); );
mount(); mount();
fireEvent.click(await screen.findByText("Marathon")); 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 () => { it("says so when enabling editing is refused", async () => {
@@ -97,7 +98,7 @@ describe("the Plex collection editor", () => {
); );
mount(); mount();
fireEvent.click(await screen.findByText("plex.collEditor.makeEditable")); 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 () => { it("says so when a CONFIRMED delete does not happen", async () => {
@@ -108,7 +109,7 @@ describe("the Plex collection editor", () => {
); );
mount(); mount();
fireEvent.click(await screen.findByText("plex.collEditor.delete")); 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(); mount();
pick(); pick();
fireEvent.click(screen.getByText("downloads.upload.submit")); 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 () => { it("says nothing when the user cancelled it themselves", async () => {
@@ -172,6 +173,6 @@ describe("the download dialog", () => {
); );
await screen.findByText("Best"); await screen.findByText("Best");
fireEvent.click(screen.getByText("downloads.dialog.submit")); fireEvent.click(screen.getByText("downloads.dialog.submit"));
await waitFor(() => expect(errors()).toEqual(["errors.writeFailed.enqueueDownload"])); await waitFor(() => expect(errors()).toEqual([msg("errors.writeFailed.enqueueDownload")]));
}); });
}); });
@@ -2,9 +2,9 @@
// //
// Below `md` a table is a list of cards, its columns flowing one after another. A column marked // Below `md` a table is a list of cards, its columns flowing one after another. A column marked
// `cardRow` takes a line of its own there — a row's lone "…" otherwise landed at the end of one // `cardRow` takes a line of its own there — a row's lone "…" otherwise landed at the end of one
// card's line and on the next line of another. // card's line and on the next line of another. A card is its row's press surface too, as the row is.
import { describe, it, expect, afterEach, vi } from "vitest"; import { describe, it, expect, afterEach, vi } from "vitest";
import { render, cleanup, screen } from "@testing-library/react"; import { render, cleanup, screen, fireEvent } from "@testing-library/react";
import DataTable, { type Column } from "./DataTable"; import DataTable, { type Column } from "./DataTable";
vi.mock("react-i18next", async (importOriginal) => ({ vi.mock("react-i18next", async (importOriginal) => ({
@@ -43,4 +43,21 @@ describe("DataTable cards", () => {
expect(screen.getByText("more").parentElement!.className).toContain("basis-full"); expect(screen.getByText("more").parentElement!.className).toContain("basis-full");
expect(screen.getByText("1 MB").parentElement!.className).not.toContain("basis-full"); expect(screen.getByText("1 MB").parentElement!.className).not.toContain("basis-full");
}); });
it("opens its object from a tap on the card, and leaves its control's tap to the control", () => {
narrow(false);
const open = vi.fn();
render(
<DataTable
rows={[{ id: "a" }]}
columns={columns}
rowKey={(r) => r.id}
rowOpen={() => open}
/>,
);
fireEvent.click(screen.getByText("1 MB"), { detail: 1 });
expect(open).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getByText("more"), { detail: 1 });
expect(open).toHaveBeenCalledTimes(1);
});
}); });
+14 -4
View File
@@ -9,6 +9,7 @@ import { alignClass } from "./tableAlign";
import { columnHideClass, filterRows, type Column, type FilterMap } from "../../lib/tableColumns"; import { columnHideClass, filterRows, type Column, type FilterMap } from "../../lib/tableColumns";
import { navItemAttrs, useListNav } from "../../lib/listNav"; import { navItemAttrs, useListNav } from "../../lib/listNav";
import { useMediaQuery } from "../../lib/useMediaQuery"; import { useMediaQuery } from "../../lib/useMediaQuery";
import { withRowOpen } from "./rowOpen";
import { SURFACE } from "./surfaces"; import { SURFACE } from "./surfaces";
import { HiddenFilterCue } from "./HiddenFilterCue"; import { HiddenFilterCue } from "./HiddenFilterCue";
import { useProgressiveRows } from "./progressiveRows"; import { useProgressiveRows } from "./progressiveRows";
@@ -57,6 +58,7 @@ interface TableRowProps<T> {
index: number; index: number;
columns: Column<T>[]; columns: Column<T>[];
rowProps?: (row: T) => Record<string, unknown>; rowProps?: (row: T) => Record<string, unknown>;
rowOpen?: (row: T) => (() => void) | null;
rowClassName?: (row: T) => string; rowClassName?: (row: T) => string;
keyboardNav: boolean; keyboardNav: boolean;
} }
@@ -69,14 +71,16 @@ function TableRow<T>({
index, index,
columns, columns,
rowProps, rowProps,
rowOpen,
rowClassName, rowClassName,
keyboardNav, keyboardNav,
}: TableRowProps<T>) { }: TableRowProps<T>) {
const open = rowOpen?.(row) ?? null;
return ( return (
<tr <tr
{...rowProps?.(row)} {...withRowOpen(rowProps?.(row), open)}
{...(keyboardNav && navItemAttrs(index))} {...(keyboardNav && navItemAttrs(index))}
className={`border-b border-border/50 state-layer transition ${rowClassName?.(row) ?? ""}`} className={`border-b border-border/50 state-layer transition ${open ? "cursor-pointer " : ""}${rowClassName?.(row) ?? ""}`}
> >
{columns.map((col) => ( {columns.map((col) => (
<td <td
@@ -102,6 +106,7 @@ export default function DataTable<T>({
emptyText, emptyText,
rowClassName, rowClassName,
rowProps, rowProps,
rowOpen,
controlsPosition = "bottom", controlsPosition = "bottom",
controlsLeading, controlsLeading,
controlsInBand = false, controlsInBand = false,
@@ -137,6 +142,10 @@ export default function DataTable<T>({
// answer the same gesture. Deliberately not a bag of named callbacks: the table stays domain-free // answer the same gesture. Deliberately not a bag of named callbacks: the table stays domain-free
// and never learns what a menu is. // and never learns what a menu is.
rowProps?: (row: T) => Record<string, unknown>; rowProps?: (row: T) => Record<string, unknown>;
// The row's default action (play, open), or null where a row has none: a press anywhere on the row
// or its card twin runs it, except on a control inside it or at the end of a text selection
// (`rowOpen.ts`). The keyboard's Enter keeps reaching it through the row's `data-nav-open` element.
rowOpen?: (row: T) => (() => void) | null;
controlsPosition?: "top" | "bottom" | "both"; controlsPosition?: "top" | "bottom" | "both";
controlsLeading?: ReactNode; controlsLeading?: ReactNode;
// Render the controls row (controlsLeading + pager) into the shell's fixed toolbar band instead // Render the controls row (controlsLeading + pager) into the shell's fixed toolbar band instead
@@ -414,6 +423,7 @@ export default function DataTable<T>({
index={i} index={i}
columns={columns} columns={columns}
rowProps={rowProps} rowProps={rowProps}
rowOpen={rowOpen}
rowClassName={rowClassName} rowClassName={rowClassName}
keyboardNav={keyboardNav} keyboardNav={keyboardNav}
/> />
@@ -430,9 +440,9 @@ export default function DataTable<T>({
{shown.map((row, i) => ( {shown.map((row, i) => (
<div <div
key={rowKey(row)} key={rowKey(row)}
{...rowProps?.(row)} {...withRowOpen(rowProps?.(row), rowOpen?.(row) ?? null)}
{...(keyboardNav && navItemAttrs(i))} {...(keyboardNav && navItemAttrs(i))}
className={`${SURFACE.card} p-3 ${rowClassName?.(row) ?? ""}`} className={`${SURFACE.card} p-3 ${rowOpen?.(row) ? "cursor-pointer " : ""}${rowClassName?.(row) ?? ""}`}
> >
{columns {columns
.filter((c) => c.cardPrimary) .filter((c) => c.cardPrimary)
+10 -3
View File
@@ -1,6 +1,6 @@
import { useState } from "react"; import { useState, type ReactNode } from "react";
import { MenuCheck } from "./MenuCheck"; import { MenuCheck } from "./MenuCheck";
import { SelectTrigger } from "./SelectTrigger"; import { GroupGlyph, SelectTrigger } from "./SelectTrigger";
import { PopoverItem, PopoverSurface } from "./AnchoredPopover"; import { PopoverItem, PopoverSurface } from "./AnchoredPopover";
import { useAnchoredPopover } from "./anchoredPopoverHooks"; import { useAnchoredPopover } from "./anchoredPopoverHooks";
import { SURFACE } from "./surfaces"; import { SURFACE } from "./surfaces";
@@ -27,6 +27,10 @@ export interface FilterSelectProps {
/** Show only the value on the trigger — for a form row whose own caption already names the field. /** Show only the value on the trigger — for a form row whose own caption already names the field.
* The label still names the control and its menu for assistive tech. */ * The label still names the control and its menu for assistive tech. */
hideLabel?: boolean; hideLabel?: boolean;
/** A glyph that names the group in place of its word, for a toolbar where the value says enough
* once the group is recognisable (the feed's watch state: an eye). The word moves to the tooltip
* and stays in the accessible name. */
icon?: ReactNode;
options: readonly FilterSelectOption[]; options: readonly FilterSelectOption[];
selected: string[]; selected: string[];
onChange: (next: string[]) => void; onChange: (next: string[]) => void;
@@ -46,6 +50,7 @@ export default function FilterSelect({
multiple = false, multiple = false,
requireOne = true, requireOne = true,
hideLabel = false, hideLabel = false,
icon,
testId, testId,
}: FilterSelectProps) { }: FilterSelectProps) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
@@ -95,7 +100,9 @@ export default function FilterSelect({
<SelectTrigger <SelectTrigger
context="toolbar" context="toolbar"
open={open} open={open}
label={hideLabel ? undefined : label} label={hideLabel || icon ? undefined : label}
icon={icon && <GroupGlyph>{icon}</GroupGlyph>}
title={icon ? label : undefined}
ref={setReference} ref={setReference}
{...pop.getReferenceProps()} {...pop.getReferenceProps()}
data-testid={testId} data-testid={testId}
+99 -5
View File
@@ -21,9 +21,9 @@ vi.mock("react-i18next", async (importOriginal) => {
vi.mock("../../lib/history", () => ({ useBackToClose: () => {} })); vi.mock("../../lib/history", () => ({ useBackToClose: () => {} }));
import Modal from "./Modal"; 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 { 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. // Fire a keydown on window (where Modal installs its handler), inside act.
const press = (key: string, shiftKey = false) => const press = (key: string, shiftKey = false) =>
@@ -121,7 +121,7 @@ describe("Modal focus trap (offsetParent stubbed 'visible')", () => {
<Body /> <Body />
</Modal>, </Modal>,
); );
act(() => void notify({ level: "info", message: "Saved" })); act(() => void notify({ level: "info", message: raw("Saved") }));
try { try {
const dismissToast = screen.getByTitle("notifications.dismiss"); 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. // 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 /> <Body />
</Modal>, </Modal>,
); );
act(() => void notify({ level: "error", message: "Nope" })); act(() => void notify({ level: "error", message: raw("Nope") }));
const dismissToast = screen.getByTitle("notifications.dismiss"); const dismissToast = screen.getByTitle("notifications.dismiss");
dismissToast.focus(); dismissToast.focus();
expect(document.activeElement).toBe(dismissToast); // the scenario is actually being driven 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); 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", () => { 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 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. // 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 /> <Body />
</Modal>, </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."); expect(screen.getByRole("dialog").textContent).toContain("Couldn't share it.");
}); });
}); });
+6 -1
View File
@@ -65,6 +65,7 @@ export default function Modal({
placement = "center", placement = "center",
initialFocus, initialFocus,
headerActions, headerActions,
url,
}: { }: {
title: ReactNode; title: ReactNode;
initialFocus: ModalFocus; initialFocus: ModalFocus;
@@ -81,10 +82,14 @@ export default function Modal({
placement?: "center" | "drawer" | "drawer-start" | "top"; placement?: "center" | "drawer" | "drawer-start" | "top";
/** Controls beside the ✕ in the header (a side panel's pin and customize, C2). */ /** Controls beside the ✕ in the header (a side panel's pin and customize, C2). */
headerActions?: ReactNode; 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(); const { t } = useTranslation();
// Browser/mouse Back closes the topmost modal instead of navigating away. // 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 // 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 // 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. // 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 PageScroller from "./PageScroller";
import BackToTop from "./BackToTop"; import BackToTop from "./BackToTop";
import { notifyNav } from "../../lib/history"; import { notifyNav } from "../../lib/history";
import { revealedNavItem } from "../../lib/listNav";
beforeAll(() => { beforeAll(() => {
// useScrollFade wires a ResizeObserver (watch-the-children); jsdom ships none. // useScrollFade wires a ResizeObserver (watch-the-children); jsdom ships none.
@@ -125,6 +126,38 @@ describe("PageScroller scroll memory", () => {
expect(main.scrollTop).toBe(450); 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. // 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", () => { it("finishes the way to a spot the spacer brings within reach after it lands", () => {
let frames: FrameRequestCallback[] = []; let frames: FrameRequestCallback[] = [];
@@ -12,6 +12,7 @@ import { StickyTopProvider } from "./stickyTop";
import { useBackToTopShown, useCornerLift } from "./backToTopState"; import { useBackToTopShown, useCornerLift } from "./backToTopState";
import { CONTENT_ID } from "./landmarks"; import { CONTENT_ID } from "./landmarks";
import { ensureNavId, peekNavId, subscribeNav } from "../../lib/history"; 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 // 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 // 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("wheel", stop);
el.removeEventListener("touchstart", stop); el.removeEventListener("touchstart", stop);
el.removeEventListener("pointerdown", stop); el.removeEventListener("pointerdown", stop);
el.removeEventListener(NAV_REVEAL_EVENT, stop);
document.removeEventListener("keydown", stop, true); document.removeEventListener("keydown", stop, true);
}; };
el.addEventListener("wheel", stop, { passive: true }); el.addEventListener("wheel", stop, { passive: true });
el.addEventListener("touchstart", stop, { passive: true }); el.addEventListener("touchstart", stop, { passive: true });
el.addEventListener("pointerdown", stop); 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); document.addEventListener("keydown", stop, true);
const tick = () => { const tick = () => {
if (cancelled) return; if (cancelled) return;
+3 -3
View File
@@ -36,10 +36,10 @@ Counter-examples, deliberately NOT here: `PosterTile` (knows about Plex montage
## What lives here ## What lives here
| area | components | | area | components |
| ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| overlays & anchoring | `AnchoredPopover` + `anchoredPopoverHooks` (the popover engine: arrows, typeahead, Escape, anchor-flip), `ActionMenu`, `MembershipMenu` (the body of a "which sets is it in" menu: checkbox rows + a field naming a new set), `ContextMenu` + `useContextMenu` (the SAME menu, opened by right-click / long-press / Shift+F10 / a row's `MoreButton` "…" via `buttonProps`; `anchor` for a panel a row opens; `PageMenu` for a page's background menu), `Modal` (+ `ModalFooter`; `initialFocus` by purpose, C4), `Overlay`, `SidePanel`, `Tooltip`, `Toaster` (+ `toastLevels`, `overlayOrder`; `lib/toastDock` docks one control under the stack, in every host), `CommandPalette`, `KeyboardHelp`, `ConfirmProvider` + `confirmContext` (`useConfirm` / `useChoice`: the app's promise-based confirm, mounted once at the root; a decision with more than two outcomes is one `useChoice`, never two chained confirms), `dialogFocus` (the focus half of the dialog contract: `Modal`'s, and the wizard's own shell), `menuGroups` (one menu from its groups, a rule only between non-empty ones), `pageMenuSlot` + `pageMenuItems` (a page's background menu, mirroring its own toolbar) | | overlays & anchoring | `AnchoredPopover` + `anchoredPopoverHooks` (the popover engine: arrows, typeahead, Escape, anchor-flip), `ActionMenu`, `MembershipMenu` (the body of a "which sets is it in" menu: checkbox rows + a field naming a new set), `ContextMenu` + `useContextMenu` (the SAME menu, opened by right-click / long-press / Shift+F10 / a row's `MoreButton` "…" via `buttonProps`; `anchor` for a panel a row opens; `PageMenu` for a page's background menu), `Modal` (+ `ModalFooter`; `initialFocus` by purpose, C4), `Overlay`, `SidePanel`, `Tooltip`, `Toaster` (+ `toastLevels`, `overlayOrder`; `lib/toastDock` docks one control under the stack, in every host), `CommandPalette`, `KeyboardHelp`, `ConfirmProvider` + `confirmContext` (`useConfirm` / `useChoice`: the app's promise-based confirm, mounted once at the root; a decision with more than two outcomes is one `useChoice`, never two chained confirms), `dialogFocus` (the focus half of the dialog contract: `Modal`'s, and the wizard's own shell), `menuGroups` (one menu from its groups, a rule only between non-empty ones), `pageMenuSlot` + `pageMenuItems` (a page's background menu, mirroring its own toolbar) |
| actions & inputs | `Button`, `IconButton`, `Pressable` (`selectable` when its text is a title or a name), `Chip` + `chipStyles`, `DragHandle` + `OrderButtons` / `useOrderItems` (the non-drag reorder, WCAG 2.5.7) (one per button family, docs/DESIGN.md; a raw `<button>` or a bare `FocusSafeButton` outside the control primitives — eslint `BUTTON_PRIMITIVES` — is a lint error), `Input` + `fieldStyles`, `RenameField` (a name typed in place of the name: Enter or blur saves, Escape cancels; the caller hands the keyboard back), `DropZone` (the dashed place a file upload starts from: a drop, or a click that opens the file dialog), `Meter` (how full something is against a limit — one danger line for every place showing the same measure), `TimecodeField` + `useTimecodeField` / `TimecodeError` (a cut's time typed in: the draft, held by the caller, until Enter or blur takes it, then the value as it landed; unreadable stays once offered, marked invalid, and the caller places the line saying how to type one in its flow, outside a label; a draft goes when the value moves under it), `NativeControls`, `MenuCheck` (the drawn check of a menu row or a whole-row checkbox button; its tick is the `chosen` colour, which `media-marks` turns amber on the player chrome), `mediaChrome` (`MEDIA_CHOSEN`, a chosen tab or toggle on the chrome), `SelectTrigger` (the field family; a raw `<input>` is a lint error), `useReturnFocus`, `surfaces` (the B5 glass roles; a bare glass class is a lint error), `Badge` (a static label), `LinkButton` / `IconLink` (a link wearing the button face) over `buttonStyles`, `Skeleton` (B8 first loads, through `LoadingState shape`), `NavRail` (B7; `NavRailGroup` folds a set of rare pages into one row with a panel), `form`, `DraftSaveBar`, `Combobox`, `FilterSelect`, `SegmentedToggle`, `SortControl`, `ColumnSortControl`, `PriorityStepper`, `UndoToolbar`, `ViewSwitcher` (a list's display modes behind one toolbar icon), `copyItems` (`useCopyItems`: the "Copy title" / "Copy link" rows every object's menu ends with) + `useCopyFeedback` (a Copy button's "Copied" moment), `MatchToggle` (a multi-value filter's any/all switch), `LanguageSwitcher` (presentational: the caller decides what a change does), `sortableA11y` (what a screen reader hears during a keyboard reorder) | | actions & inputs | `Button`, `IconButton`, `Pressable` (`selectable` when its text is a title or a name), `Chip` + `chipStyles`, `DragHandle` + `OrderButtons` / `useOrderItems` (the non-drag reorder, WCAG 2.5.7) (one per button family, docs/DESIGN.md; a raw `<button>` or a bare `FocusSafeButton` outside the control primitives — eslint `BUTTON_PRIMITIVES` — is a lint error), `Input` + `fieldStyles`, `RenameField` (a name typed in place of the name: Enter or blur saves, Escape cancels; the caller hands the keyboard back), `DropZone` (the dashed place a file upload starts from: a drop, or a click that opens the file dialog), `Meter` (how full something is against a limit — one danger line for every place showing the same measure), `TimecodeField` + `useTimecodeField` / `TimecodeError` (a cut's time typed in: the draft, held by the caller, until Enter or blur takes it, then the value as it landed; unreadable stays once offered, marked invalid, and the caller places the line saying how to type one in its flow, outside a label; a draft goes when the value moves under it), `NativeControls`, `MenuCheck` (the drawn check of a menu row or a whole-row checkbox button; its tick is the `chosen` colour, which `media-marks` turns amber on the player chrome), `mediaChrome` (`MEDIA_CHOSEN`, a chosen tab or toggle on the chrome), `SelectTrigger` (the field family; a raw `<input>` is a lint error), `useReturnFocus`, `unavailable` (an off control's props: with a reason it stays focusable, `aria-disabled` and the reason in its tooltip; without one, `disabled`), `surfaces` (the B5 glass roles; a bare glass class is a lint error), `Badge` (a static label), `LinkButton` / `IconLink` (a link wearing the button face) over `buttonStyles`, `Skeleton` (B8 first loads, through `LoadingState shape`), `NavRail` (B7; `NavRailGroup` folds a set of rare pages into one row with a panel), `form`, `DraftSaveBar`, `Combobox`, `FilterSelect`, `SegmentedToggle`, `SortControl`, `ColumnSortControl`, `PriorityStepper`, `UndoToolbar`, `ViewSwitcher` (a list's display modes behind one toolbar icon), `copyItems` (`useCopyItems`: the "Copy title" / "Copy link" rows every object's menu ends with) + `useCopyFeedback` (a Copy button's "Copied" moment), `MatchToggle` (a multi-value filter's any/all switch), `LanguageSwitcher` (presentational: the caller decides what a change does), `sortableA11y` (what a screen reader hears during a keyboard reorder) |
| data display | `DataTable` (+ `progressiveRows`: a long page mounts in steps), `tableHeader` + `tableAlign`, `HiddenFilterCue` (names a filter whose column is off screen), `Pager` + `pagerContent`, `VirtualGrid` + `gridColumns` (the column/width maths, incl. the ultrawide `maxCols` cap), `readAhead`, `Carousel`, `MediaCard` + `mediaCardStyles` (the one media card, docs/DESIGN.md "Media card") + `MediaPreview` / `useMediaPreview` (its mouse-rest preview), `NavGrid` (a plain grid, list or table body the keyboard walks — `DataTable`, `VirtualGrid` and `Carousel` take `keyboardNav`), `RemoteImage` (every network picture: no Referer, retries past the negative cache, a fallback instead of the broken-image mark — a bare `<img>` is linted out), `Avatar`, `CountBadge`, `ItemCount`, `FilterChipGroup`, `RelativeTime` ("3d ago" with the exact minute on hover, docs/DESIGN.md "Copy"), `ClampedText` (a long text clamped to a few lines; More / Less only when it is cut), `StatTiles` + `StatTile` / `StatBars` (number tiles, a breakdown as horizontal bars), `RangeTrack` + `trackPosition` + `RulerTicks` (a time track over a window of a media item: regions, slider ends the arrows step, a playhead, a background slot — the editor's cuts, the players' clip panel), `CropFrame` (a crop frame over a picture, on the picture and not its bars: drag, edges, corners, the arrows; the editor's crop, the players' clip crop), `reveal` (a card's hover chrome: hover or keyboard focus on a mouse, never on touch) | | data display | `DataTable` (+ `progressiveRows`: a long page mounts in steps; `rowOpen`: a press on the row runs its default action), `tableHeader` + `tableAlign`, `HiddenFilterCue` (names a filter whose column is off screen), `Pager` + `pagerContent`, `VirtualGrid` + `gridColumns` (the column/width maths, incl. the ultrawide `maxCols` cap), `readAhead`, `Carousel`, `MediaCard` + `mediaCardStyles` (the one media card, docs/DESIGN.md "Media card") + `MediaPreview` / `useMediaPreview` (its mouse-rest preview), `NavGrid` (a plain grid, list or table body the keyboard walks — `DataTable`, `VirtualGrid` and `Carousel` take `keyboardNav`), `RemoteImage` (every network picture: no Referer, retries past the negative cache, a fallback instead of the broken-image mark — a bare `<img>` is linted out), `Avatar`, `CountBadge`, `ItemCount`, `FilterChipGroup`, `RelativeTime` ("3d ago" with the exact minute on hover, docs/DESIGN.md "Copy"), `ClampedText` (a long text clamped to a few lines; More / Less only when it is cut), `StatTiles` + `StatTile` / `StatBars` (number tiles, a breakdown as horizontal bars), `RangeTrack` + `trackPosition` + `RulerTicks` (a time track over a window of a media item: regions, slider ends the arrows step, a playhead, a background slot — the editor's cuts, the players' clip panel), `CropFrame` (a crop frame over a picture, on the picture and not its bars: drag, edges, corners, the arrows; the editor's crop, the players' clip crop), `reveal` (a card's hover chrome: hover or keyboard focus on a mouse, never on touch) |
| page & panel structure | `PageShell` + `pageToolbarSlot` + `pageHeaderSlot` (`PageHeader`: a page fills the floating header) + `pageHeading`, `PageScroller`, `Headings` (`PageHeading`, `SectionLabel`), `SkipLink` + `landmarks`, `FilterFold` (B4), `scrollFold` (`useScrollFold`: a fixed header that folds once its content scrolls), `PanelGroup`, `PanelGroups`, `Tabs` + `tabPanel`, `BackToTop` + `backToTopState` (PageScroller keeps the page's end clear of it), `FilterRail` (the filter-rail shell a rail fills with its groups: title bar, customize, reset), `stickyTop` (a sticky header at the scroller's top turns its top fade off) | | page & panel structure | `PageShell` + `pageToolbarSlot` + `pageHeaderSlot` (`PageHeader`: a page fills the floating header) + `pageHeading`, `PageScroller`, `Headings` (`PageHeading`, `SectionLabel`), `SkipLink` + `landmarks`, `FilterFold` (B4), `scrollFold` (`useScrollFold`: a fixed header that folds once its content scrolls), `PanelGroup`, `PanelGroups`, `Tabs` + `tabPanel`, `BackToTop` + `backToTopState` (PageScroller keeps the page's end clear of it), `FilterRail` (the filter-rail shell a rail fills with its groups: title bar, customize, reset), `stickyTop` (a sticky header at the scroller's top turns its top fade off) |
| state & messaging | `QueryState`, `Banner`, `InlineNotice`, `CrashBoundary` | | state & messaging | `QueryState`, `Banner`, `InlineNotice`, `CrashBoundary` |
@@ -12,6 +12,16 @@ import { FocusSafeButton } from "./FocusSafeButton";
// the pills' faint edge, its label naming it (owner decision F14). Both hover by the accent edge. Open is // the pills' faint edge, its label naming it (owner decision F14). Both hover by the accent edge. Open is
// the accent edge and the chevron turned over. The popover wiring // the accent edge and the chevron turned over. The popover wiring
// (ref, `getReferenceProps`, the accessible name carrying the value) is the caller's. // (ref, `getReferenceProps`, the accessible name carrying the value) is the caller's.
/** A toolbar trigger's group glyph in place of its "Label:" word (FilterSelect's and SortControl's
* `icon`): muted like the word, and silent — the trigger's accessible name and tooltip say it. */
export function GroupGlyph({ children }: { children: ReactNode }) {
return (
<span aria-hidden="true" className="flex shrink-0 text-muted [&>svg]:size-3.5">
{children}
</span>
);
}
export const SelectTrigger = forwardRef< export const SelectTrigger = forwardRef<
HTMLButtonElement, HTMLButtonElement,
ButtonHTMLAttributes<HTMLButtonElement> & { ButtonHTMLAttributes<HTMLButtonElement> & {
+8 -2
View File
@@ -1,7 +1,7 @@
import { useState, type ReactNode } from "react"; import { useState, type ReactNode } from "react";
import { ArrowDown, ArrowUp } from "lucide-react"; import { ArrowDown, ArrowUp } from "lucide-react";
import { MenuCheck } from "./MenuCheck"; import { MenuCheck } from "./MenuCheck";
import { SelectTrigger } from "./SelectTrigger"; import { GroupGlyph, SelectTrigger } from "./SelectTrigger";
import { pickSort, type SortOption, type SortState } from "../../lib/columnSort"; import { pickSort, type SortOption, type SortState } from "../../lib/columnSort";
import { PopoverItem, PopoverSurface } from "./AnchoredPopover"; import { PopoverItem, PopoverSurface } from "./AnchoredPopover";
import { useAnchoredPopover } from "./anchoredPopoverHooks"; import { useAnchoredPopover } from "./anchoredPopoverHooks";
@@ -27,6 +27,7 @@ export default function SortControl({
descLabel, descLabel,
defaultLabel, defaultLabel,
trailing, trailing,
icon,
}: { }: {
options: SortOption[]; options: SortOption[];
sort: SortState; sort: SortState;
@@ -40,6 +41,9 @@ export default function SortControl({
defaultLabel?: string; defaultLabel?: string;
/** Rendered in the panel footer beside the direction segment (feed reshuffle, playlists dirty-first). */ /** Rendered in the panel footer beside the direction segment (feed reshuffle, playlists dirty-first). */
trailing?: ReactNode; trailing?: ReactNode;
/** A glyph in place of the visible "Sort:" prefix (FilterSelect's `icon`): the label moves to the
* tooltip and stays in the accessible name. */
icon?: ReactNode;
}) { }) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const pop = useAnchoredPopover({ const pop = useAnchoredPopover({
@@ -71,7 +75,9 @@ export default function SortControl({
<SelectTrigger <SelectTrigger
context="toolbar" context="toolbar"
open={open} open={open}
label={label} label={icon ? undefined : label}
icon={icon && <GroupGlyph>{icon}</GroupGlyph>}
title={icon ? label : undefined}
ref={setReference} ref={setReference}
{...pop.getReferenceProps()} {...pop.getReferenceProps()}
// Carry the active sort key AND direction in the accessible name (the visible arrow is // Carry the active sort key AND direction in the accessible name (the visible arrow is
+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: // 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 // 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 // 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): // lib/storage's useTabPersistedState (per browser tab: Admin Users, Config, Settings) and names it in
// a reload keeps the user where they were, and a fresh tab opens on the first sub-tab. Not every // 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 // 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. // 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 Toaster from "./Toaster";
import { clearAll, notify } from "../../lib/notifications"; import { clearAll, notify, raw } from "../../lib/notifications";
import { dockUnderToasts } from "../../lib/toastDock"; import { dockUnderToasts } from "../../lib/toastDock";
afterEach(() => { afterEach(() => {
@@ -31,7 +31,7 @@ describe("Toaster — one host shows the toasts", () => {
<Toaster /> <Toaster />
</div>, </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."); expect(screen.getByTestId("app").textContent).toContain("Couldn't remove the video.");
const overlay = render( 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", () => { it("lets a click through where there is no toast and nothing docked, and not where there is", () => {
const Docked = () => <button>dock</button>; const Docked = () => <button>dock</button>;
const stop = dockUnderToasts(Docked); const stop = dockUnderToasts(Docked);
notify({ level: "info", message: "hello" }); notify({ level: "info", message: raw("hello") });
render(<Toaster />); render(<Toaster />);
const region = document.querySelector<HTMLElement>("[data-toast-region]")!; 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 // 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 /> <Toaster overlay />
</div>, </div>,
); );
act(() => void notify({ level: "error", message: "boom" })); act(() => void notify({ level: "error", message: raw("boom") }));
fireEvent.click(screen.getByText("boom")); fireEvent.click(screen.getByText("boom"));
expect(backdrop).not.toHaveBeenCalled(); 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 // 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. // assertively, each time a player (or any dialog) opened or closed.
render(<Toaster />); render(<Toaster />);
act(() => void notify({ level: "error", message: "old news" })); act(() => void notify({ level: "error", message: raw("old news") }));
render(<Toaster overlay />); render(<Toaster overlay />);
const old = screen.getByText("old news").closest(".toast-card")!; const old = screen.getByText("old news").closest(".toast-card")!;
expect(old.getAttribute("role")).toBeNull(); 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"); 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 // 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). // "second" arrived (its own screen had become the handover by then).
render(<Toaster />); render(<Toaster />);
act(() => void notify({ level: "error", message: "first" })); act(() => void notify({ level: "error", message: raw("first") }));
act(() => void notify({ level: "error", message: "second" })); act(() => void notify({ level: "error", message: raw("second") }));
const card = (text: string) => screen.getByText(text).closest(".toast-card")!; const card = (text: string) => screen.getByText(text).closest(".toast-card")!;
expect(card("first").getAttribute("role")).toBe("alert"); expect(card("first").getAttribute("role")).toBe("alert");
expect(card("second").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 // 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. // arming has to wait for the next one — a screen reader reads the region's state after the task.
render(<Toaster />); render(<Toaster />);
act(() => void notify({ level: "info", message: "saved" })); act(() => void notify({ level: "info", message: raw("saved") }));
const overlay = render(<Toaster overlay />); const overlay = render(<Toaster overlay />);
const region = overlay.container.querySelector("[aria-live]")!; const region = overlay.container.querySelector("[aria-live]")!;
expect(region.textContent).toContain("saved"); // it took the toast over… expect(region.textContent).toContain("saved"); // it took the toast over…
@@ -135,7 +135,7 @@ describe("Toaster — handing the toasts to another host", () => {
<Toaster overlay /> <Toaster overlay />
<button <button
onClick={() => { onClick={() => {
notify({ level: "success", message: "upload queued" }); notify({ level: "success", message: raw("upload queued") });
setOpen(false); setOpen(false);
}} }}
> >
@@ -147,7 +147,7 @@ describe("Toaster — handing the toasts to another host", () => {
); );
} }
render(<Stage />); render(<Stage />);
act(() => void notify({ level: "error", message: "old news" })); act(() => void notify({ level: "error", message: raw("old news") }));
fireEvent.click(screen.getByText("upload")); fireEvent.click(screen.getByText("upload"));
const card = (text: string) => screen.getByText(text).closest(".toast-card")!; 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} /> <Toaster overlay order={2} />
</div>, </div>,
); );
act(() => void notify({ level: "error", message: "boom" })); act(() => void notify({ level: "error", message: raw("boom") }));
expect(screen.getByTestId("dialog").textContent).toContain("boom"); expect(screen.getByTestId("dialog").textContent).toContain("boom");
// The player swaps its host (leaving true fullscreen) while the dialog stays open. // The player swaps its host (leaving true fullscreen) while the dialog stays open.
player.rerender( player.rerender(
@@ -188,7 +188,7 @@ describe("Toaster — handing the toasts to another host", () => {
const t0 = Date.now(); const t0 = Date.now();
const now = vi.spyOn(Date, "now").mockReturnValue(t0); const now = vi.spyOn(Date, "now").mockReturnValue(t0);
render(<Toaster />); render(<Toaster />);
act(() => void notify({ level: "info", message: "saved" })); act(() => void notify({ level: "info", message: raw("saved") }));
now.mockReturnValue(t0 + 4000); now.mockReturnValue(t0 + 4000);
render(<Toaster overlay />); render(<Toaster overlay />);
const bar = screen.getByText("saved").closest(".toast-card")!.lastElementChild as HTMLElement; 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 // 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. // docked bar, above them at z-overlay, and took their clicks for as long as a toast was up.
render(<Toaster overlay />); 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]")!; const host = hosts()[0]!.closest("[data-toast-region]")!;
expect(host.className).toContain("bottom-24"); expect(host.className).toContain("bottom-24");
expect(host.className).toContain("pointer-events-none"); expect(host.className).toContain("pointer-events-none");
@@ -218,9 +218,9 @@ describe("Toaster — a toast's actions", () => {
() => () =>
void notify({ void notify({
level: "success", level: "success",
message: "Downloaded", message: raw("Downloaded"),
action: { label: "Copy link", onClick: copy }, action: { label: raw("Copy link"), onClick: copy },
secondaryAction: { label: "Open", onClick: open }, secondaryAction: { label: raw("Open"), onClick: open },
}), }),
); );
expect(screen.getByRole("button", { name: "Copy link" })).toBeTruthy(); 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 Toaster from "./Toaster";
import { useOverlayOrder } from "./overlayOrder"; import { useOverlayOrder } from "./overlayOrder";
import { clearAll, notify } from "../../lib/notifications"; import { clearAll, notify, raw } from "../../lib/notifications";
afterEach(() => { afterEach(() => {
cleanup(); cleanup();
@@ -37,7 +37,7 @@ function Harness() {
<> <>
<button <button
onClick={() => { onClick={() => {
notify({ level: "success", message: "Uploaded" }); notify({ level: "success", message: raw("Uploaded") });
setOpen(false); // …in the SAME handler: one batch, one commit 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 { useEffect, useId, useLayoutEffect, useState, useSyncExternalStore } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { X } from "lucide-react"; 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 { createStore } from "../../lib/store";
import { useToastDock } from "../../lib/toastDock"; import { useToastDock } from "../../lib/toastDock";
import { useOverlayOrder } from "./overlayOrder"; import { useOverlayOrder } from "./overlayOrder";
@@ -162,9 +162,11 @@ export default function Toaster({
> >
<Icon className={`w-5 h-5 shrink-0 mt-0.5 ${color}`} /> <Icon className={`w-5 h-5 shrink-0 mt-0.5 ${color}`} />
<div className="min-w-0 flex-1"> <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"> <div className="text-sm wrap-break-word">
{toast.message} {sayNotif(toast.message, t)}
{toast.repeat > 1 && ( {toast.repeat > 1 && (
<span className="ml-1.5 align-middle text-2xs font-semibold text-muted tabular-nums"> <span className="ml-1.5 align-middle text-2xs font-semibold text-muted tabular-nums">
×{toast.repeat} ×{toast.repeat}
@@ -174,10 +176,10 @@ export default function Toaster({
{(toast.action || toast.secondaryAction) && ( {(toast.action || toast.secondaryAction) && (
<div className="mt-1 flex flex-wrap gap-x-4"> <div className="mt-1 flex flex-wrap gap-x-4">
{[toast.action, toast.secondaryAction].map( {[toast.action, toast.secondaryAction].map(
(action) => (action, i) =>
action && ( action && (
<Button <Button
key={action.label} key={i}
variant="link" variant="link"
onClick={() => { onClick={() => {
action.onClick(); action.onClick();
@@ -185,7 +187,7 @@ export default function Toaster({
}} }}
className="text-sm" className="text-sm"
> >
{action.label} {sayNotif(action.label, t)}
</Button> </Button>
), ),
)} )}
+6 -2
View File
@@ -11,18 +11,22 @@ type Side = "top" | "bottom" | "left" | "right";
* is never clipped by an overflow/stacking ancestor, flips when the preferred side runs out of room * is never clipped by an overflow/stacking ancestor, flips when the preferred side runs out of room
* and slides back inside the viewport near an edge. * and slides back inside the viewport near an edge.
* `always` bypasses the hints toggle (for a label that IS the primary affordance, e.g. the slim nav * `always` bypasses the hints toggle (for a label that IS the primary affordance, e.g. the slim nav
* rail's flyout names); `openDelay` waits before showing (a rest-on-the-icon feel). */ * rail's flyout names); `openDelay` waits before showing (a rest-on-the-icon feel); `stable` keeps
* the wrapper with no hint to show, so a hint that comes and goes does not remount the element and
* cost it the focus. */
export default function Tooltip({ export default function Tooltip({
hint, hint,
side = "top", side = "top",
always = false, always = false,
openDelay = 0, openDelay = 0,
stable = false,
children, children,
}: { }: {
hint: string; hint: string;
side?: Side; side?: Side;
always?: boolean; always?: boolean;
openDelay?: number; openDelay?: number;
stable?: boolean;
children: React.ReactNode; children: React.ReactNode;
}) { }) {
const hintsOn = useSyncExternalStore(subscribeHints, hintsEnabled, hintsEnabled); const hintsOn = useSyncExternalStore(subscribeHints, hintsEnabled, hintsEnabled);
@@ -42,7 +46,7 @@ export default function Tooltip({
}); });
const { setReference } = pop.refs; const { setReference } = pop.refs;
if (!enabled || !hint) return <>{children}</>; if ((!enabled || !hint) && !stable) return <>{children}</>;
return ( return (
<> <>
@@ -17,6 +17,7 @@ import {
useInteractions, useInteractions,
useFloatingNodeId, useFloatingNodeId,
safePolygon, safePolygon,
type OpenChangeReason,
type Placement, type Placement,
type UseRoleProps, type UseRoleProps,
} from "@floating-ui/react"; } from "@floating-ui/react";
@@ -32,7 +33,8 @@ type PopoverMode = "menu" | "listbox" | "hover" | "dialog" | "combobox";
export interface AnchoredPopoverOptions { export interface AnchoredPopoverOptions {
open: boolean; open: boolean;
onOpenChange: (open: boolean, event?: Event) => void; /** `reason`: what opened or closed it (floating-ui's: "hover", "focus", "ancestor-scroll", …). */
onOpenChange: (open: boolean, event?: Event, reason?: OpenChangeReason) => void;
/** "menu"/"listbox"/"dialog" open on click; "hover" opens on hover+focus (tooltips, hover cards); /** "menu"/"listbox"/"dialog" open on click; "hover" opens on hover+focus (tooltips, hover cards);
* "combobox" is a typeable input + listbox where focus stays in the field (use via <Combobox>). */ * "combobox" is a typeable input + listbox where focus stays in the field (use via <Combobox>). */
mode?: PopoverMode; mode?: PopoverMode;
+5 -5
View File
@@ -2,7 +2,7 @@ import { useTranslation } from "react-i18next";
import { Copy, Link2 } from "lucide-react"; import { Copy, Link2 } from "lucide-react";
import { copyText } from "../../lib/clipboard"; import { copyText } from "../../lib/clipboard";
import { notify } from "../../lib/notifications"; import { msg, type NotifText, notify } from "../../lib/notifications";
import { type ActionMenuItem } from "./ActionMenu"; import { type ActionMenuItem } from "./ActionMenu";
// "Copy title" and "Copy link", the two rows every object's menu ends its middle group with // "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() { export function useCopyItems() {
const { t } = useTranslation(); const { t } = useTranslation();
const copy = (text: string, done: string) => { const copy = (text: string, done: NotifText) => {
void copyText(text).then((ok) => void copyText(text).then((ok) =>
notify( notify(
ok ok
? { level: "success", message: done } ? { 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"), label: t(named === "title" ? "common.copy.title" : "common.copy.name"),
icon: <Copy className="w-4 h-4" />, icon: <Copy className="w-4 h-4" />,
inGesture: true, 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"), label: linkLabel ?? t("common.copy.link"),
icon: <Link2 className="w-4 h-4" />, icon: <Link2 className="w-4 h-4" />,
inGesture: true, inGesture: true,
onSelect: () => copy(link, t("common.copy.linkDone")), onSelect: () => copy(link, msg("common.copy.linkDone")),
}, },
] ]
: []), : []),
@@ -5,7 +5,7 @@
import { useRef } from "react"; import { useRef } from "react";
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, render } from "@testing-library/react"; import { cleanup, render } from "@testing-library/react";
import { useDialogChrome } from "./dialogFocus"; import { focusablesIn, useDialogChrome } from "./dialogFocus";
afterEach(cleanup); afterEach(cleanup);
@@ -36,6 +36,25 @@ describe("useDialogChrome · the page scroll lock", () => {
}); });
}); });
describe("focusablesIn", () => {
// A trap that counted them had its "last" control where Tab never lands, and let the focus out.
it("leaves out what an inert part holds", () => {
const offsetParent = vi
.spyOn(HTMLElement.prototype, "offsetParent", "get")
.mockReturnValue(document.body);
const { container } = render(
<div>
<button>reachable</button>
<div inert>
<button>under</button>
</div>
</div>,
);
expect(focusablesIn(container).map((el) => el.textContent)).toEqual(["reachable"]);
offsetParent.mockRestore();
});
});
describe("useDialogChrome · initial focus", () => { describe("useDialogChrome · initial focus", () => {
it("lands on the first control by default", () => { it("lands on the first control by default", () => {
// jsdom has no layout, so the visibility filter (offsetParent) would see nothing focusable. // jsdom has no layout, so the visibility filter (offsetParent) would see nothing focusable.
+38 -6
View File
@@ -9,12 +9,15 @@ import { useEffect, useEffectEvent, useRef, useState, type RefObject } from "rea
// Tabbable elements inside the dialog, in DOM order. `:not([tabindex="-1"])` drops programmatically // Tabbable elements inside the dialog, in DOM order. `:not([tabindex="-1"])` drops programmatically
// focusable-only nodes; the visibility filter (offsetParent) drops display:none ones (e.g. a // focusable-only nodes; the visibility filter (offsetParent) drops display:none ones (e.g. a
// collapsed disclosure) so the trap never lands focus on something the user can't see. // collapsed disclosure) so the trap never lands focus on something the user can't see, and an inert
// part's are not tabbable: counted, the trap's last control is one Tab never reaches.
const FOCUSABLE = const FOCUSABLE =
'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'; 'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
export const focusablesIn = (root: HTMLElement): HTMLElement[] => export const focusablesIn = (root: HTMLElement): HTMLElement[] =>
[...root.querySelectorAll<HTMLElement>(FOCUSABLE)].filter((el) => el.offsetParent !== null); [...root.querySelectorAll<HTMLElement>(FOCUSABLE)].filter(
(el) => el.offsetParent !== null && !el.closest("[inert]"),
);
/** A form's first field — where a dialog opened to fill something in takes the focus: its first /** A form's first field — where a dialog opened to fill something in takes the focus: its first
* control, unless one is marked `data-initial-focus`. The mark is for a form with a control above * control, unless one is marked `data-initial-focus`. The mark is for a form with a control above
@@ -75,6 +78,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. /** 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 * `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 * panel, is that whole dialog — it must be the SAME element `trapTab` is scoped to, or the two
@@ -128,6 +141,11 @@ export function useDialogChrome(
// Today that reads the same either way (a detached owner and a null one both fail every // 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. // `contains` below), but the cleanup should not depend on a ref React has already let go of.
const ownerAtOpen = ownerRef?.current ?? scope; 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)) { if (scope && !scope.contains(document.activeElement)) {
const back = focusedRef.current; const back = focusedRef.current;
const target = const target =
@@ -144,10 +162,12 @@ export function useDialogChrome(
// it — and while it held the focus we read that as the user walking away. Then both closing // 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 // 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, // `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 = heldRef.current =
(owner?.contains(active) ?? false) || (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 // 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. // gone by then, so restoring onto it would strand the focus exactly as before.
if (scopeRef.current?.contains(document.activeElement)) if (scopeRef.current?.contains(document.activeElement))
@@ -172,16 +192,28 @@ export function useDialogChrome(
// this, a dialog closing on its own (an in-flight mutation's `onSuccess`) under a modal raised // 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 // above it pulled the focus out of that standing dialog onto our own trigger, behind its
// scrim — where Enter still activated it. // 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 owner = ownerAtOpen;
const active = document.activeElement; 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 above = active instanceof Element ? active.closest('[aria-modal="true"]') : null;
const standingAbove = const standingAbove =
above !== null && above !== null &&
above.isConnected && above.isConnected &&
!above.contains(owner as Node | null) && !above.contains(owner as Node | null) &&
!(owner?.contains(above) ?? false); !(owner?.contains(above) ?? false);
if (heldRef.current && !standingAbove && restoreTo && restoreTo.isConnected) const standingMenu = active instanceof Element && active.closest(FLOATING)?.isConnected;
restoreTo.focus(); if (!heldRef.current || !target?.isConnected) return;
if (standingAbove) handedRestore.set(above, target);
else if (!standingMenu) target.focus();
}; };
}, [restoreTo, scopeRef, ownerRef, focusScope]); }, [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"], "shell/NavSidebar.tsx": [3, "the account trigger and its menu rows"],
"features/header/PaletteLauncher.tsx": [2, "launcher controls"], "features/header/PaletteLauncher.tsx": [2, "launcher controls"],
"modules/playlists/PlaylistsRail.tsx": [1, "the load-error retry"], "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/PlexCollectionEditor.tsx": [1, "a checkbox row in a picker"],
"modules/plex/PlexInfo.tsx": [1, "the rating filter (a number)"], "modules/plex/PlexInfo.tsx": [1, "the rating filter (a number)"],
"modules/plex/PlexPlayerSettings.tsx": [1, "the clock's date-style options"], "modules/plex/PlexPlayerSettings.tsx": [1, "the clock's date-style options"],
+73
View File
@@ -0,0 +1,73 @@
import type { MouseEvent, PointerEvent } from "react";
import { endsTextSelection, movedPastSlop } from "../../lib/textSelection";
// A table row (or its narrow card twin) as the click and tap surface of its object's default action,
// the way a card is: a press anywhere on the row opens it, not only on the title inside it.
//
// What the row does NOT claim:
// - a press on a control inside it (its quick icons, its "…", a link, a field): that is the control's;
// - a click that ends a text selection, or a press that travelled (a drag across the row's padding):
// DESIGN.md "Text stays selectable" — no play or open fires;
// - an event from a portal (a popover a cell opened): React bubbles it through the row, the DOM does
// not, so only a target the row really contains counts;
// - a press that began while a field inside the row held the focus (its rename): that press ends the
// editing — its mousedown blurs the field, which saves — and does nothing more. Read at pointerdown,
// because by the click the blur has already put the row back as it is when nothing is edited.
// The long-press menu's trailing click is swallowed by its own trigger (`useContextMenu`), before it
// reaches the row.
// A field the focus can be editing in.
const FIELD = 'input, textarea, select, [contenteditable="true"]';
// What counts as a control of its own. `[tabindex]` covers a focusable cell (a hover card's trigger);
// the row's own roving tab stop is the row itself, which the containment check below leaves out.
const CONTROL =
'a, button, input, select, textarea, label, summary, [role="button"], [role="menuitem"], [role="checkbox"], [role="switch"], [contenteditable="true"], [tabindex]';
// Where each row's press began, for the drag slop, and whether it began while a field inside the row
// was being edited. Per element: the table row and its card twin are two elements, and a press on one
// must not be measured against the other.
const pressedAt = new WeakMap<Element, { x: number; y: number; editing: boolean }>();
type Handlers = {
onPointerDown?: (e: PointerEvent<HTMLElement>) => void;
onClick?: (e: MouseEvent<HTMLElement>) => void;
};
/** The row's props with its default action added on top of the caller's own (a context menu's
* trigger props, which keep their handlers). `open` null: the row has no default action. */
export function withRowOpen(
props: Record<string, unknown> | undefined,
open: (() => void) | null,
): Record<string, unknown> {
const base = props ?? {};
if (!open) return base;
const { onPointerDown, onClick } = base as Handlers;
return {
...base,
onPointerDown: (e: PointerEvent<HTMLElement>) => {
const row = e.currentTarget;
const focused = document.activeElement;
const editing = !!focused && row.contains(focused) && focused.matches(FIELD);
pressedAt.set(row, { x: e.clientX, y: e.clientY, editing });
onPointerDown?.(e);
},
onClick: (e: MouseEvent<HTMLElement>) => {
onClick?.(e);
const claimed = !e.defaultPrevented && claims(e);
// One press, one click: a later keyboard click (no press) is not judged by this one.
pressedAt.delete(e.currentTarget);
if (claimed) open();
},
};
}
function claims(e: MouseEvent<HTMLElement>): boolean {
const row = e.currentTarget;
const target = e.target;
if (!(target instanceof Element) || !row.contains(target)) return false;
const control = target.closest(CONTROL);
if (control && control !== row && row.contains(control)) return false;
const pressed = pressedAt.get(row) ?? null;
return !pressed?.editing && !endsTextSelection(e) && !movedPastSlop(pressed, e);
}
@@ -19,8 +19,9 @@ const PLATFORM_NAMESPACES = new Set([
]); ]);
const files = readSources(__dirname, "./**/*.{ts,tsx}"); const files = readSources(__dirname, "./**/*.{ts,tsx}");
// `t("ns.…")` and `t(\`ns.…\`)` — the namespace is everything before the first dot. // `t("ns.…")` and `t(\`ns.…\`)`, and a notice's `msg("ns.…")` (the same key, said when shown) — the
const KEY = /\bt\(\s*["`]([A-Za-z]+)\./g; // 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", () => { describe("components/ui/ speaks only platform i18n namespaces", () => {
it("names no feature namespace", () => { 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", () => { it("sees a feature namespace when one is used", () => {
expect([...'t("plex.loading")'.matchAll(KEY)].map((m) => m[1])).toEqual(["plex"]); expect([...'t("plex.loading")'.matchAll(KEY)].map((m) => m[1])).toEqual(["plex"]);
expect([...'msg("plex.loading")'.matchAll(KEY)].map((m) => m[1])).toEqual(["plex"]);
}); });
}); });
+10
View File
@@ -0,0 +1,10 @@
/** An unavailable control's props. With a reason it stays focusable (`aria-disabled`, which
* `FocusSafeButton` keeps inert to a click, and the reason read out with its name; the caller shows
* it in a tooltip — DESIGN.md "Unavailable with a reason stays focusable"); without one it is
* plainly off. */
export function unavailable(disabled: boolean | undefined, reason: string | undefined) {
if (!disabled) return {};
return reason
? { "aria-disabled": true as const, "aria-description": reason }
: { disabled: true };
}
@@ -11,8 +11,12 @@ vi.mock("react-i18next", async (importOriginal) => ({
useTranslation: () => ({ i18n: { language: "en" }, t: (key: string) => key }), useTranslation: () => ({ i18n: { language: "en" }, t: (key: string) => key }),
})); }));
vi.mock("../../lib/clipboard", () => ({ copyText: h.copyText })); 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"; import { useCopyFeedback } from "./useCopyFeedback";
afterEach(() => { afterEach(() => {
@@ -27,7 +31,7 @@ describe("useCopyFeedback", () => {
await act(() => result.current.copy("sfl_x")); await act(() => result.current.copy("sfl_x"));
expect(h.copyText).toHaveBeenCalledWith("sfl_x"); expect(h.copyText).toHaveBeenCalledWith("sfl_x");
expect(result.current.copied).toBe(true); 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)); act(() => void vi.advanceTimersByTime(2000));
expect(result.current.copied).toBe(false); expect(result.current.copied).toBe(false);
}); });
@@ -49,6 +53,6 @@ describe("useCopyFeedback", () => {
const { result } = renderHook(() => useCopyFeedback()); const { result } = renderHook(() => useCopyFeedback());
await act(() => result.current.copy("sfl_x")); await act(() => result.current.copy("sfl_x"));
expect(result.current.copied).toBe(false); 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 { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { copyText } from "../../lib/clipboard"; 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. // How long a Copy button reads "Copied" after it worked.
const COPIED_MS = 2000; 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). */ * A feature file, not `ui/`: it speaks the app's notifications (as `copyItems` does). */
export function useCopyFeedback(): { copied: boolean; copy: (value: string) => Promise<void> } { export function useCopyFeedback(): { copied: boolean; copy: (value: string) => Promise<void> } {
const { t } = useTranslation();
const [copied, setCopied] = useState(false); 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. // 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); const timer = useRef<number | undefined>(undefined);
@@ -23,12 +21,12 @@ export function useCopyFeedback(): { copied: boolean; copy: (value: string) => P
const copy = async (value: string) => { const copy = async (value: string) => {
if (await copyText(value)) { if (await copyText(value)) {
setCopied(true); setCopied(true);
notify({ level: "success", message: t("common.copy.done") }); notify({ level: "success", message: msg("common.copy.done") });
window.clearTimeout(timer.current); window.clearTimeout(timer.current);
timer.current = window.setTimeout(() => setCopied(false), COPIED_MS); timer.current = window.setTimeout(() => setCopied(false), COPIED_MS);
return; return;
} }
notify({ level: "warning", message: t("common.copy.failed") }); notify({ level: "warning", message: msg("common.copy.failed") });
}; };
return { copied, copy }; return { copied, copy };
} }
@@ -1,4 +1,74 @@
[ [
{
"version": "0.115.0",
"date": "2026-11-01",
"new": [
"Clear watch progress is on every YouTube video's menu, on Home, in playlists and in the player's related videos. It forgets where you left a started video and changes nothing else.",
"Channels have an Added column, and an About card opens only when the pointer rests on it.",
"In Files, a press anywhere on a row plays the file.",
"The feed's toolbar pills are named by a glyph, with Sort and View on the filters' row."
],
"fixed": [
"In a play queue, Previous and Next pass over a video that can't play, in the direction you were going, and say why when only such videos are left.",
"A Facebook download titled only “Facebook” now takes the post's text as its name.",
"The player's big view keeps its way out in sight on a tablet, and Tab no longer lands on controls hidden under it."
]
},
{
"version": "0.114.3",
"date": "2026-11-01",
"new": [],
"fixed": [
"Editing a file that is neither video nor audio now says why it can't be done, instead of a generic error."
]
},
{
"version": "0.114.2",
"date": "2026-10-30",
"new": [],
"fixed": [
"Security: a library the sign-in relies on is updated, closing two published vulnerabilities."
]
},
{
"version": "0.114.1",
"date": "2026-10-30",
"new": [],
"fixed": [
"Notifications follow a language switch: a notice on screen and every row in the notification center are said again in the new language, their buttons too."
]
},
{
"version": "0.114.0",
"date": "2026-10-29",
"new": [
"Notifications: what asks for you comes first, then everything else as one stream, newest first. Read notices older than a week fold under Earlier, and Back returns to the row you left."
],
"fixed": [
"Plex: a progress save sent just before an episode ends no longer leaves it unwatched, and a refused finish no longer sticks.",
"After a dialog closes, the keyboard focus returns to where it was, also inside a menu that stays open."
]
},
{
"version": "0.113.0",
"date": "2026-10-28",
"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",
"new": [],
"fixed": ["Security: the sign-in library is updated, closing two published vulnerabilities."]
},
{ {
"version": "0.112.0", "version": "0.112.0",
"date": "2026-10-25", "date": "2026-10-25",
@@ -1,4 +1,76 @@
[ [
{
"version": "0.115.0",
"date": "2026-11-01",
"new": [
"A Megtekintés visszaállítása minden YouTube-videó menüjében elérhető: a Kezdőlapon, a listákban és a lejátszó kapcsolódó videóinál. Egy megkezdett videónál törli, hol tartottál, mást nem változtat.",
"A csatornáknál van Hozzáadva oszlop, és a Leírás kártya csak akkor nyílik meg, ha a mutató megáll rajta.",
"A Fájloknál a sorban bárhová kattintva elindul a fájl.",
"A Feed eszköztárának gombjait ikon jelöli, a Rendezés és a Nézet a szűrők sorába került."
],
"fixed": [
"A lejátszási sorban az Előző és a Következő átlépi a nem lejátszható videót abban az irányban, amerre haladtál, és megmondja az okát, ha már csak ilyenek maradtak.",
"A csak „Facebook” címmel letöltött Facebook-videó most a bejegyzés szövegét kapja névnek.",
"A lejátszó nagy nézetében tableten is látszik a kilépés, és a Tab már nem kerül a takart gombokra."
]
},
{
"version": "0.114.3",
"date": "2026-11-01",
"new": [],
"fixed": [
"Ha nem videó- vagy hangfájlt próbálsz szerkeszteni, az alkalmazás most megmondja, miért nem lehet, általános hibaüzenet helyett."
]
},
{
"version": "0.114.2",
"date": "2026-10-30",
"new": [],
"fixed": [
"Biztonság: frissült egy könyvtár, amelyre a bejelentkezés épül, így két nyilvános sebezhetőség záródott be."
]
},
{
"version": "0.114.1",
"date": "2026-10-30",
"new": [],
"fixed": [
"Az értesítések követik a nyelvváltást: a képernyőn lévő értesítés és az értesítési központ minden sora az új nyelven szól, a gombjaik is."
]
},
{
"version": "0.114.0",
"date": "2026-10-29",
"new": [
"Értesítések: elöl az, ami rád vár, utána minden más egy folyamban, a legújabb elöl. Az egy hétnél régebbi olvasott értesítések a Korábbiak alá csukódnak, és a Vissza arra a sorra tér vissza, ahonnan elmentél."
],
"fixed": [
"Plex: az epizód vége előtt közvetlenül elküldött mentés már nem hagyja megnézetlenül, és egy elutasított befejezés sem ragad be.",
"Párbeszédablak bezárása után a billentyűfókusz oda tér vissza, ahol volt, egy nyitva maradó menüben is."
]
},
{
"version": "0.113.0",
"date": "2026-10-28",
"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",
"new": [],
"fixed": [
"Biztonság: frissült a bejelentkezési könyvtár, ami két nyilvánosan ismert sérülékenységet zár be."
]
},
{ {
"version": "0.112.0", "version": "0.112.0",
"date": "2026-10-25", "date": "2026-10-25",
+5 -5
View File
@@ -4,7 +4,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { qk } from "../../lib/queryKeys"; import { qk } from "../../lib/queryKeys";
import { Plus, Trash2 } from "lucide-react"; import { Plus, Trash2 } from "lucide-react";
import { api, type Tag } from "../../lib/api"; 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 { notifyActionError } from "../../lib/actionErrors";
import { useScrollFade } from "../../lib/useScrollFade"; import { useScrollFade } from "../../lib/useScrollFade";
import { Button } from "../../components/ui/Button"; 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 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. // (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); notifyActionError(e, fallback);
invalidate(); invalidate();
}; };
const rename = useMutation({ const rename = useMutation({
mutationFn: (v: { id: number; name: string }) => api.updateTag(v.id, { name: v.name }), mutationFn: (v: { id: number; name: string }) => api.updateTag(v.id, { name: v.name }),
onSuccess: invalidate, onSuccess: invalidate,
onError: (e) => onFailed(e, t("tagManager.renameFailed")), onError: (e) => onFailed(e, msg("tagManager.renameFailed")),
}); });
const del = useMutation({ const del = useMutation({
mutationFn: (v: { id: number; name: string }) => api.deleteTag(v.id), mutationFn: (v: { id: number; name: string }) => api.deleteTag(v.id),
onSuccess: (_d, v) => { onSuccess: (_d, v) => {
invalidate(); 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. // 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 { plexLang } from "../../i18n";
import { api, type DiscoverCard, type PinKind } from "../../lib/api"; import { api, type DiscoverCard, type PinKind } from "../../lib/api";
import { notifyActionError } from "../../lib/actionErrors"; import { notifyActionError } from "../../lib/actionErrors";
import { notify } from "../../lib/notifications"; import { msg, type NotifText, notify } from "../../lib/notifications";
import { qk } from "../../lib/queryKeys"; import { qk } from "../../lib/queryKeys";
import { type ActionMenuItem } from "../../components/ui/ActionMenu"; 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 /** `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 / * 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. */ * 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. */ /** 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]); }, [qc]);
const add = useCallback( const add = useCallback(
async (target: PinTarget, title: string) => { async (target: PinTarget, title: NotifText) => {
try { try {
await api.addComfortPin(target.kind, target.ref); await api.addComfortPin(target.kind, target.ref);
// The shelf lives on another page, so without this the click has no visible consequence at // 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. // 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) { } catch (e) {
notifyActionError(e, t("home.pin.addFailed")); notifyActionError(e, msg("home.pin.addFailed"));
} }
invalidate(); invalidate();
}, },
[invalidate, t], [invalidate],
); );
const remove = useCallback( const remove = useCallback(
(pinId: number, title: string) => { (pinId: number, title: NotifText) => {
void (async () => { void (async () => {
try { try {
await api.removeComfortPin(pinId); await api.removeComfortPin(pinId);
notify({ level: "success", message: t("home.pin.removed", { title }) }); notify({ level: "success", message: msg("home.pin.removed", { title }) });
} catch (e) { } catch (e) {
notifyActionError(e, t("home.pin.removeFailed")); notifyActionError(e, msg("home.pin.removeFailed"));
} }
invalidate(); invalidate();
})(); })();
}, },
[invalidate, t], [invalidate],
); );
const toggle = useCallback( const toggle = useCallback(
(target: PinTarget, title: string) => { (target: PinTarget, title: NotifText) => {
const pinId = pinIdOf(target); const pinId = pinIdOf(target);
if (pinId == null) void add(target, title); if (pinId == null) void add(target, title);
else remove(pinId, title); else remove(pinId, title);
@@ -173,7 +173,7 @@ export function useComfortPin(open: boolean): ComfortPin {
); );
const item = useCallback( const item = useCallback(
(target: PinTarget | null, title: string): ActionMenuItem[] => { (target: PinTarget | null, title: NotifText): ActionMenuItem[] => {
if (!target) return []; if (!target) return [];
const pinId = pinIdOf(target); const pinId = pinIdOf(target);
// Still loading: an honest "we don't know yet" row rather than a confident "Pin" over // Still loading: an honest "we don't know yet" row rather than a confident "Pin" over
@@ -18,7 +18,7 @@ import {
} from "../../lib/debugRecorder"; } from "../../lib/debugRecorder";
import { formatDateTimeSec, formatDuration } from "../../lib/format"; import { formatDateTimeSec, formatDuration } from "../../lib/format";
import { useLayer } from "../../lib/layerStack"; import { useLayer } from "../../lib/layerStack";
import { notify } from "../../lib/notifications"; import { msg, notify } from "../../lib/notifications";
import { qk } from "../../lib/queryKeys"; import { qk } from "../../lib/queryKeys";
import { dockUnderToasts } from "../../lib/toastDock"; import { dockUnderToasts } from "../../lib/toastDock";
import { useMe } from "../../lib/useMe"; import { useMe } from "../../lib/useMe";
@@ -73,7 +73,7 @@ export default function DebugRecorder() {
if (!id || !until) return; if (!id || !until) return;
const wait = Math.min(Date.parse(until) - Date.now(), 2 ** 31 - 1); const wait = Math.min(Date.parse(until) - Date.now(), 2 ** 31 - 1);
const timer = window.setTimeout(() => { 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); openComposer(id);
void qc.invalidateQueries({ queryKey: qk.me() }); void qc.invalidateQueries({ queryKey: qk.me() });
}, wait); }, 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 { api, type DebugScreenshot, type DebugSession, type ServerLogRow } from "../../lib/api";
import { closeComposer } from "../../lib/debugRecorder"; import { closeComposer } from "../../lib/debugRecorder";
import { formatBytes, formatDateTimeSec, formatUnit } from "../../lib/format"; import { formatBytes, formatDateTimeSec, formatUnit } from "../../lib/format";
import { notify } from "../../lib/notifications"; import { msg, notify } from "../../lib/notifications";
import { useMe } from "../../lib/useMe"; import { useMe } from "../../lib/useMe";
import { import {
draftSteps, draftSteps,
@@ -217,7 +217,7 @@ function ComposerBody({
meta: { failTitle: "recorder.composer.sendFailed" }, meta: { failTitle: "recorder.composer.sendFailed" },
onSuccess: (r) => { onSuccess: (r) => {
qc.setQueryData(qk.debugSession(session.id), r.session); 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. // A failure half-way still created the issue: show what the server now has.
onSettled: async () => { onSettled: async () => {
@@ -1,6 +1,5 @@
import { useCallback } from "react"; import { useCallback } from "react";
import { useIsMutating, useMutation, useQueryClient } from "@tanstack/react-query"; import { useIsMutating, useMutation, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { api, type DebugSession, type DebugSessionLevel } from "../../lib/api"; import { api, type DebugSession, type DebugSessionLevel } from "../../lib/api";
import { notifyActionError } from "../../lib/actionErrors"; import { notifyActionError } from "../../lib/actionErrors";
@@ -14,7 +13,7 @@ import {
} from "../../lib/debugRecorder"; } from "../../lib/debugRecorder";
import { flushNow, liveSession } from "../../lib/diagnostics"; import { flushNow, liveSession } from "../../lib/diagnostics";
import { log } from "../../lib/log"; import { log } from "../../lib/log";
import { notify } from "../../lib/notifications"; import { msg, notify } from "../../lib/notifications";
import { qk } from "../../lib/queryKeys"; import { qk } from "../../lib/queryKeys";
import { updateMeCache, useMe } from "../../lib/useMe"; 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 * 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. */ * answer, so a reload, another tab or the cap all show the same thing. */
export function useDebugRecording() { export function useDebugRecording() {
const { t } = useTranslation();
const qc = useQueryClient(); const qc = useQueryClient();
const me = useMe(); const me = useMe();
const session = liveSession(me?.debug_session); const session = liveSession(me?.debug_session);
@@ -54,7 +52,7 @@ export function useDebugRecording() {
meta: { failTitle: "recorder.pill.stopFailed" }, meta: { failTitle: "recorder.pill.stopFailed" },
onSuccess: (r) => { onSuccess: (r) => {
setSession(null); setSession(null);
notify({ level: "success", message: t("recorder.pill.stopped") }); notify({ level: "success", message: msg("recorder.pill.stopped") });
openComposer(r.session.id); 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. // 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 }); 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 n = nextStep(sessionId);
const text = label.trim(); const text = label.trim();
log.info("debug.step", text || `step ${n}`, { n, ...(text ? { label: text } : {}) }); 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 /** 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))); await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
image = await captureTab(); image = await captureTab();
} catch (err) { } catch (err) {
notifyActionError(err, t("recorder.pill.shotFailed")); notifyActionError(err, msg("recorder.pill.shotFailed"));
} }
hide(false); hide(false);
if (image) shoot.mutate({ id: session.id, image }); 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. // Lazy: the full profile editor only opens from the dialog's "manage presets" affordance.
const ProfileEditor = lazy(() => import("../../modules/settings/ProfileEditor")); const ProfileEditor = lazy(() => import("../../modules/settings/ProfileEditor"));
import { notify } from "../../lib/notifications"; import { msg, notify } from "../../lib/notifications";
import { notifyActionError } from "../../lib/actionErrors"; import { notifyActionError } from "../../lib/actionErrors";
import { showInTray } from "../../lib/downloadQueue"; import { showInTray } from "../../lib/downloadQueue";
import { profileName } from "../../lib/vocab"; import { profileName } from "../../lib/vocab";
@@ -63,15 +63,15 @@ export default function DownloadDialog({
setLastProfile(chosen); setLastProfile(chosen);
notify({ notify({
level: "success", level: "success",
message: t("downloads.dialog.added"), message: msg("downloads.dialog.added"),
action: { label: t("downloads.dialog.view"), onClick: () => showInTray(job.id) }, action: { label: msg("downloads.dialog.view"), onClick: () => showInTray(job.id) },
}); });
onClose(); onClose();
} catch (e) { } catch (e) {
// A quota or validation refusal (422/409) is already in the global error dialog, which the // 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 // reporter skips; a 403/404/429 or a lost connection is not, and used to leave the dialog
// open with nothing said. // open with nothing said.
notifyActionError(e, t("errors.writeFailed.enqueueDownload")); notifyActionError(e, msg("errors.writeFailed.enqueueDownload"));
setBusy(false); setBusy(false);
} }
}; };
@@ -70,6 +70,7 @@ vi.mock("../../lib/notifications", async (importOriginal) => ({
})); }));
import DownloadTray from "./DownloadTray"; import DownloadTray from "./DownloadTray";
import { msg, type NotifText, raw } from "../../lib/notifications";
import { noteQueue, resetQueueWatch, showInTray } from "../../lib/downloadQueue"; import { noteQueue, resetQueueWatch, showInTray } from "../../lib/downloadQueue";
import { pushLayer, removeLayer } from "../../lib/layerStack"; import { pushLayer, removeLayer } from "../../lib/layerStack";
import { qk } from "../../lib/queryKeys"; import { qk } from "../../lib/queryKeys";
@@ -199,14 +200,15 @@ describe("DownloadTray", () => {
poll([job(1, "done")], view); poll([job(1, "done")], view);
await waitFor(() => expect(h.notify).toHaveBeenCalledTimes(1)); await waitFor(() => expect(h.notify).toHaveBeenCalledTimes(1));
const notice = h.notify.mock.calls[0]![0] as { const notice = h.notify.mock.calls[0]![0] as {
title: string; title: NotifText;
message: string; message: NotifText;
action: { label: string; onClick: () => void }; action: { label: NotifText; onClick: () => void };
secondaryAction: { label: string; onClick: () => void }; secondaryAction: { label: NotifText; onClick: () => void };
}; };
expect(notice.title).toBe("tray.done.title:Clip 1"); expect(notice.title).toEqual(msg("tray.done.title", { title: raw("Clip 1") }));
expect(notice.message).toBe("tray.done.phone"); expect(notice.message).toEqual(msg("tray.done.phone"));
expect(notice.action.label).toBe("tray.done.copyLink"); expect(notice.action.label).toEqual(msg("tray.done.copyLink"));
expect(notice.secondaryAction.label).toEqual(msg("tray.done.open"));
notice.action.onClick(); notice.action.onClick();
expect(h.share).toHaveBeenCalled(); expect(h.share).toHaveBeenCalled();
notice.secondaryAction.onClick(); notice.secondaryAction.onClick();
@@ -225,9 +227,9 @@ describe("DownloadTray", () => {
await waitFor(() => expect(h.notify).toHaveBeenCalledTimes(1)); await waitFor(() => expect(h.notify).toHaveBeenCalledTimes(1));
poll([job(1, "done"), job(2, "done")], view); poll([job(1, "done"), job(2, "done")], view);
await waitFor(() => expect(h.notify).toHaveBeenCalledTimes(2)); await waitFor(() => expect(h.notify).toHaveBeenCalledTimes(2));
expect(h.notify.mock.calls.map((c) => (c[0] as { title: string }).title)).toEqual([ expect(h.notify.mock.calls.map((c) => (c[0] as { title: NotifText }).title)).toEqual([
"tray.done.title:Clip 1", msg("tray.done.title", { title: raw("Clip 1") }),
"tray.done.title:Clip 2", msg("tray.done.title", { title: raw("Clip 2") }),
]); ]);
}); });
@@ -236,7 +238,9 @@ describe("DownloadTray", () => {
const view = mount(); const view = mount();
poll([job(1, "done", { asset: { media_kind: "file" } as never })], view); poll([job(1, "done", { asset: { media_kind: "file" } as never })], view);
await waitFor(() => 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); poll([job(1, "done", { source_key: "upload" })], view);
await waitFor(() => await waitFor(() =>
expect(h.notify).toHaveBeenCalledWith( 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"; } from "../../lib/downloadQueue";
import { anyBlockingLayer } from "../../lib/layerStack"; import { anyBlockingLayer } from "../../lib/layerStack";
import { focusNavItem, navItemAttrs } from "../../lib/listNav"; import { focusNavItem, navItemAttrs } from "../../lib/listNav";
import { notify } from "../../lib/notifications"; import { msg, notify, raw } from "../../lib/notifications";
import { qk } from "../../lib/queryKeys"; import { qk } from "../../lib/queryKeys";
import { LS } from "../../lib/storage"; import { LS } from "../../lib/storage";
import { useLiveQuery } from "../../lib/useLiveQuery"; import { useLiveQuery } from "../../lib/useLiveQuery";
@@ -191,16 +191,16 @@ export default function DownloadTray() {
notify({ notify({
level: "success", level: "success",
// An upload the worker had to convert first is done here too — it was never downloaded. // 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: msg(job.source_key === "upload" ? "tray.done.uploadedTitle" : "tray.done.title", {
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: { action: {
label: t("tray.done.copyLink"), label: msg("tray.done.copyLink"),
onClick: () => void quickShareLink(qc, { kind: "file", id: job.id }, t), onClick: () => void quickShareLink(qc, { kind: "file", id: job.id }),
}, },
secondaryAction: { secondaryAction: {
label: t("tray.done.open"), label: msg("tray.done.open"),
onClick: () => openFile(job.id, job.folder_id), onClick: () => openFile(job.id, job.folder_id),
}, },
}); });
@@ -298,7 +298,7 @@ export default function DownloadTray() {
id: "copyLink", id: "copyLink",
label: t("tray.done.copyLink"), label: t("tray.done.copyLink"),
icon: <Link className="w-4 h-4" />, 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 const menuItems = menu.target
@@ -9,6 +9,7 @@ import { useCopyItems } from "../../components/ui/copyItems";
import { jobTitle } from "./downloadTitle"; import { jobTitle } from "./downloadTitle";
import { type ActionMenuItem } from "../../components/ui/ActionMenu"; import { type ActionMenuItem } from "../../components/ui/ActionMenu";
import { menuGroups } from "../../components/ui/menuGroups"; 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 // 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 // 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.resumeDownload(id)
: api.cancelDownload(id), : api.cancelDownload(id),
onSuccess: () => invalidateDownloads(qc), 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) => { 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 { ownKey } from "../../lib/storage";
import { useConfirm } from "../../components/ui/confirmContext"; import { useConfirm } from "../../components/ui/confirmContext";
import type { SavedViewsContext, ViewSource } from "./savedViewsContext"; 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 // 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 // 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 isDirty = displayView != null && activeView == null;
const refresh = () => void qc.invalidateQueries({ queryKey: qk.savedViews(source) }); 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; let ok = true;
try { try {
await fn(); await fn();
@@ -146,28 +147,28 @@ export function useSavedViewsStore(cfg: SavedViewsConfig): SavedViewsContext {
if (!clean) return false; if (!clean) return false;
return runViewMutation( return runViewMutation(
() => api.createView(clean, cfg.stripForSave(cfg.filters), source), () => api.createView(clean, cfg.stripForSave(cfg.filters), source),
t("views.actionFailed"), msg("views.actionFailed"),
); );
}, },
rename: async (id, name) => { rename: async (id, name) => {
const next = name.trim(); const next = name.trim();
if (!next) return false; 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) => updateFilters: (id, filters) =>
runViewMutation(() => api.updateView(id, { filters }), t("views.actionFailed")), runViewMutation(() => api.updateView(id, { filters }), msg("views.actionFailed")),
saveCurrent: async (id) => { saveCurrent: async (id) => {
const filters = cfg.stripForSave(cfg.filters); const filters = cfg.stripForSave(cfg.filters);
const ok = await runViewMutation( const ok = await runViewMutation(
() => api.updateView(id, { filters }), () => api.updateView(id, { filters }),
t("views.actionFailed"), msg("views.actionFailed"),
); );
return ok ? filters : null; return ok ? filters : null;
}, },
toggleDefault: (v) => toggleDefault: (v) =>
runViewMutation( runViewMutation(
() => api.updateView(v.id, { is_default: !v.is_default }), () => api.updateView(v.id, { is_default: !v.is_default }),
t("views.actionFailed"), msg("views.actionFailed"),
).then(() => undefined), ).then(() => undefined),
remove: async (v) => { remove: async (v) => {
const ok = await confirm({ const ok = await confirm({
@@ -177,14 +178,14 @@ export function useSavedViewsStore(cfg: SavedViewsConfig): SavedViewsContext {
}); });
if (!ok) return false; if (!ok) return false;
if (stickyId === v.id) setStickyId(null); 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) => { reorder: async (nextIds) => {
qc.setQueryData<SavedView[]>( qc.setQueryData<SavedView[]>(
qk.savedViews(source), qk.savedViews(source),
nextIds.map((id) => views.find((v) => v.id === id)!).filter(Boolean), 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 { NavigationProvider } from "./NavigationProvider";
import { useNavigation, useNavigationActions } from "./navigationContext"; import { useNavigation, useNavigationActions } from "./navigationContext";
import { urlPage } from "./urlPage";
import { channelUrl } from "../../lib/urlState";
import { useBackToClose } from "../../lib/history"; import { useBackToClose } from "../../lib/history";
import { PageHeading } from "../../components/ui/Headings"; 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"); 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", () => { it("pushes nothing when it is already that page", () => {
const { result } = render(); const { result } = render();
act(() => result.current.setPage("plex")); 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", () => { describe("NavigationProvider · Back", () => {
// A tab that had a since-removed module open, reloaded onto the new build: its older history // 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. // 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"); 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. // …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.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", () => { 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"); window.history.replaceState(null, "", "/?page=scheduler&keep=1");
const { result } = render(); const { result } = render();
expect(result.current.page).toBe("feed"); 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" }); expect(window.history.state).toMatchObject({ sfPage: "feed" });
}); });
@@ -361,7 +463,7 @@ describe("NavigationProvider · a page the account cannot open", () => {
qc.setQueryData(qk.me(), member); qc.setQueryData(qk.me(), member);
}); });
await settle(); await settle();
expect(window.location.search).toBe(""); expect(window.location.search).toBe("?page=feed");
expect(window.history.state).toMatchObject({ sfPage: "feed" }); expect(window.history.state).toMatchObject({ sfPage: "feed" });
expect(getAccountRaw(PAGE_KEY)).toBe("feed"); expect(getAccountRaw(PAGE_KEY)).toBe("feed");
act(() => { act(() => {
@@ -381,7 +483,7 @@ describe("NavigationProvider · a page the account cannot open", () => {
}); });
await settle(); await settle();
expect(result.current.channelView).toBeNull(); expect(result.current.channelView).toBeNull();
expect(window.location.search).toBe(""); expect(window.location.search).toBe("?page=feed");
expect(window.history.state?._chan).toBeUndefined(); 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 { useLatest } from "../../lib/useLatest";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useMe } from "../../lib/useMe"; import { useMe } from "../../lib/useMe";
import { resolvePage, retiredParams, urlWithParams, type Page } from "../../lib/urlState"; import {
import { ACTIVITY_KEYS } from "../../lib/activityUrlState"; channelUrl,
resolvePage,
retiredParams,
urlWithParams,
type Page,
} from "../../lib/urlState";
import { useDocumentObjectTitle } from "../../lib/documentTitle"; import { useDocumentObjectTitle } from "../../lib/documentTitle";
import { getAccountRaw, setAccountRaw } from "../../lib/storage"; import { getAccountRaw, setAccountRaw } from "../../lib/storage";
import { import {
@@ -12,8 +17,17 @@ import {
nextNavId, nextNavId,
notifyNav, notifyNav,
rememberNavPosition, rememberNavPosition,
replaceView,
} from "../../lib/history"; } 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 { import {
channelViewFrom, channelViewFrom,
loadInitialPage, loadInitialPage,
@@ -37,24 +51,35 @@ import {
// //
// The contexts, their value types and the hooks live in navigationContext.ts. // 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> { function pageRootParams(page: Page): Record<string, string | null> {
return { return {
page: page === "feed" ? null : page, page,
chan: null, ...closedChannelParams(),
v: null, ...nulls(PLAYER_KEYS),
pl: null, ...nulls(moduleUrlKeys()),
pscope: null,
psort: null,
pshow: null,
pf: null,
ctab: null,
csort: null,
cf: null,
...Object.fromEntries(ACTIVITY_KEYS.map((k) => [k, null])),
}; };
} }
/** 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 }) { export function NavigationProvider({ children }: { children: ReactNode }) {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
// Held here as well as in Settings: this is the instance mounted on every boot, so a value only // 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. // and the address bar (stamped from it below) agree.
const me = useMe(); const me = useMe();
const page = reachablePage(chosenPage, me); const page = reachablePage(chosenPage, me);
// Ephemeral: not persisted and not in the URL, so a reload returns to the normal feed (a search // The live YouTube search: named in the address (`yt`, and `ytn` for its count, lib/ytSearchUrlState)
// spends quota, so we never auto-replay it) — UNLESS it was zero-quota scrape-sourced, which rides // so a reload or a link opens it again; its own history entry, so Back steps player → search → feed.
// in history.state and is free to re-fetch. The popstate handler derives it from history.state so // Whether it RUNS by itself is not the address's to say: `ytAsked` is the term this document was
// Back steps player → search → feed instead of the search vanishing without a history entry. // asked to search for (sent from the search box, or "Search YouTube" pressed), on the entries
const [ytSearch, setYtSearch] = useState<string | null>(() => { // it has stayed on since — leaving the search ends it. Anything else — a reload, a link, a Back or
const st = window.history.state; // a Forward onto a search — runs only when it costs no quota (Feed decides).
return st?._yt && st?._ytScrape ? (st._yt as string) : null; 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 // 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 // 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 // it, and a bookmarked ?chan link opens it. Reload of an in-app entry reads history.state._chan; a
// entry reads history.state._chan; a fresh bookmark has only the ?chan param. // fresh bookmark has only the ?chan param.
const [channelView, setChannelView] = useState<ChannelView | null>(() => const [channelView, setChannelView] = useState<ChannelView | null>(() =>
channelViewFrom(window.history.state, window.location.search), channelViewFrom(window.history.state, window.location.search),
); );
@@ -107,31 +144,36 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
closeOverlaysThenNavigate(() => { closeOverlaysThenNavigate(() => {
// A channel page overlays the content column, so a nav click must close it even when the // 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). // 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?.(); then?.();
return; return;
} }
setChannelView(null); // leave any open channel page when navigating via the rail 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); setPageState(next);
setAccountRaw(PAGE_KEY, next); setAccountRaw(PAGE_KEY, next);
// Push an in-app history entry so browser/mouse Back steps through pages instead of leaving // 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 // 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 // root, and the address bar reflects the page (the feed too) while clearing `chan` and
// view-specific params (chan/v/pl and the plex content keys pscope/psort/pshow/pf) — a page // every module's own keys (pageRootParams) — a page switch resets to that module's root, and
// switch resets to that module's root. Feed filters (owned by FeedFiltersProvider) are left // each module's store mirrors its state back in on its page. The item it was opened from is
// intact; the plex/channels keys re-mirror from PlexProvider / ChannelsProvider on return to // still focused: the state setters above have not re-rendered anything yet.
// their page. The item it was opened from is still focused: the state setters above have not // The press on a search over its own page ends only the search: the page's view is the one
// re-rendered anything yet. // on screen and stays named — its store, unchanged, writes nothing back in.
rememberNavPosition(); rememberNavPosition();
window.history.pushState( window.history.pushState(
{ sfPage: next, sfScroll: nextNavId() }, { sfPage: next, sfScroll: nextNavId() },
"", "",
urlWithParams(pageRootParams(next)), urlWithParams(samePage ? searchEndParams() : pageRootParams(next)),
); );
notifyNav(); notifyNav();
then?.(); 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 // 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. // inside one.
const openChannel = useCallback((id: string, name?: string) => { const openChannel = useCallback((id: string, name?: string) => {
closeOverlaysThenNavigate(() => { 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) { if (window.history.state?._chan) {
const st = window.history.state; const st = window.history.state;
window.history.replaceState({ ...st, _chan: id, _chanName: name ?? null }, "", url); replaceView({ ...st, _chan: id, _chanName: name ?? null }, url);
} else { } else {
const st = rememberNavPosition(); const st = rememberNavPosition();
window.history.pushState({ ...st, _chan: id, _chanName: name ?? null }, "", url); 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 // 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 // below it to step back to (a full tab, see backStaysInDocument): clear it in place — the
// entry's channel marks and the ?chan param. // 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); setChannelView(null);
const { _chan: _id, _chanName: _name, ...st } = window.history.state ?? {}; 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) => { const enterYtSearch = useCallback((q: string) => {
setYtSearch(q); setYtSearch(q);
// Drop any prior _ytScrape marker — the new search's source isn't known yet; Feed re-stamps it setYtAsked(q);
// once the results resolve, so a reload only restores a confirmed scrape-sourced search. const st = window.history.state ?? {};
const { _ytScrape: _drop, ...st } = window.history.state || {}; const here = new URLSearchParams(window.location.search);
if (st._yt) if (st._yt || ytSearchFrom(here) != null) {
window.history.replaceState({ ...st, _yt: q }, ""); // refine current search // Refine the search on screen, in place, at the count it shows (one a link opened too: it
else { // is the first entry of this document, so its new `_yt` mark has nothing of ours below it).
// New sub-view entry. replaceView({ ...st, _yt: q }, urlWithParams(ytSearchParams(q, ytCountFrom(here))));
const { _ytScrape: _dropToo, ...left } = rememberNavPosition(); } else {
window.history.pushState({ ...left, sfPage: "feed", _yt: q }, ""); // 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(() => { const exitYtSearch = useCallback(() => {
// Pop our search entry (so Forward still works); fall back to a clear in place if we don't own // 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). // one — a search a link or a reload opened is marked (`_yt`) but was pushed by nobody: below it is
if (window.history.state?._yt && backStaysInDocument()) window.history.back(); // 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 { else {
setYtSearch(null); endYtSearch();
const { _yt: _q, _ytScrape: _scrape, ...st } = window.history.state ?? {}; const { _yt: _q, _ytOwn: _own, ...st } = window.history.state ?? {};
window.history.replaceState(st, ""); 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 // 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 // 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); const [pageSearchWasOn, setPageSearchWasOn] = useState(page);
if (pageSearchWasOn !== page) { if (pageSearchWasOn !== page) {
setPageSearchWasOn(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"): // 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 // 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. // the popstate handler only needs to read history.state.
useEffect(() => { useEffect(() => {
// A reload drops a stale _yt EXCEPT when scrape-sourced (zero quota): then keep _yt + _ytScrape // The entry of a search the address names carries its `_yt` mark, whichever way it opened (sent
// so it restores and re-fetches free (ytSearch above already initialised from it). _chan/_chanName // from the box, a reload, a link): a channel opened from its results reads the search under it
// are always kept so a channel page survives F5. // 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 || {}; const st = window.history.state || {};
// Mirror the resolved initial page to the address bar (feed implicit) so a page reached from the // 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 preserves any // stored landing pref — not just from the URL — is itself bookmarkable. urlWithParams keeps the
// ?chan / ?v / ?pl / filter params the loaders already read. // 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 // 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). // part of the page that replaced it (an `at` tab).
const linked = new URLSearchParams(window.location.search).get("page"); const linked = new URLSearchParams(window.location.search).get("page");
const remembered = getAccountRaw(PAGE_KEY); 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({ const url = urlWithParams({
...nulls(shown ? moduleUrlKeys() : foreignUrlKeys(mountPage)),
...retiredParams(linked ?? (resolvePage(remembered) === mountPage ? remembered : null)), ...retiredParams(linked ?? (resolvePage(remembered) === mountPage ? remembered : null)),
page: mountPage === "feed" ? null : mountPage, page: mountPage,
}); });
if (st._yt && st._ytScrape) { const named =
window.history.replaceState({ ...st, sfPage: mountPage }, "", url); mountPage === "feed" ? ytSearchFrom(new URLSearchParams(window.location.search)) : null;
if (named != null) {
window.history.replaceState({ ...st, _yt: named, sfPage: mountPage }, "", url);
} else { } else {
const { _yt: _staleYt, _ytScrape: _staleScrape, ...rest } = st; const { _yt: _staleYt, _ytScrape: _staleScrape, _ytOwn: _staleOwn, ...rest } = st;
window.history.replaceState({ ...rest, sfPage: mountPage }, "", url); window.history.replaceState({ ...rest, sfPage: mountPage }, "", url);
} }
function onPop(e: PopStateEvent) { function onPop(e: PopStateEvent) {
@@ -267,13 +350,22 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
window.history.replaceState( window.history.replaceState(
{ ...window.history.state, sfPage: p }, { ...window.history.state, sfPage: p },
"", "",
urlWithParams({ ...retiredParams(sf), page: p === "feed" ? null : p }), urlWithParams({ ...retiredParams(sf), page: p }),
); );
setPageState(p); setPageState(p);
setAccountRaw(PAGE_KEY, p); setAccountRaw(PAGE_KEY, p);
// The YouTube-search sub-view and the channel page ride in history.state, so Back/Forward // The YouTube search is read from the address the browser restored, so Back/Forward opens or
// restores or clears them to match the entry we landed on. // clears it to match the entry we landed on. One landed on without it has ended: a Forward back
setYtSearch((e.state?._yt as string) ?? null); // 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 // 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 // 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 // popstate lands here (e.g. after an overlay's programmatic history.back()). channelViewFrom
@@ -285,12 +377,26 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
}, [mountPage]); }, [mountPage]);
const state = useMemo<NavigationState>( 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>( 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 ( 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>; 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). // Look up a module's definition by page id (every Page is covered, incl. settings).
export function moduleDef(id: Page): ModuleDef { export function moduleDef(id: Page): ModuleDef {
return BY_ID[id]; return BY_ID[id];
@@ -1,5 +1,6 @@
import { createContext, useContext } from "react"; import { createContext, useContext } from "react";
import type { Page } from "../../lib/urlState"; import type { Page } from "../../lib/urlState";
import { DEFAULT_YT_COUNT } from "../../lib/ytSearchUrlState";
import type { ChannelView } from "./navigationInit"; import type { ChannelView } from "./navigationInit";
// The navigation contexts + their hooks, split out of NavigationProvider so that file exports only // The navigation contexts + their hooks, split out of NavigationProvider so that file exports only
@@ -15,8 +16,13 @@ export interface NavigationState {
page: Page; page: Page;
/** The open channel page (null = none). Overlays `page`; rides in history.state._chan. */ /** The open channel page (null = none). Overlays `page`; rides in history.state._chan. */
channelView: ChannelView | null; 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; 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 { export interface NavigationActions {
@@ -33,12 +39,17 @@ export interface NavigationActions {
closeChannel: () => void; closeChannel: () => void;
enterYtSearch: (q: string) => void; enterYtSearch: (q: string) => void;
exitYtSearch: () => void; exitYtSearch: () => void;
/** Run the search on screen: the "Search YouTube" press. */
askYtSearch: () => void;
setYtCount: (n: number) => void;
} }
export const NavigationStateContext = createContext<NavigationState>({ export const NavigationStateContext = createContext<NavigationState>({
page: "feed", page: "feed",
channelView: null, channelView: null,
ytSearch: null, ytSearch: null,
ytCount: DEFAULT_YT_COUNT,
ytSearchAsked: false,
}); });
export const NavigationActionsContext = createContext<NavigationActions>({ export const NavigationActionsContext = createContext<NavigationActions>({
setPage: () => {}, setPage: () => {},
@@ -46,6 +57,8 @@ export const NavigationActionsContext = createContext<NavigationActions>({
closeChannel: () => {}, closeChannel: () => {},
enterYtSearch: () => {}, enterYtSearch: () => {},
exitYtSearch: () => {}, exitYtSearch: () => {},
askYtSearch: () => {},
setYtCount: () => {},
}); });
export function useNavigation(): NavigationState { export function useNavigation(): NavigationState {

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