The 'usage' query rendered both its loading and its error state as 'No usage' (the same false-empty
R3 targets), while the sibling 'status' query got a proper state. Split the fallback into
error(+retry) / loading / empty.
- notifyYouTubeActionError: on any non-403 error, show the server's own reason (err.detail, e.g.
"Not enough quota left today") instead of the caller's generic fallback, which was discarded
(U-3.2.8). 403 still offers the Connect affordance.
- App boot error now offers a Retry (StateMessage) instead of a dead "Something went wrong" (U-4.2).
- deletePlaylist prefers the server's reason over a blanket "couldn't delete on YouTube" that
misleads when the failure wasn't the YouTube side.
- Already covered in earlier sprints (verified): the live-search "Load more" already surfaces the
quota error inline via err.detail, and ChannelPage subscribe/unsubscribe already route through
notifyYouTubeActionError.
Replaces the plain "Loading…" text in every R3 loading branch with a single shared LoadingState
(a spinning lucide Loader2 in the accent colour + label, centered, role=status/aria-live) — one
component so all loads read the same and can be swapped for skeletons later in one place. Applied
across Feed, Channels, ChannelDiscovery, AuditLog, NotificationsPanel, Messages, Scheduler, Stats,
PlexBrowse (grid + subviews), PlexPlaylistView, ConfigPanel, DownloadCenter, AdminUsers, ChatThread.
Left the small "load more" footer and lazy Suspense fallbacks as-is.
- Blank-flash (showed an empty state on first load before data): DownloadCenter (queue + library
tabs), AdminUsers (roles + invites), ChatThread, PlaylistsRail now show a loading indicator
(and an error+retry) instead of a false-empty flash — guarded on `&& !data` so live data isn't
disturbed on refetch.
- Forever-loading (`isLoading || !data` loops forever on a failed fetch): ConfigPanel, the three
PlexBrowse subviews (playlist/show/season), and Stats' admin dashboard get an `isError && !data`
error+retry path before the loading branch.
- Playlists' detail pane already had an error path (isError+retry); its list lives in PlaylistsRail
(now covered), so no change needed there.
- The honest-state branch now guards on `isError && !data`, so a background/window-focus refetch
failure keeps already-visible content instead of replacing the list with a full error screen
(regression the bare `isError` introduced). Applied to AuditLog, Channels, ChannelDiscovery,
PlexBrowse, PlexPlaylistView, Messages(people), Feed — matching the guard already used in
Messages(conversations)/NotificationsPanel/Scheduler.
- Drop the unused DataTable loading/error/onRetry props (every table page uses the outer
StateMessage branch, not the props) — reverts DataTable to its simple empty block.
- StateMessage announces errors with role=alert/aria-live=assertive (was polite for all tones).
- Remove the now-unused common.empty string (en+hu), left over from the removed QueryState render-prop.
The error state didn't exist as a concept: a failed main query rendered as an "empty" list
("No channels" while the request actually died), so a backend restart read as "everything is
empty". Add the convention that an empty state may NEVER show while a query is erroring.
- New components/QueryState.tsx: shared StateMessage (loading / error+Retry, house style, with
role=status/aria-live — the app had zero live regions).
- DataTable gains loading/error/onRetry props (suppresses empty-text on error).
- isError branch (before the empty branch) added across the false-empty pages: Feed (retry),
Channels, ChannelDiscovery, AuditLog, NotificationsPanel, Messages (conversations + people),
Scheduler, Stats, PlexBrowse grid, PlexPlaylistView. ChannelPage's main content is Feed, now
covered. common.loadError/empty strings (en+hu).
Follow-up to user prod testing of 0.51.0:
- re-download now clears job.display_name so the card title + Content-Disposition filename are
re-derived from the freshly-cleaned title (previously an auto-name from an older un-cleaned
title — still carrying a "… views · … reactions |" prefix — survived the rebuild).
- on-disk AND served names are now a strict shell-safe slug: only [A-Za-z0-9._-] survive; every
other char (the "!" the user saw, plus ( ) [ ] & $ ' " ; | ? * …) collapses to "_". The id no
longer uses [brackets] (glob metacharacters). Shared _slug helper for sanitize + display_filename.
- GC gains a 5th pass: sweep on-disk files/dirs no ready asset owns (old accented channel dirs, a
poster from a superseded naming scheme left after a re-download), skipping the dot system trees
and anything newer than an hour so an in-flight download is never touched.
- Tests: slug strips !/$/()[]; rel_path is bracket-free; orphan-sweep keeps owned/system/fresh files.
- redownload now ALWAYS rebuilds into a fresh private asset instead of ever reusing a shared
cached file: the cache sig is salted with job_id + current asset_id, so the fork is distinct
from both the dedup pool and the job's own current asset. This resolves the co-held silent
no-op (finding #1) — a shared asset is left intact for its other holders while THIS job
re-fetches its own copy. Resolving the fork BEFORE releasing the old hold (and the unique
salted key) also removes the IntegrityError-rollback-after-file-delete window (finding #2).
- format_sig gains an optional `salt` for that fork identity.
- titles: don't consume the space after `|`, so a "stats | #hashtags" remainder keeps the
leading whitespace _TRAILING_HASHTAGS needs — and fall back to the prefix+hashtag-stripped
text, so neither the stats nor the SEO hashtags are resurrected when a title empties (#3).
- worker: early-fill asset.title falls back to the video id so a title that cleaned to empty
doesn't show a blank card while downloading (#5).
- Tests for the salt fork and the hashtag-remainder case.
(Finding #4 — quota on re-download — intentionally not changed: a re-download reuses the
existing job and re-fetches ~the same bytes, so calling check_enqueue would wrongly reject on
the job-count cap while footprint is unchanged.)
- redownload: never clobber a co-held (deduped) asset. Drop the force-reset-to-pending;
_release_asset already deletes+recreates a solo asset (genuine re-fetch), while a shared
ready asset is left intact and the re-queued job re-points to it via _finish_from_cache —
so one user's re-download can't delete another's file mid-serve (finding #1).
- redownload: reset queue_pos to the tail so a re-download doesn't front-run newer queued
jobs on the worker's `ORDER BY queue_pos` claim (finding #3). Made service.next_queue_pos public.
- formats: video-only mp4 selector prefers every video-only option before any progressive
`best`, so a "Video only" download stays audio-free whenever a video-only stream exists (#2).
- titles: a title that is only the engagement prefix no longer resurrects the stripped stats
(return the stripped text / empty, not the raw) (#4).
- worker: probe the file's duration when yt-dlp gave none, so a long re-encode shows real
progress instead of a frozen 0% (#5); tighten the ensure_browser_playable docstring to its
actual mp4-profile scope (#6).
Mini-epic "Downloads correctness & UX" (S1–S3), on top of the iOS-AV1 + 720p fixes.
S1 — naming & titles:
- ASCII-fold on-disk dir/file names AND the served (Content-Disposition) filename: no
spaces (underscore-joined), no accents (á→a, ő→o, ß→ss), pure ASCII — portable and free
of the `?` mojibake accented names show in non-UTF-8 tools. (Forward-only; existing files
clean up via re-download.)
- Strip a leading social engagement prefix ("1.6M views · 66K reactions | …") from titles.
S2 — universal browser playability (generalizes the iOS re-encode):
- ensure_browser_playable guarantees the stored file is H.264 + AAC/MP3 in mp4 — the only
combo every browser decodes. Re-encode just the offending stream(s) (video for AV1/VP9/HEVC,
audio for Opus/etc), or remux when only the container is wrong. Gated on the mp4 compat
profile (an explicit vcodec / non-mp4 custom profile opts out).
S3 — force re-download (preserves share links):
- POST /downloads/{job_id}/redownload keeps the job row so its public DownloadLink survives
and resolves to the freshly-downloaded file; deletes the old file and re-fetches under the
current rules. A failed re-download leaves the job in error (the intended broken-link case).
- Library "Re-download" action (icon + confirm), api method, hu/en strings.
Tests: naming/title-strip (test_naming), browser_transcode_flags matrix; existing
download_filename expectation updated for the underscore policy.
Shared /watch links showed a "broken play button" on iPad (any iOS
browser — all forced onto WebKit) while playing fine on desktop Chrome.
Root cause: the stored mp4's video codec was AV1 (confirmed via ffprobe),
which WebKit can't decode. The existing H.264 guard was only a
format_sort *preference*, so a non-YouTube source (Facebook) that ships
AV1 as its best video-only stream slipped through.
- B1: for the mp4 compat profile, prefer H.264 at the SELECTION level
with a progressive fallback (bestvideo[vcodec^=avc1]+bestaudio /
best[vcodec^=avc1] / anything) so an avc1 progressive is chosen over
an AV1-only video-only stream.
- B2: re-encode the rare AV1/VP9-only result to H.264+AAC (+faststart)
in the worker before storing, gated on the compat profile; corrects
the asset codec metadata to match.
- Bump _SIG_VERSION 2->3 so cached AV1 assets re-derive.
- Default download preset is now 720p (migration 0058 re-seeds builtins
720p-first; ProfileEditor BLANK 1080->720).
- Tests for the selector, compat opt-outs, and the transcode predicate.
/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).