7ed6ccf171eed8f995d34869341dbdc046ae12a7
342
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
7ed6ccf171 |
fix(feed): the card menu's reset uses the card's own reset string
Two keys for one action had already drifted once; the menu now reads card.resetState like the card's button. |
||
|
|
92979fa920 |
refactor(ui): one rule for resettable, role key and display name; an honest reset label
The card menu's reset read 'Clear watch progress' but also un-hides and un-watches; it now says so. The resettable rule, the demo-first role key and the display-name fallback each lived in several components tied by comments; they are shared helpers now, and the menu reuses the card's own strings instead of duplicate keys. |
||
|
|
373415a2eb |
feat(account): show the account its own last sign-in
Settings → Account names the latest sign-in on any device (only the admin's user list did, though the 0.88 notes said otherwise), and the role in the reader's language instead of the raw `user`. |
||
|
|
582c033e1c |
feat(feed): the card menu carries the card's own actions
Right-click, long-press or Shift+F10 on a feed card offered only the comfort-shelf pin; watched, saved, hidden and the progress reset were reachable only by hovering or Tabbing through every card. |
||
|
|
ef652ee5d8 |
fix(ui): honest tab titles and a download field that names every site
Signed out, the tab read Home's question over the sign-in form; the legal pages read just "Siftlode". The download field asked only for a YouTube link although Facebook and the rest download too. |
||
|
|
29094f5f51 |
fix(sync): read a dark RSS feed through the API instead
YouTube's RSS goes dark per channel for hours (prod 2026-09-18/19: 393 of 396 feeds answered 404 for four hours, sticky per channel, so a retry does not help), and nothing else read a followed channel's new uploads. A channel whose feed has not answered for `rss_fallback_hours` (admin knob, default 2, 0 = off) now gets one page of its uploads playlist through the Data API (1 unit), at most once per window, yielding to the backfill reserve. Spend is attributed to its own quota action. |
||
|
|
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. |
||
|
|
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. |