4e3e68d6e624264a6494b6bb8c148ab97b573427
342
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
eb29f22eb7 |
fix(diagnostics): say which read failed, and let Live restart
- "Load older" is not offered off a loading filter's placeholder rows, whose cursor belongs to the previous filter. - A failed older page, stale rows and missing counts each get their own message (HU/EN). - Live shows as off once a failed read stopped the polling; pressing it again reads now. |
||
|
|
8f6f89832f |
fix(diagnostics): honest live console after review
- Live stops polling on a failed read and marks the list as possibly stale (with Retry), instead of re-raising the error dialog every tick. - While live, a refetch reads the newest page only, whatever the cache holds (a late "Load older", a revisited filter set). - Copy is disabled and the list dimmed while a filter change loads. - Dates follow the UI language; the pivot chip uses the shared .chip. - Tests: query-string wire test, Copy, every facet's skip key. |
||
|
|
142593083f |
feat(diagnostics): walk the log lines from the keyboard; name the console "Server log"
Both log viewers put their lines in a NavGrid: j/k (and the arrows) step from line to line, and Enter opens one exactly as a click does (the line's button is its `data-nav-open`). A focused line is named by its code and message. The console's nav entry is "Szervernapló" / "Server log", its page title: "Diagnosztika" was already the Settings tab that holds the user's own log, and two places with one name read as one place. |
||
|
|
b5996a0e56 |
feat(diagnostics): the admin log console
Admin → Diagnostics reads the server's stored log: one list, newest first, where a line opens in place to its full record (time, source and logger, account, request and job ids, version, message, attributes, stack). A request or job id narrows the list to that request or job with every other filter off, shown as a removable chip. Filters: level group, source, account, event, period and a free-text search; all of them live in the URL (`l*` keys, which setPage clears like the Plex and Channels keys), so a filtered view is a link and Back restores it. "Live" re-reads the newest page every few seconds while the tab is visible; paging into older rows turns it off. Copy puts the loaded lines on the clipboard as JSON lines. The facet counts now honour every filter but their own, as the feed's do: a request pivot narrows them, and a picked level does not zero its siblings. A value in use stays offered even when the counts drop it. "Saját napló" and the console draw a line with one shared component, `LogLine`, whose phone layout gives the message its own full-width line (sharing the code's line left it a few characters). The level groups moved to lib/log.ts for both. The console's own reads are logged at DEBUG on success, like the healthcheck: at INFO its live tail filled the log with itself. |
||
|
|
4ab128ccab | Merge branch 'main' into feature/e22-activity-trail | ||
|
|
791ba5f507 |
fix: a calendar date is the same day everywhere; legal pages use the shared language picker
formatDate read a date-only value as UTC midnight and showed it in the viewer's zone, so the legal pages' last update read a day early west of UTC. Date-only values are shown in UTC. The legal pages' hand-rolled language buttons are the landing page's LanguageSwitcher. formatDateTime and formatDate get the two-language test formatNumber already had. |
||
|
|
5883ea509d |
feat: Settings → Diagnostics, a consented log level and the tab's own log
A user can raise their diagnostics level to Részletes (DEBUG) or Teljes (TRACE) for 24 hours from a new Settings tab, after a consent box that says what is recorded, that the server keeps it and for how long (the instance's own retention), and that it lapses. Alap is the base and needs no consent. - users.diagnostics_level/_until (migration 0087), written only by PUT /api/me/diagnostics, which computes the expiry itself; the demo account cannot raise it. /api/me reports the level in force. - The server logs an elevated user's requests at that level and stores every line of them (logs.LevelGate on the stdout handler and the sink; the root is lowered only while an elevation is active). Teljes adds the redacted query string to the request line. - POST /api/diagnostics/events stores the browser's log as source=web rows: account from the session, body capped while streaming, events/attrs capped, re-redacted, events under the consented level dropped, timestamps clamped, 30 batches a minute, 409 once the level lapsed. - lib/diagnostics.ts applies the level to the tab and streams only what happens after consent, in batches, never its own request lines, with a keepalive flush on pagehide; stops on lapse (its own timer), on withdrawal and on 409. - Detail hooks at DEBUG: which control was clicked (test id, icon, app copy in English — never user text, input values or a link's query), visibility/bfcache/resize, filter changes (lib/filterLog.ts; typed search only at TRACE), player events (lib/mediaLog.ts; media errors are warnings at every level). Offline/online at the base level. - "Saját napló" viewer: live, newest first, level groups with counts, search, row details, copy the filtered events as JSON lines, clear. - Privacy policy and Terms exist in Hungarian and English (a contract test holds both languages to the same sections); the policy now describes the server logs and diagnostic logging, self-service deletion and password sign-in. Fixed on the way: - A sync auth dependency ran logs.set_user on a copied context, so the account never reached a handler's own log lines (measured); a per-request mutable scope carries it now. - The Settings tab rail left a phone no room (clipped labels, buttons over text): below `sm` it is a scrolling row above the content. - PUT /api/me/preferences took a bare dict body; it is a Pydantic model now. |
||
|
|
b59fd38a9c |
feat: store warnings and request lines in log_events
The log now has a stored, queryable copy for the diagnostics console: every WARNING and above plus the request line of every request, kept for log_retention_days (default 7, no "forever") and pruned hourly by the new log_gc job. Rows are built on the logging thread from the same redacted fields as the stdout line, queued without blocking and written in batches by one background thread on its own connection. A full queue or a failed write drops and reports instead of retrying. Flood control keeps 30 of any one message per minute plus a count of the rest; anonymous request lines are capped at 60 a minute per client, and the client address is never stored. Deleting an account deletes its stored events. Measured on prod first: a YouTube RSS outage wrote 11k warnings in four hours, one per channel per poll. The poll now writes one warning with per-reason counts and a few examples, and a failure on our side gets one traceback per poll instead of one per channel. |
||
|
|
88a166e4aa |
fix: call the audit log "Auditnapló" in Hungarian
"Naplózás" (logging) named the audit log in the nav, its page and its Configuration tab, right beside the new "Naplózási szint" (log level) under Diagnosztika — two different things under one word. The scheduler hint also pointed at a "Beállítások → Naplózás" path that does not exist; it is Konfiguráció → Auditnapló, as the English already says. |
||
|
|
07766c43e8 |
feat: closed-set admin knobs are pickers, not free text
log_level, search_source, download_layout and the transcode hw/preset were typed by hand, and every consumer carried its own guess at what a typo meant. The value set is now a Literal in config.py: pydantic rejects a bad env value at boot (any letter case accepted), sysconfig refuses a bad override and derives the admin picker's options from the same Literal, and the consumers read the value as-is. The Configuration page renders a choice key as a segmented bar, or a dropdown past six options, with translated option names. Its tabs now follow the registry's order instead of a hand-kept copy that had already dropped the explore group. A single-choice FilterSelect now opens on the option in use: in the browser it could land on the first row instead. |
||
|
|
2b56c5c2eb |
fix: name the error yt-dlp actually raises for a cookie-emptied client set
A review round read yt-dlp's source and found the comment behind the previous commit's safety-net marker did not match it. Measured in the worker with a real account cookie and `player_client=android_vr`: yt-dlp logs `Skipping client "android_vr" since it does not support cookies`, drops it, and the request left with no client fails with "Requested format is not available" — not "No player clients have been requested". That raise sits EARLIER in yt-dlp, before the cookie-capability removal, so the cookie path can never reach it; and an unknown or renamed client is skipped with a warning and replaced by yt-dlp's defaults, so the other scenario the comment named cannot reach it either. So the marker bought nothing: the symptom that does occur was already retriable here before this branch. It is dropped, the comment now states what was measured, and the test asserts the real symptom instead of a message the code cannot produce. The cookie-capability filter itself is vindicated by the same measurement — without it every signed-in download would burn its first attempt on a client yt-dlp discards. Also from the round: an enqueue test for a `source_kind="youtube"` job, the one join nothing else made (a YouTube job stores a bare 11-char ref and the URL the cookie gate keys on is rebuilt by the adapter — both ends were pinned apart, the join was not, and a break there would stop every YouTube cookie silently while signed-out downloads kept working). And copy corrections: the group's only unhedged claim is hedged, "described the video" loses the pipeline vocabulary, and four Hungarian phrasings that read as translated English are rewritten. |
||
|
|
c06a456a9a |
fix: a YouTube stream refused after extraction is the gate, not a network fault
Measured on the isolated test stack the same night, on the very same video ~90 minutes after it was answering with the bot message: yt-dlp got the player response and picked its formats, and the CDN then refused the stream itself — "unable to download video data: HTTP Error 403: Forbidden". Signed out it did that on every attempt; with the owner's cookie attached the video downloaded in full. So the wall moves between the player response and the stream, and the text it wears there was costing the download twice. retriable: the format URLs belong to the client that minted them, so the next client set is a real second chance — but this text classifies as `network` and nothing in the YouTube marker list matched it, so the ladder ended the job on attempt 1 and never tried the fallback. catalog: `youtube.media_forbidden` for the cookie-less case, so the UI stops calling it a network error when nothing was wrong with the network, and names what actually gets past it. Scoped exactly as narrowly as the measurement: another host keeps the generic `network` category, and so does a download that already carried a cookie — telling a signed-in user to sign in is the failure the catalog exists to avoid. EN + HU copy. |
||
|
|
864f673ef5 |
fix: a YouTube video behind the bot wall is downloadable with cookies
A YouTube link can fail with "Sign in to confirm you're not a bot" while every other YouTube download works — YouTube gates INDIVIDUAL videos behind a signed-in session. Measured on prod job 273: all twelve of yt-dlp's player clients fail on that one video while its neighbours download fine, the bgutil PO-token sidecar is live and answering, and the newest yt-dlp fails identically. So the download is not broken by our stack or a stale yt-dlp, and no retry, client switch or upgrade gets past it. A cookie is the only way in. The failure copy already sends the user to Settings -> Account -> Download authentication, but YouTube was not in the cookie host registry, so that dropdown could not help them: the app asked for something it did not offer. credentials: YouTube joins SUPPORTED_HOSTS (youtube.com, youtu.be, youtube-nocookie.com). The Settings dropdown, the SEC-12 host scope and the private-asset cache salt all derive from the row, so that is the whole wiring. adapters: a signed-in YouTube download runs only cookie-capable player clients. Measured against yt-dlp's own INNERTUBE_CLIENTS table: with account cookies attached yt-dlp REMOVES every client whose SUPPORTS_COOKIES is False, and a set it empties that way raises "No player clients have been requested" — which is not a per-client flake it retries past. Our primary set is android_vr, one of those, so without this the cookie would have failed the download on attempt 1 and never reached the POT-backed web fallback it exists for. A test pins the client list against the installed yt-dlp so a version bump turns the gate red instead of silently un-authing every YouTube download, and the empty-set message joins the retriable markers as the second line of defence. errors: catalog entries for the wall with and without a cookie, so a user who has already added cookies is told they were rejected (YouTube rotates exported ones) rather than to add some. The marker is quote-agnostic: YouTube writes the apostrophe as U+2019, so "you're not a bot" with a straight quote never matches the real text. EN + HU copy. |
||
|
|
6c63df6704 |
feat: one structured log for every process, with a request id
A deployment printed four different log formats and none of them could be joined up: uvicorn's log_config.json for the API, a hand-rolled StreamHandler in main.py for anything not started by uvicorn, logging.basicConfig() in the worker and in the download child (no timestamp at all), and alembic's fileConfig. app/logs.py replaces all four with one JSON line per event (OpenTelemetry's log data model, no SDK): ts, level, severity, source, logger, event code, message, attributes, plus the correlation the context carries — request id, account, download job. Redaction is applied at the HANDLER, not at the call sites: every message, every attribute and every traceback is scrubbed on the way out, so a secret cannot leak by being forgotten at one of ~125 call sites. Passwords, tokens, cookies, secret config values and email addresses never reach a line. The access log is ours now (`http.request`): the route template instead of the concrete path, the status, the duration and the account — and never the query string, which uvicorn's access log printed on every single request (`?account=`, `?source=`, the `?token=` of a share link). A well-formed client request id is accepted and echoed in X-Request-Id, so a browser line and a server line join up. The account comes from `request.state`, filled by the auth dependency: the middleware is the outermost one, so it runs outside SessionMiddleware and in a different task context. `log_level` becomes an admin knob (sysconfig), applied without a restart and re-applied at startup — best-effort on purpose, since a logging knob must never be what stops the app from booting. Every compose service gets bounded json-file logs (3×50 MB): Docker keeps every line forever by default, and the API now writes one line per request. Verified on the localdev stack: JSON everywhere including alembic and the worker, the query strings gone, a supplied X-Request-Id echoed and logged, and three /healthz hits of which only the one made while the level was DEBUG produced a line. |
||
|
|
e299c66121 |
feat: show each account's last sign-in in Users
A right-aligned column at the end of every Users & roles row says how long ago the account signed in, with the exact moment as the hint and as a line in the account panel; a dash where nothing is recorded, and the demo account says it is not recorded. formatDateTime formats that moment in the UI language rather than the browser's. |
||
|
|
5346efcd10 |
fix(boot): a new tab knows its account before anything renders
With Settings -> Appearance -> Startup turned off, every NEW tab or window still opened on Home; only a same-tab reload honoured it. The provider tree mounted before /api/me answered, so NavigationProvider's initializer could not read the per-account opt-out, landed on Home and mirrored ?page=home into the address bar. When App pinned the account a moment later, the re-hydrate re-read that mirror and stayed on Home. The same mount-then-patch shape made a new tab start on default filters, the default theme and the browser language, and correct each after the first paint. BootGate (main.tsx, above the providers) now owns the setup-status and /api/me queries and mounts the tree only once the identity is settled: it pins the tab's account and adopts the account's language in a layout effect, so the first frame of the app is already right. The tree is keyed on the account id, which makes "per-account state is read once, for this account" a structural guarantee. With that, the whole re-hydrate machinery is gone: useAccountRehydrate, the reactive active-account store, the markDirty guards, the pin-deferred saved-view seed and the present-dimension bookkeeping of the shared Channels/Plex links. PrefsProvider, FeedFiltersProvider, usePref and App's panel layouts seed from the me cache instead of adopting it in an effect; the theme is applied in layout effects. The app chunk is fetched in parallel with the probe, and the boot screen only fades its label in after 400 ms, on one clock across the gate and the Suspense fallback. Module switches no longer flash a placeholder either. Measured frame by frame in Chromium and WebKit, every first visit to a module showed "Loading..." for 17-70 ms: the lazy chunk's Suspense fallback, then often the first fetch's LoadingState. Pages are now lazyWithPreload components, and App fetches the chunks of every module the account can reach (plus the channel page) once the first page is idle; a preloaded page renders synchronously and never suspends. LoadingState, the one shared loading visual, fades in only after 300 ms, so a fast load shows nothing in between and a slow one still gets its spinner. |
||
|
|
e887233210 |
fix(downloads): E11.5 epic-end review round 1
A refused Facebook cookie on a group post is no longer "no video in this post":
measured on dev, Facebook answers with its login page (no post id at all), so the
adapter now raises its own POST_NOT_SHOWN verdict and the catalog explains it as
the refused cookie. The adapter owns both post verdicts and the "cannot parse
data" wall wording, so the Chrome retry and the catalog read one constant. The
group-post "no formats" entry is cookie-scoped (with a cookie yt-dlp only gets the
post's video link) and uses the adapter's own group-post test instead of a
"/groups/" substring.
Job transitions that move an asset's ref count (resume, cancel, delete,
redownload) re-read the job under a row lock, asset first, so two overlapping
requests can no longer release the same hold. get_or_create_asset loses an insert
race inside a SAVEPOINT instead of rolling back the caller's pending writes (Retry
lost its cleared error). Every worker-side job failure goes through
finalize_if_running and writes error_detail, so a cancel that landed first is kept
and a failure never shows an earlier one's copy. The worker asks the download
child's own cookie gate (credentials.usable_cookie) when a job inherits a failed
asset.
UI: the Facebook copy no longer points at a Retry button the failure window does
not have, says "paste" like Settings, and the HU wording is fixed
("Újraletöltés" on the re-download confirm). A category this client has no copy
for falls back to the generic sentence instead of a raw key.
Tests: route serialization of error_detail, the probed duration through a real
_download, the page-read cap and a failed read, a Retry off a pending asset, the
confirm-button labels, the lock and savepoint races.
|
||
|
|
b17d92d7c2 |
fix(downloads): confirm dialogs name the action on their confirm button
The download center's confirm button repeated the dialog's question
("Re-download this file?"). It now reads the action: Stop download,
Re-download, Remove, Remove from my list.
|
||
|
|
90ee34a1b4 |
feat(downloads): a Facebook group post downloads its own video, or says it has none
Given a group post's link, yt-dlp either found no formats or took another video from the page's feed data, so a "successful" retry of such a post was a wrong download. With the user's cookie the Facebook adapter now reads the post page, takes the single video node in the script blocks that carry the post id, and downloads that video's watch link (the Chrome retry does the rest). No single video in the post fails with its own message and the group-post copy, reworded to say the post has no video; the post link is never handed to yt-dlp as a fallback. Without a cookie nothing changes. |
||
|
|
18c11ca437 |
feat(downloads): a per-site failure catalog — Facebook's walls say why and what to do
A failed Facebook download read "Download failed": yt-dlp words the login wall (18+ reel, closed group) as "Cannot parse data", which no category matched. Measured on dev with the owner's cookie before writing a rule — the same text is the login wall without a cookie and a refused cookie with one. - errors.diagnose(msg, url, cookie_attached) tries a host-scoped catalog first (all-of markers, cookie state, link fragment) and falls back to the category. First entries: facebook.login_wall, facebook.cookie_rejected, facebook.group_post_video. The module doc is the recipe for the next entry. - download_jobs.error_detail (0084) + backfill of the stored Facebook parse failures that were unknown. - The pipeline's failure branch passes the link and whether the cookie went with it; resume and redownload clear code and detail (redownload never cleared error_code). - A job meeting an already-failed asset got the text but no code, so it read "Download failed" where the first job said why. It is diagnosed again, with the cookie state from credentials.is_authed_asset (now shared with the cookie gate); the redacted network text classifies back to network. - FE: failCopyKeys gives the row and the failure window one lookup — catalog copy, else the category. Copy in EN and HU; a contract test pins every backend entry to both locales. |
||
|
|
c6b552e75f |
fix(keys): epic-end review round 3 — the part-way clip count shows, busy buttons keep focus, cards say their names
Targeted round over 3cd0c525's fix surfaces: 14 raised, 14 fixed. - VideoEditor: the "only X of Y clips" toast went silent exactly on the job-cap/quota refusal it was written for (req had already reported the error); it now always shows. The cut list holds still while Create queues, and a retry continues the "(i/N)" series. - ChannelPage: Subscribe/Unsubscribe use Button `loading`, "Load more" and ChannelDiscovery's Subscribe block the click with aria-disabled instead of native disabled; busy Block/Hide no longer light up on hover. - listNav: an item can name itself (`navItemAttrs(i, name)`) — Plex posters, seasons, episodes, message rows and download rows pass their title; focus on a toast's Undo keeps the removed card's slot. - AnchoredPopover: the hand-back only for focus-managed surfaces (a hover card no longer pulls focus back); the dead panel-ref check is gone. - AccountPanel: no confirmation mail offered to the demo account; send_failed copy no longer blames the message for an unreachable server. Pager comment points at scrollParent. |
||
|
|
3cd0c525e2 |
fix(keys): epic-end review round 2 — focus comes back to the card, the right rail and the reason
Whole-epic deep-review of E11 (18 raised, 12 kept; B and G refuted, four latent test-hardening notes dropped), plus the admin refusal copy gap round 1 surfaced. - A context menu opened from a card the keyboard is on gives the focus back to the card. floating-ui returns focus through the opener's first tabbable descendant even when handed a ref, so for a non-tabbable opener the surface restores it itself (measured in Chrome: j, Shift+F10, Escape left it on the thumbnail link). - Back finds a named list (a rail) by its name: a rail that appeared above it after the cache expired sent the focus into the wrong rail. An unnamed list is matched only when the page has the same number of lists. - The slot kept for a removed item is forgotten once focus moves on to page content (search, a chip), so j after new results starts at the first visible item; a menu or dialog opened from the item keeps it. - The list keys reach the cheat sheet only while a list has an item. - A j-focused card root is a named group while it holds the focus. - The channel page's Block and Hide toggles keep the focus while their request runs. - A split export that fails part-way says how many clips were added and deselects them, so a retry adds only the rest. - Config save, the one-click share link and "log out other sessions" report failures with their reason. - VirtualGrid uses the one scroll-parent walk listNav measures against. - Admin refusals (activate, resend, invite, set-password link) say why in HU and EN instead of "try again"; the email-off special cases collapse into that one reason. The contract reads the codes from localizeDetail. HU copy points to Konfiguráció → E-mail / SMTP; HU "unreachable" matches the EN change of round 1. |
||
|
|
f130a1c05a |
fix(keys): epic-end review round 1 — Back, menus and dialogs keep the focus where it belongs
Deep-review of E11 (19 raised, 14 confirmed; 2 refuted, 3 latent test-hardening notes dropped). - Back from a sub-view (a Plex show page, a Messages thread) restores the item it was opened from even when focus was still inside the view: the first attempt now waits one task, past the sub-view's own popstate listener (measured in Chrome: it left focus on <body>). - The restore listener is installed at load, so the first Back after a reload finds it; a push cancels a restore still waiting for the last landing's lists. - Navigating over an overlay asks it again, a render apart, while its entry stays: the Plex player peels one panel per Back step, so Shift+W over info + stats closed one panel and dropped the navigation. - j/k: a native <button> item keeps its own Tab stop; landing twice on the same item no longer leaks its tabindex; after the focused item is removed (a hidden card) the next j goes on from its slot when that slot is on screen. - An abandoned `g` no longer swallows a modifier chord (g, then Mod+K opened the browser search). - A hover card above a dialog no longer switches its Tab trap off (player, Modal, wizard, Lightbox): new layerStack `anyBlockingLayerAbove`. - "Manage views…" closes its menu before opening the dialog, through a shared `closeThenAct` that ActionMenu now uses too. - Copy: the EN welcome says "Feed"; the EN unreachable reason no longer claims a change; stale comments about PlayerModal's scroll lock and dialog contract. |
||
|
|
bbd31c7143 |
feat(keys): Back returns focus to the item a view was opened from; "Feed" everywhere; every backend id named
Owner answers to the E11 S5 raises (2026-09-13): 1 → A, 2 → "Feed" in every language, 3 → translate the
proxy label and fix every config key missing in BOTH languages ("maintenance", "tmdb" — old bugs).
Focus across a view change (1-A). Opening a Plex title from Home's rail, a channel from the Channels
table or a title from a Plex grid switches the page or the sub-view, which unmounts the list; Back used
to hand focus to <body>, so the next `j` started over at the top. The YouTube player (an overlay)
already restored onto the card.
- `lib/listNav`: `navPosition()` (list's place in reading order, the page's list count, item index)
and `restoreNavPosition()`: focuses the item once the page has as many lists on screen as when it was
left, polls on a timer (a hidden tab runs no animation frames), gives up after 2 s or at once when
something already holds focus, focuses without scrolling (the scroll restore owns the position).
- `lib/history`: `rememberNavPosition()` stamps `sfNav` onto the entry being left and returns the state
without it (a new entry must not inherit it); one popstate listener restores and spends the stamp,
so a later landing on the same entry (an overlay closing over it) cannot pull focus to an old item.
- Called before every view push: `setPage`, `openChannel`, `enterYtSearch`, `useHistorySubview.open`,
the Channels tab push. `navPosition.wiring.contract.test` fails on any `history.pushState(` without
it (the overlay stack's own push is the one exemption).
"Feed" (2). The module is "Feed" in both languages: every "hírfolyam" in the Hungarian UI (≈45 strings,
suffixed: Feedből, Feeded, Feedben…) and the prose "feed" in English that means the module ("your
Feed", "Hide from Feed"); "RSS feed" keeps its meaning. The welcome email says it too.
Backend ids without a name (3). The Configuration page showed `maintenance`, `tmdb`,
`maintenance_revalidate_batch`, `download_upload_max_bytes`, `audit_retention_days`, `plex_overlay_map`,
`tmdb_api_key` and `tmdb_language` as raw identifiers — the lookup falls back to the id, and the i18n
parity test stayed green because both languages missed the same keys. Added labels + hints (EN/HU),
the two groups (and their tab order), the missing `scheduler.jobDesc.demo_reset`, removed the stale
`plex_sync_interval_min` field, and "YouTube API kimenő proxy". `i18n/backendIds.contract.test` reads
`sysconfig.SPECS`, `scheduler.JOB_INTERVALS` and the audit/quota action enums from the backend sources
and fails on an id without its name in either language, or a name left for an id the backend dropped.
|
||
|
|
8731508645 |
feat(keys): the conversation list walks with j/k; the cheat sheet names keys in the reader's language
E11 S5 — the HU/EN sweep and the keyboard-only pass over every module.
- The keyboard cheat sheet printed "Space" and "Menu" on a Hungarian sheet: a keycap that carries a
WORD (not a legend every layout prints alike, like Esc, Ctrl or Enter) is now named by the caller in
the reader's language (`formatChord(chord, words)`, `keys.cap.*`: Szóköz / Menü).
- The Plex player's ⌫/Esc line said "Stop & back to feed", but the player goes back to wherever it
was opened from (Home, a Plex page): "Stop & go back" / "Leállítás és vissza".
- Messages: the conversation list is a keyboard list (NavGrid + navItemAttrs, the name button is the
open element; the pop-out button keeps its own Enter). D1 covers every list whose rows open one
thing; this one was missed in S4. Notification cards have no single open action and stay out, like
the download queue.
Sweep, measured: every static `t("…")` key used in the source exists in both languages (the only
misses are template prefixes); the HU values equal to EN are proper nouns and loanwords (Admin, Plex,
Trakt, Shorts). Palette in Hungarian: "téma" makes "Téma: Midnight" the active row ahead of the channel
"Topik - mindig témában", live region "7 találat".
Keyboard-only pass in Chrome, Hungarian UI — 1024×768: every `g` chord lands on its module, `j` rings
the first item (solid 2px accent) on Home, Feed, Channels, Playlists, Plex and Downloads, `s` focuses
the search on Feed, Channels, Playlists and Plex, no page scrolls sideways. 3440×1440: `j` → → ↓
walks Home, Feed (8 columns: 0 → 1 → 9), Channels, Playlists, Plex and Downloads, no sideways scroll.
The help sheet lists every line translated; inside the Plex player "Szóköz / K" and "⌫ / Esc
Leállítás és vissza". Messages: a real `j` rings the row, Enter opens the thread.
|
||
|
|
8fb276aa90 |
feat(keys): j/k walk the feed, s finds the search box, g jumps to a module
A feed card cost ~10 Tab stops, so the tenth card sat ~135 Tabs from the top of the page, and the only keyboard focus ring (a 60% accent outline on the thumbnail) all but vanished on the dark glass. First slice of the E11 keyboard layer (S4a); Home, Plex and the tables follow in S4b/S4c. - `lib/listNav`: one window listener over every registered list, walked in document order. `j`/`k` step item to item (on into the next list — owner decision D4), the arrows move around a grid, Enter clicks the item's own `data-nav-open` element, so it opens exactly like a click. An item root is never a Tab stop: it gets `tabindex=-1` only while the keyboard is on it, and keeps it until the next item takes it, so the player's focus restore lands back on the card. The first `j` takes the first card you can see, not the top of the list. - `VirtualGrid` takes part with `keyboardNav` (column count + scrolling an unmounted row in); `VirtualFeed` turns it on, so the feed, the library and the channel page all have it. - Keymap: two-step sequences (`g h`) whose pending step belongs to the scope that started it; the page-level scopes (`page`, `search`, `list`) are quiet while a layer that owns the keyboard is open, enforced in `dispatch`. A test pins the owner's rule that none of them needs a modifier on the Hungarian layout. - `g h/f/c/p/x/n/m/d/s/,` jump to a module this account can reach; `s` focuses the header search box and selects the old query; Escape in it hands the keyboard back to the page (Plex's typeahead spends its first Escape on its panel). - Hover popovers are passive layers: an open tooltip still takes Escape first but no longer silences the page keys. - Focus ring (owner decision D2 = B): solid 2px accent around the whole item; the app-wide button/ link/row ring goes from 60% to solid too. The item keeps a transparent resting outline because the video card transitions `all` — without it every `j` flashed a 3px white ring (measured in Chrome). - Help sheet: the new keys, in a fixed order after the surface's own; a `g` chord only for a reachable module; opened over a player or dialog it leaves out the page-level keys and the context-menu key, which nothing can reach there (the latter pre-existing since E10). - CLAUDE.md: a canonical-patterns row for list/grid keys (`useListNav` + `navItemAttrs` + `data-nav-open`, not a private arrow-key handler). |
||
|
|
78a9324381 |
fix(ui): the palette ranks what you typed, and lib stops leaning on components
Typing "theme" put the channel "The Meltzone Podcast" (matched only through its handle `@themeltzone`) above every "Theme: …" row, and Enter opened the channel — the palette listed matches in pure group order and always highlighted the first one. - CommandPalette ranks each word: label starts with it > elsewhere in the label > only the hint, keywords or group name; an item ranks by its worst word, then the sum. The group order stays fixed, rows are ranked inside each group, and the best row overall starts highlighted. The highlight is held by id and follows the best match until the arrows or the pointer pick a row, so a channel list landing mid-typing no longer shifts it. - A11y: result groups are `role="group"` named by their heading; `aria-expanded` is false with nothing to choose; the listbox stays mounted (hidden) so `aria-controls` never names a missing element, and "Nothing matches" sits outside it; a polite status announces the count once the typing pauses. - `Column`/`FilterMap`/`filterRows`/`isActive` move from `ui/tableHeader` to `lib/tableColumns`, and the player's auto/loop modes from `playerModalControls` to `lib/playerModes` — three lib helpers imported the column contract from a ui component, and `playerModalHooks` its types from a feature component. - eslint: `lib/` may not import from `components/` at any depth, type-only and re-exports included; the boundary contract test proves it fires. CLAUDE.md and the ui README say so. - keymap: `dispatch`'s docblock sat stacked on `matchesShortcut`'s; moved onto `dispatch`. |
||
|
|
8a50538275 |
fix(ui): a write that fails says what failed and why, wherever it fails
`req` opens its blocking dialog for 400/409/422/5xx only. A 403/404/429 was left to the caller and a 502-504 or lost connection got just the self-clearing connection notice, so 34 mutations without a reporting onError and 15 direct api writes with a bare or missing catch failed in silence: a hidden channel that snapped back, a share link that never revoked, a confirmed account delete that ended with nothing said. - lib/queryClient: one MutationCache reporter. react-query awaits it BEFORE the mutation's own onError, so it waits a task and skips a failure the site already reported; the toast title comes from `meta.failTitle`, `meta.errorShownInline` opts out. - notifyActionError: title names WHAT failed, the line under it says WHY (`failureReason`: a structured localized detail, else the status). It marks the error reported (`markReported`), so nothing reports one failure twice; `notifyKnownFailure` for a site's own specific sentence. - Every silent site named (45 new HU/EN titles, 4 reasons). Stale "the global dialog shows it" comments corrected on the job actions, channel block, Plex sync, cookies, download enqueue, account delete and usePref. - Channels passed the tag hook's channel id to the reporter as the error; the tag and priority hooks now report themselves, with the real error. - A contract test keeps every useMutation naming its failure; CLAUDE.md gains the pattern row. - history.dom.test: the "navigates only once the entries are back" assertion raced an async act's macrotask flush (1 in 5 runs); it now asserts from a later popstate listener. |
||
|
|
a0114deff1 |
fix(api,ui): a refused write says why, wherever the refusal happens
Round 5 taught `notifyActionError` to discard a plain-string `detail` in favour
of the caller's translated fallback, because `req` leaves 403/404/429 to the
caller and those routes hand-write English. Three places did not get the
message.
**The demo account lost its explanation.** `require_human` refused with a plain
string, so from that commit on the shared demo account got its caller's generic
"couldn't do that" and no hint that the demo account was the reason — and the
gate is not backed by a UI gate everywhere: the comfort shelf has no `is_demo`
check, so the pin button is live and simply fails. A structured `{code: "demo"}`
and an `errors.demo` string in both locales. The two auth routes that hand-roll
the same check were swept with it.
**`ChatThread` carried a second copy of the contract.** It filtered 404/429
itself and then did `e.detail || t(…)`, the pre-fix order, so the central fix
could not reach it and "That member hasn't set up messaging yet." stayed English
under a Hungarian UI. It goes through the shared helper now, and a contract test
pins the remaining raw-`detail` consumers as an explicit SET — never a count —
so a new copy fails the gate instead of shipping. The five on that list are
inline surfaces (a form field's error line, a setup banner) and are deferred
with the ~60 backend routes whose details they print.
**The Plex "Add to playlist" dialog was still silent.** Same shape as the
collection editor round 5 fixed and the YouTube twin that already had the fix:
no catch, so a playlist deleted on another device answered a structured 404 that
nothing reported — the tick never flipped, the gone row stayed, and the floating
promise became an unhandled rejection.
Also: the `ABORTED` sentinel's VALUE was asserted nowhere. The consumer test
hand-built the error it expected, so replacing `xhr.onabort`'s rejection with a
friendlier message left the lane green — and every upload the user cancels would
then raise a red "the upload failed" toast, which is what the sentinel prevents.
Both producer tests assert through `isAborted` now.
And two backend tests were pinning fewer sites than their own docstrings claimed:
the channel-404 loop said "all five" and issued three requests (explore and
subscribe unexercised, the excuse for subscribe naming a different call site
with a different reason slug), and the recipient/job test named three refusals
and requested two — `share_download`'s `not_found("recipient")` had no test in
the suite at all, nor did the locale keys added beside it. Every site named now
has a request behind it.
6 tests, mutation-checked against 7 mutations: the demo detail at both the gate
and an auth route, a re-hand-rolled ChatThread, the silent Plex dialog, the
abort sentinel, and each unpinned 404 — all red on the test that names them.
|
||
|
|
4a7b65d187 |
fix(api): a refused write is named, in the reader's language
Deep-review round 5, over round 4's own fixes.
- `notifyActionError` preferred the server's `detail` over the caller's translated fallback, and
`reportFailure` deliberately leaves 403/404/429 to the caller — so for exactly those statuses the
dozen call sites round 4 converted started printing whatever English a route hand-wrote into an
otherwise Hungarian UI ("No user with that email.", "Not subscribed to this channel", "Unknown
job"). A STRUCTURED detail is still preferred: `req` has already run it through `localizeDetail`,
so it is both localized and more specific. A plain string is not.
- The other half of the same defect, on the backend: round 4's `get_or_404(what="channel")` sweep
reached exactly ONE of the five identical sites in channels.py and shipped the HU/EN string with
it, so the Block button said it in Hungarian and the four controls beside it in English. All five
are converted, and the subscription / share-recipient / scheduler-job 404s with them (two new
reasons in both locales).
- `PlexCollectionEditor`'s add/remove, take-over and delete were try/finally with no catch — in the
very file round 4 edited to give `createNew` one. 403 and 404 are the caller's to report, so the
tick simply never appeared and a CONFIRMED "delete collection" did nothing, silently.
- `UploadDialog` is the only `uploadReq` caller and kept an empty catch whose comment still claimed
the global error modal had it. It had not since `reportFailure` became shared: a proxy 502 raised
only the self-clearing connection notice and a 413/403/404 nothing at all, so the dialog snapped
back to its pre-upload state with no error named. The user's own Cancel stays silent — and since
a cancel and a network drop both carry status 0, that difference is now a named `isAborted`
rather than a string literal at each end.
Regression tests for each (5 vitest, 2 pytest, plus two existing assertions corrected), all
mutation-checked: 6 mutations, each red on the assertion it is named after. vitest 1007,
pytest 1127.
|
||
|
|
267fd7f70f |
fix(playlists): a refused edit is taken back on screen, and a gone list or tag is said, not swallowed
Deep-review round 4, the playlist surfaces. - A failed move or reorder relied on the resync to put the rows back — but the server's list is unchanged, so the refetch comes back deep-equal, react-query keeps the same `data` object, and the re-seed effect keyed on it never ran: the moved row stayed gone, the rejected order stayed on screen. Both restore from the cached detail now. - A failed removal puts its row back itself: when the server is unreachable the resync fails too, and round 3's "keep the list on a failed refetch" guard then kept showing it as removed. - The Plex playlist page (remove / rename / delete / reorder), the add-to-playlist popover and the tag manager had no failure handling at all: a list or tag deleted elsewhere (a 404, now a structured `not_found` the request layer leaves to the caller) failed in silence and an optimistic removal stayed removed. Each says it and resyncs; deleting a Plex playlist that is already gone just leaves the page. - Playlists' own loading/error branches use the shared `LoadingState` / `StateMessage` (the loading line had no live region). - The "player stays mounted" tests count mounts: a remount ended with the same flag as staying up. |
||
|
|
6d49da17fe |
fix(api): a write that hit a connection failure is still reported, and every failure is reported once
Deep-review round 4, the "one report per failure" contract round 3 introduced. - `req` marked gateway (502/503/504) and network failures "already reported" because they raise the connection notice. But that notice is about connectivity and removes itself on the very next answer — which every caller's own resync refetch fetches at once — so a delete that hit a keepalive reset flashed the notice and was then reported nowhere, the list still there. Only the durable reports (the error dialog, the 401 sign-out) count now; the caller says the action failed. - A connection notice the user closed swallowed every later one for the rest of the visit; it is raised again. - `req` and `uploadReq` share one failure policy (`reportFailure`). The upload copy had drifted: a 502 opened the blocking "Server error (502)" dialog and cleared a notice that was still true. - A taken Plex collection name is a structured 409, localized, and the create/rename calls are quiet: it was an English dialog plus a Hungarian toast. `notifyCollectionEditError` is gone. - The mutation onErrors that toasted unconditionally after a non-quiet request (invite approve / deny / add, demo whitelist, audit clear, scheduler, share, full history, edit, config tests, tag attach) go through `notifyActionError`; the playlist delete's hand-rolled copy of it too (it takes a level now). The user role / suspend / delete actions had no onError at all: a user deleted in another tab failed silently and stayed listed — now said, and the list refetched. |
||
|
|
31dc4184f5 |
fix(api): a paused viewer keeps its HLS session, a refused subtitle publish backs off, and a NUL is a 422 everywhere
Deep-review round 4, backend half. - The periodic HLS reaper (round 3) applied the "finished and unfetched for 30 s" rule that only a NEW session request may apply. A remux finishes long before the film, and a paused player with a full buffer fetches nothing, so a one-minute pause killed the session and playback died ~30 s after resuming. The periodic pass is `reap_abandoned` now (the long idle limit, finished or not). - A subtitle `publish` that could not keep its output (full / read-only cache volume) returned quietly, so every poll queued the window and the whole-container pass again. It reports failure and the pass is backed off like a failed read; a cached VTT that cannot be READ is answered `unavailable` (`cache_unreadable`) instead of scheduling reads that can never land. - The subtitle routes bound `rating_key` / `sub_ord` (they become a cache file name: a 250-digit one was ENAMETOOLONG, which pathlib's exists() re-raises -> a 500), and the cache helpers no longer throw on such a name. - One app-wide handler turns psycopg's "text fields cannot contain NUL" DataError into a 422: round 3 guarded only the by-rating_key lookup, and `contains=`, `contains_group=`, reorder/bulk key lists and the collection search still 500'd. - The hand-written `db.get(...) is None -> 404 "Unknown ..."` siblings use `get_or_404` with a `what` (channel / video / user), so the Hungarian UI no longer shows English for a gone row. |
||
|
|
1cfe6a7f59 |
fix(api): a gone row and a refused playlist action are said in the reader's language, and a failure is reported once
Deep-review round 3 of bug hunt 2026-09.
- get_or_404 / owned_or_404 answer a structured {code: "not_found", reason: <what>}
(errors.notFound.* in HU+EN, generic fallback). The structured-404 fix of round 2 had gone into the
ad-hoc `_own_playlist` (CLAUDE.md's "Not" pattern) while the shared helpers stayed string-only, so
views, tags, pins, Plex playlists, invites and users still 404'd in English. `_own_playlist` now
goes through owned_or_404; admin's hand-rolled user lookups use get_or_404.
- A move into a list that is gone names the TARGET (reason "move_target") — "this playlist doesn't
exist any more" was read as the list on screen, which still exists.
- Every refusal in the playlists router is a {code: "playlist", reason} (playlists.errors.* HU+EN)
instead of a bare English string shown verbatim in the Hungarian UI.
- `req` / `uploadReq` mark the errors they already put on screen (the error dialog, the connection
notice, the 401 sign-out), and notifyActionError skips those. The watched toggles, the saved views
and a playlist delete toasted a fault the dialog was already showing; Playlists' two hand-written
status filters now go through the same check.
|
||
|
|
dcd8a9566a |
fix(playlists): the player's Remove follows the list it was launched from, the player survives a change of selection, and a gone list is said in the reader's language
- D3 snapshotted the removal's TARGET, but whether the player offered Remove still followed the
selected list: launched from Watch later (no Remove, by design), selecting another list under it
made Remove appear — and delete from Watch later. The snapshot now carries the launch list's
editability too.
- Selecting another list under an open player (the command palette works over it) put the page into
"Loading…", and the player was rendered only in the loaded branch — it vanished mid-playback and
came back at its launch video. It now sits at one place in the tree whatever the page is doing.
- A removal takes the video out of the CACHED list as well as the rows: the player's progress
checkpoint rewrote that entry before the refetch landed, handing the membership guard a list that
still held the video — the row came back and the undo history went.
- The watched-state callback refreshed the selected list, not the launch list (D3's sibling).
- `_own_playlist`'s 404 is structured (`{"code": "playlist", "reason": "not_found"}`) and localised:
the bare "Unknown playlist" reached the Hungarian UI in English, which also made round 1's new
`removeFailed` string unreachable. Both Remove and Move report through `notifyActionError` now.
- The "viewer moved on under the confirm" test stepped back only once, which lands on the same video
whether or not the removed one left the queue; it now takes the second step that tells them apart.
|
||
|
|
060deb89d2 |
fix(playlists): "remove" inside the player targets the list it came from, reports failure, and keeps the cursor and focus honest
Deep-review R5 — five defects in the Remove button
|
||
|
|
f76a2afbf1 |
feat(ui): the four small gaps — player delete, repeat-move, a real channel link, YT search sorting
Items 2, 4, 5 and 7 of the bug hunt, one round. They are independent, but they are all the same kind of thing: an action that exists somewhere else in the app and is missing where you actually want it. **5 — the channel name in the YT player is a link again.** It was a `<button>`, so middle-click and Ctrl/Cmd-click did nothing on a thing that looks exactly like a link (the YouTube icon beside it IS an anchor and opens a tab). It is now a real `<a>` whose href is the same `?chan=<id>` URL `openChannel` pushes, with a plain left click still taking the in-app path — that path does more than navigate, since the player must close and reclaim its own Back-to-close history entry first. The "is this click ours or the browser's" test is now ONE predicate (`isPlainLeftClick`), which VideoCard's card link also uses, so a second such link cannot forget the middle-click arm. **2 — "Remove" from inside the player.** The parent owns the removal (the SAME `removeItem` the row menu and the inline ✕ use: one confirm, one optimistic update, one undo-stack rebase), and the player owns what happens next, because it is the only side that knows which item is playing — its launch queue is frozen at mount. It drops the item, plays what followed, and closes when the list runs out. That forced a real fix in Playlists: the player's mount was derived from `shown` every render, so anything that dropped the launch row yanked the player off the screen mid-playback. That was a latent edge (change the view and it happens) and the normal case for this feature. The parent now snapshots what it launched with, and only onClose takes the player down. **4 — "Move to <the last list you moved to>".** The submenu is two levels deep and moving a run of videos into the same list is the shape this gets used in. The remembered id is a per-account pref; the row is derived from `moveTargets`, which already excludes the list you are looking at, so it cannot offer a move to where the video already is. Remembered only on a SUCCESSFUL move — a shortcut to a move that failed is a shortcut to failing again. Absent, not disabled, when there is nothing to repeat: there is nothing to explain. **7 — the live-YouTube results can be sorted.** Client-side, over the page already loaded. Not a new `order=` param, and this is the decision worth recording: the owner's case is "even one page of results", i.e. reorder what is on screen; a re-query would REPLACE the results, which is not what "sort these" means; and the zero-quota scrape source has no order parameter at all, so a server-side control would silently do nothing (or quietly cost 100 quota units) depending on which source served the page. Only keys a search hit carries are offered — `subscribers`/`priority` describe a subscription, which a search hit is not, and `shuffle` on a ranked list is noise. Its own pref, so sorting a search page never re-sorts the library behind it. Verified in the browser on localdev against the real account: the channel name renders as `<a href="…&chan=…">` and a ctrl-click / middle-click is left to the browser while a plain click still opens the channel in-app; removing from the player stepped 1/3 → 1/2 onto the next video and closed on the last one; the row menu grew "Move to “…”" only after a move and only on a list that is not the target; and the results sort reordered by views 41.7M → 29.8M → 24.2M → 15.8M. (The pane's own ctrl-click arrives without the modifier — a harness limit, so the modifier cases were driven as real MouseEvents.) Scratch playlists and the touched prefs were cleaned up afterwards. 22 vitest, mutation-checked against 14 mutations (no href at all · href without the rest of the query · every click hijacked in-app · the middle-click arm dropped from the predicate · plain click no longer in-app · always jumping to the top of the list · the removed item left in the queue · no clamp when the last item goes · an empty queue not reported as "nothing left" · sort-then-reverse flipping ties · unknown values first · titles compared numerically · shuffle offered on search results · direction ignored) — each red on exactly the test that names it. Item 4 has no test of its own: its rule is enforced by construction, not by a check. |
||
|
|
5a42d76e8a |
feat(plex): an off-by-default loudness normalisation toggle for playback
Bug hunt 2026-09, item 14 — "how does the multichannel downmix work, and is it
normalised?" It isn't: `plex/stream.py` has always encoded `-c:a aac -ac 2 -b:a
192k`, i.e. fixed stereo with ffmpeg's default downmix matrix and no loudness
handling at all, which is why a film mastered for a cinema arrives with whispered
dialogue and deafening action.
New admin knob `plex_audio_loudnorm` (Configuration → Plex), DEFAULT OFF: it makes
a film markedly louder and narrows its dynamic range, which is a taste call rather
than a fix. It sits at the end of the Plex group, after the software-transcode
knobs — it is an audio concern, not one of the libx264 ones.
-af aformat=channel_layouts=stereo,loudnorm=I=-16:TP=-1.5:LRA=11,aresample=48000
Three things about that chain are load-bearing and each has a test:
* the downmix is FORCED FIRST rather than left to `-ac 2` — measuring 5.1 and
folding it down afterwards would normalise something other than what is heard;
* the trailing resample is not decoration: `loudnorm` runs its own resampler at
192 kHz internally, which the AAC encoder will not take;
* one `-af`, no stream index, so EVERY audio rendition of a multi-audio file is
normalised — not just the first.
The knob is also part of the session directory name (`…_multi-ln_+0.00`), because
it changes the audio the segments contain, and a session left over from before the
admin flipped it must not be mistaken for one that honours it. The suffix is
appended with a DASH: `_SESSION_DIR_RE` treats the tag as one underscore-free
field, and that regex is the only thing keeping the orphan sweep off what may be a
shared scratch path. A test pins both halves — the name changes, and the sweep
still recognises it.
Measured end-to-end on the app's own HLS segments (a real 5.1 AC3 file, same item,
same 160 s offset, same ten segments): OFF -35.2 LUFS, ON -17.2 LUFS. On a 60 s
bench: I -27.6 → -16.6 LUFS with LRA 13.3 → 9.8 LU. A/V sync is untouched — under
this pipeline's own `-copyts` the audio start PTS is identical with and without the
filter, so the 3 s lookahead desyncs nothing.
Direct-played files are served untouched and stay so; a 5.1 mix is always `remux`,
so the reported case is fully covered.
|
||
|
|
ed4ab4e13c |
fix(plex): subtitles stop blocking the request, and stop re-reading the file
Bug hunt 2026-09, items 8 and 12 — the two things the Plex player was measurably
lying about.
Item 8: picking a subtitle track showed nothing for a long time, and the reported
workaround was to stop/start until it suddenly worked. Measured on prod against a
17.06 GB MKV with two embedded SubRip tracks:
full pass, one track 154 s
full pass, both tracks in ONE run 111 s
-c:s copy (no WebVTT conversion) 143 s
from 1:00:00 to the end of file 66 s
a 630 s window at 1:00:00, both 8 s
So the conversion was never the cost: ffmpeg streams every byte of the container
between its seek point and its stop point, and the array delivers ~140 MB/s. The
only lever is reading less. Three changes follow from that:
* a window around the playhead is extracted and published FIRST (seconds), and
the full pass completes behind it;
* every embedded text track rides that same single read — picking Hungarian used
to cost a full pass and then switching to English cost another one;
* none of it happens inside a request any more. The endpoint answers 202 while a
background worker extracts, the player polls /status (carrying its position, so
the window follows the viewer), and one ffmpeg run finally has a timeout.
-copyts is deliberately absent from a windowed pass: with it ffmpeg ignores -t and
reads the whole container anyway (measured — a [0, 600 s] window still took the
full 145 s), so a window comes back on a zero-based clock and is shifted to
absolute time here. Cached tracks written by older builds have no coverage sidecar
and count as complete, so nothing is re-extracted after the upgrade.
The <track> is no longer mounted before the server has something to put in it; a
"Preparing subtitles: …" notice stands in, and a track that can never be shown says
why instead of leaving the viewer to conclude the picker does nothing.
Item 12: "Bitrate" read a constant 211 kbps on every remux, because it was the
master playlist's BANDWIDTH — which ffmpeg derives from the one stream whose rate
it knows, the -b:a 192k AAC track, plus 10%. It is a measurement now: bytes per
second of MEDIA from hls.js FRAG_LOADED, accumulated per fragment type and summed
(a multi-audio session's video and audio renditions cover the same media seconds,
so pooling them would report half the truth), and an honest dash until the first
fragment lands. The panel also finally names the audio and subtitle track in play.
Verified in the browser against the real Plex library, and every new test
mutation-checked.
|
||
|
|
e4ffd59ca7 |
fix(e24): review round 2 — a buried history entry, a stale hero, and a spinner that was not ours
Nine of the round's ten surviving findings. The tenth (a failed shelf reorder leaving an order the server rejected on screen) is on ROADMAP.md: its obvious remedy reintroduces the overlapping-move defect the code already documents, so it wants a design, not a review edit. - `navigateOverOverlays` buried the live overlay entries before knowing whether the navigation pushed one. `setPage` no-ops when the page is already current and Home is the default landing page, so Shift+W over a player ON Home marked its entry buried under a push that never happened: the unmount consumed the mark instead of reclaiming the entry, and the next browser Back was a press that did nothing. `setPage` now reports whether it navigated, and one `buryUnder` rule serves both entry points — `closeThenNavigate` takes the same no-op navigation from a palette command for the page you are already on. - The hero could answer a new time budget with the previous one's pick. The chip row resets seed/seen/press but cannot reset an answer that already resolved, and `keepPreviousData` chained through the disabled seed-0 query — so a reroll under "10 perc" showed the film picked for "Bármennyi" while it loaded, and kept it if the request failed. `lastPick` now carries the server's own budget echo, and the placeholder is scoped to the budget. - The reroll button spun and swallowed clicks after mark-watched, bookmark, pin and shelf moves: the pick key sits under the invalidated `discover` prefix on purpose, so raw `isFetching` was true for background refetches. It now means "a press of mine is outstanding" — which still covers the first press, where there is no placeholder. - Failed writes on Home toasted the page's LOAD-failure string over a fully loaded page; they get their own key (HU+EN). The empty-state reroll had no `loading`, so the press was unacknowledged and a second click skipped a tier. `danger` moved into the shared unpin row so the same command is not red on the shelf and neutral in the rail below it. - Two comments claimed things the code does not do: the `exclude` cap guards no "unbounded scan" (it never reaches SQL), and the route test that named the cap sends three keys. Tests: the burial rule gets its own file (it is the one assertion that lets a reconcile issue a real programmatic back()); App's keymap wiring gets a source contract, because dropping it left all 818 tests green; `add_pin`'s IntegrityError arm was reached by nothing. Every new test mutation-checked red, both edges where the predicate has two. Gate: ruff, ruff format, 3x tsc, eslint (0 errors), prettier, knip, vitest 822, pytest 1005. |
||
|
|
828dad015c |
feat(e24): reach the Home surface — the landing toggle, a palette command, Shift+W
S4's reachability half. Home already led the nav order; what was missing was every way IN other than the rail. - Settings → Appearance → Startup: "Start on Home", the documented escape hatch for the landing override. Deliberately still localStorage and not a server pref — `loadInitialPage` answers before /api/me lands, and a landing module decided after the first paint is a visible jump. Its two stored values are now ONE exported constant, so the writer and the boot-time reader cannot drift on a magic string. - The command palette gets "Meglepetés" / "Surprise me": it navigates to Home and rerolls the hero on arrival. No second "Mit nézzek?" row beside it — the Home module row already matches that phrase through its descriptive title. - A global Shift+W jumps to Home. A chord, not a bare `w`: the Plex player binds bare letters and `w` sits on `s`, so a blind reach for the subtitle key would have thrown a viewer out of the film. The palette's request crosses a lazy module boundary, so it is a token that waits (lib/homeIntent) rather than an event fired into an empty room. Browser-measured: taken with a compare-and-clear, because StrictMode's double-invoked mount effect otherwise rerolled twice and landed on the page two picks deep. HU/EN parity kept; UAT on localdev in both languages, including the empty shelf. |
||
|
|
ccd5f3739a |
feat(e24): pin to the comfort shelf from every card and row
S3 wires the shelf's editor. The gesture is the E10 right-click menu, so the decision "this is comfort media" can be made where the thing is — the feed, a Plex grid, a channel row, a playlist — rather than only on the page that lists the shelf. One shared piece, `components/comfortPin.tsx`: the five pin-kind constructors, the shelf query, and ONE `ActionMenuItem` that switches Pin/Unpin honestly (and says "not known yet" with a disabled row until the shelf has answered, since a confident "Pin" over something already pinned is exactly the quiet lie the house rules forbid). Surfaces that already have a row menu compose the item in (`Playlists`, `PlaylistsRail`); the rest mount `ComfortPinMenu`. Two structural notes: - The feed's menu lives in `VirtualFeed`, not in `Feed` — that is the list, and it is also what gives the channel page the gesture for free, since it renders `<Feed channelScoped>` all the way down. - Cards take `menuProps` as a FUNCTION of the row, not the built props object. `triggerProps` is stable across renders while its return value is not, and a fresh object prop would blow past `VideoCard`'s memo for every card in the virtualized feed on every parent render. `DataTable` and `ChannelLayoutGrid` grow a generic `rowProps` so a channel row answers the same gesture in both of the manager's layouts; the table stays domain-free and never learns what a menu is. On Home the shelf tile's menu is the whole editor: four move rows (front / left / right / end, reusing the playlist menu's ordering idiom) plus the unpin, over menu rows rather than drag & drop — dragging inside a horizontal, scrolling Carousel fights the scroll, and menu rows are keyboard-reachable for free. The reorder is optimistic on the cached payload and re-asks the server on failure rather than showing an order that was never accepted. The exhausted shelf tile (a pinned channel with nothing unwatched left) is `aria-disabled`, not `disabled`: measured in Chromium, a disabled button still receives contextmenu and pointerdown, but it loses focus — and with it Shift+F10, the only keyboard route to the menu it most needs. Verified end to end in the browser against the live account: all five pin kinds, the reorder (persisted across a reload), the unpin, and the honest Unpin label on a resume tile that is already on the shelf. |
||
|
|
f3c0b7be23 |
feat(e24): the Home surface — one question, one answer, both sources
E24 S2. A new `home` module in layout C+ (the mockup the owner picked): the time-budget chips on top, one large hero that answers them, then the comfort shelf and a cross-source resume rail. Home is the landing — deliberately OVER the remembered last module, since landing on whatever you happened to close the app on is the habit this epic exists to replace; `LS.startOnHome = "0"` opts an account back out (Settings toggle lands in S4). No third card component: each unified card carries the native feed / Plex payload, so the rails render through the existing VideoCard (`cardsSmall`) and PlexPosterCard, and `aspect` decides the tile shape without branching on source. Plex playback hands off to the Plex module via a new `jumpTo` type — the player lives there with its lazy hls.js and its provider tree, and mounting a second one on the landing page to save one hand-off would be the expensive way round. Four defects found by driving it in a real browser against the live account, each now pinned by a mutation-checked test: - **The reroll gave the same card back.** A new seed only re-draws the SURPRISE tier; the resume tier deterministically returns the most recently progressed thing that fits, so anyone with something half-finished pressed a dead button. `/pick` now takes an `exclude` list of `<source>:<id>` keys and the client keeps a rolling window of 8. Excluding a SHOW also excludes the episode it resolves to — otherwise the same suggestion comes back wearing a hat. - **"Nothing to suggest yet" flashed over a full library** whenever a reroll was in flight: the new query key has no data for a beat. An empty state standing in for a loading one is the dishonesty the house rules name outright. The hero now falls back to the loaded payload while a pick is pending, and only trusts a null once the pick has RESOLVED. - **A budget change replaced the page — chips included — with a spinner**, so the control you just clicked vanished under your cursor. `keepPreviousData`, the same reason the feed uses it. - **The hero printed twice**, once as itself and once as the first resume tile. Two smaller UI calls made from the same session: the budget caption sits NEXT to its chips rather than in SegmentedToggle's justify-between mode, which on an 80rem page throws the answers to the far edge; and the feed takes the YouTube mark its own toolbar already uses, since Home now owns the house icon. Gate green: ruff, ruff format, 3x tsc, eslint, prettier, knip, vitest 780, pytest 989. Verified end to end on localdev against the real account: budget chips re-answer (matched on REMAINING time — a 51-minute video with 2:46 left is a ten-minute answer), the reroll walks three distinct heroes, and a Plex show tile opens its drill-down in the Plex module. |
||
|
|
20e17c0ccb |
fix(e10): review round 1 — the boundary that did not bind, and 14 more
The epic-end review found 32 verified issues. This is the first fix round: the three highs, the user-facing mediums, and the tests that were passing for the wrong reason. Docs and the remaining low-severity tail are next. The boundary rule was the worst of them. Both `no-restricted-imports` regexes were written in plain "..." literals, where `\.` is an unknown escape that collapses to the any-char `.` — so the rule read like a boundary and matched far less than one. `../../lib/api` (the barrel every feature component imports) and `../../components/Feed` (the same file `../Feed` names) both sailed through a green gate. String.raw, a third pattern for the second spelling, and a vitest over ESLint.lintText so a rule that silently matches nothing can never look like a clean codebase again. closeThenNavigate waited for exactly one reclaim popstate. With a second Back-closable layer still mounted — a palette opened over the player — the navigation unmounted that layer too, and its reconcile backed over the entry the navigation had just pushed: "go to Channels" closed the player and landed on the feed. It now waits for the LAST programmatic back, then adopts the entries the navigation buried, and falls back after 250ms rather than hanging if no popstate is ever coming. The rail's inline rename died on arrival. With no domReference, floating- ui's returnFocus falls through to a module-global stack and restores some unrelated element from an earlier popover — blurring the field into its own commit-and-close, from the second popover of a session onward. The context menu now owns the restore: back to the row, but only if the row is still there and nothing else has claimed focus. Also: - Backspace under the cheat sheet stopped Plex playback and dropped to the grid; `back` now defers to any layer above, like `escape` beside it. - A move invalidated only the source list, so the target kept serving pre-move contents for 30s while the rail already counted N+1. - Every move error a user can reach is a 404, which `req` leaves to the caller — so the row snapped back in silence and the shipped moveErrors strings were unreachable. - swallowClick armed on long-press but was disarmed only by the click it was armed for; any press that produced no click ate the next tap on the whole list. - The context menu stayed parked over whatever row scrolled under it. - overflow-clip on the chip lanes ate the focus ring's top and bottom. - "Why is this off" rode on disabled rows, which show no tooltip and take no focus: a new `inert` item keeps the reason readable. - Shift+F10 was advertised app-wide but wired only on Playlists; it has its own scope now, and useContextMenu matches through the shared parser instead of its own literals. - Mod+K could not close the palette, `h` machine-gunned on auto-repeat, and both were bound above the branch that mounts their overlays. - Palette options were in the tab order under aria-activedescendant, and a scroll-synthesised mousemove stole the keyboard selection. - The OSD live region was created together with its own text. - Ending a rename stranded focus on <body>. - The Button variants' hover halves are `enabled:` — the one migrated site that had guarded its own lost the guard on the way in. - HU builtinLocked named the list "Watch later" in a Hungarian sentence. Tests: the chip-lane contract counted per FILE, so a second hand-rolled lane in an already-compliant file was invisible; the closeThenNavigate ordering test only passed on a popstate left behind by its neighbours and failed alone, so it moved to its own file. |
||
|
|
cc8cf96c19 |
feat(e10): a jump-to palette on Ctrl/Cmd+K
Reaching a channel three modules away cost a rail, a page and a scroll. Now it costs a keystroke and half a word: Mod+K searches modules, channels, playlists, the saved views applicable where you stand, and the app-wide actions. Scope agreed with the product owner: navigation + actions, but only the reversible local ones (the shortcuts sheet, the theme). "Sync from YouTube" is deliberately absent — it is a long-running job against a quota, it wants the progress and error surface the Channels page already gives it, and one mistyped Enter is a worse failure than the walk it would have saved. Matching is word-wise and accent-folded, because this is a bilingual app and nobody reaches for the accent keys in a hurry: "pod thevr" finds TheVR Podcast and "penzugy" finds pénzügy. The arrows walk one flat result list so crossing a group heading is invisible, and the field carries the combobox/listbox aria tree that virtual selection needs. ui/CommandPalette is domain-free (items are a prop, per the ui/ import rule); AppCommandPalette supplies them. The shell is Modal again, with a new `top` placement so a search-first dialog grows downwards instead of jumping as results change. Also fixes a bug the palette hit on its first run, and that PlayerModal had already hit once: closing a dialog reclaims its Back-to-close history entry with a programmatic history.back(), which undoes any pushState made before it lands — so a command that navigates only closed the palette. The rule is now closeThenNavigate() in history.ts with a test, and PlayerModal's inline copy is gone. The global `h` moves out of KeyboardHelp into useGlobalKeymap beside Mod+K, so one listener owns the whole `global` scope and dispatch's exhaustiveness keeps meaning what it says. |
||
|
|
12822a9e04 |
feat(e10): put every keyboard shortcut in one table
Six surfaces each owned a private keydown listener with its own key literals and its own idea of "am I typing?", and they had drifted: PlexPlayer matched `case "a"` without checking modifiers, so Ctrl+A (select all) cycled the audio track, and its typing guard missed <select> and contentEditable where the other two surfaces covered both. lib/keymap.ts is a TABLE, not a dispatcher. Each surface keeps its own listener and closures - PlexPlayer's refs are not worth re-plumbing - but the chords, the modifier rules and the typing guard live in one place, and dispatch() binds a scope's rows to handlers through a mapped type, so a row without a handler (or a handler without a row) is a tsc error rather than a comment someone forgets. Matching is now exact on modifiers, which is what kills the Ctrl+A bug and lets Shift+arrow be its own row beside the bare arrow. Escape opts out of the typing guard (allowInInput) so it stays the way out of a player's own subtitle-offset field. The player's shortcut sheet renders helpLines() instead of a hand-copied array beside the handler - the two used to be edited separately, which is how a sheet ends up documenting a key that no longer exists. Labels move to the new `keys` i18n namespace (EN + HU), and a test asserts every row is named in both. |
||
|
|
928eac4c89 |
feat(e10): open the app's menu with a right-click, starting with playlist rows
S2's headline was never a new Menu: `ActionMenu` already sits on `AnchoredPopover` (arrows, typeahead, Escape, anchor-flip). What was missing is an INPUT PATH into it, so this adds `useContextMenu` + `<ContextMenu>` over the same `ActionMenuItem[]` and the same `MenuItemList` — a row cannot grow a right-click menu that looks or behaves unlike its own ⋯ menu. Three ways in: right-click anywhere on the row, long-press on touch/pen, and Shift+F10 / the Menu key. The LEFT button is untouched and keeps the row's own action. One popover per list, anchored to a virtual element at the pointer (native behaviour, and a table of 200 rows would otherwise mount 200 floating contexts to keep 199 shut). Platform bits it needed: `hoverAlso` on AnchoredPopover (a submenu falls open under a mouse and is tapped by a finger — mouse-only, so a tap's synthetic mouseenter can't race the click toggle) and `elementRef` on PopoverItem (the submenu anchors to the very menuitem that opens it, where aria-haspopup/expanded belong). `ActionMenuItem` becomes a discriminated union — a row either fires or opens a submenu — plus `danger` and `separatorBefore`. First consumers, both in Playlists: - item rows: play · move to another list (a submenu of every other playlist, one gesture) · to top / up / down / bottom · remove. Reordering goes through the same undoable store as the drag handle and is disabled under a view for the same reason, with the same explanation rather than silence. - rail rows: rename (in the row that was right-clicked, keeping its cover so the rail doesn't jump) · delete. Both now run through `usePlaylistActions`, which also takes over the detail header's pencil and trash — the delete dialog's YouTube branch and the "what gets selected next" rule exist once, not twice. - the rail gains a "recently changed" sort key off the new `updated_at`. NOT the default: a rail that reshuffles itself while you look at it costs more than it saves. |
||
|
|
358d722ce9 |
fix(e12): close what round one broke, and revert two fixes that overreached
Round two of the review loop, on round one's own diff. Two of its findings were regressions the fixes themselves introduced, which is exactly what the loop is for: **A per-user refusal was writing onto a shared row.** MediaAssets are deduped across users, and the generic failure handler puts the message on the asset — right for an upstream failure, wrong for "your storage is full". Two users queuing the same video share one asset, so the first user hitting their cap failed the second user's job with the first one's private reason, telling someone with an empty library to free up space. The refusal now raises `_OwnerRefused`, caught separately: the job fails, the asset goes back to `pending` for whoever else holds it. Pinned by a test that drives `_download` directly — the refusal happens before extraction, so nothing needs stubbing. **The restore scripts restarted the app, undoing the restore.** `docker compose start` re-runs the container command, and that runs `alembic upgrade head` — so on the rollback path this documents as recommended (restore the dump, THEN pin the older tag) the newer image immediately re-applied the migrations the restore had just undone, and on a FAILED restore it put the app back on a half-restored schema. Both are the opposite of why the stop was added. Nothing is restarted automatically now; the scripts say what to run and when, and the docs' snippets say the same. **Reverted rather than patched:** the OnboardingWizard toast (the wizard already renders that failure inline, next to a Retry button — three recoveries for one failure), and `useVerifyEntry`'s "each new link clears the other". The latter blanked the signed-out card's only banner, where nothing renders the reset flag to replace it. The two states are independent again; what changes is that `dismiss` peels ONE layer, which is what the original finding was actually about. Also: `Stats` uses the YouTube helper rather than the bare one, so the route's English 403 detail no longer beats the translated fallback under a Hungarian UI; `urlparse` guarded alongside `.port` (an unclosed bracket raised just as late), the reason renamed `malformed` since "bad port" misdescribes it; three docstrings narrowed to what the code actually does (the worker's byte check is per-job, not an aggregate ledger, and not on the edit path; the redirect residual leaks a reachability oracle, which the note had denied; the invalidation helper does not cover suspend); the pasted Windows recipe wrapped so `throw` can stop it; the bash one-liner's failure branch no longer exits 0; and the tests that were missing for the round-one fixes — the account panel's error paths, the invalidation set, and the shared-asset case above. Gate green: vitest 685, pytest 950. |
||
|
|
fe38dfbcca |
fix(e12): close the eighteen findings the E12 review round left standing
The deep review of the branch raised 47 findings, 18 of which survived adversarial verification. Grouped by what they actually were: **The size ceiling inverted at its boundary.** `remaining_bytes` clamped to 0 for a user already at their storage cap, and `build_ydl_opts` reads 0 as "no limit" — so the one user over quota was the only one downloading with no ceiling at all. `check_enqueue` could not catch it: it ran while the job was queued and the user was still under the cap. The arithmetic moves into `quota.remaining_bytes` (where every other max_bytes decision lives, and where it can be tested), and the worker now fails the job outright rather than proceeding unbounded — as a non-retryable `quota_exceeded`, since retrying cannot help until space is freed. **Silent failures in the new account panel.** All three actions went through `quiet: true` endpoints, which suppress the global error dialog on the promise that the caller reports inline — with no `onError`, that promise was broken and a refusal rendered as nothing. On the broken-SMTP instance this panel exists for, an admin would read silence as "the mail went out". The co-invalidation set and the email-off predicate move to `lib/adminUsers`, shared with the pending-access queue, so the two copies of these mutations cannot drift apart again — which is how half of this was lost in the first place. **A rollback recipe that destroyed data without working.** `alembic downgrade -1` steps back one migration, but a release routinely ships several (0.80.0 shipped four): it drops the newest one's columns and leaves the schema at a revision the older image also cannot resolve, i.e. the crash loop it was meant to escape, minus the data. The dump restore is now the recommended path, and the alembic route asks the target version for its own head revision instead of counting backwards. Also: redirects named in the urlguard docstring beside the DNS-rebinding residual they share a fix with (SEC-17); a malformed port refused as `bad_port` instead of escaping as a 500; the pull cooldown extended to the sibling route spending the same QuotaAction; restore stops the app before dropping its tables; the PowerShell cleanup can no longer decide the script's verdict on 5.1, nor report the wrong exit code; the documented one-liners hardened to match the scripts; the registry's retention window stated where pinning is advertised; the two banners made mutually exclusive so neither can shadow the other; `hover:bg-hover` (a utility this project never generated) replaced by the `selectable` recipe; the clipboard helper reused; and the sync cooldown's copy swept to the two callers that discarded it. Verified: gate green (vitest 678, pytest 949), and the panel, the row hover and the reset banner UAT'd on localdev. |
||
|
|
f98b060e2b |
feat(admin): an account panel, and a set-password link that needs no email
Design option C, chosen by the user 2026-09-05. Nothing in the admin UI ever showed an account's GATE STATE: an admin could see a "Pending approval" chip but not whether the address was confirmed, whether SEC-10 had cleared the password, or which Google account was attached — so every support conversation started by guessing. Clicking a row now opens a drawer that says which gate the person is actually stuck behind, with the recovery actions under that readout rather than in the row. The drawer is `Modal placement="drawer"`, not a second dialog: the layer stack, ESC, the focus trap, Back-to-close and the scroll lock are the hard parts, and duplicating them is how two shells drift apart. It slides in, and honours prefers-reduced-motion like the rest. The link itself (SEC-15, SEC-16, and the everyday "no SMTP and someone forgot theirs") reuses the existing token machinery and the SAME `#reset=` entry the mailed link uses, so the signed-out card consumes it with no new flow. It is a distinct token KIND for one reason: a mailed reset arrives in the mailbox, so completing it proves ownership and confirms the address; an admin-issued link proves nothing about the mailbox — the admin hands it over by whatever means they choose — so it must not satisfy the email gate. Claiming otherwise would be the same false attestation SEC-15 is about. Issuing does not revoke the current password: a misclick must not lock anyone out. USING it rewrites the hash and bumps the session epoch, which is what evicts a squatter — SEC-16's actual remedy. Spending either kind burns the siblings of both, so a completed reset cannot leave a live admin link behind. The non-consuming probe learned the new kind too, or every admin link would have opened onto "this link is spent". The audit log records that a link was issued, by whom, for whom — never the link, which is a live credential-setting secret and the last thing that belongs in a long-lived log a human reads. Not gated on account state on purpose: it is useful for a pre-empted address, a revoked password, a Google-only account that wants one, and a plain forgotten password. Narrowing it would only make it refuse exactly when the admin needs it. The shared demo account is the one exception. Verified on localdev, both languages, end to end: issue from the panel -> the signed-in tab shows the honest "reset links open in a signed-out window" banner with the token still in the address bar -> signing out draws the "Set a new password" form, which is only possible because the probe recognises the new kind. Gate: pytest 925 -> 942, vitest 676. |