/code-review (S1, finding 3): C-3.19 switched the banner from CURRENT_VERSION (always
RELEASE_NOTES[0]) to FRONTEND_VERSION (VITE_APP_VERSION), which is "dev" in the Vite
dev server and can drift from the notes — so the modal highlighted nothing. The modal
(which already imports RELEASE_NOTES, keeping App decoupled) now falls back to the
newest note when the passed version isn't listed.
The demo account is shared, so writing deep_requested onto its subscription polluted
shared state (harmless today — the scheduler's deep paths are human-only — but a
future scheduler path trusting the flag would leak quota). Guard the flag WRITE at the
source (not user.is_demo), which also makes deep_turned_on always False for demo, so
the redundant is_demo check on the immediate backfill is removed.
/code-review (S1, finding 1): sanitize truncated the title at 120 CHARACTERS, but a
path component's hard limit is 255 BYTES and rel_path packs channel + date + id + ext
into that same component — so a long accented/multi-byte title (Hungarian titles are
common here) could already overflow to ENAMETOOLONG, and the new _a{id} suffix shrank
the margin further. sanitize now truncates on a UTF-8 boundary by byte length, so the
title (≤120B) + channel (≤80B) + fixed parts stay comfortably under 255B. Two tests:
byte-cap on accented input, and every rel_path component ≤255B for a pathological
long-accented channel+title+asset_id.
/code-review caught that role="status" on the toast container implies
aria-atomic="true", so every new toast made a screen reader re-read the entire
stack instead of just the new one — the opposite of the U-F fix's intent. Use a bare
aria-live="polite" (defaults atomic=false, announcing only the added toast); error
toasts keep role="alert". Also simplified the conditional role to a plain ternary.
Review found my C-3.23 additions double-notified: the global error modal (api.ts req)
already surfaces 400/409/422/500 with the backend's specific message, so a caller
toast on those codes is a second, weaker surface. Only 403/404 are caller-handled and
silent — and only a YouTube WRITE action has a meaningful 403 (missing scope).
So the correct set is just the ChannelPage unsubscribe handler (a YouTube write, 403 →
"connect", matching subscribe). Reverted the redundant onErrors on block (a local DB
op, not YouTube), SettingsPanel Plex watch toggle/reimport, and the DownloadCenter job
action — the global modal already speaks for them. Removed the three now-unused keys.
Toaster: moved the polite live region onto the stable, always-mounted container (a
polite region must pre-exist to announce later insertions); error/fatal toasts keep
role="alert", which announces on insertion regardless.
House policy: user actions must report failure. Added onError to:
- ChannelPage block + unsubscribe → notifyYouTubeActionError (a 403 = missing YT
scope shows the connect message; else the translated fallback);
- SettingsPanel PlexWatchSync toggle + reimport → a plain error notify (these are
Plex, not YouTube, so notifyYouTubeActionError's "connect YouTube" would mislead);
- DownloadCenter job action (pause/resume/cancel/delete) → a plain error notify.
New keys (HU+EN): channels.notify.blockFailed, settings.plexSync.failed,
downloads.actionFailed.
The toast is the app's main feedback channel but had no live region, so a screen
reader never spoke it. Each toast now carries role + aria-live by severity:
error/fatal interrupt (alert/assertive), everything else waits its turn
(status/polite).
House policy is HU+EN for every user-facing string. Modal's shared close button was a
hardcoded title="Close" (in every modal) — now t("common.close") + an aria-label.
Welcome's lightbox close aria-label and Feed's "this video" notification fallback are
translated too (new feed.thisVideo, HU+EN). The other Close hardcodes the review
listed were already fixed since.
Review found two low-severity items:
- toast: armDismiss now always clears the prior timer (and arms only when duration is
truthy), so a coalesced repeat that upgrades a toast to requiresInteraction no longer
lets the original timer fire and dismiss it. Was a pre-existing gap the C-3.25 change
hadn't closed.
- overlay: documented the known limitation of useDismiss's blanket stopPropagation — a
PlexPlayer menu open during an auto-skip countdown swallows the Escape that would also
cancel the skip. Proper fix needs R8's shared layer-registry (topmost-only dispatch),
not a broad stopPropagation; accepted until then.
A coalesced repeat re-surfaced the toast and armed a new dismiss timer without
clearing the old one, so it vanished at the ORIGINAL deadline (6s not the expected
11s). Timers are now tracked in a Map<id> and cleared before re-arming (and on
manual dismiss).
Three player/overlay interaction fixes (PlexPlayer changes share a file, hence one
commit):
- U-C (Escape double-close): useDismiss's Escape now stopPropagation()s (its document
listener bubbles before a player's window listener, so one Escape closes only the
popover, not the popover AND the player). Modal exports modalCount(); both players'
Escape defers when a Modal is open above them, so Escape closes the dialog, not the
player under it and then the dialog.
- U-3.1.4: PlexPlayer's Space now defers to a focused BUTTON/A (e.g. "Skip intro")
instead of toggling playback — matching PlayerModal.
- U-3.3.12: the audio/subtitle menus used theme-adaptive glass-menu + forced
text-white (white-on-white in light theme); they now use the player's own explicit
dark panel (border-white/15 + bg-neutral-900/95), readable in both themes.
PanelGroups registered only a PointerSensor, so its focusable, "reorderable" group
grips did nothing from the keyboard. Added KeyboardSensor + sortableKeyboardCoordinates,
matching the other sortable lists (Playlists/PlexPlaylistView already had it).
App.tsx imported CURRENT_VERSION from releaseNotes, which pulled its ~900-line
changelog into the eager App chunk and defeated the lazy ReleaseNotes split. App now
uses FRONTEND_VERSION (the built VITE_APP_VERSION, equal to RELEASE_NOTES[0].version
by release convention) from lib/version, and CURRENT_VERSION is removed. Verified in
the built bundle: the changelog text now lives only in the ReleaseNotes lazy chunk.
A MediaAsset is unique on (source_kind, source_ref, format_sig), so the same video in
two formats is two assets — but rel_path keyed only on video_id, so they computed the
same path: the second download silently overwrote the first, and deleting either
removed the shared file. rel_path now takes an optional asset_id and appends "_a{id}"
(which Plex ignores — the .nfo sidecar carries the metadata); the worker passes the
asset id. Only affects NEW downloads (existing assets keep their stored rel_path).
Covered by a new test (two ids → two paths; omitted → the plain name, unchanged).
The demo account could spend real YouTube quota where the human-only guard was
missing (the channel_detail enrichment already had it):
- channels PATCH: an immediate run_recent_backfill when deep_requested is turned on;
- discovery: the channel-detail enrich on the Discover tab;
- feed get_video_detail: the off-catalog videos.list lookup for a video we don't
store (the DB-hit path stays free; demo now gets a 404 for unknown videos).
The asset-busy and claim-fail branches wrote status='queued' unconditionally, so a
pause/cancel the route had just written was clobbered back to queued and the job
silently revived. New _requeue_if_running does the requeue as a conditional UPDATE
(...WHERE status='running'), a no-op once the route has moved the job off 'running'.
C-3.5: /auth/request-access had no rate limiter (unlike register/login/reset/demo)
and answered "approved" for an allow-listed email — a public enumeration oracle for
the allow-list, and an unthrottled invite/admin-mail firehose. It now rate-limits per
client IP and returns a uniform "pending" for every outcome (allowed, pending, new,
throttled); an already-allowed user can still just sign in with Google.
C-3.11: _consume_token was a read-check-write, so two concurrent requests could both
consume the same verify/reset token. It's now a single conditional UPDATE
(...WHERE used_at IS NULL AND expires_at >= now RETURNING user_id) — only the request
that flips used_at gets a user back.
Internal-only epic (gates, tests, compiler strictness); no user-facing change, so
the release note is chores-only. Bundles: the siftlode check/publish gate
(tsc/eslint/prettier/knip/vitest/pytest) with a hard pre-publish gate + e2e
freshness guard, ESLint flat config + one-time Prettier pass, the backend pytest
harness (isolated test image), frontend vitest growth, the e2e scroll-restore fix,
and noUncheckedIndexedAccess (+ the zero-fallout compiler flags).
Enable noUncheckedIndexedAccess and fix all 98 call sites it surfaced across 18
files. Every fix is behaviour-preserving — the flag flagged reads TypeScript
couldn't prove in-bounds, all of which were already guarded by a length/index check,
a findIndex result, or a non-empty invariant:
- read-once so a ternary's narrowing sticks (Feed overrides, linkify mention);
- non-null assertion AFTER an existing guard (PlayerModal/VideoEditor queue+cut-list
indexing, modules step, useUndoable stacks) with a comment stating the invariant;
- nullish fallback where undefined is a real possibility (GlassTuner slider ?? def,
ConfigPanel active group ?? [], descriptionLinks id ?? null);
- optional chaining where the entry genuinely can be absent (PlexBrowse IO entry);
- a non-empty tuple type for the RELATIVE_UNITS constant (encodes "always has [0]").
The single highest-leverage fix: PlayerModal's `active` became `queue?.[index] ??
video` (always Video), clearing 34 of the 45 errors in that file at once. Verified:
tsc/eslint/prettier clean, 56 vitest + 34 pytest + 17/17 e2e green (the e2e suite
exercises the feed/player/channel components touched here).
Three strictness flags that the codebase already satisfies (0 errors each):
- noFallthroughCasesInSwitch on src + e2e — a missing `break` is now a type error.
- noUnusedLocals + noUnusedParameters on the e2e project, so it matches src and node
and dead spec scaffolding can't accumulate (it was the one project without them).
- allowJs + checkJs on the node project, pulling tailwind.config.js and
postcss.config.js under the type checker (the last hole in C-4.5) — a real type
error in either is now caught (verified: `content: 123` fails; Config stays
permissive on plugin keys by design).
noUncheckedIndexedAccess is NOT here — it has 98 call-site fixes and lands as its own
reviewable sub-sprint (S3b).
Review round 1: the two existing safe_abs_path escape tests both pass even against
the classic buggy `str(path).startswith(str(root))` containment check, so a
regression to that bug would have shipped green. Add the one input that
distinguishes the correct `root not in path.parents` guard: a sibling dir whose name
prefixes the root (/root vs /rootx). Mutation-verified — swapping the guard to
startswith now fails this test. Also corrected the requirements-dev header (installed
by Dockerfile.test, not a nonexistent `dev` stage).
The backend had zero tests. This adds 33, all pure (no DB, no network), plus the
harness to run them in the gate:
- backend/Dockerfile.test: the app's deps + pinned pytest/ruff, code bind-mounted at
run time so it always tests the working tree. Isolated from the prod Dockerfile, so
the published image never carries test tooling.
- requirements-dev.txt: pytest==8.4.2, ruff==0.15.21 (the version the gate already
runs) — the backend lane is now pinned, not just host-global.
- tests: normalize_title (de-shout, hashtag strip, emoji drop, trilingual letters
survive); storage sanitize/rel_path/download_filename and the safe_abs_path
traversal guard (../ and absolute-path escapes rejected — verified by mutation);
links HMAC grants (round-trip, wrong-token, tampered sig/exp, expiry) + is_expired;
a DB-free app-assembly smoke (every router wires up, OpenAPI generates).
DB-backed router smoke (auth/feed/downloads) needs a test Postgres + migrations —
deferred. useCardPager needs hook-test infra (jsdom/renderHook) — deferred.
22 vitest cases for two pure helpers that had none:
- columnSort: numeric-vs-locale comparison, asc/desc, no-mutation, the null/junk
coercion in parseSortState, and the unsorted→asc→desc→unsorted click cycle.
- linkify: http/www/bare-domain URLs, the version-number non-match, the
"<platform>: @handle" mention (handle linked, prefix kept as text), alias→canonical
base URL, bare @handle/#hashtag left plain, and multi-link ordering. Nodes are
inspected structurally, so the existing node-env vitest needs no DOM.
useCardPager (a hook) is deferred to when component/hook test infra lands with the
backend pytest sprint.
The "Back restores the feed scroll position" spec failed against the production
build while the app was in fact correct. Root cause, found by instrumenting
PageScroller and comparing the baked :8080 build to live :5173:
`getByTestId("video-card-channel").first()` grabs the first card in the DOM, which
after scrolling 3000px is in the virtualizer's overscan ABOVE the viewport.
Playwright scrolls it into view to click it, moving the feed from 2879 to ~1017–1253
BEFORE navigating; PageScroller's save-on-scroll records that moved position, so Back
faithfully restores the moved position — under the test's own tolerance. A real user
clicks a card they can see, which does not move the feed.
New helper clickVisibleChannelLink() clicks a card whose bounding box is inside the
viewport. Restore now lands at ~2804 (was ~1017). 17/17 pass; stable over 3 reruns.
No app change — the scroll-restore logic was already correct.
Lists the repo-wide format commit so `git blame` skips it. One-time local setup:
`git config blame.ignoreRevsFile .git-blame-ignore-revs` (Forgejo honours it too).
Prettier 3 (pinned), printWidth 100, double quotes — chosen to match the existing
hand-formatting and minimise reflow. This commit is ONLY the mechanical format pass
(`prettier --write`) plus the config/ignore/scripts, isolated so it never pollutes a
feature diff. Verified behaviour-neutral: typecheck, eslint (0 errors), and vitest
all green before and after. See .git-blame-ignore-revs — `git blame` skips this sha.
ESLint 9 flat config, pinned. Narrow by design: the load-bearing rule is
react-hooks (rules-of-hooks + exhaustive-deps) — 33 disable comments existed for a
rule nothing ran. tsc already owns unused vars / undefined / types, so those are
turned off here to report each finding once. no-explicit-any is off: all 19 live in
the api.ts god-module that R7 rewrites, and it flips the rule back on then.
reportUnusedDisableDirectives is an error, which immediately caught two inert
`eslint-disable` comments (App.tsx, AddToPlaylist.tsx) whose effects have stable
deps — removed. no-unused-expressions allows the side-effect ternary idiom the
codebase already uses (`v.paused ? v.play() : v.pause()`).
Errors: 0. Remaining 38 are warnings (28 react-refresh DX, 10 exhaustive-deps) —
pre-existing, visible in the lint output, tracked to their epics.
Review round 1 fallout, all of it verified against real builds:
- .dockerignore: `node_modules/` is anchored at the context root and has no
implicit `**/` (unlike gitignore), so it never matched frontend/node_modules.
`COPY frontend/ ./` was overlaying the Windows host's node_modules on top of
what `npm ci` had installed — the image carried @esbuild/win32-x64 and 31 .cmd
shims, and the new typecheck ran a tsc resolved from that merged tree. Build
context transfer drops 3.05 GB -> 19 kB. Also excludes e2e/.auth (a live
Playwright session), the report/result dirs and logs.
- build no longer type-checks the e2e project: a type error in a spec must not
make the app unbuildable for a self-hoster. The gate still checks it via
`npm run typecheck` (now three named lanes).
- tsconfig.json references tsconfig.node.json, so editors check vite.config.ts /
vitest.config.ts under the same config the gate uses instead of an inferred one.
- vitest include accepts .test.tsx: an uncollected test file does not fail, it
silently never runs.
`npm run build` now runs `typecheck` first, so the Docker image is built through the
same gate as local dev — until now the image was a bare `vite build`, and esbuild
strips types without checking them.
typecheck covers three projects: src, the Node-side build configs (new
tsconfig.node.json — vite.config.ts/vitest.config.ts were type-checked by nothing),
and e2e. knip moves from `npx knip@latest` to a pinned devDependency so the gate
stops depending on the network and on whichever version shipped that morning.
Closes E4. The playlist detail gains real sortable/filterable column headers
over the drag-reorderable list, and the crucial distinction: the sort and the
filters are a VIEW, while the stored order changes only on drag or an explicit
save. Picking a sort used to write straight through to the server.
Also extracts the column header out of DataTable (shared by both tables now,
which fixed the filter popover remounting on every keystroke), and closes eight
findings from the 2026-07-20 reviews that lived in these files.
Sorting a playlist whose stored order already matches the sort produced a
warning, no Save button and no visible change — which reads as a malfunction,
not as "there is nothing to save". And when the order DID change, three separate
texts piled up in the toolbar (a reset link, a save hint, a "reordering is off"
notice), none of which answered the question actually being asked: did I just
change my playlist?
One line now names which of the three situations you are in — the view matches
the saved order, the view differs and can be saved, or a filter is on and saving
is impossible — and says in each case that the saved order is untouched. The
disabled grip still explains itself on hover and focus, so that notice no longer
needs to stand in the toolbar.
- The dragged row trailed the cursor by 150ms. The row carried Tailwind's bare
`transition` utility (copied from DataTable's <tr>), which animates TRANSFORM
— exactly the property dnd-kit drives from the pointer. transition-colors now,
and the row being dragged drops dnd-kit's own transition too.
- The list looked sorted by Channel when it was really in its stored order that
happens to be channel-grouped, and nothing said so. Sorting by "#" ascending
IS the stored order, so it is now the neutral state: the header shows a real
arrow for it, cycling off any column lands back on it, and it stays the state
where drag is enabled and no save is offered.
- A choice dialog with two or more actions stacks vertically. Side by side they
wrapped raggedly, and the wrapped one read as a lesser second group when the
two are peers. Cancel goes last, actions run least to most destructive.
A truncate cell is white-space:nowrap, so its min-content width is the whole
title — under table-layout:auto the Title column grew until Length and the
remove button hung off the scroller (measured: table 1059px inside an 855px
wrapper, main.scrollWidth 1074 vs clientWidth 887). Fixed layout with a declared
width on every column but Title, which absorbs the rest.
Header (shared, so every table gets these):
- The filter funnel could not close itself: useDismiss only knew the panel, so
the trigger's mousedown closed it and the click reopened it. It now knows the
trigger, captured at open time so it survives the close.
- Escape stranded focus on <body>; it goes back to the funnel, which also gained
aria-expanded/aria-haspopup.
- A non-sortable header rendered a focusable no-op button — two unlabeled dead
stops per playlist table, whose grip and actions headers are empty. Plain span
unless the column sorts.
Playlist:
- The disabled drag grip explained itself to nobody: `disabled` drops a button
from the tab order and suppresses its tooltip. It is aria-disabled now, stays
focusable, and the reason is its accessible name.
- That reason also lied on a built-in list, where the block has nothing to do
with sorting or filtering. Third branch, own string.
- The open player followed a position in a derived list, so a refetch or a view
change could slide a different video under it or unmount it mid-playback. It
follows the video id, like PlayerModal's own queue already does.
- A failed reorder could not resync: the id set is unchanged by a reorder, so
the arriving detail was judged "same list" and the rejected order stayed on
screen as the base for the next drag.
- The same guard is now keyed by playlist. Two lists holding the same videos
looked identical to it, so the second rendered in the first's order and
inherited its undo stack — where Undo would write it back to the wrong list.
- A one-item list filtered to nothing had no Reset view: the whole control strip
was gated on the item count while the funnels rendered regardless.
- Both delete outcomes now read as actions ("Delete here only" / "Delete here
and on YouTube"); "Here only" scanned as the safe choice while also deleting.
- Keyboard dragging shipped with dnd-kit's hardcoded English screen-reader
instructions and announcements. Localized, with live positions.
The column sort and the filters are a VIEW; the playlist's stored order only
changes when the user presses "Save this order", which still goes through the
undoable path. Picking a sort used to write straight to the server, so merely
looking at the list by title rewrote the playlist and marked a YouTube-linked
mirror dirty.
The table:
- playlistColumns.tsx holds the column defs; the body renders them as table rows
and fills the grip cell itself, since the handle needs the row's sortable
listeners that a render closure can't reach.
- The # column shows the STORED position, so a view-sort never renumbers the
playlist under the reader.
- Saving is withheld while a filter is active (it would drop the hidden items),
and drag is disabled under any view, with the reason spelled out rather than
silently doing nothing.
- applyView/sameOrder live in lib/playlistView.ts with tests: the sort and the
channel grouping compose, and getting that wrong produces a plausible order
that the save button would then persist.
- dnd-kit KeyboardSensor: the grip was focusable and called itself "reorder",
but only a pointer could move it.
The guards:
- Delete is ONE dialog with every outcome. It was two chained confirms where the
second one's Cancel/Escape/backdrop meant "delete here only" — dismissing the
dialog deleted the playlist. ConfirmProvider gained useChoice() for it.
- ConfirmProvider focuses Cancel when an offered action is destructive; the
confirm button took autoFocus unconditionally, so a stray Enter on an open
dialog ran "Delete user" or "Clear audit log".
- Removing an item asks first, and rebases the undo history instead of throwing
it away: useUndoable.rebase maps every snapshot, so the reorder history stays
valid for the remaining items.
- A failed detail query showed "Loading…" forever; it now says so and offers a
retry. Rename and reorder failures are surfaced instead of swallowed (reorder
was a bare .then(), which also produced an unhandled rejection).
- Local reorder/remove no longer require YouTube write scope.
- playlistName() replaces the watch-later localizer copied into three files.