d349bc90847e4ec01ee5a390c11198886898eebb
1752
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
d349bc9084 |
fix(plex): a collection's failed watched toggle is put back the way it was
C1 moved the optimistic flip onto the key the page reads — and so made visible that nothing corrects it: the resync only invalidated `plex-library`, so a failed toggle stayed "watched" beside its own error toast, and the collection's other cached sort orders stayed stale after a good one. The resync now takes this page's `detail` prefix too, as the Discover and person pages do for theirs. The C1 test holds the request open, so it still pins the optimistic write on its own. |
||
|
|
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.
|
||
|
|
6de2cd950d |
fix(ui): toasts raised while a full-screen player is open show inside it, not under its scrim
The app's Toaster lives in the content column, which is a stacking context (its `@container` implies layout containment), so both full-screen players — portalled to <body> at the same z-index — painted over every toast. Round 1's "a failed Remove is reported" fix was therefore still silent inside the player: its toast sat under an 80% black, blurred scrim (elementFromPoint returned the scrim), and a click on it closed the player. An overlay now mounts its own `<Toaster overlay />`, and exactly one host shows the toasts: the most recently mounted overlay, else the app's. Same component, same look — and inside the aria-modal dialog, where a screen reader can still reach it. A click on a toast inside an overlay stays there. Both players get one (the Plex player's also works in native fullscreen, where nothing outside the fullscreen element is drawn). Verified on localdev: the removal failure reads above the scrim, bottom-left, and clicking it leaves the player open. |
||
|
|
645d14f45d |
fix(plex): a subtitle track's failed load is heard and retried with a pause, and the poll asks about the right item, position and failure
- The <track>'s `error` never reached `onSubTrackError`: it does not bubble, and React 18 attaches no listener of its own to a <track> (measured in Chrome and jsdom — `onError` 0 calls, `onErrorCapture` 1). Round 1's remount-and-give-up was dead code behind it. The hook now hands the player `onErrorCapture`, and the test fires a real non-bubbling `error` at a rendered <track>. - Once reachable, the retry burned its whole budget within milliseconds of an outage (a remount fetches at once) and the give-up outlived turning subtitles off and on. Retries now wait (2 s, doubling), running out is reported at once, and picking the track again starts a fresh budget. - Until the first timeupdate the poll sent `sessionK` — still 0 while the session request was out, and the PREVIOUS item's start after a move to the next episode. It now sends where the session was asked to start, and the track choice belongs to the item it was made for, so the next item is not asked about the old ordinal at the old position. - A failing poll over a stale `partial` answer was silent: react-query keeps the last good data, the track stayed "showable", and the notice only rendered for an unmounted track. The hook now decides what the player says (`subNotice`), independently of whether a window is mounted. |
||
|
|
7360339039 |
fix(api): a non-finite number in a JSON body is refused with a 422, not a 500
The JSON parser under FastAPI accepts `Infinity`/`NaN`, and both progress checkpoints truncate the position with `int()` — a hand-written checkpoint came back as a 500 with a traceback. Their bodies now refuse non-finite floats, and the app's validation handler — FastAPI's own shape — echoes a non-finite input by name: the default one failed to serialise its own error (JSON has no NaN), which was a 500 by another route. Sibling of the subtitle routes' non-finite query parameters. |
||
|
|
473d9f02f2 |
fix(plex): subtitle windows land where the full pass does, and the extractor stops re-reading, respawning and outliving the process
Deep-review round 2 of the 2026-09 bug hunt, over the round-1 fix diff. - A window was LATE by the distance to the keyframe its input seek landed on, and then doubled for good once the full pass merged in: a cue already running at the window's start came out at a negative time, and the webvtt muxer's default shifted the whole output so it sat at 0. Measured on the real Westworld S01E06 at 25:00: every window cue +5.005 s, 682 cues merged instead of 608. `-avoid_negative_ts disabled` keeps the times exact (the parser reads ffmpeg's signed-field spelling of a negative time); now 608/608 on that file, and a real-ffmpeg test pins it. - A full pass that failed was re-armed every minute while the viewer watched what it did publish — a whole-container read per minute. It now backs off for an hour; windows keep running meanwhile. - A failed WINDOW set the track's failure memo, so any uncovered position read "failed" — a final answer that ends the player's poll, even with the full pass about to deliver, and one any account could plant with an absurd `at`. It now marks only the span it failed to read; "failed" is given once the full pass AND a window there have both failed. - Windows join by what they COVER, not a bucketed identity: a backward seek no longer waits behind a window that cannot cover it, and a playhead crossing a bucket edge no longer queues a second read. The queue is bounded per track and overall. - shutdown() sets a flag a worker checks before it spawns ffmpeg (one still in its Plex lookup used to start a read after the kill sweep); queued-job cancellation and the lifespan call are now tested. - A window starts where ffmpeg is told to seek (rounded to the ms it gets), a rounded-up millisecond carries into the minute (no more `00:00:60.000`), a key's first publish claims nothing before its VTT lands, and `at`/`offset`/`start`/`aoff` refuse non-finite values (`at` also an absurd one). |
||
|
|
d2e225666b |
test(playlists): let react-query's notify tick run inside act before the reopen click
The D1 regression test patched the cache and clicked at once; react-query delivers a cache write to its observers on a setTimeout(0), so under the gate's parallel load the click could beat the page's re-render and read the old row (748, not 830). Test-only race — a human reopen comes seconds later. Stable across three full-suite runs; the D1 mutation still turns it red. |
||
|
|
efd95fca01 |
fix(ui): the card's channel name is a real link too; call sites the bug hunt left untested now have tests
Deep-review R6 — the small items: - The video card's channel name was still a <button> (pre-existing): ctrl/middle-click did nothing there while the title above it and the player's channel name honoured them. It renders through ChannelNameLink now, which grew a forwarded ref (the clamp measurement), pass-through attributes, a `tooltip`, and an opt-in `stopPropagation` so no click reaches the card's row. - ChannelNameLink rendered an empty, still-tabbable <a> for a channel with no title yet (a stub first seen through a live search; pre-existing at this site). It falls back to the translated "Channel". - The feed's SortKey→SortOption mapping existed twice in Feed.tsx; one `feedSortOptions` in feedSort.ts serves both modes (DIRECTIONLESS is module-private again). - The backfill refresh's comment claimed a one-page cost. It refetches every loaded page of the grid, and that is the right price: backfilled videos are older, so they land below the head. Tests for call sites that could be deleted with every lane green: the channel page running the backfill refresh (knip had been blinded by the hook test's import), Feed handing the grid the live results in the chosen key AND direction, the card using the link. Plus the alt-click arm of the shared click predicate, and a plex filter-chip test that claimed it made no request while firing a real one. Verified on localdev: the feed's channel names are anchors (same 14 px muted look), a plain click opens the channel in-app. 7 mutations, each red on its assertion. |
||
|
|
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
|
||
|
|
4869d6c108 |
fix(player): the resume position reaches the playlist page and live search, not just the feed
Deep-review R4.
|
||
|
|
d20317e0f1 |
fix(plex): the collection page's watched toggle and Sort control survive the sort joining the query key
Deep-review R3. When
|
||
|
|
d2ba65e383 |
fix(plex): the subtitle poll backs off on errors, a failed <track> is replaced, and a resume asks for the right window
Deep-review R2, plus one defect found while verifying it on localdev. - The status poll re-armed every second after a FAILED request: `data` stays empty on an error, so it read as "still preparing" for as long as the player was open — and the request was not quiet, so a persistent 500 re-opened the global error dialog every second. It now backs off to 10 s (a blip still heals on its own) and is quiet; the player already shows the failure inline. - A <track> whose own VTT fetch failed never fetched again: with the server's answer unchanged its key was unchanged, so React kept the dead element — silently blank subtitles. The key (now built in the hook, where it is tested) carries a failed-load count; after two remounts the track is reported as unavailable instead. - Found in UAT: until the first timeupdate `abs` is 0, so after a resume every poll said at=0 and the first window was cut around the head of the film (a resume at 20:00 polled at=0 for as long as it stayed paused). The poll reports the session's start until playback ticks. And the window's dedup identity is the track plus a window-length bucket of its start — keyed on the track alone (R1), the right window queued behind that wrong one. The poll's start/stop rules finally have tests (every earlier one resolved a single fixed answer). Verified on localdev against Westworld S1E6 resumed at 20:00: the polls carried at=1197, one subtitle line on screen at 21:02, the poll stopped at `ready`; the real file's window (56 cues) + full pass (608) merged to 608 — the old merge would have served 664. 11 mutations, all red. |
||
|
|
cf7ec5e9ec |
fix(plex): the subtitle extractor's seams — doubled cues, a truncated track cached as whole, a poll that respawned ffmpeg
Deep-review of the bug-hunt round (R1). The background extractor from
|
||
|
|
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. |
||
|
|
889d2acf52 |
feat(plex): the collection page sorts, and a linked collection has a name
Two halves of the same report ("Collectionben nem működik a rendezés?"). The pasted browse URL
sorts correctly — that part was working. What did not:
The Collection INFO page had no sort control at all and a hardcoded release-year-ascending order.
It now shows the browse grid's Sort control, with the browse grid's vocabulary, because a second
sort vocabulary on a neighbouring surface is exactly the kind of drift this repo keeps paying for:
`PLEX_SORT_KEYS` is now one tuple in `plex/query.py`, and a test pins BOTH implementations to it —
browse's key→column map (extracted from the handler to be reachable) and the collection page's
key→member map. A key added to the vocabulary without teaching one of them fails the gate instead
of putting a dead option in the menu.
Ordering a collection cannot be a single ORDER BY: its two legs are different tables, so the merged
member list is sorted in Python. Members with no value for the key sink to the END in both
directions (a movie with no rating is not "the worst rated" — the browse grid's `nullslast()`), and
ties always resolve title-ascending, so flipping the direction reverses the ranking rather than the
alphabet inside it. `lastwatched` needed the show leg's `resumed_at`, so the page now takes ONE
rollup pass that yields both the badge status and the recency instead of the status alone — without
it that menu entry would have ordered every show as never-watched. The default stays release-year
ascending: a franchise reads in release order, and that is what the page did before.
The other half: a shared Plex link carries the collection filter as bare rating_keys, so a link
opened cold had no name for them — the sidebar token rendered `17045` and the chip could only say
"Collection". `GET /api/plex/collections` takes a `keys=` CSV now, and a display-only hook resolves
just the missing ids. It deliberately does NOT write them into `collectionTitles`: that map is
hashed into the Plex query keys, so storing a resolved label there would split the cache from the
identical state and refetch the grid for a piece of text.
Verified in the browser on localdev against the real Plex library: the movie collection's menu
offers Length and the show collection's does not (the same scope rule as browse); rating-ascending
reorders 8.1/7.4/7.2/6.6/6.5 correctly; `lastwatched` on a show collection brings the in-progress
shows up; the choice survives a reload (it is a server pref, on its own key so opening a collection
never re-sorts the library behind it); and the reported URL now reads "Pirates of the Caribbean
Collection" in both the sidebar and the chip.
12 pytest + 8 vitest, mutation-checked against 14 mutations (direction ignored, nulls first, no
stable tiebreak, unknown key untouched, wrong default, show recency dropped, empty `keys=` falling
through to everything, a key missing from either sort map, every id refetched, ids unsorted,
fetched titles unmerged, the chip ignoring them, the hook's result never reaching the builder) —
each red on exactly the test that names it.
|
||
|
|
782362b808 |
fix(channels): the video grid follows a running backfill instead of freezing until F5
"Get full history" on a channel page left the list on the cards the channel already had. The
count above it climbed into the thousands, the grid did not move, and only a browser F5 brought
the rest in.
The mechanism: the backfill runs in a BackgroundTask, so the PATCH that starts it returns before
a single row is written — and the mutation invalidates `qk.feed()` on that RESPONSE, which just
refetches the OLD catalogue. Nothing afterwards refetches it: the feed's infinite query has no
interval, and the app-wide defaults are `staleTime: 30s` + `refetchOnWindowFocus: false`. The job
emits no event either (the realtime layer only carries messages today), so the channel detail's
4s live poll is the only thing in the app that sees the job progress — and it refreshed only
`qk.channel`.
So the poll now drives the grid too: on a tick where the stored count moved (or the backfill
finished), it invalidates `qk.feed()` and the `qk.feedSummary()` behind the count pill. The window
is the poll's own (`deep_requested && !backfill_done` on the previous reading), so a detail
refetch caused by some other mutation — each of which invalidates the feed itself — cannot come
through here twice.
Measured on localdev against the real account, same instrument, `document.visibilityState`
forced visible (the poll deliberately pauses on a hidden tab, and a headless pane reads hidden —
without that the first measurement was about the harness, not the fix):
before — CASCADA 19 -> 176 stored: the header updated live to "162 normal", the grid and its
count pill sat on "5 items" for 50s.
after — fern 54 -> 139 stored: "54 items" -> "139 items" by ~12s, no reload.
6 vitest, mutation-checked against six mutations (no invalidation at all; grid only, count pill
forgotten; predicate ignored; no channel-identity guard; completion with an unchanged count
dropped; poll-window guard dropped) — each one red on exactly the test that names it.
|
||
|
|
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.
|
||
|
|
b8bff504d8 |
fix(plex): "Clear resume point" now tells Plex about an in-progress item
The button deletes the local row and mirrors the un-mark to Plex, but it decided whether to push from the previous STATUS alone (prev_status != "new") — and an in-progress item's row is status="new" carrying a position, which is exactly how the importer maps a bare viewOffset. So for the single case the button exists for, nothing was ever sent to Plex: the offset stayed, and the next pull imported it straight back. The user cleared Protector twice and it returned to Continue watching at 92s both times. Push now when Plex holds anything to clear — a view count, or a resume point. "hidden" stays Siftlode-only and pushes nothing, which is what this endpoint's docstring always claimed and the status-only guard contradicted. One unscrobble clears BOTH viewCount and viewOffset — measured against the live server from a watched+offset state and an offset-only one, so the push itself needed no change. The client docstring that claimed "clears viewCount" is what sent the first diagnosis down a wrong path (a timeline write with time=0, which does not clear an offset at all); it now records what the server actually does. Verified end to end on the real Plex server: the button leaves viewCount=None viewOffset=None, and a full 44,885-item re-import brings back no row for it. |
||
|
|
1630a3df3c |
fix(player): the YT progress checkpoint reaches the cache, so a reopen cannot rewind it
The player checkpointed every 5s and the server stored it, but nothing wrote the new position back into react-query: the close path only marked qk.feed() stale with refetchType "none", and nothing refetches the feed until it remounts (staleTime 30s, refetchOnWindowFocus off). So the resume read the position the cache had been FETCHED with, and the next checkpoint then persisted that older value over the newer one. Measured on localdev: played to 830s, closed (server had 830), reopened the same card without a reload -> the row was back to 751s. 79 seconds of progress destroyed by a reopen, and the card's progress bar never moved either. The Plex player already solved this for its detail query, with a comment describing the identical failure; this is the feed-side twin that was missed in that sweep. patchCachedProgress writes the position the SERVER stored (the endpoint clears trivially-early and near-finished checkpoints to 0, so mirroring our own reading would disagree with the row) into every cached feed page and playlist detail, keeping array identity where nothing changed. A session map covers the second half: the launch queue is frozen at mount, so stepping next->prev back to an item played earlier in the same session resumed from that snapshot. Verified in the browser: 811 -> close 816 -> reopen 825, and the card now shows Continue/Restart. |
||
|
|
14b5fe2e31 |
release: 0.83.0 (feature/e24-anti-paralysis)
gate / frontend (push) Failing after 2m5s
gate / backend (push) Successful in 7m0s
|
||
|
|
d260aabfd7 | chore(release): 0.83.0 — the Home surface | ||
|
|
d832bb19ce |
fix(e24): review round 4 — a test that pinned nothing, and prose still ahead of the code
Round 4 reviewed round 3's commit. Nothing in production behaviour was wrong; what was wrong was the round's own test and its comments, which is the failure class this loop keeps finding. - The new NavigationProvider case named after the `&& !channelViewRef.current` clause never reached it. With storage cleared and a bare URL the provider lands on "home" (E24's own default), not "feed", so asking for "feed" was an ordinary page change and the guard's first conjunct short-circuited. Deleting the clause left all 828 tests green — a dead nav control (the rail's own module stops closing an open channel page) with no red anywhere. The test now asserts the precondition and calls `setPage` with the page that IS current. - Both new Home cases rejected a promise and then asserted after one fixed macrotask. React-query notifies through `notifyManager`'s own `setTimeout(0)`, registered after any timer the test schedules, so the assertion raced it; two reviewers measured intermittent reds. They poll for a signal that the rejection LANDED instead — the reroll button releasing, which is also the honest one for the empty-answer case, where the empty state is already on screen while the press is in flight and asserting it early would pass either way. - `buryUnder`'s comment still opened with "one rule for both entry points" ten lines above the paragraph saying the rule is complete only for one of them, listed the non-reporting palette rows without `openChannel` — which has its own `replaceState` no-push branch and is reached from PlayerModal too — and pointed at a ROADMAP.md this repository does not contain. - `history.dom.test.ts`'s header claimed its layers close by genuine Backs only, which two of its own tests contradict; and `CommandPalette.ordering.dom.test.tsx` still claimed to be "the only assertion in the suite" doing what the file corrected in round 3 also does. Round 3's message said that pair was handled and only one side had been. The provider test was re-mutation-checked (dropping the clause now goes red), and both hardened Home tests still go red against their own reverts. Seven consecutive full runs green. Gate: ruff, ruff format, 3x tsc, eslint (0 errors), prettier, knip, vitest 828, pytest 1005. |
||
|
|
59efc8f2e8 |
fix(e24): review round 3 — the holes round 2's own fixes left
Round 3 reviewed the round-2 commit across six dimensions. Fifteen findings, five survived three adversarial refuters each; three died on 2+ refutations (a keyboard-focus claim that is a property of `ui/Button` at all six of its `loading` call sites, a contract-test false-pass that needs a deliberate revert plus a self-incriminating comment, and a MAX_EXCLUDE coupling whose slice is unreachable from the real client). - `rerolling` was still wrong for a FAILED press. `data === undefined` is true forever for a key whose request failed (`retry: false`), so the next unrelated mutation re-disabled the reroll button — the same defect round 2 reported, one branch over. `!isFetched` is the flag that remembers a key settled; `!isError` would not have worked, because query-core resets a data-less query to "pending" for the duration of a refetch, hiding the error exactly when it matters. - The hero fallback used `??`, which cannot tell "this budget's last pick resolved to NO hero" from "nothing remembered". So a press that failed after an empty one resurrected the card the page had already excluded as seen and just said it had nothing beyond. The tagged slot is now authoritative for its own budget. - `setPage`'s boolean — the input the whole burial rule reads — was asserted by nothing. The type fences the SHAPE, so deleting a `return` fails tsc, but swapping the two values passed typecheck and all 822 tests. NavigationProvider gets its first render test, on the value AND on the effects the early return skips. - `closeThenNavigate`'s door into `buryUnder` was equally unpinned: reverting that one call site to the old unconditional burial left the suite green. It has the mirror case now. - The comment claiming one rule serves both entry points was overstated. `buryUnder` fails OPEN, and `CommandItem.run` is typed `() => void`, so only the palette's bare `() => setPage(page)` rows forward the boolean at all. Nothing regressed — that path buried unconditionally before — but the prose said more than the code does, so it now says what is true and points at the deferred fix. Same for a "the only assertion in the suite" claim that the file it cites as precedent also makes. Every new test mutation-checked red for the right reason, both edges where there are two. Gate: ruff, ruff format, 3x tsc, eslint (0 errors), prettier, knip, vitest 828, pytest 1005. |
||
|
|
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. |
||
|
|
3709b71a94 |
fix(e24): the review's low findings — dead constants, honest labels, a 500 that should be a 404
`add_pin`'s IntegrityError handler assumed the unique constraint was the only way in and re-raised otherwise, so a target row pruned between `resolve_ref` confirming it and the INSERT (a Plex sync dropping a removed item) tripped the FK and handed the user a 500. Semantically that is the same "no such thing" the pre-check already returns. Untested: the race needs two concurrent transactions. The five per-kind unique constraints had no test at all — the route's own dedupe returns before reaching the DB, and the migration drift check filters constraint diffs out as noise, so they could go missing with every lane green. Frontend, all from the same review: the shelf's unpin row is now `danger` like every other destructive row in the app; the hero's artwork button says what it will actually do and names the item instead of always announcing "Play"; the empty-budget message offers a reroll rather than `StateMessage`'s "Retry", which promised a re-run of something that had not failed; `setState` and the watch-later toggle catch their rejections instead of leaving an unhandled promise and a click that silently does nothing. `StateMessage` gained a `compact` rung, which retires the frontend's only two `!important` overrides — they existed to beat the primitive's own padding from the call site, and were the copyable idiom for the next surface that wanted a compact empty state. Dead vocabulary removed or made load-bearing: `DISCOVER_BUDGETS` was exported, re-exported and consumed by nothing while `Home.tsx` re-typed the same four bands (a `readonly DiscoverBudget[]` accepts any subset, so a fifth band would have typechecked green and rendered no chip); `PIN_KINDS`' value export had no consumer at all; `useComfortPin` no longer publishes `pinIdOf`/`toggle`, which nothing outside the module calls. Three comments corrected against the code they describe: a pointer to a `resolve.py` that was never written, the file header's claim that Home defines no card component of its own, and the `seen` window still quoting the 8 that was measured and abandoned for 32. The rest of the review's survivors are logged on ROADMAP.md under "Deferred — E24 epic-end deep-review (2026-09-08)" — the ones whose dissent was substantive, and the ones whose fix is wider than the finding. |
||
|
|
cedebe0ffb |
test(e24): close the review's coverage gaps, and correct a comment that was untrue
`test_only_a_show_row_pays_for_a_resolution` seeded no watch state, so every rail query inner-joined its way to zero rows and the loop body — with it the assertion — never ran. It now seeds a video, a movie and an episode, and asserts all three kinds are present before the loop, so it cannot go vacuous again. The Plex half of the surprise rail had no test for either of its promises: every test that reached `plex_surprise` passed budget="any" against a library with no state rows, so the duration bounds and the already-started exclusion could both be deleted with the suite green. The YouTube half has had tests for the same two rules since S1 — the asymmetric half of one principle. `plex_item` and `plex_show` — two of the five pinnable kinds — were pinned by no test at all, leaving the rating_key → internal-id translation and both shelf arms uncovered, including the self-reference cycle that once 500'd the shelf. And `GET /api/discover/pick` was exercised only through its domain function, so the route's own query-string translation could regress silently; the new test excludes all three candidates, which is what proves the whole list parsed rather than one unsplit string. `PIN_COLUMNS`/`PIN_KINDS` claimed in a comment to be the one place the pin vocabulary is spoken and had no readers anywhere: `resolve_ref` re-spelled the column names and the route re-typed the kind list. Both now drive off the constants. The `_NO_TS` sentinel was introduced as a crash fix and is not one. The key it replaced, `(ts is not None, ts)`, cannot raise — tuple comparison finds the first UNEQUAL element via `==`, so two null keys compare equal and `None < None` is never reached; three independent runs confirmed it over every permutation. The rationale now says what the sentinel actually buys, lives once in `plex.rollup`, and the third rail that was still on the older spelling has been swept to match. Its test is renamed and re-scoped to the mutation it does catch. Frontend: the reroll's second press asserted `expect.any(Array)`, which cannot tell an accumulating exclusion window from a replacing one — both that and SEEN_WINDOW = 1 (each reinstating the measured nine-card cycle) left the suite green. And `onInfo`'s jump type was untested while `onPlay`'s was pinned twice, so pointing the ⓘ affordance at the player typechecked and shipped green. All eight fixes mutation-checked; every mutant died on its intended test. |
||
|
|
fdc1eabbe0 |
fix(e24): the review's seven frontend defects on the Home surface
Shift+W was dead whenever anything was open. The global handler called `setPage` directly, so the page change unmounted the player (it lives inside the module column) and that unmount's reconcile reclaimed the entry the navigation had just pushed — the player closed and the page snapped back. `closeThenNavigate` cannot serve here: a global key does not know which overlay is open, or whether one is. `navigateOverOverlays` is the same fix one step shorter — bury the live overlay entries first, so the unmount consumes the marks instead of issuing `history.back()`. `q.isError` had no `&& !data` guard, alone among 33 call sites and against the convention `ui/QueryState` documents: react-query flips status to error on a failed REFETCH while keeping the payload, and every mutation here invalidates the discover prefix, so one blip while marking a card watched blanked a fully populated landing page. The reroll's key rooted at "discover-pick", outside the prefix every mutation invalidates, so a rerolled hero outlived the page: play it, finish it, and it was still the answer with a zeroed progress bar. It now sits under the same root. A FAILED reroll also rewound the hero to the card the page loaded with, several presses back, because a new key per press means `keepPreviousData` (pending-only) cannot cover an error; the last resolved pick is held instead. The bookmark on a rail tile never updated — `VideoCard` reads `video.saved` from this payload and keeps no optimistic state — so the star stayed empty and the next click re-sent the same call. The shelf reorder wrote a captured snapshot back on failure rather than re-asking the server, which its own comment promised: two overlapping moves each reverted to their own snapshot and settled on an order neither side held, and since `before` is the whole payload a rollback also discarded any rail refresh that had landed meanwhile. It now invalidates the discover PREFIX on settle, which also fixes the sibling budgets keeping the pre-move order. The hero/rail de-duplication matched one key, so for a Plex series — the rail row is the show, the hero is the episode it resolves to — the same series printed twice; it now matches both keys, as `hero.pick`'s own exclusion already did. And when the hero empties the resume rail, the rail is hidden rather than announcing "nothing half-finished right now" under a hero captioned "where you left off". Ten regression tests, all mutation-checked. Three of them were rewritten after the first mutation round showed they could not go red: two asserted before the state under test had settled, one matched a title the Plex tile does not render. |
||
|
|
84f4b20e5a |
fix(e24): the review's three backend defects — blocklist, shelf order, next episode
The resume rail built its own SELECT instead of going through `filtered_query` and copied only three of its predicates, so it dropped the per-user channel blocklist the module docstring claims it inherits. Blocking a channel never touches `video_states`, so a half-watched video from it kept its resume position and headed the rail — and, at the default budget with the resume tier leading, became the landing page's hero: the app answering "what should I watch" with the thing the user had just banned. The clause is now `feedquery.not_blocked_cond`, shared with `filtered_query` rather than copied a second time; the feed's SCOPE clauses stay deliberately unapplied, for the reason `yt_video_row` already gives. `next_position` returned the shelf's row COUNT while nothing ever renumbers positions — `remove_pin` deletes, and every FK cascade removes a pin when its target disappears. Past the first hole the count under-counts, so a new pin landed in the middle of the shelf and, past the second, on a position another pin already held. `max(position) + 1`, the idiom `routes/playlists` already uses. `next_episode` took the FIRST in-progress episode in broadcast order while `plex.rollup`, which drives the show page's Resume button, takes the last. One show therefore had two different "continue watching" answers, with the Home tile ranking it by its most recent touch and then offering the episode abandoned weeks earlier. The new test asserts the two implementations against each other rather than against a literal. All three mutation-checked: each new test goes red for the defect it names. |
||
|
|
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. |
||
|
|
82914b56ca |
fix(e24): the Home surface's three UAT findings — Back, the reroll, and a show card
All three were measured before they were touched, and the diagnosis moved twice.
**Back from a Plex title played off Home landed on the Plex module's root.** One
click pushed three history entries: the page navigation, the sub-view opened by
the `jumpTo` hand-off, and — under StrictMode, whose effect body runs twice
before `setJumpTo(null)` re-renders — that same sub-view a second time. So the
first Back visibly did nothing and the second stranded the user on a Discover
page they had never visited.
A cross-module hand-off is the CONTINUATION of the navigation that brought you
there, not a second navigation, so it now REPLACES the page entry instead of
stacking on it (`useHistorySubview.open(view, {replace})`, asked for by the jump
target). Replacing is also idempotent, which is what disarms the StrictMode
double-run. The intent rides on `PlexJumpTarget` rather than being inferred,
because the header typeahead jumps from INSIDE the module, where the entry being
replaced would be a sub-view the user really was looking at.
**"Mást kérek" cycled nine cards and never left the resume tier.** Measured:
twelve presses, nine distinct titles, period nine, every one `tier: "resume"` —
the shelf and the surprise tier were unreachable by pressing the button. Two
causes multiplied: the tiers were tried in a fixed priority order over a 20-row
resume rail, so the button just walked down it; and the client's rolling
exclusion window of 8 guaranteed the wrap.
The press count now rotates which tier answers first (`start`), so each press
offers a different KIND of answer — the user's call, given the choice between
that, a wider window, and a surprise-only reroll. The fallthrough is kept, so a
press does not come back empty because the tier it led with had nothing. The
window is matched to the server's own cap rather than a smaller number of ours.
**A half-watched series on Home opened the show page.** A movie already went
straight to the player; only shows did not — and the comfort shelf and the hero
both resolved a show to its next episode while this rail alone did not, handing
back six seasons to choose between. The rail row now carries `next`, and the
client already played `next ?? card`, so this is a backend fix.
Affording it needed `queries.next_episode` to stop loading every episode of the
show into Python to pick one of them (236 rows for Friends, 227 for JAG) — three
ordered LIMIT 1 probes instead. The show→episode rule now lives in ONE function
(`rails.playable`), which is what the shelf, the hero and the rail all call; two
implementations are how they came to disagree.
Ten mutations run, all ten red for the right test — including a first pass where
the press count reached `api.discoverPick` and stopped there, invisible because
every Home test mocks that module. The slice now has its own URL test.
Verified in a clean browser tab against the live account: one Back returns to
Home, the rotation gives resume/comfort/surprise across nine presses with no
repeat, and Westworld opens at S1E1 rather than at its show page.
|
||
|
|
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. |
||
|
|
53b50231ff |
feat(e24): the Home surface read-model — hero, resume rail, comfort shelf
E24 S1. The anti-paralysis answer layer, served from our own Postgres with no upstream call: one hero suggestion, one CROSS-SOURCE resume rail, and the user's own comfort shelf. Survey first, because the roadmap line was wrong about the starting point: Plex already ships the whole trio (E5's Discover rails, including a reshuffling Surprise me), the YouTube side ships none of it, and nothing anywhere answers the question across both. The data already existed on both sides — VideoState and PlexState are structurally identical, the feed has an `in_progress` filter and a seeded shuffle, and the YT player already resumes position. - app/discover/budget.py — the time bands. "How much time do you have" (10 perc / 30 perc / egy este / mindegy) replaces 15/D's mood questionnaire: deterministic, answerable from a column we have, and it picks the source for free. Matched against REMAINING time, so a film with 8 minutes left is a ten-minute answer. An unknown runtime fits only the unbounded band. - app/discover/cards.py — a thin unified card that carries the NATIVE feed / Plex payload, so the page renders with the existing card components instead of a third one. `aspect` is the one new field: a rail needs to know poster from 16:9, and that is a property of the artwork, not of the module. - app/discover/queries.py — the SQL, reusing feedquery and plex.rollup rather than the Plex Discover ROUTE, whose LibraryFilters narrowing belongs to a surface that has a sidebar. One seed drives both halves so a reroll moves them together. - app/discover/hero.py — resume -> comfort -> surprise, with the chosen tier in the payload so the client labels it honestly instead of guessing. - comfort_pins (0083): five nullable CASCADE-ing FKs under num_nonnulls(...) = 1 rather than a flag or a polymorphic string ref. A flag cannot order a shelf or hold a channel; a string ref would let a deleted library item leave a dead tile on the one surface whose whole job is to be reliable. Two honest-state rules the tests pin: the surprise draw excludes anything already started (the resume tier may have skipped it for not fitting, and offering it back as "Meglepetés" would contradict the question), and which source leads the draw follows the seed, so the Plex library can actually answer "egy este". Gate green: ruff, ruff format, 3x tsc, eslint, prettier, knip, vitest 770, pytest 987. |
||
|
|
5703c4d713 |
fix(plex): a null progress timestamp must sort a rail row last, not raise
The Continue-watching and Surprise rails sorted on `(ts is not None, ts)`. That key LOOKS null-safe and is not: two rows that both lack a timestamp compare equal on the flag and then compare `None < None`, which raises — so a show whose rollup has no `resumed_at`, or a movie state written before `progress_updated_at` existed, could 500 the rail instead of sorting last. Replaced with an explicit epoch sentinel, and named it beside the other rail constants so the sibling rail E24 adds cannot re-derive the same broken key. |
||
|
|
013f19faca |
refactor(plex): move content_libs down into the query layer
The browse scope is a pure DB query with no request state, but it lived in routes/plex/_shared.py. E24's Home surface needs the same scope from a domain package, and a domain package importing from routes/ inverts the one-way dependency CLAUDE.md draws. Moved to app/plex/query.py and renamed to content_libs (public — it is a domain helper now). Swept all five call sites and both tests in one go rather than leaving a re-export shim: the canonical-patterns table already names the routes.admin re-export as the anti-pattern. |
||
|
|
8d9a99ce54 |
release: 0.82.0 (feature/e10-platform-ergonomics)
gate / frontend (push) Failing after 2m7s
gate / backend (push) Successful in 3m36s
|
||
|
|
45dd4bdda7 | chore(release): 0.82.0 — E10 Platform & Ergonomics | ||
|
|
dac391a890 |
fix(e10): review round 3 — settle the focus race in a browser instead of guessing again
Round 3 reviewed round 2's diff. Trend across the loop: 32 findings over the whole epic, 15 over round 1's fixes, 11 over round 2's. What it caught is mostly the same shape as before — a guard narrowed until it was wrong in the other direction — plus one item this branch had now got wrong three different ways. Backspace ownership over the player, third and final answer. Round 1 deferred to nothing (the cheat sheet took the key and the player stopped underneath it). Round 1's fix deferred to `anyLayerAbove` (the player's own gear/tracks popovers push layers too, so it stopped closing them). Round 2's fix named the sheet and palette (the app-wide ErrorDialog is neither, so the player tore down under it). The rule that actually holds is not a list of overlays: the player's own panels answer first, because handleBack's cascade is what closes them, and anything else layered above takes the key. The rail's inline rename is settled by measurement rather than argument. Two rounds reasoned about whether a React passive effect lands before or after floating-ui's return-focus microtask and reached opposite conclusions; a jsdom harness built to decide it reproduced no failure for ANY shape, because focus never enters the menu there — so it was deleted rather than kept as a test that discriminates nothing. Checked in a real browser instead: the field opens and holds focus. The shipped fix works, and the comment now says what was measured rather than what was inferred. The same check found that round 1's OTHER rail fix never worked at all. Escape does run its own handler (typing then Escape leaves the name unchanged, so it cancels rather than blur-commits) and the row button is focusable when focused from outside — yet focus still lands on <body>. Three explanations, none of which survived. So the refocus machinery is reverted to the shape that shipped before this epic and the gap is documented where the next person will hit it, with the evidence and an instruction to instrument before theorising. A fourth guess would have been worse than a known bug. Also: the two comments in Playlists.tsx that still claimed the disabled menu rows explain themselves — untrue since round 2 reverted `inert`, and exactly the comment-vs-code drift CLAUDE.md calls the source of the worst bugs here. And the move-failure toast printed HttpError.detail verbatim, which for the reachable 404 is the backend's bare English "Unknown playlist"; only a structured detail has been through localizeDetail, so only a structured detail reaches the toast now. |
||
|
|
4b33036e7c |
fix(e10): review round 2 — revert what round 1 got wrong, narrow what it got too broad
Round 2 reviewed round 1's own fix diff, which is where a fix round does
its damage. It found 15 issues in 792 lines — a much higher density than
round 1 found in the whole epic — and most were regressions round 1
introduced. So this follows the rule rather than fighting it: revert the
fixes whose mechanism was wrong, narrow the ones whose guard was too
broad, and stop reaching for a third variant.
REVERTED, because the mechanism does not work:
- The `inert` menu item. Round 1 swapped a native `disabled` for
`aria-disabled` so a row's "why is this off" tooltip could be reached.
floating-ui's list navigation skips `aria-disabled="true"` exactly as it
skips `disabled` (isListIndexDisabled) — and AnchoredPopover's own
comment, written before this branch, already said so. The swap fixed
nothing and made the mouse worse: without native `disabled` the row took
pointer events, the full active highlight and DOM focus, then no-opped
on click. The underlying finding is logged where the prop was, unfixed
and honest: the reason has to go somewhere a disabled row is not.
- `useContextMenu` owning the focus restore. Turning floating-ui's
returnFocus off platform-wide traded one bug for two: Playlists spreads
triggerProps on a bare <tr>, so restoring to it is a spec no-op and
Escape stranded focus on <body>; and with dismissOnScroll now on, the
restore's plain focus() scrolled the row back into view, undoing the
scroll that closed the menu. Patching that needs three more guards on
one fix. The original bug — the rail's rename field dying on arrival —
is fixed at the field instead: it focuses from an effect, which is
scheduled after the microtask that was blurring it.
NARROWED, because the fix was right and the guard was not:
- PlexPlayer's Backspace deferred to `anyLayerAbove`, but the player's own
gear and tracks menus register layers via useDismiss — so Backspace
stopped closing them and did nothing at all. Only the cheat sheet and
the palette own that key, so it names them.
- `historyDepth = 0` also desynced the counter from overlays the
navigation does NOT unmount (About and ReleaseNotes live outside the
page column), leaving one Back to both navigate and close them. The
entries are marked buried and consumed silently by reconcile instead.
- The move's error toast fired for every status, so a transport fault
surfaced twice on top of req's own dialog. Scoped to 403/404.
- The palette's moved-pointer guard could not judge the FIRST move of a
session and went stale across a close. `e.movementX/Y` asks the browser
directly and needs no state.
- `useKeyScope("contextMenu")` runs once per hook, and the Playlists page
mounts two — so the cheat sheet rendered the chords twice under a
duplicated React key. The scope stack dedupes.
- The `enabled:hover:` sweep missed the half that lives in CSS, so a
disabled glass button still lifted. `.glass-hover:not(:disabled):hover`.
- ShareDialog's migrated buttons passed a colour className that the
variant's own colour class outranks under clsx, so Revoke hovered
neutral and a password-protected link looked unprotected. The colour
goes on the icon, as the Copy button beside them already did.
- `matchesShortcut` took an untyped id and ids are only unique within a
scope, so `back` would have answered for the wrong player. Keyed on
scope + id, both literal.
And the systemic one: round 2 reverted eight of round 1's behaviour fixes
as a single mutation and all 763 tests still passed. Round 1 tested the
three fixes that were easy to test and none of the rest. So the guards
with the widest blast radius are pinned now, each mutation-checked to
fail for its own reason: the global keymap's repeat/allowInInput/disabled
handling (a new dom test), the cheat sheet bound to the real
useContextMenu rather than a scope stub, the scope dedupe, the history
burial, and the Button accessible-name union — whose @ts-expect-error
fixtures turn a widened type into a red typecheck lane.
The chipLane contract test that round 1 rewrote to count per lane counted
the import line as a lane, giving every file one free mirror — so the
exact hole it was rewritten to close was still open. Mutation-checked
both ways this time.
|
||
|
|
80dea4f307 |
fix(e10): review round 1, part 2 — the docs the epic invalidated, and three contracts made real
Finishes round 1. Part 1 took the runtime bugs; this takes the prose that had gone false and the three "the comment says so" gaps behind it. CLAUDE.md is auto-loaded into every agent session and ui/README.md is not, so an architecture paragraph that still described a flat components bag was the most expensive stale sentence in the repo: the next agent adding a popover reads it, sees no platform layer, and writes the generic component into components/ — the invisible-layer failure this epic exists to end. It now names components/ui/ and says the boundary is lint-enforced. The canonical-patterns table gained the three rows the epic established: ui/Button over a hand-rolled <button>, a keymap row over a private keydown listener, and disabled-when-busy vs inert-when-inapplicable. ui/README.md's inventory was written in this branch's first commit and never learned about CommandPalette, ContextMenu/useContextMenu, KeyboardHelp or gridColumns — in a section whose own closing rule is that a second component painting the same thing is how the last inconsistency happened. It also claimed "everything is rem-derived" while VirtualGrid's minCol/GRID_GAP are deliberately px; that is now stated as the exception it is, and a new section records which promises here are tests rather than sentences. Three contracts stopped being prose: - A text-less Button must carry a name. "Required for iconOnly" was a doc comment, which is why the migration shipped two nameless icon buttons through eight green lanes. It is a union now, covering both spellings (iconOnly, and size="icon"/"icon-sm"), satisfied by label, aria-label or title. It caught one more on the way in: Banner's dismiss took an optional dismissTitle, so it could render unnamed — it falls back to common.close now. - gridColumns' gutter arithmetic. Every existing assertion survived replacing floor((w + GAP) / (minCol + GAP)) with the naive floor(w / (minCol + GAP)): the fixed widths agree, the capped cases are cap-bound, and the loops compare columnsFor against itself. Pinned at the exact-fit boundaries where the two first disagree (536 and 828). - ShareDialog's link row. Only Copy was migrated in S1, leaving one 2rem button between two 1.75rem ones — and on a coarse pointer .touch-target grew only the migrated one to 2.75rem while the destructive Revoke stayed small. Also: the maxCols docstring said 8 columns of 384px where the test it shipped with asserts 369. |
||
|
|
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. |
||
|
|
7d302f71b7 |
feat(e10): let an ultrawide buy bigger cards instead of more of them
The viewport contract has two ends, and only the floor was enforced. An auto-fill grid answers "more room" with more columns, never bigger ones, so it settles just above its minimum column at any width: measured at 3440px, the feed laid out 11 columns of 264px. A feed diluted across an ultrawide fails the contract as surely as a broken one at the floor. Multi-column feed views now carry a `maxCols`; past it the leftover width flows into the items. At 3440: cards 11x264 -> 8x370, cardsSmall 15 -> 11x264 (still visibly the dense card, and never as wide as cards gets), tiles 7 -> 6x498. Each cap is set so the item tops out near 1.5x its own minCol, which is what keeps a view recognisably itself. A cap can only bind ABOVE the width at which that many columns already fit, so it is a pure ultrawide affordance — 1024, 1920 and 2560 lay out exactly as before. Verified in the browser at all three widths and across all five views. The Channels grid needs no cap: the page's own 96rem --content-mw already bounds it. Also renames VirtualGrid's `maxCol` to `colMaxWidth`. It was never a column count — it is the single-column reading-width cap — and `maxCol`/`maxCols` one letter apart is the kind of drift that has bitten this repo before. The column maths moves to ui/gridColumns.ts as a pure function so both ends of the contract are unit-testable without a layout engine; the tests assert the cap's numbers, that it never binds at the floor, and that the dense card cannot grow into the standard one. |
||
|
|
f99240d8ca |
fix(e10): stop the hidden chip measurer scrolling the whole app sideways
The four chip lanes render an intentionally unbounded hidden "mirror" so the shared fit core can read each chip's natural width. An absolutely positioned box still contributes to its ancestors' SCROLLABLE overflow, and `visibility: hidden` does not exempt it — and nothing between the toolbar band and <html> clipped it. At the 1024x768 floor that meant the channel manager's document scroll width was 1258px: the whole app scrolled 249px sideways into dead space and dragged the fixed header to x=-177. The same mirror was also 228px wide inside the Tags column's 144px cell, which pushed the table to 990px inside its 906px container — i.e. it broke DataTable's "the table never exceeds its container" contract and truncated channel names to "The...". One cause, two symptoms. The lane now carries a shared `chipLaneClass` = `relative overflow-clip`, so the contract lives in one constant instead of four hand-rolled class strings. `clip` rather than `hidden` because it creates no scrollport, so nothing can ever scroll the mirror into view. Measurement is untouched: offsetLeft/offsetWidth are layout values and a clip does not change layout. Measured after: document 1258 -> 1024, sideways scroll 249 -> 0, table 990 -> 921 with no cell overflow, and the "+N" collapse still fits exactly as before. A source-level test asserts every component rendering a mirror uses the lane class — the drift a fifth hand-rolled lane would introduce, which no DOM test can catch since jsdom has no layout engine to overflow. |
||
|
|
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. |
||
|
|
86163958d6 |
feat(e10): one keyboard cheat sheet for the whole app
The Plex player had a shortcuts overlay nobody else did, so the YouTube player's Space/F/arrows and the Plex browser's Backspace were documented nowhere. ui/KeyboardHelp is that sheet for every surface, opened by a global `h` - not the conventional `?`, which is a Shift chord on a Hungarian layout, while `h` is the key this app's reader already has in their fingers from the player. It renders the shared table filtered to the scopes that are LIVE: a surface registers its scope with useKeyScope while mounted, and only while its keys really do something - PlexBrowse deactivates its own scope inside the player, where its Backspace no-ops, so the sheet cannot promise a "Back one level" the handler declines to perform. Same rule for the `when` rows: ⇧←/⇧→ are listed only inside a series, an answer the player publishes because the sheet cannot ask. No new overlay primitive, after checking: Modal already portals into document.fullscreenElement ?? document.body (so this works over a fullscreen player), registers an Escape layer, traps focus and handles browser Back. Third time this epic that the thing was already built. Shift+F10 is in the table as `handledLocally` - documented and sharing the chord parser, but matched by useContextMenu on the focused row. dispatch neither routes it nor demands a handler for it, so the exhaustiveness check stays honest about what it actually covers. Also: A/S have cycled the Plex audio and subtitle tracks since E5, but silently - the only way to see where you landed was to open the tracks menu, the exact round-trip the shortcut exists to avoid. cycleAudio and cycleSubtitle now return the track they moved to, and the player flashes it over the video. |
||
|
|
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. |
||
|
|
b28be682fe |
feat(e10): make "move a video to another list" one atomic request
The one-step move S2 needs has no honest client-side shape: "add to the target, then
remove from the source" leaves the video in BOTH lists whenever the second call fails,
and the reverse order loses it entirely. So it is one endpoint in one transaction —
POST /api/playlists/{id}/items/{video_id}/move — which re-parents the existing item row
(so `added_at` survives), recomputes `dirty` on both sides, and is idempotent when the
target already holds the video.
Also publishes `updated_at` on the playlist summary, and bumps it explicitly for the
edits that only write `playlist_items` (add / remove / reorder / move). UpdatedAtMixin's
`onupdate` fires on an UPDATE of the playlist row, so without `_touch` the rail's coming
"recently changed" sort would have seen renames and nothing else.
|
||
|
|
7e97559615 |
feat(e10): put the 1024x768 size contract inside the primitives
S1c, the last part of S1. The contract now lives in components/ui/ and in one CSS recipe instead of in ~160 call sites: - Minimum target 2.75rem, on coarse pointers only (.touch-target). A 32px icon button is fine with a mouse -- precise pointer, hover to confirm the aim -- and not fine with a thumb, so it grows exactly where there is no precise pointer, which on this app's real second device is the tablet. It grows the real box rather than painting an invisible ::after hit area, because two expanded hit areas on icon buttons 4px apart would overlap and the overlap fires the WRONG button. `link` is excluded: it is text inside a sentence. - 2.75rem rather than 44px so the target obeys the text-size slider like everything else; verified that the type/geometry ladder was already rem-derived off --font-scale, so nothing else was needed there. - The ladder is documented as a closed set in Button.tsx, with the site counts it was derived from, and ui/README.md states the contract. Measured in the browser: fine pointer 36x36 and 34px-tall buttons (unchanged), coarse pointer the same buttons at 44x44 and 44px tall. 1024x768 renders identically. 3x tsc, eslint, knip, prettier and 690 vitest green. |