Compare commits

..
34 Commits
Author SHA1 Message Date
peter 14b5fe2e31 release: 0.83.0 (feature/e24-anti-paralysis)
gate / frontend (push) Failing after 2m5s
gate / backend (push) Successful in 7m0s
2026-09-09 22:04:14 +02:00
peter d260aabfd7 chore(release): 0.83.0 — the Home surface 2026-09-09 22:01:33 +02:00
peter 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.
2026-09-09 01:02:22 +02:00
peter 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.
2026-09-09 00:39:37 +02:00
peter 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.
2026-09-09 00:12:53 +02:00
peter 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.
2026-09-08 03:23:59 +02:00
peter 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.
2026-09-08 03:14:21 +02:00
peter 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.
2026-09-08 03:03:45 +02:00
peter 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.
2026-09-08 02:32:08 +02:00
peter 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.
2026-09-08 00:06:34 +02:00
peter 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.
2026-09-06 05:38:28 +02:00
peter 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.
2026-09-06 05:07:06 +02:00
peter 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.
2026-09-06 04:14:14 +02:00
peter 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.
2026-09-06 03:31:26 +02:00
peter 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.
2026-09-06 03:31:09 +02:00
peter 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.
2026-09-06 03:31:02 +02:00
peter 8d9a99ce54 release: 0.82.0 (feature/e10-platform-ergonomics)
gate / frontend (push) Failing after 2m7s
gate / backend (push) Successful in 3m36s
2026-09-06 02:37:10 +02:00
peter 45dd4bdda7 chore(release): 0.82.0 — E10 Platform & Ergonomics 2026-09-06 02:37:07 +02:00
peter 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.
2026-09-06 02:22:23 +02:00
peter 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.
2026-09-06 01:57:32 +02:00
peter 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.
2026-09-06 01:30:40 +02:00
peter 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.
2026-09-05 23:52:29 +02:00
peter 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.
2026-09-05 22:22:28 +02:00
peter 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.
2026-09-05 22:22:14 +02:00
peter 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.
2026-09-05 21:33:29 +02:00
peter 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.
2026-09-05 21:15:42 +02:00
peter 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.
2026-09-05 20:58:17 +02:00
peter 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.
2026-09-05 19:59:56 +02:00
peter 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.
2026-09-05 19:59:41 +02:00
peter 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.
2026-09-05 18:49:48 +02:00
peter 721467ef69 feat(e10): finish the mappable Button migration, including the ui/ layer itself
Third sweep, 20 more sites: the rest of AdminUsers (the access-request
and invite clusters), Scheduler, SettingsPanel, SetupWizard, About, Feed,
KeyGate, OnboardingWizard -- and, importantly, four platform components
that were still hand-rolling their own buttons: Modal's close, Banner's
dismiss, SidePanel's two toggles and QueryState's retry. A platform layer
that bypasses its own primitive is the same defect one level up.

Hand-rolled buttons with their own geometry: 110 -> 39; Button call sites
37 -> 106. What is left is deliberate, and it is the list Button's own
docstring excludes plus two families this pass refused to force: video
overlay controls painted on white/N, drag handles, chip removers,
accent-solid pills, the bg-card family on the signed-out screen, and
sites whose paint is conditional (`suspended ? warn : neutral`) or
otherwise has no variant yet. Forcing those through a className override
would be unsafe without tailwind-merge, which is exactly the hazard this
sprint documented.

3x tsc, eslint, knip, prettier, 689 vitest green; UAT on Settings and the
About dialog (its close button and full-width CTA are both migrated),
clean console.
2026-09-05 18:41:41 +02:00
peter 47e2293de3 feat(e10): migrate the notification, message, auth and Plex action buttons
Second sweep, 34 more sites: NotificationsPanel (14 -- eight identical
accent text links, three ghost icon buttons, two ghost text actions),
Welcome's five auth links, KeyGate, Messages, NavSidebar, ChatThread,
ShareDialog, ChannelPage, PlexPlaylistView, PlexPlaylistAdd,
PlexCollectionEditor and plexViews.

Moves the font-weight out of Button's BASE and into each variant, so the
`link` variant can be semibold (which is what every text link in this app
looks like) while the action variants stay medium, and no element can end
up with two font-* utilities.

Welcome is the one signed-out surface this session cannot see, so only
its byte-identical link swaps were taken; its primary submit and the
bg-card button family are left for a pass that can UAT the screen.

3x tsc, eslint, knip, prettier, 689 vitest green; UAT on Notifications,
Messages and the nav rail, clean console in a fresh tab.
2026-09-05 18:39:22 +02:00
peter 29bb914a95 feat(e10): re-aim the Button variants at the paint the app actually uses, and migrate the first 15 sites
Counting the un-migrated call sites showed the primitive and the app had
drifted into two different looks: the majority of hand-rolled buttons
hover to accent (outline) and fill with card (ghost), while Button
hovered to fg and filled with surface. That split is the inconsistency
this epic exists to remove, so the primitive follows the majority rather
than the other way round -- 16 existing call sites shift slightly, ~90
hand-rolled ones stop being special.

Adds the variants the call sites prove are missing (outline-accent,
outline-danger, outline-warn, link), an icon-sm size for the p-1.5 icon
family, and a size=none so a link owns no button padding; renames
danger-outline to danger-soft so it cannot be confused with
outline-danger (red at rest vs red on hover). Two new tests lock the
link-size default and the rule that a variant never adds a second
font-weight utility -- without tailwind-merge, two font-* utilities are
decided by stylesheet order, not class order, so that is a real hazard.

Migrates AdminUsers, AuditLog, Channels, TagManager, Playlists,
PlaylistsRail and SettingsPanel (Button call sites 37 -> 52). Left alone
deliberately: conditional-paint sites, video-overlay white buttons, drag
handles, chip removers and card-click targets -- the families Button's
own docstring excludes.

3x tsc, eslint, knip, prettier and 689 vitest green; visual UAT on Users,
Access requests and Playlists at 1280x900, no console errors.
2026-09-05 18:31:00 +02:00
peter 5bfcf5d83a refactor(e10): move the platform components into components/ui/ and enforce the boundary
The app HAS a platform layer -- AnchoredPopover (22 importers, with
arrows/typeahead/Escape/anchor-flip), QueryState (21), Modal (17),
PageShell, DataTable, Tabs, Tooltip and ~26 more -- but it sat flat among
~160 feature components with nothing marking it as one. Several of those
files describe themselves as 'the one place' or 'canonical' and were
still bypassed by hand-rolled markup, because a layer nobody can see is a
layer nobody uses; three separate inventory passes of this codebase
misread it the same way.

Moves the 33 domain-free modules into components/ui/, rewrites the
imports in 89 files, and makes the boundary a lint rule rather than a
convention: inside ui/ a one-level-up import (a feature component) and
any lib/api slice are errors. The rule was verified to fire on both and
to leave generic lib/ helpers alone. README.md states which side of the
line a component belongs on and why.

No behaviour change: 3x tsc, eslint, knip, prettier and 687 vitest tests
all green; no backend file touched.
2026-09-05 16:58:29 +02:00
235 changed files with 10114 additions and 1146 deletions
+49
View File
@@ -7,6 +7,55 @@ edit the release notes and re-run the generator.
Upgrading, pinning a version and rolling back: [docs/self-hosting.md](docs/self-hosting.md#upgrading).
## 0.83.0 — 2026-09-09
Siftlode now opens on a page that answers one question: what should I watch right now. You say how much time you have — ten minutes, half an hour, a whole evening — and it gives you one suggestion, from YouTube or from Plex, with a button that picks up where you left off. Under it sits a shelf of the things you always come back to, which you fill yourself.
### Features
- A new Home page, and it is where Siftlode starts. It asks how much time you have — 10 minutes, 30 minutes, an evening, or it does not matter — and answers with ONE suggestion rather than a wall of thumbnails. The question is on top, the answer directly under it. If you would rather land on the feed as before, Settings → Appearance → Startup turns it off.
- The time you have picks the source, not just the length. Ten minutes gets you a short video; an evening is allowed to offer a film. It matches on the time you have LEFT in something, so a series episode you are four minutes from finishing counts as a four-minute answer.
- The suggestion is one of three things, and it says which: something you left half-finished, something off your comfort shelf, or a surprise. Continue takes you back to the second you stopped. Something else rerolls it — there is no separate surprise-me page, the reroll is it.
- A comfort shelf you fill yourself, for the media you put on without thinking. It takes a whole channel, a whole show, a film, a single video or a playlist — because comfort viewing is put on TheVR, not play this one episode. One click resolves it: a show gives you the next unwatched episode, a channel its newest unwatched video. The shelf can be reordered into the order you want.
- Pin from wherever you are: the same ⋯ menu — or a right-click — on a feed video, a channel row, a playlist, a Plex poster or a Plex row now carries a Comfort shelf entry. Feed video cards answer a right-click for the first time.
- Continue watching, on both sides at once. The rail merges what you left half-watched on YouTube with what you left half-watched on Plex, most recently touched first — the YouTube half of that did not exist before.
- Shift+W jumps to Home from anywhere, including out of a player, and it is in the keyboard sheet. The jump-to box (Ctrl+K) gained a Surprise me row, and typing what should I watch finds Home itself.
### Internal
- E24, four sprints. S1 built a discover read-model as its own backend package — the unified card shape maps the EXISTING Plex and feed serializers rather than adding a base serializer, the rails reuse the feed query layer and the Plex rollup, and the tiered hero rule is one shared function that returns the tier it chose, so the UI prints an honest kicker instead of guessing. The shelf is a typed comfort_pins table with a one-of check constraint and a real foreign key per kind, not a polymorphic string reference: a deleted Plex item must not leave a dead pin on the shelf. S2 built the page on the existing carousel and the two existing card components rather than a third. S3 wired pinning through the context-menu input path E10 shipped, which absorbed that epic's parked video-card item. S4 added the ways in.
- The closing review ran four rounds, 46 → 23 → 19 → 15 → 14, each round reviewing the previous round's own fix diff, with three adversarial verifiers per finding in rounds one to three. Four of round one's findings died at 2-of-3 and are recorded so they are not raised again. What the eight-lane gate could not have caught: the resume rail built its own query and dropped the per-user channel blocklist, so a video from a channel you had just banned could become the landing page's suggestion; the shelf numbered a new pin by row COUNT while nothing renumbers, so after any non-tail unpin a pin landed mid-shelf and could collide; the Home tile and the show page disagreed about which episode comes next, one taking the first in-progress episode and the other the last; and Shift+W was dead whenever anything was open, because the page change unmounted the player and its cleanup reclaimed the history entry the navigation had just pushed. Rounds three and four stopped finding defects in the epic and started finding them in the review's own fix commits — a test named after a branch it never reached, comments claiming more than the code does — which is the signal to stop.
- Two process notes worth keeping. A commit in this epic fixed a bug that does not exist: its message and two comments claimed a sort key raises when two rows both lack a timestamp, which tuple comparison never reaches — three verifiers executed it independently, and the rationale is now corrected in place rather than quietly deleted. And three of the regression tests written during the fix rounds could not go red for the defect they named until they were rewritten, which is why every test added in a fix round on this epic was mutation-checked.
## 0.82.0 — 2026-09-06
Siftlode gets a keyboard. There is one place that lists every shortcut, a jump-to box that takes you anywhere by name, and a right-click menu on playlist rows — plus the whole app now holds together at a tablet's width and stops thinning itself out on a very wide screen.
### Features
- Press h anywhere and Siftlode tells you what you can press. One sheet for the whole app, and it only lists keys that actually do something where you are standing — the player's keys inside the player, undo where there is something to undo. It is h rather than the usual ?, because on a Hungarian keyboard ? is a two-key chord and h is already the key you use in the Plex player.
- Ctrl+K (⌘K on a Mac) opens a jump-to box: start typing and it finds your modules, channels, playlists and saved views by name, plus a few actions like switching theme. It ignores accents and word order, so "penzugy" finds "Pénzügy" and "pod thevr" finds "TheVR Podcast".
- Playlist rows answer a right-click. The same menu the ⋯ button opens — right-click anywhere on the row, long-press on a tablet, or Shift+F10 from the keyboard. Rail rows can be renamed in place and deleted from it, and a video can be moved straight to another list through a submenu.
- Moving a video between lists is now one request instead of two. It used to add it there and then remove it here, which left the video in both lists whenever the second step failed; and if the move cannot be done, it now says so instead of the row quietly sliding back.
- The app is built for 1024×768 as its floor — a tablet in landscape — and every page is measured against it rather than eyeballed. That caught a bug no screenshot could show: a hidden element used to measure filter chips was dragging the whole page 249px sideways on the channel manager, pulling the header off-screen with it.
- A very wide screen now gets bigger cards instead of more of them. At 3440px the feed used to lay out eleven narrow columns and keep the cards at their minimum size; it now spends the extra width on the cards, which is what a screen you watch from across the room wants. Nothing changes at 1024, 1920 or a normal 2560.
- Switching audio or subtitle track in the Plex player finally tells you where you landed. The keys already worked — what was missing was the answer, so you had to open the tracks menu to see which one you had picked.
### Fixes
- Ctrl+A in the Plex player selects text again instead of cycling the audio track. Six separate keyboard handlers had drifted apart; the one in the player matched the letter without checking whether Ctrl was held.
- Typing into a dropdown or an editable area no longer triggers app shortcuts. Some surfaces checked for a text box and a text area but not for either of those.
- The keyboard sheet no longer promises keys that do nothing where you are. Backspace does not claim to go back one level while the player is up, because there it does not.
- Choosing a destination from the jump-to box lands you there. A command that navigates used to be undone by the box closing a fraction of a second later.
- A button that is switched off no longer lights up under the pointer as though it could be clicked.
- Two buttons in Messages announced themselves to a screen reader as just "button"; both are the only way out of the view they sit in.
- In Hungarian, the built-in list is called "Megnézem később" everywhere, including in the message explaining that it cannot be renamed — which used to call it "Watch later".
### Internal
- E10, four sprints. S1 finished a half-done component adoption: the app's shared button primitive gained the variants the call sites proved were missing, ~60 hand-rolled buttons moved onto it, and the 1024×768 size contract (minimum touch target, the type scale bound to the text-size slider) moved into the primitives instead of living in ~160 call sites. The platform components moved into `components/ui/` and the boundary that makes it a layer — no feature components, no domain API slices — is enforced by eslint. S2 added a right-click/long-press/Shift+F10 input path onto the menu the app already had, rather than a second menu, plus an atomic move endpoint. S3 collapsed six drifting keydown listeners into one table whose handler map makes a table/implementation mismatch a type error, and built the cheat sheet and palette on it. S4 swept the viewport contract by measuring rather than looking.
- The closing review ran three rounds, 32 → 15 → 11, each one reviewing the previous round's own fix diff. Round 1's 77 raw findings deduplicated to 64 and 32 survived three-refuter adversarial verification; six died at 2-of-3 and are recorded so they are not raised again. Rounds 2 and 3 each found that the previous round's fixes had introduced regressions, and both reverted rather than attempting a third variant — an aria-disabled menu row that the popover library skips exactly as it skips a disabled one (a comment one file away already said so), and a focus-restore that broke two other surfaces. The eslint rule meant to enforce the new layer boundary turned out to match almost nothing: its patterns were written in plain string literals, where the escape for a literal dot collapses into a wildcard, so the two commonest violations passed a green gate. It now has its own test. One bug was settled by measuring in a real browser after three rounds produced three contradictory explanations of React's commit ordering, and the jsdom harness built to decide it was deleted for reproducing nothing. Residuals are on the roadmap rather than closed: a disabled menu row's explanation still reaches nobody, Escape from an inline rename strands focus, and the overlay history bookkeeping counts buried entries rather than marking them.
## 0.81.0 — 2026-09-05
If you run your own Siftlode, this one is mostly for you: there is now a way back from a bad upgrade, backup scripts that actually restore, and a way to let someone in on an instance that cannot send mail. If you just use Siftlode, the visible part is that a download can no longer quietly blow past your storage limit, and that the box refuses addresses on the server's own network.
+31 -24
View File
@@ -26,8 +26,8 @@ The local gate is **ruff (`check` + `format --check`) + typecheck (3 tsc project
keep commits logical and Conventional-Commit-styled.
- **No AI attribution in commits.** No `Co-Authored-By` trailer, no "generated by" lines. (This file
being AI-written is the intended, visible exception; commits are not.)
- **Release: user-owned in timing, agent-run in execution.** The user decides *when* to ship; on their
go-ahead *you* run `siftlode release` from the feature branch (merge to `main` → publish → deploy →
- **Release: user-owned in timing, agent-run in execution.** The user decides _when_ to ship; on their
go-ahead _you_ run `siftlode release` from the feature branch (merge to `main` → publish → deploy →
verify `/api/version`). Never self-ship — bring work to a green, committed state and stop for the
go-ahead first, then run it yourself.
- **Public repo.** Keep ops/topology/secrets out — those live in a separate private repo. Nothing here
@@ -41,7 +41,7 @@ how it gets built. Concretely:
- **Plan before you build.** For anything non-trivial, present a short plan (sprint-level for larger
work) and get a go-ahead before writing code.
- **Offer choices, mark the optimal — and explain the trade-off.** When several solutions are viable,
list them and mark the recommended one. This is the *one* place to spend extra words: give the *why*
list them and mark the recommended one. This is the _one_ place to spend extra words: give the _why_
behind each option and the weight of the decision. These decision points are where the product owner
engages with the build and learns from it, so make them understandable — a reasoned menu, not a bare one.
- **Mockups for design/UX decisions.** For any visual or interaction choice, show mockup options to
@@ -59,31 +59,38 @@ how it gets built. Concretely:
`downloads/`, `youtube/` (shared by routes + scheduler + worker) → flat core (`auth`, `quota`,
`sysconfig`, `state`, `audit`, `notifications`, `userscope`, `realtime`, `ratelimit`, `progress`,
`security`) → `models/db/config`. **Dependency direction is one-way: core never imports routes.**
Frontend: `main.tsx` provider tree → `App.tsx` shell → `moduleRegistry` route-chunks → components; data
via react-query (`qk` factory), per-account `localStorage`, and server prefs; API through `lib/api/*`
slices over `core.req<T>`.
Frontend: `main.tsx` provider tree → `App.tsx` shell → `moduleRegistry` route-chunks → module
components → the **platform layer `components/ui/`** (domain-free primitives: the popover engine and
its menus, `Modal`/`Overlay`, `Button`/`form`, `DataTable`/`VirtualGrid`, `QueryState`, …). That
boundary is one-way and **lint-enforced**, not just described: a platform component may not import a
feature component or a domain API slice (`no-restricted-imports` in `eslint.config.js`, with its own
test). A new generic component goes in `ui/`; see [`frontend/src/components/ui/README.md`](frontend/src/components/ui/README.md).
Data via react-query (`qk` factory), per-account `localStorage`, and server prefs; API through
`lib/api/*` slices over `core.req<T>`.
**Not wanted** (reviewed and rejected as over-engineering for this size): microservices, event bus,
CQRS, a repository/DI framework, a router library, a generic CRUD-router factory or base serializer.
## Canonical patterns — when you have a choice, pick THIS
| Area | Use | Not |
|---|---|---|
| ORM | SQLAlchemy 2.0 `select()` | `db.query(...)` |
| Request body | a Pydantic model | `payload: dict` |
| Ownership check | `owned_or_404` / `get_or_404` | ad-hoc `_own_playlist`/`_owned`/`_own_job` |
| Admin gate import | `app.auth` | the `app.routes.admin` re-export |
| Upstream failure status | `422` with JSON detail | `502` (reserve 502 for raw byte-proxies) |
| User-facing error | structured `detail` (`{code, reason}`) | a bare English string under a bilingual UI |
| Mutation response | `{"ok": true, …}` (`{"deleted": id}`) | ad-hoc `{"unsubscribed"}`/`{"cleared": n}`/echo |
| Transaction commit | the handler/job-wrapper commits; helpers `flush` | helpers that commit (only `quota` owns its session) |
| Admin knob storage | `sysconfig` (values) | env / `app_state` / `scheduler_settings` scatter |
| Job status word | the `downloads/status.py` StrEnum | loose `"canceled"`/`"cancelled"` literals |
| Current time | inline `datetime.now(timezone.utc)` | new `now_utc`/`_now` helpers |
| FE per-account state | `usePref` | raw `readAccount`/`writeAccount` or bespoke hooks |
| FE control | the chip/segmented primitives (`Select` only for >6 options) | a native `<select>` |
| FE loading/empty/error | `QueryState` / `StateMessage` | ad-hoc conditionals |
| Area | Use | Not |
| ----------------------- | ------------------------------------------------------------ | ------------------------------------------------------ |
| ORM | SQLAlchemy 2.0 `select()` | `db.query(...)` |
| Request body | a Pydantic model | `payload: dict` |
| Ownership check | `owned_or_404` / `get_or_404` | ad-hoc `_own_playlist`/`_owned`/`_own_job` |
| Admin gate import | `app.auth` | the `app.routes.admin` re-export |
| Upstream failure status | `422` with JSON detail | `502` (reserve 502 for raw byte-proxies) |
| User-facing error | structured `detail` (`{code, reason}`) | a bare English string under a bilingual UI |
| Mutation response | `{"ok": true, …}` (`{"deleted": id}`) | ad-hoc `{"unsubscribed"}`/`{"cleared": n}`/echo |
| Transaction commit | the handler/job-wrapper commits; helpers `flush` | helpers that commit (only `quota` owns its session) |
| Admin knob storage | `sysconfig` (values) | env / `app_state` / `scheduler_settings` scatter |
| Job status word | the `downloads/status.py` StrEnum | loose `"canceled"`/`"cancelled"` literals |
| Current time | inline `datetime.now(timezone.utc)` | new `now_utc`/`_now` helpers |
| FE per-account state | `usePref` | raw `readAccount`/`writeAccount` or bespoke hooks |
| FE action button | `ui/Button` — pick a `SIZES` rung | a hand-rolled `<button className="px-3 py-2 …">` |
| FE keyboard shortcut | a row in `lib/keymap.ts` + `dispatch()` | a private `keydown` listener with its own key literals |
| FE control | the chip/segmented primitives (`Select` only for >6 options) | a native `<select>` |
| FE loading/empty/error | `QueryState` / `StateMessage` | ad-hoc conditionals |
## House rules that bite (learned from real bugs)
@@ -100,6 +107,6 @@ CQRS, a repository/DI framework, a router library, a generic CRUD-router factory
---
*Run-it-locally and the full gate commands live in [`docs/development.md`](docs/development.md); backend
_Run-it-locally and the full gate commands live in [`docs/development.md`](docs/development.md); backend
code conventions in [`backend/app/README.md`](backend/app/README.md). This file stays the source of
truth for the canonical-patterns table and the gate/workflow/collaboration rules above.*
truth for the canonical-patterns table and the gate/workflow/collaboration rules above._
+1 -1
View File
@@ -1 +1 @@
0.81.0
0.83.0
@@ -0,0 +1,78 @@
"""comfort_pins — the shelf you fall back to when you cannot decide
Revision ID: 0083_comfort_pins
Revises: 0082_invite_lang
Create Date: 2026-09-06
E24's one new table. The shelf holds five different things — a video, a YouTube channel, a Plex show,
a Plex movie, a playlist — because comfort media is usually "put on X", a series or a channel, and
almost never one particular video.
Three shapes were possible and two are wrong here:
- a boolean on `video_states` / `plex_states` cannot ORDER a shelf, and has nowhere to put a channel
or a show (neither has a per-user state row at all);
- a polymorphic `(kind, ref)` string pair orders and holds anything, but drops the foreign key — and a
Plex item removed from the library, or a channel the user unsubscribes and prunes, would then leave
a dead tile on the one surface whose entire job is to be reliable when nothing else is.
So: one nullable CASCADE-ing FK per kind, plus `num_nonnulls(...) = 1`. The union invariant lives in
the schema rather than in a comment at every insert site, which is the same rule that the
`cancelled`/`canceled` bug taught this codebase.
NULLs are DISTINCT in a Postgres unique index, so the five per-kind `(user_id, <ref>)` uniques each
stop a double-pin without colliding with the other four kinds' NULL rows.
No backfill: an empty shelf is the honest state for every existing account. The surface renders its
own empty state and invites the first pin.
"""
import sqlalchemy as sa
from alembic import op
revision: str = "0083_comfort_pins"
down_revision: str | None = "0082_invite_lang"
branch_labels: str | None = None
depends_on: str | None = None
def upgrade() -> None:
op.create_table(
"comfort_pins",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column(
"user_id",
sa.Integer(),
sa.ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("video_id", sa.String(length=16), sa.ForeignKey("videos.id", ondelete="CASCADE")),
sa.Column(
"channel_id", sa.String(length=32), sa.ForeignKey("channels.id", ondelete="CASCADE")
),
sa.Column("plex_item_id", sa.Integer(), sa.ForeignKey("plex_items.id", ondelete="CASCADE")),
sa.Column("plex_show_id", sa.Integer(), sa.ForeignKey("plex_shows.id", ondelete="CASCADE")),
sa.Column("playlist_id", sa.Integer(), sa.ForeignKey("playlists.id", ondelete="CASCADE")),
sa.Column("position", sa.Integer(), nullable=False, server_default="0"),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
sa.CheckConstraint(
"num_nonnulls(video_id, channel_id, plex_item_id, plex_show_id, playlist_id) = 1",
name="ck_comfort_pin_one_ref",
),
sa.UniqueConstraint("user_id", "video_id", name="uq_comfort_pin_video"),
sa.UniqueConstraint("user_id", "channel_id", name="uq_comfort_pin_channel"),
sa.UniqueConstraint("user_id", "plex_item_id", name="uq_comfort_pin_plex_item"),
sa.UniqueConstraint("user_id", "plex_show_id", name="uq_comfort_pin_plex_show"),
sa.UniqueConstraint("user_id", "playlist_id", name="uq_comfort_pin_playlist"),
)
op.create_index("ix_comfort_pins_user_id", "comfort_pins", ["user_id"])
def downgrade() -> None:
op.drop_index("ix_comfort_pins_user_id", table_name="comfort_pins")
op.drop_table("comfort_pins")
+10
View File
@@ -0,0 +1,10 @@
"""E24 — the read-model behind the Home surface ("Mit nézzek most?").
The anti-paralysis answer layer: one hero suggestion, a cross-source resume rail, and the user's own
comfort shelf, all served from our own Postgres with no upstream call at serve time (the same rule
the Plex Discover surface follows).
Layering, mirroring `app/plex/`: `budget` (the time bands) and `cards` (the unified row shape) are
pure; `queries` holds the SQL; `rails`, `pins` and `hero` compose. Nothing here imports from
`app.routes` — `routes/discover.py` is a thin HTTP shell over these.
"""
+62
View File
@@ -0,0 +1,62 @@
"""The time budget — E24's replacement for "what mood are you in?".
`random-gondolatok` 15/D asked for a mood questionnaire before the recommendations. The same file's
later BA-interview note (#23) says the mood half is not what makes this work: the friction is
deciding at all. A mood→genre mapping would also be invented data — we store no mood signal and never
will — whereas "how much time do you have" is a question the library can actually answer, from a
column we already have.
So the surface asks about TIME, and the answer is deterministic: four bands, no model, no latency.
The band also picks the source for free — ten minutes is a YouTube video, an evening is a movie —
which is exactly the cross-source behaviour the Home surface exists to provide.
The bands overlap on purpose. "I have 30 minutes" should not be answered with a two-minute Short just
because it fits; a lower bound is as much a part of the promise as the upper one.
"""
from typing import Literal
Budget = Literal["short", "medium", "long", "any"]
DEFAULT_BUDGET: Budget = "any"
# budget -> (min_seconds, max_seconds); None = unbounded on that side.
BANDS: dict[str, tuple[int | None, int | None]] = {
"short": (None, 12 * 60), # "10 perc" — a bit of slack over the round number the chip shows
"medium": (8 * 60, 40 * 60), # "30 perc"
"long": (40 * 60, None), # "Egy este"
"any": (None, None), # "Mindegy"
}
def band(budget: str) -> tuple[int | None, int | None]:
"""The (min, max) seconds for a budget. Unknown budgets fall back to the unbounded band rather
than raising: the route validates the vocabulary with a Literal, so anything reaching here with a
bad value is our own bug, and an unfiltered shelf is a better failure than a 500 on the landing
page."""
return BANDS.get(budget, BANDS["any"])
def remaining_seconds(duration_seconds: int | None, position_seconds: int) -> int | None:
"""What is actually LEFT to watch. The question is "how much time do you have", so a 90-minute
film with 8 minutes to go belongs in the ten-minute band, not the evening one. Unknown duration
stays unknown — see `fits`."""
if duration_seconds is None:
return None
return max(0, duration_seconds - max(0, position_seconds))
def fits(seconds: int | None, budget: str) -> bool:
"""Does a runtime fit the budget?
An unknown duration fits only the unbounded band. We cannot promise an item finishes inside ten
minutes when we do not know how long it is, and quietly including it would make the one control
on this page dishonest — the failure mode the house rules call an un-honest state."""
lo, hi = band(budget)
if seconds is None:
return lo is None and hi is None
if lo is not None and seconds < lo:
return False
if hi is not None and seconds > hi:
return False
return True
+141
View File
@@ -0,0 +1,141 @@
"""The unified discover card: one row shape the Home surface can render without knowing which
module it came from.
This is deliberately a THIN mapping, not a new serializer layer. The per-source projections already
exist and are already the shapes the frontend's `VideoCard` and `PlexPosterCard` consume —
`feedquery.serialize` for YouTube, `plex.cards.movie_card`/`show_card`/`leaf_card` for Plex — so each
unified card carries the native payload under `video` / `plex` alongside the handful of fields the
rail chrome itself needs (title, thumbnail, progress, runtime). The Home page therefore renders with
the existing card components rather than a third one, and a field added to a feed row or a Plex card
still gets added in exactly one place. CLAUDE.md's "not wanted" list names a base serializer for
precisely this reason.
`aspect` is the one genuinely new field. A rail has to know whether a tile is a 16:9 still or a 2:3
poster, and that is a property of the ARTWORK, not of the module: a Plex episode ships a landscape
still exactly like a YouTube video does, while a movie and a show ship posters. Emitting it lets the
rail layer stay domain-free instead of branching on `source`.
`subtitle` only ever carries DATA — a channel name, a show title, a year. Nothing here composes an
English phrase: this repo is HU+EN with zero key drift, and a sentence assembled in the backend is a
string no translation file can reach. Anything that needs wording (season counts, "N videó") is left
to the client, which has both the native payload and the translations.
"""
from app.discover.budget import remaining_seconds
from app.models import Channel, Playlist
# Plex artwork is a poster for the things you browse and a landscape still for the things you play.
_POSTER_KINDS = ("movie", "show")
def _base(
*,
source: str, # "youtube" | "plex"
kind: str, # "video" | "movie" | "show" | "episode" | "channel" | "playlist"
id: str,
title: str | None,
aspect: str, # "wide" (16:9) | "poster" (2:3)
subtitle: str | None = None,
thumb: str | None = None,
duration_seconds: int | None = None,
position_seconds: int = 0,
status: str = "new",
) -> dict:
return {
"source": source,
"kind": kind,
"id": id,
"title": title or "",
"subtitle": subtitle,
"thumb": thumb,
"aspect": aspect,
"duration_seconds": duration_seconds,
"position_seconds": position_seconds,
"remaining_seconds": remaining_seconds(duration_seconds, position_seconds),
"status": status,
# Native payloads — at most one is ever set. The client opens the player from these.
"video": None,
"plex": None,
}
def key(card: dict) -> str:
"""The identity of a card ACROSS sources — `<source>:<id>`. A Plex rating_key and a YouTube video
id could in principle collide as bare strings, and the reroll's exclusion list has to be able to
say "not that one" about either."""
return f"{card['source']}:{card['id']}"
def from_video(row: dict) -> dict:
"""`row` is a `feedquery.serialize` dict — the same object the feed grid renders."""
card = _base(
source="youtube",
kind="video",
id=row["id"],
title=row["title"],
aspect="wide",
subtitle=row.get("channel_title"),
thumb=row.get("thumbnail_url"),
duration_seconds=row.get("duration_seconds"),
position_seconds=row.get("position_seconds") or 0,
status=row.get("status") or "new",
)
card["video"] = row
return card
def from_plex(row: dict) -> dict:
"""`row` is a `plex.cards` dict (movie / show / leaf). Its own `type` is the kind."""
kind = row.get("type") or "movie"
if kind == "episode":
subtitle = row.get("show_title")
elif kind == "show":
subtitle = None # season count lives in the native payload; the client words it
else:
subtitle = str(row["year"]) if row.get("year") else None
card = _base(
source="plex",
kind=kind,
id=row["id"],
title=row.get("title"),
aspect="poster" if kind in _POSTER_KINDS else "wide",
subtitle=subtitle,
thumb=row.get("thumb"),
duration_seconds=row.get("duration_seconds"),
position_seconds=row.get("position_seconds") or 0,
status=row.get("status") or "new",
)
card["plex"] = row
return card
def from_channel(ch: Channel, unwatched: int) -> dict:
"""A pinned channel. It is not itself playable — the shelf resolves it to its newest unwatched
video on click (see `pins._shelf_card`) — so the tile advertises what is waiting there instead of a
runtime it does not have."""
card = _base(
source="youtube",
kind="channel",
id=ch.id,
title=ch.title,
aspect="wide",
thumb=ch.thumbnail_url,
)
card["unwatched_count"] = unwatched
return card
def from_playlist(pl: Playlist, item_count: int, thumb: str | None) -> dict:
"""A pinned playlist. `thumb` is its first item's thumbnail — a shelf of blank tiles would defeat
the point of a surface you are supposed to be able to scan at a glance."""
card = _base(
source="youtube",
kind="playlist",
id=str(pl.id),
title=pl.name,
aspect="wide",
thumb=thumb,
)
card["item_count"] = item_count
return card
+104
View File
@@ -0,0 +1,104 @@
"""The hero: the single suggestion the Home surface leads with.
One rule, in one function, because the page's entire promise is that it answers rather than offers.
Three tiers, tried in order:
1. **resume** — something already started whose REMAINING time fits the budget. Almost always the
right answer: it is the thing the user themselves chose most recently, and finishing it costs no
decision at all.
2. **comfort** — a pinned shelf entry that fits. The user's own declared fallback, which beats
anything we could infer.
3. **surprise** — a seeded draw from what is unseen and fits.
That order is the PAGE's answer. "Mást kérek" rotates it (`start`), so each press offers a different
KIND of answer rather than the next row of the same list — the user's call, 2026-09-06, after the
button was measured cycling nine resume cards and never once reaching the shelf.
The tier travels with the card so the client can label it honestly ("Ott hagytad abba" vs "A
polcodról" vs "Meglepetés") instead of guessing from the shape of the payload — a kicker that says
"where you left off" over a film the user has never opened is exactly the kind of quiet lie the house
rules call a dishonest state.
Budgets are matched against REMAINING time throughout (`budget.remaining_seconds`, which equals the
runtime for anything not yet started): with eight minutes left, a 90-minute film is a ten-minute
answer.
"""
from sqlalchemy.orm import Session
from app.discover import cards, pins, rails
from app.discover.budget import fits
from app.models import User
HERO_TIERS = ("resume", "comfort", "surprise")
# How deep the surprise tier draws before giving up. Deliberately deeper than one, so an excluded
# pick moves the tier along instead of emptying it (the shuffle is seeded — the same seed always
# hands back the same order, so without the headroom "already shown" would mean "nothing left").
SURPRISE_DRAW = 12
def pick(
db: Session,
user: User,
budget: str,
seed: int,
*,
resume: list[dict] | None = None,
shelf: list[dict] | None = None,
exclude: set[str] | None = None,
start: int = 0,
) -> dict | None:
"""`{"tier": ..., "card": ...}`, or None when nothing at all fits.
`resume` and `shelf` may be passed pre-computed so the consolidated `/api/discover` response does
not build the same two rails twice; the reroll endpoint passes neither and pays only for what it
reaches.
`exclude` is what makes "Mást kérek" mean something. A new seed alone only re-draws the SURPRISE
tier: the resume tier deterministically returns the most recently progressed thing that fits, so
pressing reroll on a user who has something half-finished handed back the identical card and the
button looked dead (measured in the browser, 2026-09-06). Skipping the cards already shown makes
every press produce a different answer, at every tier.
`start` ROTATES which tier is tried first — 0 keeps the priority order (the page's own first
answer), 1 leads with comfort, 2 with surprise. Exclusion alone was not enough: the resume rail
holds up to 20 rows, so the button walked down it and never reached the shelf or a surprise at
all (measured, 2026-09-06: twelve presses returned nine cards, every one of them `resume`). The
fallthrough is kept, so a press does not come back empty just because the tier it led with has
nothing to offer."""
skip = exclude or set()
def _resume() -> dict | None:
for card in rails.continue_watching(db, user) if resume is None else resume:
playable = rails.playable(db, user.id, card)
# Excluded on the card the USER saw (the show), not only on what it resolves to, so a
# rerolled-past show does not come straight back as its own next episode.
if cards.key(card) in skip or (playable is not None and cards.key(playable) in skip):
continue
if playable is not None and fits(playable["remaining_seconds"], budget):
return playable
return None
def _comfort() -> dict | None:
for card in pins.playables(pins.shelf(db, user) if shelf is None else shelf):
if cards.key(card) not in skip and fits(card["remaining_seconds"], budget):
return card
return None
def _surprise() -> dict | None:
# A wider draw than the one card we need: the shuffle is seeded, so without the headroom an
# excluded pick would leave the tier empty rather than move past it.
for card in rails.surprise(db, user, budget, seed, limit=SURPRISE_DRAW):
if cards.key(card) not in skip:
return card
return None
tiers = {"resume": _resume, "comfort": _comfort, "surprise": _surprise}
offset = start % len(HERO_TIERS)
for tier in HERO_TIERS[offset:] + HERO_TIERS[:offset]:
card = tiers[tier]()
if card is not None:
return {"tier": tier, "card": card}
return None
+173
View File
@@ -0,0 +1,173 @@
"""The comfort shelf: pin a thing you can always fall back to, and have it be one click from playing.
Five kinds are pinnable — a video, a YouTube channel, a Plex show, a Plex movie, a playlist — because
comfort media is almost always "put on X", a series or a channel, and only sometimes one particular
video. `models.ComfortPin` carries that union as five nullable FKs under a `num_nonnulls(...) = 1`
check; this module is where the union is spoken about in one vocabulary, so no call site invents a
second one.
Every shelf card carries a resolved `next`: the thing that actually plays when you click the tile —
a show's in-progress-or-next episode, a channel's newest unwatched video, a playlist's first
unwatched item. It costs a query per non-leaf pin, which is affordable precisely because this shelf is
short and hand-curated, and it buys the property the whole surface exists for: one click, no
intermediate page, no second decision.
Domain module, so nothing here raises HTTP. `resolve_ref` returns None for "no such thing, or not
yours" and the route turns that into its 404 — core never imports routes.
"""
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.discover import cards, queries
from app.models import Channel, ComfortPin, PlexItem, PlexShow, Playlist, User, Video
from app.plex import rollup
from app.plex.cards import leaf_card, show_card
from app.plex.lookups import by_rating_key
# kind -> the ComfortPin column that holds it. The client's vocabulary and the schema's, in one place.
PIN_COLUMNS: dict[str, str] = {
"video": "video_id",
"channel": "channel_id",
"plex_item": "plex_item_id",
"plex_show": "plex_show_id",
"playlist": "playlist_id",
}
PIN_KINDS = tuple(PIN_COLUMNS)
def resolve_ref(db: Session, user: User, kind: str, ref: str) -> tuple[str, object] | None:
"""Translate the client's (kind, ref) into the (column, value) the row stores, or None.
The client speaks the ids the rest of the API speaks: a video id, a channel id, a Plex
`rating_key`, a playlist id. Plex's internal integer keys never leave the backend, so the
translation belongs here rather than in five call sites.
The COLUMN half of every answer comes from `PIN_COLUMNS`; only the LOOKUP differs per kind
(a direct `db.get`, a rating-key translation, an int parse plus an ownership check). Before the
E24 review the branches spelled the column names out again and `PIN_COLUMNS` had no readers at
all — a constant that called itself the one vocabulary while nothing spoke it."""
col = PIN_COLUMNS.get(kind)
if col is None:
return None
if kind == "video":
return (col, ref) if db.get(Video, ref) is not None else None
if kind == "channel":
return (col, ref) if db.get(Channel, ref) is not None else None
if kind == "plex_item":
it = by_rating_key(db, PlexItem, ref)
return (col, it.id) if it is not None else None
if kind == "plex_show":
sh = by_rating_key(db, PlexShow, ref)
return (col, sh.id) if sh is not None else None
try:
pid = int(ref)
except ValueError:
return None
pl = db.get(Playlist, pid)
# A playlist is per-user, unlike the other four (which are shared catalog rows whose PRIVACY
# lives in the state tables). Someone else's list must read as "no such thing", not as a 403 —
# the same reason `owned_or_404` 404s rather than 403s.
return (col, pl.id) if pl is not None and pl.user_id == user.id else None
def list_pins(db: Session, user: User) -> list[ComfortPin]:
return list(
db.execute(
select(ComfortPin)
.where(ComfortPin.user_id == user.id)
.order_by(ComfortPin.position.asc(), ComfortPin.id.asc())
)
.scalars()
.all()
)
def next_position(db: Session, user: User) -> int:
"""New pins land at the end of the shelf.
`max(position) + 1`, not the row count. Positions are never renumbered on removal — `remove_pin`
deletes the row, and every FK cascade silently removes a pin when the thing it points at goes
away — so past the first hole a count under-counts and would drop the new pin into the MIDDLE of
the shelf, or onto a position another pin already holds (nothing in the schema forbids that).
Same idiom as `routes/playlists._next_playlist_position`."""
return (
db.scalar(
select(func.coalesce(func.max(ComfortPin.position), -1)).where(
ComfortPin.user_id == user.id
)
)
+ 1
)
def _shelf_card(db: Session, user: User, pin: ComfortPin) -> dict | None:
"""One pin as a unified card with its resolved `next`. None when the pinned row has gone (the FK
cascade removes the pin itself, so this is only the window between a delete and a refetch)."""
card: dict | None = None
nxt: dict | None = None
if pin.video_id is not None:
row = queries.yt_video_row(db, user, pin.video_id)
if row is None:
return None
card = cards.from_video(row)
nxt = dict(card) # a video plays itself
elif pin.channel_id is not None:
ch = db.get(Channel, pin.channel_id)
if ch is None:
return None
card = cards.from_channel(ch, queries.channel_unwatched_count(db, user, ch.id))
row = queries.channel_next_video(db, user, ch.id)
nxt = cards.from_video(row) if row else None
elif pin.plex_item_id is not None:
it = db.get(PlexItem, pin.plex_item_id)
if it is None:
return None
st = rollup.item_state_map(db, user.id, [it.id]).get(it.id)
card = cards.from_plex(leaf_card(it, st))
nxt = dict(card) # a movie or a single episode plays itself
elif pin.plex_show_id is not None:
sh = db.get(PlexShow, pin.plex_show_id)
if sh is None:
return None
status = rollup.state_map(db, user.id, [sh.id]).get(sh.id, "new")
card = cards.from_plex(show_card(sh, status))
ep = queries.next_episode(db, user.id, sh.id)
nxt = cards.from_plex(ep) if ep else None
elif pin.playlist_id is not None:
pl = db.get(Playlist, pin.playlist_id)
if pl is None:
return None
count, row = queries.playlist_summary(db, user, pl.id)
card = cards.from_playlist(pl, count, row.get("thumbnail_url") if row else None)
nxt = cards.from_video(row) if row else None
if card is None:
return None
# A COPY, never the card itself: a self-playing pin (a video, a movie) would otherwise make
# `card["next"] is card`, which serializes as a circular reference and 500s the whole shelf. The
# copy is taken before `pin_id`/`next` are attached, so `next` stays a plain playable card.
card["pin_id"] = pin.id
card["next"] = nxt
return card
def shelf(db: Session, user: User) -> list[dict]:
"""The whole shelf, in the user's own order."""
out = []
for pin in list_pins(db, user):
card = _shelf_card(db, user, pin)
if card is not None:
out.append(card)
return out
def playables(shelf_cards: list[dict]) -> list[dict]:
"""The `next` of every shelf entry that has one — what the hero may draw from. A pinned channel
with nothing unwatched left, or an empty playlist, simply is not an answer right now."""
return [c["next"] for c in shelf_cards if c.get("next")]
+348
View File
@@ -0,0 +1,348 @@
"""The row fetchers behind the Home surface — one place where "what is startable right now" is
expressed as SQL, for both sources.
Two rules shape this module:
**Reuse the query layer, not the route.** The YouTube half calls `feedquery.filtered_query` /
`sort_keys`, so the Home surface inherits the feed's own definitions of subscribed, blocked,
explore-visible and in-progress for free, and cannot drift from them. The Plex half calls
`plex.rollup` and the `plex.cards` serializers rather than `routes/plex/discover.py`: that route
narrows EVERY rail by the Plex sidebar's `LibraryFilters`, which is right for a surface that HAS a
sidebar and wrong for a landing page that does not.
**One seed drives both halves.** The feed orders a shuffle by `md5(id || seed)` so a keyset cursor
stays valid across pages; the Plex surprise rail uses `ORDER BY random()`, which is fine for a single
un-paged draw but cannot be reproduced. A cross-source reroll needs both halves to move together —
otherwise "Mást kérek" re-draws the Plex picks while the YouTube ones stand still — so the Plex side
here takes the feed's seeded-md5 idiom instead of `random()`.
"""
from sqlalchemy import and_, func, or_, select
from sqlalchemy.orm import Session, aliased
from app.discover.budget import band
from app.feedquery import (
feed_columns,
filtered_query,
not_blocked_cond,
order_by,
serialize,
sort_keys,
)
from app.models import (
Channel,
PlaylistItem,
PlexItem,
PlexShow,
PlexState,
User,
Video,
VideoState,
)
from app.plex import rollup
from app.plex.cards import leaf_card, movie_card, show_card
from app.plex.query import apply_show_state_filter, content_libs
# How many rows a single rail may carry. Matches `routes/plex/discover.py`'s RAIL_LIMIT on purpose —
# the two surfaces should feel like the same app, and a rail is a rail.
RAIL_LIMIT = 20
# The feed parameters a Home rail runs with. Spelled out rather than borrowed from the route's
# `_feed_params` defaults, because those are an HTTP contract that may change for the feed's own
# reasons; the landing page's scope should not move with them silently.
_FEED_BASE: dict = {
"tags": [],
"tag_modes": {},
"untagged": False,
"channel_ids": [],
"q": None,
"min_duration": None,
"max_duration": None,
"max_age_days": None,
"published_after": None,
"published_before": None,
"show_normal": True,
"include_shorts": False,
"include_live": False,
"show": "unwatched",
"scope": "my",
"library_source": "organic",
}
def _seeded(col, seed: int):
"""The feed's deterministic shuffle expression, reused for Plex so one seed moves both halves."""
return func.md5(func.concat(col, str(seed)))
# ── YouTube ───────────────────────────────────────────────────────────────────────────────────────
def yt_in_progress(db: Session, user: User, limit: int = RAIL_LIMIT) -> list[tuple[object, dict]]:
"""Started-but-not-finished videos, most recently progressed first, as (progressed_at, row).
The predicate is `feedquery`'s `show="in_progress"` verbatim (a stored resume position, status not
watched/hidden) — but the query is built here rather than through `filtered_query` because this
rail wants an ordering by `progress_updated_at`, and that column lives on the state alias the feed
query keeps private. Driving from `video_states` is also the faster plan: it is a per-user table of
a few hundred rows, against a catalog of many thousands.
Building it here means the feed's OTHER predicates have to be chosen deliberately rather than
inherited. `not_blocked_cond` is shared, because "never show me this channel" outranks "you
started it" — blocking leaves the state row untouched, so without this the video the user just
banned heads the resume rail. The feed's SCOPE clauses (subscribed / via_explore accessibility)
are deliberately NOT applied, for the reason `yt_video_row` gives: something you have already
started must stay resumable even after you unsubscribe or an exploration expires."""
st = aliased(VideoState)
q = (
select(*feed_columns(user, st), st.progress_updated_at.label("progressed_at"))
.join(Channel, Channel.id == Video.channel_id)
.join(st, and_(st.video_id == Video.id, st.user_id == user.id))
.where(
st.position_seconds > 0,
st.status.notin_(("watched", "hidden")),
Video.unavailable_since.is_(None),
not_blocked_cond(user),
)
.order_by(st.progress_updated_at.desc().nullslast())
.limit(limit)
)
return [(r.progressed_at, serialize(r)) for r in db.execute(q).all()]
def yt_surprise(db: Session, user: User, budget: str, seed: int, limit: int) -> list[dict]:
"""Unwatched videos from the user's own feed scope, in seeded-shuffle order, inside the budget.
Shorts are opened up only for the shortest band: "I have ten minutes" is the one answer a Short is
actually a good response to, and the feed's own default keeps them out otherwise."""
lo, hi = band(budget)
params = dict(_FEED_BASE, min_duration=lo, max_duration=hi, include_shorts=budget == "short")
query, rank_expr = filtered_query(db, user, **params)
keys = sort_keys("shuffle", seed, rank_expr)
# Over-fetch, then drop anything already started. The feed's `unwatched` scope keeps watched and
# hidden out but deliberately keeps in-progress rows in (they belong in the feed). Here they must
# go: the resume tier has already had its chance at them, and it may have passed precisely because
# the REMAINING time did not fit the budget — offering the same item back as a surprise, whose
# runtime does fit, would label a half-finished thing "Meglepetés" and contradict the question.
rows = db.execute(query.order_by(*order_by(keys)).limit(limit * 4)).all()
out = [serialize(r) for r in rows]
return [r for r in out if not r["position_seconds"]][:limit]
def channel_next_video(db: Session, user: User, channel_id: str) -> dict | None:
"""The newest unwatched video of a pinned channel — what "put on that channel" resolves to."""
# `library_source="all"`: a pinned channel means the whole channel, including videos that only
# ever arrived through a search. The feed's "organic" default is a feed-scoping choice, not a
# statement about what belongs to a channel.
params = dict(_FEED_BASE, channel_ids=[channel_id], scope="all", library_source="all")
query, rank_expr = filtered_query(db, user, **params)
keys = sort_keys("newest", 0, rank_expr)
row = db.execute(query.order_by(*order_by(keys)).limit(1)).first()
return serialize(row) if row is not None else None
def yt_video_row(db: Session, user: User, video_id: str) -> dict | None:
"""One video as a feed row, with this user's watch state.
Built directly rather than through `filtered_query` on purpose: a PINNED video must keep working
even when it falls outside the feed's scope — the user unsubscribed from the channel, or blocked
it, or the video only ever arrived through a search. The shelf is the surface you fall back to;
it silently losing an entry because a filter moved is exactly the failure it must not have."""
st = aliased(VideoState)
row = db.execute(
select(*feed_columns(user, st))
.join(Channel, Channel.id == Video.channel_id)
.outerjoin(st, and_(st.video_id == Video.id, st.user_id == user.id))
.where(Video.id == video_id)
).first()
return serialize(row) if row is not None else None
def channel_unwatched_count(db: Session, user: User, channel_id: str) -> int:
"""How many unwatched videos a pinned channel is holding — the tile's only honest promise, since a
channel has no runtime of its own."""
params = dict(_FEED_BASE, channel_ids=[channel_id], scope="all", library_source="all")
query, _ = filtered_query(db, user, **params)
return int(db.execute(select(func.count()).select_from(query.subquery())).scalar() or 0)
def playlist_summary(db: Session, user: User, playlist_id: int) -> tuple[int, dict | None]:
"""(item count, the row to play next) for a pinned playlist — its first not-yet-watched item, or
its first item once the whole list is watched. The same row doubles as the tile's artwork, so a
shelf of playlists is scannable instead of a row of blank boxes."""
st = aliased(VideoState)
rows = db.execute(
select(*feed_columns(user, st), PlaylistItem.position)
.join(PlaylistItem, PlaylistItem.video_id == Video.id)
.join(Channel, Channel.id == Video.channel_id)
.outerjoin(st, and_(st.video_id == Video.id, st.user_id == user.id))
.where(PlaylistItem.playlist_id == playlist_id)
.order_by(PlaylistItem.position.asc())
).all()
if not rows:
return 0, None
nxt = next((r for r in rows if (r.status or "new") not in ("watched", "hidden")), rows[0])
return len(rows), serialize(nxt)
# ── Plex ──────────────────────────────────────────────────────────────────────────────────────────
def _unseen_movie_conds(uid: int):
"""A movie the user has neither finished nor hidden. A LEFT-JOIN-IS-NULL would also drop rows that
merely carry a resume position, which are exactly the ones Continue watching wants back."""
seen = (
select(1)
.select_from(PlexState)
.where(
PlexState.item_id == PlexItem.id,
PlexState.user_id == uid,
or_(
PlexState.status.in_(("watched", "hidden")),
# Started counts as "not a surprise" too — see the note in `yt_surprise`.
PlexState.position_seconds > 0,
),
)
.exists()
)
return ~seen
def plex_in_progress(db: Session, uid: int, limit: int = RAIL_LIMIT) -> list[tuple[object, dict]]:
"""In-progress movies + partially-watched shows as (progressed_at, card).
Mirrors `routes/plex/discover.py::_continue_watching` — same `PlexState` predicate for movies (a
NULL status still counts as started, so `notin_` alone would drop it), and the same shared
`rollup.agg_subquery` + `apply_show_state_filter` for shows, so the two rails cannot disagree on
what "in progress" means. What it does NOT inherit is that route's `LibraryFilters` narrowing:
this surface has no Plex sidebar to read a filter from."""
movie_libs, show_libs = content_libs(db)
out: list[tuple[object, dict]] = []
if movie_libs:
mq = (
select(PlexItem, PlexState)
.join(PlexState, and_(PlexState.item_id == PlexItem.id, PlexState.user_id == uid))
.where(
PlexItem.kind == "movie",
PlexItem.library_id.in_(movie_libs),
PlexState.position_seconds > 0,
or_(
PlexState.status.is_(None),
PlexState.status.notin_(("watched", "hidden")),
),
)
.order_by(PlexState.progress_updated_at.desc().nullslast())
.limit(limit)
)
for it, st in db.execute(mq).all():
out.append((st.progress_updated_at, movie_card(it, st)))
if show_libs:
prog = rollup.agg_subquery(db, uid, include_resumed=True).subquery()
sq = apply_show_state_filter(
select(PlexShow, prog.c.resumed_at).join(prog, prog.c.show_id == PlexShow.id),
prog.c.total,
prog.c.watched,
prog.c.inprog,
"in_progress",
).where(PlexShow.library_id.in_(show_libs))
for sh, resumed_at in db.execute(
sq.order_by(prog.c.resumed_at.desc().nullslast()).limit(limit)
).all():
out.append((resumed_at, show_card(sh, "in_progress")))
return out
def plex_surprise(db: Session, uid: int, budget: str, seed: int, limit: int) -> list[dict]:
"""Unseen playable movies inside the budget, in seeded-shuffle order.
Movies only. A show is not a thing you can start playing, and resolving every candidate show to
its next episode just to draw three cards would be a query per row for a rail whose whole value is
that it answers instantly — shows reach this surface through Continue watching and the comfort
shelf, which is where "put the next episode on" actually belongs.
No `playable` filter, matching the Plex surprise rail: `PlexItem.playable` is a playback CLASS
("direct" / "remux" / "transcode"), not a yes/no, and NULL there means "not probed yet" rather than
"will not play". Excluding un-probed items would quietly hide a fresh library from the one surface
that is supposed to have an answer. The class travels on the card, so the client still shows it."""
movie_libs, _ = content_libs(db)
if not movie_libs:
return []
lo, hi = band(budget)
q = (
select(PlexItem)
.where(
PlexItem.kind == "movie",
PlexItem.library_id.in_(movie_libs),
_unseen_movie_conds(uid),
)
.order_by(_seeded(PlexItem.rating_key, seed))
.limit(limit)
)
if lo is not None:
q = q.where(PlexItem.duration_s >= lo)
if hi is not None:
q = q.where(PlexItem.duration_s <= hi)
items = db.execute(q).scalars().all()
states = rollup.item_state_map(db, uid, [it.id for it in items])
return [movie_card(it, states.get(it.id)) for it in items]
# Watch states that take an episode out of the running for "what would play next".
_DONE = ("watched", "hidden")
def next_episode(db: Session, uid: int, show_id: int) -> dict | None:
"""What "put that show on" means: the episode in progress, else the first unwatched one, else the
first — over the same `(season, episode)` broadcast ordering `detail.py::_episode_nav` uses.
Three ordered `LIMIT 1` probes rather than one scan picked apart in Python. The Python version
loaded EVERY episode of the show to choose one of them — 236 rows for Friends, 227 for JAG — which
the comfort shelf could just about afford (a handful of hand-picked pins) and the resume rail
could not, since it resolves a show per rail row on the landing page.
Returned as a `leaf_card` so it carries the show title the episode row itself does not."""
asc = (
PlexItem.season_number.asc().nullsfirst(),
PlexItem.episode_number.asc().nullsfirst(),
)
base = (
select(PlexItem, PlexState)
.outerjoin(PlexState, and_(PlexState.item_id == PlexItem.id, PlexState.user_id == uid))
.where(PlexItem.show_id == show_id, PlexItem.kind == "episode")
.limit(1)
)
# In progress — the LAST one in broadcast order, which is `plex.rollup.rollup`'s `inprog[-1]`,
# i.e. what the show page's own Resume button plays. Two episodes of one show are in progress at
# once more often than it sounds (`watch_sync._reconcile_inprogress_movies` documents that a
# scrub to the end leaves a stale `status='new', position>0` row until the daily reconcile), and
# taking opposite ends of that set is how one show came to have two different "continue" answers:
# the Home tile ranked it by its LATEST touch and then offered the EARLIEST started episode.
# `position_seconds > 0` already requires the state row to exist, so no null guard.
pick = db.execute(
base.where(PlexState.position_seconds > 0, PlexState.status.notin_(_DONE)).order_by(
PlexItem.season_number.desc().nullslast(),
PlexItem.episode_number.desc().nullslast(),
)
).first()
# Unwatched — INCLUDING an episode with no state row at all, which is the common case and the one
# a bare `status NOT IN (...)` silently drops (NULL is not false, it is unknown, so the row fails
# the filter).
pick = (
pick
or db.execute(
base.where(or_(PlexState.id.is_(None), PlexState.status.notin_(_DONE))).order_by(*asc)
).first()
)
# Fully watched show: start it again from the top.
pick = pick or db.execute(base.order_by(*asc)).first()
if pick is None:
return None
ep, st = pick
show = db.get(PlexShow, show_id)
return leaf_card(
ep, st, show_title=show.title if show else None, show_rk=show.rating_key if show else None
)
+97
View File
@@ -0,0 +1,97 @@
"""The Home surface's rails: cross-source lists, assembled from `queries.py` and emitted as unified
cards.
The whole point of this module is that a rail is ONE list, not a YouTube list beside a Plex list. Two
side-by-side rails would just move the choice the user could not make ("which half do I look at?") one
level up, which is the opposite of what this epic is for.
"""
from sqlalchemy.orm import Session
from app import sysconfig
from app.discover import cards, queries
from app.discover.queries import RAIL_LIMIT
from app.models import PlexShow, User
from app.plex.lookups import by_rating_key
from app.plex.rollup import NO_TS
def plex_available(db: Session) -> bool:
"""Plex is a deployment-level toggle, not a per-user one (see `routes/me.py`). With it off, the
Home rails are YouTube-only and say nothing about Plex."""
return sysconfig.get_bool(db, "plex_enabled")
def playable(db: Session, uid: int, card: dict) -> dict | None:
"""The thing that would actually START from this card — itself for anything with a runtime, and
for a Plex SHOW the episode that is next (in progress, else the first unwatched, else the first).
ONE function, because three surfaces need the same answer and a second implementation is how the
shelf and the rail came to disagree in the first place: the hero calls it to decide what it is
offering, `continue_watching` calls it to fill each show tile's `next`, and the comfort shelf
resolves the same way in `pins`. None when the show has no episodes mirrored yet.
A card that already carries a resolved `next` (a rail row) is taken at its word rather than
re-queried — the hero reads the rail it was handed."""
if not (card.get("source") == "plex" and card.get("kind") == "show"):
return card
if "next" in card:
return card["next"]
sh = by_rating_key(db, PlexShow, card["id"])
if sh is None:
return None
ep = queries.next_episode(db, uid, sh.id)
return cards.from_plex(ep) if ep else None
def continue_watching(db: Session, user: User, limit: int = RAIL_LIMIT) -> list[dict]:
"""One resume rail across both sources, most recently progressed first.
A Plex SHOW enters as a show CARD — that is what the Plex rail shows, and it is the artwork the
user recognises — but it carries `next`: the episode that would actually start. Without it a
click on a half-watched series landed on the show page with six seasons to choose between, which
is the decision this whole surface exists to remove (the comfort shelf and the hero already
resolved; only this rail did not). The client plays `next ?? card`, so the tile stays a poster and
the click stays one click."""
rows: list[tuple[object, dict]] = [
(ts, cards.from_video(r)) for ts, r in queries.yt_in_progress(db, user, limit)
]
if plex_available(db):
rows += [(ts, cards.from_plex(c)) for ts, c in queries.plex_in_progress(db, user.id, limit)]
rows.sort(key=lambda r: r[0] or NO_TS, reverse=True)
out = [card for _, card in rows[:limit]]
# Resolve AFTER the slice: only the rows that survive it are worth a query.
for card in out:
if card["kind"] == "show":
card["next"] = playable(db, user.id, card)
return out
def surprise(db: Session, user: User, budget: str, seed: int, limit: int = 3) -> list[dict]:
"""The hero's draw pool: unseen things that fit the budget, from both sources under ONE seed.
Interleaved rather than concatenated — the same alternation `routes/plex/discover.py` uses for its
movie/show split — so a short pool never comes back all-YouTube or all-Plex just because one side
happened to be queried first.
WHICH source leads is decided by the seed, not by a hardcoded order. The hero draws a pool of ONE,
so a fixed order would mean the Plex library could never answer "egy este" while a single YouTube
video also fit — the reroll would look broken and the cross-source promise would quietly be a
YouTube promise. Parity on the same seed that orders the rows keeps it deterministic: the same
seed always gives the same answer, and pressing "Mást kérek" moves both halves together."""
halves = [
(cards.from_video, queries.yt_surprise(db, user, budget, seed, limit)),
(
cards.from_plex,
queries.plex_surprise(db, user.id, budget, seed, limit) if plex_available(db) else [],
),
]
if seed % 2:
halves.reverse()
out: list[dict] = []
for i in range(max(len(rows) for _, rows in halves)):
for to_card, rows in halves:
if i < len(rows):
out.append(to_card(rows[i]))
return out[:limit]
+14 -7
View File
@@ -117,6 +117,19 @@ def tags_by_category(db: Session, tag_ids: list[int]) -> dict[str, list[int]]:
return out
def not_blocked_cond(user: User):
"""Per-user channel blocklist: never show this user videos from a channel they blocked
(applies to feed, Library, explore, the channel page and the Home resume rail alike).
A shared expression rather than a clause each surface writes for itself. Blocking does not touch
`video_states`, so a half-watched video from a newly blocked channel survives with its resume
position — and the one query that copied only PART of this predicate set is exactly how that
video came back as the landing page's suggestion after the user had said "never again"."""
return Video.channel_id.notin_(
select(BlockedChannel.channel_id).where(BlockedChannel.user_id == user.id)
)
def filtered_query(
db: Session,
user: User,
@@ -207,13 +220,7 @@ def filtered_query(
)
)
# Per-user channel blocklist: never show this user videos from a channel they blocked
# (applies to feed, Library, explore, and the channel page alike).
query = query.where(
Video.channel_id.notin_(
select(BlockedChannel.channel_id).where(BlockedChannel.user_id == user.id)
)
)
query = query.where(not_blocked_cond(user))
# Source filter. In the shared Library ("all" scope) it uses the GLOBAL via_search flag
# (search-discovered by anyone). In "my" scope it's the user's OWN provenance:
+2
View File
@@ -33,6 +33,7 @@ from app.routes import (
auth as auth_routes,
channels,
config as config_routes,
discover as discover_routes,
downloads,
feed,
health,
@@ -187,6 +188,7 @@ app.include_router(auth_routes.router)
app.include_router(sync.router)
app.include_router(tags.router)
app.include_router(feed.router)
app.include_router(discover_routes.router)
app.include_router(search_routes.router)
app.include_router(me.router)
app.include_router(notifications.router)
+50
View File
@@ -3,6 +3,7 @@ from datetime import date, datetime
from sqlalchemy import (
BigInteger,
Boolean,
CheckConstraint,
Computed,
Date,
DateTime,
@@ -1406,3 +1407,52 @@ class PlexPlaylistItem(Base):
ForeignKey("plex_items.id", ondelete="CASCADE"), index=True
)
position: Mapped[int] = mapped_column(Integer, default=0, server_default="0", nullable=False)
class ComfortPin(Base, TimestampMixin):
"""One entry on a user's comfort shelf — the anti-paralysis fallback: "just put on X" (E24).
Deliberately NOT a boolean on `VideoState`/`PlexState`, and NOT a polymorphic `(kind, ref)` string
pair. A flag cannot ORDER a shelf and cannot hold a channel or a show — and comfort media is
usually "put on X", a series or a channel, not one particular video. A string ref would order and
hold anything, but it would also lose the foreign key: a Plex item dropped from the library, or an
unsubscribed channel, would leave a dead tile on the one surface whose whole job is to be
reliable. So instead: one nullable CASCADE-ing FK per pinnable kind, and a check constraint that
exactly one of them is set.
That constraint is the house rule applied to a five-way union — the invariant lives in the schema,
not in a comment every insert site has to remember. `kind`/`ref` below derive FROM the columns, so
there is no second vocabulary to keep in sync.
NULLs are DISTINCT in a Postgres unique index, so the five per-kind unique constraints each stop a
double-pin of the same thing without colliding across kinds."""
__tablename__ = "comfort_pins"
__table_args__ = (
CheckConstraint(
"num_nonnulls(video_id, channel_id, plex_item_id, plex_show_id, playlist_id) = 1",
name="ck_comfort_pin_one_ref",
),
UniqueConstraint("user_id", "video_id", name="uq_comfort_pin_video"),
UniqueConstraint("user_id", "channel_id", name="uq_comfort_pin_channel"),
UniqueConstraint("user_id", "plex_item_id", name="uq_comfort_pin_plex_item"),
UniqueConstraint("user_id", "plex_show_id", name="uq_comfort_pin_plex_show"),
UniqueConstraint("user_id", "playlist_id", name="uq_comfort_pin_playlist"),
)
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
video_id: Mapped[str | None] = mapped_column(ForeignKey("videos.id", ondelete="CASCADE"))
channel_id: Mapped[str | None] = mapped_column(ForeignKey("channels.id", ondelete="CASCADE"))
plex_item_id: Mapped[int | None] = mapped_column(
ForeignKey("plex_items.id", ondelete="CASCADE")
)
plex_show_id: Mapped[int | None] = mapped_column(
ForeignKey("plex_shows.id", ondelete="CASCADE")
)
playlist_id: Mapped[int | None] = mapped_column(ForeignKey("playlists.id", ondelete="CASCADE"))
# 0-based shelf order. The shelf is short and hand-curated, so a dense integer the reorder
# endpoint rewrites wholesale beats a fractional index nobody would ever exercise here.
position: Mapped[int] = mapped_column(Integer, default=0, server_default="0", nullable=False)
+25 -1
View File
@@ -8,8 +8,9 @@ from datetime import datetime, timedelta, timezone
from sqlalchemy import and_, case, func, or_, select, type_coerce
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Session
from app.models import PlexItem
from app.models import PlexItem, PlexLibrary
TS_CONFIG = "public.unaccent_simple"
@@ -342,3 +343,26 @@ def apply_show_state_filter(query, tot, wat, inp, show: str):
if show == "unwatched":
return query.where(wat == 0, inp == 0)
return query
def content_libs(db: Session) -> tuple[list[int], list[int]]:
"""The BROWSABLE content libraries, partitioned into (movie_lib_ids, show_lib_ids) — the single
source of the browse scope. Excludes HU overlay libraries (`overlay_of` set): those are never
browsed on their own, they only lend their Hungarian metadata to the EN twin's rows (E5 S5).
Lives here, in the query layer, rather than in `routes/plex/_shared.py` where it started: E24's
Home surface needs the same scope, and a domain package importing from `routes/` would invert the
one-way dependency CLAUDE.md draws. It never touched request state, so nothing was lost by the
move."""
libs = (
db.execute(
select(PlexLibrary).where(
PlexLibrary.enabled.is_(True), PlexLibrary.overlay_of.is_(None)
)
)
.scalars()
.all()
)
movie_lib_ids = [lb.id for lb in libs if lb.kind == "movie"]
show_lib_ids = [lb.id for lb in libs if lb.kind == "show"]
return movie_lib_ids, show_lib_ids
+14
View File
@@ -3,11 +3,25 @@
Drives the show/season grid badges and the Resume / Play-from-start / mark-all buttons.
"""
from datetime import datetime, timezone
from sqlalchemy import and_, case, func, or_, select
from sqlalchemy.orm import Session, aliased
from app.models import PlexItem, PlexState
# Sort sentinel for a rail row whose timestamp is missing, so `key=lambda r: r[0] or NO_TS` reads the
# same at every rail that merges two type-homogeneous lists in Python.
#
# It is NOT a crash fix, whatever the E24 commit that introduced it says. The `(ts is not None, ts)`
# key it replaced cannot raise: tuple comparison finds the first UNEQUAL element via `==`, so two
# `(False, None)` keys compare equal and `None < None` is never reached — verified by execution
# during the E24 epic-end review, over every permutation of null/non-null rows. What the sentinel
# buys is one idiom instead of two, and a key that says "missing sorts last" outright rather than
# encoding it in a boolean flag. A BARE `r[0]` would genuinely raise, which is the mutation the
# rails' tests do guard against.
NO_TS = datetime.min.replace(tzinfo=timezone.utc)
def show_status(total: int, watched: int, inprog: int) -> str:
"""Roll a show's episodes up into a single watch state: fully watched, partially started, or new."""
+197
View File
@@ -0,0 +1,197 @@
"""E24 · the Home surface's HTTP shell.
Thin by construction: every decision — what counts as in progress, what fits a time budget, which
tier the hero comes from — lives in `app/discover/`, so the scheduler, a future CLI or a test can ask
the same questions without a request. This file validates, gates and serializes.
The consolidated `GET /api/discover` mirrors the Plex Discover route's one-payload shape (E5 S4): a
landing page that fires four requests to answer one question would spend its whole budget on the
thing it exists to remove.
"""
from typing import Literal
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy import select, update
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.auth import current_user, require_human
from app.db import get_db
from app.discover import hero as hero_mod
from app.discover import pins, rails
from app.discover.budget import Budget, DEFAULT_BUDGET
from app.models import ComfortPin, User
from app.routes._common import owned_or_404
router = APIRouter(prefix="/api/discover", tags=["discover"])
class PinIn(BaseModel):
# From `pins.PIN_KINDS`, not a second hand-written list: the vocabulary, the schema columns and
# this request contract have to move together, and re-typing them here is how they would not.
kind: Literal[*pins.PIN_KINDS]
# The id the rest of the API speaks for that kind: a video id, a channel id, a Plex rating_key,
# or a playlist id. `pins.resolve_ref` does the translation to the row's internal key.
ref: str
class ReorderIn(BaseModel):
ids: list[int]
@router.get("")
def get_discover(
budget: Budget = DEFAULT_BUDGET,
seed: int = 0,
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
"""Everything the Home page renders, in one pass.
`resume` and `shelf` are built once and handed to the hero, so the two rails the hero draws from
are not queried twice for a single page load."""
resume = rails.continue_watching(db, user)
shelf = pins.shelf(db, user)
return {
"budget": budget,
"hero": hero_mod.pick(db, user, budget, seed, resume=resume, shelf=shelf),
"continue_watching": resume,
"comfort": shelf,
}
# How many already-shown cards a reroll may carry, matched to the client's own rolling window
# (`SEEN_WINDOW` in `Home.tsx`). A cheap sanity clamp, and deliberately no more than that: `exclude`
# never reaches SQL — `hero.pick` uses it only as set membership over an already-bounded candidate
# pool (the resume rail's `RAIL_LIMIT` rows, the shelf, `SURPRISE_DRAW` surprise draws) — so there is
# no scan for a crafted URL to unbound, and the set is built in full before this slice anyway. The
# comment that claimed otherwise was corrected in round 2; the request line's own length is what
# actually bounds a hostile `exclude`.
MAX_EXCLUDE = 32
@router.get("/pick")
def get_pick(
budget: Budget = DEFAULT_BUDGET,
seed: int = 0,
exclude: str = "",
start: int = 0,
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
"""The "Mást kérek" reroll: a hero on its own, for a new seed and/or a new budget.
One seed drives BOTH sources (see `discover/queries.py`) — otherwise a reroll would re-draw the
Plex half while the YouTube half stood still, and the button would look broken half the time.
`exclude` is a comma-separated list of `<source>:<id>` keys the user has already been shown. It
is what makes the button honest: a seed alone only moves the surprise tier, so a user with
something half-finished got the same card back every press.
`start` is the press count: it rotates which tier answers first, so consecutive presses offer a
different KIND of answer instead of walking down the resume rail. Taken modulo the tier count in
`hero.pick`, so any integer is valid and no value can 422 a button."""
skip = {p for p in exclude.split(",") if p}
return {
"budget": budget,
"hero": hero_mod.pick(
db, user, budget, seed, exclude=set(list(skip)[:MAX_EXCLUDE]), start=start
),
}
@router.get("/pins")
def list_pins(
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
"""The shelf on its own — for the shelf editor, which needs it without the rest of the page."""
return {"items": pins.shelf(db, user)}
@router.post("/pins")
def add_pin(
body: PinIn,
user: User = Depends(require_human),
db: Session = Depends(get_db),
) -> dict:
"""Pin something to the comfort shelf.
`require_human`, like every other personal-state write: the shared demo account must not be able
to curate a communal shelf. Re-pinning something already on the shelf is a no-op that returns the
existing pin rather than a 409 — the gesture is "make sure this is on my shelf", and the unique
constraint is the mechanism, not the message."""
resolved = pins.resolve_ref(db, user, body.kind, body.ref)
if resolved is None:
raise HTTPException(status_code=404, detail="Unknown item")
column, value = resolved
existing = db.execute(
select(ComfortPin).where(
ComfortPin.user_id == user.id,
getattr(ComfortPin, column) == value,
)
).scalar_one_or_none()
if existing is not None:
return {"ok": True, "id": existing.id, "created": False}
pin = ComfortPin(user_id=user.id, position=pins.next_position(db, user), **{column: value})
db.add(pin)
try:
db.commit()
except IntegrityError:
# A concurrent pin of the same thing from a second tab. The unique constraint did its job;
# the user's intent ("be on my shelf") is satisfied either way, so report the winner rather
# than a 500 the user can do nothing with.
db.rollback()
winner = db.execute(
select(ComfortPin).where(
ComfortPin.user_id == user.id,
getattr(ComfortPin, column) == value,
)
).scalar_one_or_none()
if winner is None:
# Not the unique constraint, then: the only other way in is the target row being pruned
# between `resolve_ref` confirming it and this INSERT (a Plex sync dropping a removed
# item), which trips the FK. Semantically that is the same "no such thing" the pre-check
# returns above — re-raising handed the user a 500 they could do nothing with.
raise HTTPException(status_code=404, detail="Unknown item")
return {"ok": True, "id": winner.id, "created": False}
return {"ok": True, "id": pin.id, "created": True}
@router.delete("/pins/{pin_id}")
def remove_pin(
pin_id: int,
user: User = Depends(require_human),
db: Session = Depends(get_db),
) -> dict:
pin = owned_or_404(db, ComfortPin, pin_id, user)
db.delete(pin)
db.commit()
return {"deleted": pin_id}
@router.post("/pins/reorder")
def reorder_pins(
body: ReorderIn,
user: User = Depends(require_human),
db: Session = Depends(get_db),
) -> dict:
"""Rewrite the shelf order from a full list of pin ids.
Whole-list rather than a move-one delta: the shelf is short, and a full rewrite cannot leave two
tiles claiming the same slot the way an interrupted sequence of single moves can. Ids that are not
this user's are ignored rather than 404'd — a stale tab replaying an order that includes a pin
deleted meanwhile should reorder what still exists, not fail wholesale."""
owned = {p.id for p in pins.list_pins(db, user)}
for position, pin_id in enumerate(i for i in body.ids if i in owned):
db.execute(
update(ComfortPin)
.where(ComfortPin.id == pin_id, ComfortPin.user_id == user.id)
.values(position=position)
)
db.commit()
return {"ok": True}
+75
View File
@@ -3,6 +3,8 @@ CRUD (create / rename / delete, add / remove / reorder items) plus YouTube two-w
mirror the user's own YouTube playlists in (`/sync-youtube`) and push/revert local edits back
out (`/{id}/push-plan`, `/{id}/revert-youtube`), behind the optional write scope."""
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy import func, select, and_
@@ -52,6 +54,16 @@ def _own_playlist(db: Session, user: User, playlist_id: int) -> Playlist:
return pl
def _touch(pl: Playlist) -> None:
"""Bump the playlist's `updated_at` for an edit that only writes `playlist_items`.
`UpdatedAtMixin`'s `onupdate` fires on an UPDATE of the PLAYLIST row, and add / remove /
reorder / move write the item rows instead — so without this the rail's "recently changed"
sort would only ever see renames. The assignment is what makes the column mean "last time
this list changed", which is what the rail promises the reader."""
pl.updated_at = datetime.now(timezone.utc)
def _next_playlist_position(db: Session, user_id: int) -> int:
"""Append position for a new playlist in this user's rail."""
return (
@@ -91,6 +103,7 @@ def _add_item(db: Session, pl: Playlist, video_id: str, *, mark_dirty: bool) ->
)
if mark_dirty:
recompute_dirty(db, pl)
_touch(pl)
db.commit()
return True
@@ -108,6 +121,7 @@ def _remove_item(db: Session, pl: Playlist, video_id: str, *, mark_dirty: bool)
db.delete(item)
if mark_dirty:
recompute_dirty(db, pl)
_touch(pl)
db.commit()
return True
@@ -129,6 +143,9 @@ def _summary(
"item_count": count,
"total_duration_seconds": total_duration,
"cover_thumbnail": cover,
# When this list last CHANGED (rename, add/remove/move/reorder) — the rail's
# "recently changed first" sort reads it. See _touch for why it isn't automatic.
"updated_at": pl.updated_at,
}
if has_video is not None:
out["has_video"] = has_video
@@ -520,6 +537,63 @@ def remove_item(
return {"playlist_id": pl.id, "video_id": video_id}
class MoveItemIn(BaseModel):
target_playlist_id: int
@router.post("/{playlist_id}/items/{video_id}/move")
def move_item(
playlist_id: int,
video_id: str,
body: MoveItemIn,
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
"""Move one video from a playlist into another of the caller's playlists, atomically.
ONE transaction on purpose. The obvious client-side shape — add to the target, then remove
from the source — leaves the video in BOTH lists whenever the second call fails, which is
exactly the kind of half-applied state this codebase treats as a bug rather than an edge
case. Idempotent: if the video is already in the target, the source row is simply dropped
(moving into a list that already has it is a removal, not a duplicate)."""
src = _own_playlist(db, user, playlist_id)
dst = _own_playlist(db, user, body.target_playlist_id)
if src.id == dst.id:
raise HTTPException(
status_code=400, detail={"code": "playlist_move", "reason": "same_playlist"}
)
item = db.scalar(
select(PlaylistItem).where(
PlaylistItem.playlist_id == src.id, PlaylistItem.video_id == video_id
)
)
if item is None:
# A concurrent edit (another tab, or a YouTube re-pull) already took it out.
raise HTTPException(
status_code=404, detail={"code": "playlist_move", "reason": "not_in_playlist"}
)
already = db.scalar(
select(PlaylistItem).where(
PlaylistItem.playlist_id == dst.id, PlaylistItem.video_id == video_id
)
)
if already is not None:
db.delete(item) # the uq_playlist_video constraint would reject a second row anyway
else:
# Re-parent the existing row rather than delete+insert: `added_at` ("when I saved this")
# survives the move, and there is no window where the video belongs to neither list.
item.playlist_id = dst.id
item.position = _next_item_position(db, dst.id)
# Both sides changed, so both get the dirty recompute and the timestamp — the asymmetric
# version of this (touching only the source) is the recurring smell CLAUDE.md warns about.
recompute_dirty(db, src)
recompute_dirty(db, dst)
_touch(src)
_touch(dst)
db.commit()
return {"ok": True, "from_playlist_id": src.id, "to_playlist_id": dst.id, "video_id": video_id}
class ReorderItemsIn(BaseModel):
video_ids: list[str] = []
@@ -555,5 +629,6 @@ def reorder_items(
it.position = pos
pos += 1
recompute_dirty(db, pl)
_touch(pl)
db.commit()
return {"playlist_id": pl.id, "count": pos}
+1 -23
View File
@@ -3,13 +3,9 @@
import logging
from fastapi import HTTPException
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models import (
PlexItem,
PlexLibrary,
)
from app.models import PlexItem
from app.plex.lookups import by_rating_key
log = logging.getLogger("siftlode.plex")
@@ -25,21 +21,3 @@ def item_or_404(db: Session, rating_key: str) -> PlexItem:
def _norm_lang(lang: str | None) -> str:
"""The overlay has an EN base + an optional HU twin; anything other than `hu` serves EN."""
return "hu" if lang == "hu" else "en"
def _content_libs(db: Session) -> tuple[list[int], list[int]]:
"""The BROWSABLE content libraries, partitioned into (movie_lib_ids, show_lib_ids) — the single
source of the browse scope. Excludes HU overlay libraries (`overlay_of` set): those are never
browsed on their own, they only lend their Hungarian metadata to the EN twin's rows (E5 S5)."""
libs = (
db.execute(
select(PlexLibrary).where(
PlexLibrary.enabled.is_(True), PlexLibrary.overlay_of.is_(None)
)
)
.scalars()
.all()
)
movie_lib_ids = [lb.id for lb in libs if lb.kind == "movie"]
show_lib_ids = [lb.id for lb in libs if lb.kind == "show"]
return movie_lib_ids, show_lib_ids
+4 -3
View File
@@ -34,6 +34,7 @@ from app.plex.query import (
apply_meta_filters,
apply_movie_state_filter,
apply_show_state_filter,
content_libs,
field_recheck,
genres_col,
meta_filter_conds,
@@ -47,7 +48,7 @@ from app.plex.query import (
vector_col,
vector_match,
)
from app.routes.plex._shared import _content_libs, _norm_lang
from app.routes.plex._shared import _norm_lang
from app.utils import to_tsquery_str
router = APIRouter(prefix="/api/plex", tags=["plex"])
@@ -109,7 +110,7 @@ def unified_library(
offset = max(0, offset)
lang = _norm_lang(lang)
p = f.as_dict()
movie_lib_ids, show_lib_ids = _content_libs(db)
movie_lib_ids, show_lib_ids = content_libs(db)
want_movies = scope in ("movie", "both") and bool(movie_lib_ids)
want_shows = scope in ("show", "both") and bool(show_lib_ids)
@@ -328,7 +329,7 @@ def facets(
"duration_min": None,
"duration_max": None,
}
all_movie_ids, all_show_ids = _content_libs(db)
all_movie_ids, all_show_ids = content_libs(db)
movie_ids = all_movie_ids if scope in ("movie", "both") else []
show_ids = all_show_ids if scope in ("show", "both") else []
if not movie_ids and not show_ids:
+2 -3
View File
@@ -19,8 +19,7 @@ from app.plex import sync as plex_sync
from app.plex.cards import movie_card, show_card
from app.plex.client import PlexClient, PlexError, PlexNotConfigured
from app.plex.lookups import by_rating_key
from app.plex.query import like_escape, runtime_by_key, top_art_keys
from app.routes.plex._shared import _content_libs
from app.plex.query import content_libs, like_escape, runtime_by_key, top_art_keys
# The most-restrictive → least ordering used to pick a collection's effective content rating from its
# members (Plex shows one cert on the collection; we don't store it, so derive it).
@@ -128,7 +127,7 @@ def collections(
# members (a collection lives in one library, so exactly one side has art) — a show collection would
# otherwise render blank; the collection's own poster is the next fallback, then the colour hash.
# Runtime is movie-only (shows have no single duration).
movie_ids, show_ids = _content_libs(db)
movie_ids, show_ids = content_libs(db)
keys = [c.rating_key for c in rows]
art_m = top_art_keys(db, PlexItem, movie_ids, PlexItem.collection_keys, keys, per=2)
art_s = top_art_keys(db, PlexShow, show_ids, PlexShow.collection_keys, keys, per=2)
+9 -7
View File
@@ -27,12 +27,13 @@ from app.plex.query import (
LibraryFilters,
apply_meta_filters,
apply_show_state_filter,
content_libs,
genres_col,
meta_filter_conds,
top_art_keys,
)
from app.plex.tmdb import resolve_recs
from app.routes.plex._shared import _content_libs, _norm_lang
from app.routes.plex._shared import _norm_lang
router = APIRouter(prefix="/api/plex", tags=["plex"])
@@ -43,7 +44,7 @@ SURPRISE_PER_KIND = 3 # Surprise-me: at most this many movies + this many shows
def _scope_libs(db: Session, scope: str) -> tuple[list[int], list[int]]:
"""The movie/show library ids visible for this scope (empty list = out of scope)."""
movie_ids, show_ids = _content_libs(db)
movie_ids, show_ids = content_libs(db)
return (
movie_ids if scope in ("movie", "both") else [],
show_ids if scope in ("show", "both") else [],
@@ -112,10 +113,11 @@ def _recently_added(
else []
)
# Merge the two type-homogeneous lists by added_at, then serialize each side in one batch so the
# per-card state lookups stay batched (no N+1). The `(is not None, value)` key sorts a missing
# timestamp last under reverse.
# per-card state lookups stay batched (no N+1). Same `or NO_TS` key as the two rails below — the
# E24 review found this one still on the older `(is not None, value)` form, i.e. one rule in two
# spellings, with the two comments explaining it differently.
tagged = [(m.added_at, "movie", m) for m in movies] + [(s.added_at, "show", s) for s in shows]
tagged.sort(key=lambda t: (t[0] is not None, t[0]), reverse=True)
tagged.sort(key=lambda t: t[0] or rollup.NO_TS, reverse=True)
top = tagged[:RAIL_LIMIT]
# Key the serialized cards back by rating_key (globally unique in Plex — a movie and a show never
# share one), so the merge doesn't depend on object identity staying alive.
@@ -170,7 +172,7 @@ def _recently_watched(
)
for ep, st, sh_title, sh_rk in db.execute(eq).all():
rows.append((st.watched_at, leaf_card(ep, st, show_title=sh_title, show_rk=sh_rk)))
rows.sort(key=lambda r: (r[0] is not None, r[0]), reverse=True)
rows.sort(key=lambda r: r[0] or rollup.NO_TS, reverse=True)
return [card for _, card in rows[:RAIL_LIMIT]]
@@ -234,7 +236,7 @@ def _continue_watching(
for sh, resumed_at in srows:
rows.append((resumed_at, show_card(sh, "in_progress")))
rows.sort(key=lambda r: (r[0] is not None, r[0]), reverse=True)
rows.sort(key=lambda r: r[0] or rollup.NO_TS, reverse=True)
return [card for _, card in rows[:RAIL_LIMIT]]
+3 -4
View File
@@ -19,8 +19,7 @@ from app.models import (
from app.plex import rollup
from app.plex import tmdb as plex_tmdb
from app.plex.cards import movie_card, show_card
from app.plex.query import split_csv
from app.routes.plex._shared import _content_libs
from app.plex.query import content_libs, split_csv
router = APIRouter(prefix="/api/plex", tags=["plex"])
log = logging.getLogger("siftlode.plex")
@@ -71,7 +70,7 @@ def people_cards(
wanted = split_csv(names)[:16]
if not wanted:
return {"people": []}
movie_lib_ids, show_lib_ids = _content_libs(db)
movie_lib_ids, show_lib_ids = content_libs(db)
want_movies = scope in ("movie", "both") and bool(movie_lib_ids)
want_shows = scope in ("show", "both") and bool(show_lib_ids)
photos = {
@@ -185,7 +184,7 @@ def person_detail(
and _should_schedule_enrich(person.id)
):
background_tasks.add_task(_enrich_person_bg, person.id)
movie_lib_ids, show_lib_ids = _content_libs(db)
movie_lib_ids, show_lib_ids = content_libs(db)
movies: list[dict] = []
if movie_lib_ids:
+942
View File
@@ -0,0 +1,942 @@
"""E24 S1 — the Home surface's read-model.
Covers the four things that can silently rot here: the time-budget bands (pure), the cross-source
resume rail's merge order, the hero's tier ladder, and the comfort shelf (its schema-level one-of
invariant, its cascade, and the resolved `next` that makes one click enough).
DB lane (`db` fixture) except for the budget block — skipped without Postgres.
"""
from datetime import datetime, timedelta, timezone
import pytest
from sqlalchemy import delete, func, select, text
from sqlalchemy.exc import IntegrityError
from app.discover import hero, pins, queries, rails
from app.discover.budget import BANDS, fits, remaining_seconds
from app.plex import rollup
from app.plex.cards import leaf_card
from app.models import (
BlockedChannel,
Channel,
ComfortPin,
PlexItem,
PlexLibrary,
PlexShow,
PlexState,
Playlist,
PlaylistItem,
Subscription,
User,
Video,
VideoState,
)
NOW = datetime(2026, 6, 1, tzinfo=timezone.utc)
# ── budget (pure) ─────────────────────────────────────────────────────────────────────────────────
def test_bands_cover_every_runtime_and_overlap_deliberately():
"""Every runtime has a home in `any`, and the bands overlap so "30 perc" is not answered with a
two-minute Short just because it fits under the cap."""
assert fits(3, "any") and fits(10_000, "any")
assert fits(2 * 60, "short") and not fits(2 * 60, "medium") # a Short is not a 30-minute answer
assert fits(30 * 60, "medium") and not fits(30 * 60, "short")
assert fits(110 * 60, "long") and not fits(110 * 60, "medium")
def test_unknown_duration_only_fits_the_unbounded_band():
"""We cannot promise an unknown-length item finishes inside ten minutes, so it is not offered as
an answer to a bounded question — the one control on the page has to mean what it says."""
for budget in BANDS:
assert fits(None, budget) is (budget == "any")
def test_budget_is_matched_against_what_is_LEFT():
"""A 90-minute film with 8 minutes to go is a ten-minute answer."""
assert remaining_seconds(90 * 60, 82 * 60) == 8 * 60
assert fits(remaining_seconds(90 * 60, 82 * 60), "short")
assert not fits(remaining_seconds(90 * 60, 0), "short")
assert remaining_seconds(None, 30) is None
assert remaining_seconds(600, 900) == 0 # a position past the end never goes negative
# ── fixtures ──────────────────────────────────────────────────────────────────────────────────────
def _seed_yt(db) -> tuple[User, Channel]:
"""A subscribed channel with three videos of distinct lengths, so a budget can pick between them."""
user = User(email="discover-e24@example.com")
ch = Channel(id="UCe24", title="Comfort Channel", handle="@comfort")
db.add_all([user, ch])
db.flush()
db.add(Subscription(user_id=user.id, channel_id=ch.id))
db.add_all(
[
Video(
id="vshort",
channel_id=ch.id,
title="Ten minute video",
duration_seconds=9 * 60,
published_at=NOW - timedelta(days=1),
),
Video(
id="vmed",
channel_id=ch.id,
title="Half hour video",
duration_seconds=30 * 60,
published_at=NOW - timedelta(days=2),
),
Video(
id="vlong",
channel_id=ch.id,
title="Feature length video",
duration_seconds=95 * 60,
published_at=NOW - timedelta(days=3),
),
]
)
db.commit()
return user, ch
def _seed_plex(db, user: User) -> tuple[PlexItem, PlexShow, PlexItem]:
lib_m = PlexLibrary(plex_key="90", title="Movies", kind="movie", enabled=True)
lib_s = PlexLibrary(plex_key="91", title="Shows", kind="show", enabled=True)
db.add_all([lib_m, lib_s])
db.flush()
movie = PlexItem(
rating_key="pm1",
library_id=lib_m.id,
kind="movie",
title="A Movie",
duration_s=100 * 60,
playable="direct",
)
show = PlexShow(rating_key="ps1", library_id=lib_s.id, title="A Show", child_count=1)
db.add_all([movie, show])
db.flush()
ep1 = PlexItem(
rating_key="ps1e1",
library_id=lib_s.id,
kind="episode",
title="Ep 1",
show_id=show.id,
season_number=1,
episode_number=1,
duration_s=45 * 60,
)
ep2 = PlexItem(
rating_key="ps1e2",
library_id=lib_s.id,
kind="episode",
title="Ep 2",
show_id=show.id,
season_number=1,
episode_number=2,
duration_s=45 * 60,
)
db.add_all([ep1, ep2])
db.commit()
return movie, show, ep1
def _enable_plex(db):
from app import sysconfig
sysconfig.set_value(db, "plex_enabled", True)
db.commit()
# ── continue watching ─────────────────────────────────────────────────────────────────────────────
def test_continue_watching_merges_both_sources_by_recency(db):
"""One rail, not two lists side by side — and ordered by when the user last touched each thing,
across sources. Two side-by-side rails would just move the decision up a level."""
user, _ = _seed_yt(db)
movie, _, _ = _seed_plex(db, user)
_enable_plex(db)
db.add_all(
[
VideoState(
user_id=user.id,
video_id="vmed",
status="new",
position_seconds=300,
progress_updated_at=NOW - timedelta(hours=5),
),
PlexState(
user_id=user.id,
item_id=movie.id,
status="new",
position_seconds=600,
progress_updated_at=NOW - timedelta(hours=1),
),
]
)
db.commit()
got = rails.continue_watching(db, user)
assert [(c["source"], c["id"]) for c in got] == [("plex", "pm1"), ("youtube", "vmed")]
assert got[0]["remaining_seconds"] == 100 * 60 - 600
def test_continue_watching_is_youtube_only_when_plex_is_off(db):
"""Plex is a deployment toggle; with it off the surface says nothing about Plex at all."""
user, _ = _seed_yt(db)
movie, _, _ = _seed_plex(db, user)
db.add_all(
[
VideoState(
user_id=user.id,
video_id="vmed",
status="new",
position_seconds=300,
progress_updated_at=NOW,
),
PlexState(
user_id=user.id,
item_id=movie.id,
status="new",
position_seconds=600,
progress_updated_at=NOW,
),
]
)
db.commit()
assert {c["source"] for c in rails.continue_watching(db, user)} == {"youtube"}
def test_the_resume_rail_obeys_the_channel_blocklist(db):
"""Blocking a channel does not touch `video_states`, so a half-watched video from it survives
with its resume position — and this rail builds its own SELECT instead of going through
`filtered_query`. "Never show me this channel" has to outrank "you started it", or the landing
page answers "what should I watch" with the thing the user has just banned."""
user, ch = _seed_yt(db)
db.add(
VideoState(
user_id=user.id,
video_id="vmed",
status="new",
position_seconds=300,
progress_updated_at=NOW,
)
)
db.commit()
assert [c["id"] for c in rails.continue_watching(db, user)] == ["vmed"]
db.add(BlockedChannel(user_id=user.id, channel_id=ch.id))
db.commit()
assert rails.continue_watching(db, user) == []
# And so the hero, whose resume tier draws from that rail, has nothing to offer either.
assert hero.pick(db, user, "any", 0) is None
def test_a_null_timestamp_sorts_last(db):
"""A missing progress timestamp sorts last rather than 500ing the landing page.
Narrower than it was written to be, and the epic-end review corrected it: the previous key,
`(ts is not None, ts)`, does NOT raise on two nulls — tuple comparison finds the first UNEQUAL
element via `==`, so `None < None` is never reached, and this test passes under that key too.
What it does discriminate is the naive `key=lambda r: r[0]`, which really does raise. See
`plex.rollup.NO_TS` for the corrected rationale."""
user, _ = _seed_yt(db)
db.add_all(
[
VideoState(
user_id=user.id, video_id="vmed", status="new", position_seconds=1
), # no progress_updated_at
VideoState(
user_id=user.id, video_id="vlong", status="new", position_seconds=1
), # nor here
VideoState(
user_id=user.id,
video_id="vshort",
status="new",
position_seconds=1,
progress_updated_at=NOW,
),
]
)
db.commit()
got = rails.continue_watching(db, user)
assert got[0]["id"] == "vshort"
assert len(got) == 3
# ── hero ──────────────────────────────────────────────────────────────────────────────────────────
def test_hero_prefers_resume_and_labels_the_tier(db):
user, _ = _seed_yt(db)
db.add(
VideoState(
user_id=user.id,
video_id="vmed",
status="new",
position_seconds=300,
progress_updated_at=NOW,
)
)
db.commit()
got = hero.pick(db, user, "any", seed=1)
assert got["tier"] == "resume"
assert got["card"]["id"] == "vmed"
def test_hero_skips_a_resume_that_does_not_fit_the_budget(db):
""" "I have ten minutes" must not be answered with the 80 minutes left on a film, even though
that film is the most recent thing the user touched."""
user, _ = _seed_yt(db)
db.add(
VideoState(
user_id=user.id,
video_id="vlong",
status="new",
position_seconds=60,
progress_updated_at=NOW,
)
)
db.commit()
got = hero.pick(db, user, "short", seed=1)
assert got["tier"] == "surprise"
assert got["card"]["duration_seconds"] <= BANDS["short"][1]
def test_hero_falls_back_to_the_shelf_before_guessing(db):
"""The user's own declared fallback beats anything we could infer."""
user, ch = _seed_yt(db)
db.add(ComfortPin(user_id=user.id, video_id="vmed", position=0))
db.commit()
got = hero.pick(db, user, "medium", seed=1)
assert got["tier"] == "comfort"
assert got["card"]["id"] == "vmed"
def test_hero_resolves_a_show_to_the_episode_that_would_play(db):
"""A show has no Play button. The hero of a partially-watched show is its next episode."""
user, _ = _seed_yt(db)
_, show, ep1 = _seed_plex(db, user)
_enable_plex(db)
db.add(
PlexState(
user_id=user.id,
item_id=ep1.id,
status="watched",
watched_at=NOW,
progress_updated_at=NOW,
)
)
db.commit()
got = hero.pick(db, user, "any", seed=1)
assert got["tier"] == "resume"
assert got["card"]["kind"] == "episode"
assert got["card"]["id"] == "ps1e2" # ep1 is finished → ep2 is what plays
def test_hero_is_none_when_nothing_fits(db):
"""An empty answer is an honest answer: the page renders its own empty state rather than being
handed a card that does not answer what was asked. Here the whole library is under 40 minutes,
so "egy este" has nothing to offer."""
user, ch = _seed_yt(db)
db.execute(text("DELETE FROM videos WHERE id = 'vlong'"))
db.commit()
assert hero.pick(db, user, "long", seed=1) is None
def test_surprise_is_reproducible_for_a_seed(db):
"""The reroll button depends on this: one seed drives both sources, so the same seed must give
the same answer and a different seed must be free to give another."""
user, _ = _seed_yt(db)
a = rails.surprise(db, user, "any", seed=7, limit=3)
b = rails.surprise(db, user, "any", seed=7, limit=3)
assert [c["id"] for c in a] == [c["id"] for c in b]
seeds = {tuple(c["id"] for c in rails.surprise(db, user, "any", s, 3)) for s in range(12)}
assert len(seeds) > 1 # the seed actually reaches the ordering
def test_surprise_never_offers_something_already_started(db):
"""The resume tier may have skipped an item because its REMAINING time did not fit; handing the
same item back as "Meglepetés" — where the full runtime does fit — would contradict the question
the user just answered."""
user, _ = _seed_yt(db)
db.add(
VideoState(
user_id=user.id,
video_id="vlong",
status="new",
position_seconds=90 * 60,
progress_updated_at=NOW,
)
)
db.commit()
assert "vlong" not in {c["id"] for c in rails.surprise(db, user, "long", seed=3, limit=5)}
def test_the_seed_decides_which_source_leads(db):
"""With a pool of one, a fixed source order would mean the Plex library could never answer "egy
este" whenever a YouTube video also fit — the cross-source promise would quietly be a YouTube
promise."""
user, _ = _seed_yt(db)
_seed_plex(db, user)
_enable_plex(db)
leaders = {rails.surprise(db, user, "any", s, limit=1)[0]["source"] for s in range(6)}
assert leaders == {"youtube", "plex"}
def test_the_reroll_must_be_able_to_move_past_the_resume_tier(db):
"""A new seed alone only re-draws the SURPRISE tier — the resume tier deterministically returns
the most recently progressed thing that fits, so without an exclusion the reroll handed the same
card straight back and the button looked dead (found in the browser, 2026-09-06)."""
user, _ = _seed_yt(db)
db.add_all(
[
VideoState(
user_id=user.id,
video_id="vmed",
status="new",
position_seconds=300,
progress_updated_at=NOW,
),
VideoState(
user_id=user.id,
video_id="vshort",
status="new",
position_seconds=60,
progress_updated_at=NOW - timedelta(hours=1),
),
]
)
db.commit()
first = hero.pick(db, user, "any", seed=1)
assert first["card"]["id"] == "vmed"
# Same seed, same everything — only the exclusion differs.
second = hero.pick(db, user, "any", seed=1, exclude={"youtube:vmed"})
assert second["card"]["id"] == "vshort"
def test_the_reroll_rotates_which_tier_answers(db):
"""Measured 2026-09-06: exclusion alone left the button walking down the resume rail (20 rows)
and cycling nine cards, every one of them `resume` — the shelf and the surprise tier were
unreachable by pressing it. `start` is the press count, so consecutive presses offer a different
KIND of answer."""
user, _ = _seed_yt(db)
# One of each: something half-finished, something pinned, and a third video for the draw.
db.add(
VideoState(
user_id=user.id,
video_id="vmed",
status="new",
position_seconds=300,
progress_updated_at=NOW,
)
)
db.add(ComfortPin(user_id=user.id, video_id="vlong", position=0))
db.commit()
tiers = [hero.pick(db, user, "any", seed=1, start=n)["tier"] for n in range(4)]
assert tiers == ["resume", "comfort", "surprise", "resume"]
def test_the_rotation_still_falls_through_an_empty_tier(db):
"""A press must not come back empty just because the tier it happened to lead with has nothing.
Here there is no shelf at all, so the comfort-led press has to answer from somewhere else."""
user, _ = _seed_yt(db)
assert db.execute(select(func.count()).select_from(ComfortPin)).scalar_one() == 0
got = hero.pick(db, user, "any", seed=1, start=1)
assert got is not None and got["tier"] != "comfort"
def test_excluding_a_show_also_excludes_the_episode_it_resolves_to(db):
"""The user rerolled past the SHOW they were shown; handing back its next episode under a
different title would be the same suggestion wearing a hat."""
user, _ = _seed_yt(db)
_, show, ep1 = _seed_plex(db, user)
_enable_plex(db)
db.add(
PlexState(
user_id=user.id,
item_id=ep1.id,
status="new",
position_seconds=120,
progress_updated_at=NOW,
)
)
db.commit()
shown = hero.pick(db, user, "any", seed=1)
assert shown["card"]["id"] == "ps1e1" # the show resolved to its in-progress episode
# Excluding what the USER saw — the show — must move the tier on, not return the episode again.
after = hero.pick(db, user, "any", seed=1, exclude={"plex:ps1"})
assert after is None or after["card"]["id"] != "ps1e1"
# ── comfort shelf ─────────────────────────────────────────────────────────────────────────────────
def test_a_pin_must_reference_exactly_one_thing(db):
"""The union invariant lives in the schema, not in a comment at every insert site."""
user, ch = _seed_yt(db)
for kwargs in ({}, {"video_id": "vmed", "channel_id": ch.id}):
db.add(ComfortPin(user_id=user.id, position=0, **kwargs))
with pytest.raises(IntegrityError):
db.flush()
db.rollback()
def test_the_same_thing_cannot_be_pinned_twice(db):
"""The per-kind unique constraints are what make the route's concurrent-tab path safe (it catches
`IntegrityError` precisely because of them) — and the migration drift check filters constraint
diffs out as noise, so nothing else would notice them going missing. The route's own dedupe
returns early without ever reaching the DB, so only this test exercises them."""
user, _ = _seed_yt(db)
db.add(ComfortPin(user_id=user.id, video_id="vmed", position=0))
db.commit()
db.add(ComfortPin(user_id=user.id, video_id="vmed", position=1))
with pytest.raises(IntegrityError):
db.flush()
db.rollback()
def test_deleting_the_pinned_row_removes_the_pin(db):
"""A film dropped from the library must not leave a dead tile on the one surface whose whole job
is to be reliable — which is why the shelf holds foreign keys, not strings."""
user, _ = _seed_yt(db)
db.add(ComfortPin(user_id=user.id, video_id="vmed", position=0))
db.commit()
db.execute(text("DELETE FROM videos WHERE id = 'vmed'"))
db.commit()
assert db.execute(select(func.count()).select_from(ComfortPin)).scalar() == 0
def test_shelf_resolves_a_channel_to_its_newest_unwatched_video(db):
""" "Put that channel on" has to be one click, so the tile carries what would play."""
user, ch = _seed_yt(db)
db.add(ComfortPin(user_id=user.id, channel_id=ch.id, position=0))
db.commit()
(card,) = pins.shelf(db, user)
assert card["kind"] == "channel"
assert card["unwatched_count"] == 3
assert card["next"]["id"] == "vshort" # newest
def test_shelf_resolves_a_playlist_to_its_first_unwatched_item(db):
user, _ = _seed_yt(db)
pl = Playlist(user_id=user.id, name="Elalvós")
db.add(pl)
db.flush()
db.add_all(
[
PlaylistItem(playlist_id=pl.id, video_id="vshort", position=0),
PlaylistItem(playlist_id=pl.id, video_id="vmed", position=1),
]
)
db.add(VideoState(user_id=user.id, video_id="vshort", status="watched", watched_at=NOW))
db.add(ComfortPin(user_id=user.id, playlist_id=pl.id, position=0))
db.commit()
(card,) = pins.shelf(db, user)
assert card["kind"] == "playlist"
assert card["item_count"] == 2
assert card["next"]["id"] == "vmed"
def test_a_pinned_video_survives_leaving_the_feed_scope(db):
"""The shelf is the fallback surface. An entry silently vanishing because the user unsubscribed
from the channel is exactly the failure it must not have."""
user, ch = _seed_yt(db)
db.add(ComfortPin(user_id=user.id, video_id="vmed", position=0))
db.commit()
db.execute(delete(Subscription))
db.commit()
(card,) = pins.shelf(db, user)
assert card["id"] == "vmed"
assert card["next"]["id"] == "vmed"
def test_the_plex_surprise_obeys_the_time_budget(db):
"""The one control on the page has to mean what it says: a 100-minute film is not an answer to
"10 perc". The YouTube half of this rule has had its own test since S1; the Plex half's duration
bounds could be deleted with the whole suite still green, because every test that reached
`plex_surprise` passed budget="any"."""
user, _ = _seed_yt(db)
_seed_plex(db, user)
_enable_plex(db)
drawn = lambda budget: { # noqa: E731
(c["source"], c["id"]) for s in range(6) for c in rails.surprise(db, user, budget, s, 5)
}
assert ("plex", "pm1") not in drawn("short")
# …and it IS offered where it fits, so this cannot pass by the rail being empty.
assert ("plex", "pm1") in drawn("long")
def test_the_plex_surprise_never_offers_something_already_started(db):
"""A half-watched film is not a surprise, and the resume tier has already had its chance at it.
The mirror of `test_surprise_never_offers_something_already_started` for the other source — the
asymmetric half of one principle, which the house rules say to fix everywhere."""
user, _ = _seed_yt(db)
movie, _, _ = _seed_plex(db, user)
_enable_plex(db)
db.add(
PlexState(
user_id=user.id,
item_id=movie.id,
status="new",
position_seconds=600,
progress_updated_at=NOW,
)
)
db.commit()
drawn = {
(c["source"], c["id"]) for s in range(6) for c in rails.surprise(db, user, "long", s, 5)
}
assert ("plex", "pm1") not in drawn
def test_a_new_pin_lands_at_the_end_even_after_a_hole(db):
"""Positions are never renumbered on removal — `remove_pin` deletes the row, and every FK cascade
silently removes a pin when its target disappears — so the append position has to come from
`max(position)`, not from the row count. With a count, the first hole drops the next pin into the
MIDDLE of the shelf and the second makes two pins claim one slot."""
user, ch = _seed_yt(db)
db.add_all(
[
ComfortPin(user_id=user.id, video_id="vshort", position=0),
ComfortPin(user_id=user.id, video_id="vmed", position=1),
ComfortPin(user_id=user.id, video_id="vlong", position=2),
]
)
db.commit()
db.execute(delete(ComfortPin).where(ComfortPin.video_id.in_(("vshort", "vmed"))))
db.commit()
db.add(ComfortPin(user_id=user.id, channel_id=ch.id, position=pins.next_position(db, user)))
db.commit()
assert [c["kind"] for c in pins.shelf(db, user)] == ["video", "channel"]
positions = (
db.execute(select(ComfortPin.position).order_by(ComfortPin.position)).scalars().all()
)
assert len(set(positions)) == len(positions) # and no two tiles claim the same slot
def test_a_self_playing_pin_does_not_reference_itself(db):
"""`next` must be a distinct dict even when the pin plays itself. Aliasing the card into its own
`next` is a cycle, and the serializer turns the whole shelf into a 500 over it."""
user, _ = _seed_yt(db)
db.add(ComfortPin(user_id=user.id, video_id="vmed", position=0))
db.commit()
(card,) = pins.shelf(db, user)
assert card["next"] is not card
assert "next" not in card["next"]
def test_the_resume_rail_says_what_would_actually_play(db):
"""A show tile that only opens the show page moves the decision instead of removing it — the
click lands on six seasons to choose between, which is the paralysis this whole surface exists
for. The shelf and the hero already resolved; this rail did not (reported 2026-09-06). The tile
stays the show (its artwork is what the user recognises); `next` is what starts."""
user, _ = _seed_yt(db)
_, show, ep1 = _seed_plex(db, user)
_enable_plex(db)
db.add(
PlexState(
user_id=user.id,
item_id=ep1.id,
status="watched",
watched_at=NOW,
progress_updated_at=NOW,
)
)
db.commit()
rail = rails.continue_watching(db, user)
tile = next(c for c in rail if c["kind"] == "show")
assert tile["id"] == "ps1" # still the show
assert tile["next"]["kind"] == "episode"
assert tile["next"]["id"] == "ps1e2" # ep1 is finished → ep2 is what plays
def test_only_a_show_row_pays_for_a_resolution(db):
"""`next` is the answer to "a show has no Play button". Attaching it to a movie or a video would
both cost a query for nothing and re-open the self-reference cycle the shelf already hit.
The states are what make this test exist: without them every rail query inner-joins its way to
zero rows, the loop body never runs, and the assertion is vacuous — which is exactly how the
epic-end review found it."""
user, _ = _seed_yt(db)
movie, _, ep1 = _seed_plex(db, user)
_enable_plex(db)
db.add_all(
[
VideoState(
user_id=user.id,
video_id="vmed",
status="new",
position_seconds=300,
progress_updated_at=NOW,
),
PlexState(
user_id=user.id,
item_id=movie.id,
status="new",
position_seconds=600,
progress_updated_at=NOW,
),
PlexState(
user_id=user.id,
item_id=ep1.id,
status="new",
position_seconds=120,
progress_updated_at=NOW,
),
]
)
db.commit()
rail = rails.continue_watching(db, user)
# A video, a movie and a show — one row of each kind that can appear here, so the assertion below
# actually exercises both sides of the equality.
assert {c["kind"] for c in rail} == {"video", "movie", "show"}
for card in rail:
assert ("next" in card) == (card["kind"] == "show"), card["id"]
def test_next_episode_takes_an_episode_with_no_state_row_at_all(db):
"""The NULL trap in the SQL rewrite: `status NOT IN ('watched','hidden')` is UNKNOWN — not true —
for an episode the user has never touched, so a bare filter drops exactly the common case and the
rail would offer episode 1 of a show already half-watched."""
user, _ = _seed_yt(db)
_, show, ep1 = _seed_plex(db, user)
db.add(PlexState(user_id=user.id, item_id=ep1.id, status="watched", watched_at=NOW))
db.commit() # ep2 has NO PlexState row
assert queries.next_episode(db, user.id, show.id)["id"] == "ps1e2"
def test_a_finished_show_starts_again_from_the_top(db):
"""Nothing unwatched left is not "nothing to play" — it is "watch it again"."""
user, _ = _seed_yt(db)
_, show, ep1 = _seed_plex(db, user)
ep2 = db.execute(select(PlexItem).where(PlexItem.rating_key == "ps1e2")).scalar_one()
db.add_all(
[
PlexState(user_id=user.id, item_id=ep1.id, status="watched", watched_at=NOW),
PlexState(user_id=user.id, item_id=ep2.id, status="watched", watched_at=NOW),
]
)
db.commit()
assert queries.next_episode(db, user.id, show.id)["id"] == "ps1e1"
def test_next_episode_agrees_with_the_show_page_when_two_are_in_progress(db):
"""One show, one answer. `plex.rollup.rollup` — which drives the show page's Resume button —
takes the LAST in-progress episode in broadcast order; taking the FIRST here gave the same show
two different "continue" answers, with the rail ranking it by its most recent touch and then
offering the episode abandoned weeks earlier. Two in-progress episodes are not exotic: a scrub to
the end leaves a stale `status='new', position>0` row until the daily reconcile."""
user, _ = _seed_yt(db)
_, show, ep1 = _seed_plex(db, user)
ep2 = db.execute(select(PlexItem).where(PlexItem.rating_key == "ps1e2")).scalar_one()
db.add_all(
[
PlexState(
user_id=user.id,
item_id=ep1.id,
status="new",
position_seconds=300,
progress_updated_at=NOW - timedelta(days=9),
),
PlexState(
user_id=user.id,
item_id=ep2.id,
status="new",
position_seconds=600,
progress_updated_at=NOW,
),
]
)
db.commit()
got = queries.next_episode(db, user.id, show.id)
assert got["id"] == "ps1e2"
# Asserted against the other implementation rather than against a literal, so the two cannot
# drift apart again without this failing.
ordered = [
leaf_card(ep, db.execute(select(PlexState).where(PlexState.item_id == ep.id)).scalar_one())
for ep in (ep1, ep2)
]
assert rollup.rollup(ordered)["resume"]["id"] == got["id"]
def test_next_episode_prefers_the_one_in_progress(db):
"""In progress beats "next unwatched": resuming is cheaper than starting."""
user, _ = _seed_yt(db)
_, show, ep1 = _seed_plex(db, user)
ep2 = db.execute(select(PlexItem).where(PlexItem.rating_key == "ps1e2")).scalar_one()
db.add_all(
[
PlexState(user_id=user.id, item_id=ep1.id, status="watched", watched_at=NOW),
PlexState(user_id=user.id, item_id=ep2.id, status="new", position_seconds=120),
]
)
db.commit()
assert queries.next_episode(db, user.id, show.id)["id"] == "ps1e2"
# ── HTTP surface ──────────────────────────────────────────────────────────────────────────────────
def test_discover_endpoint_answers_in_one_pass(db, as_user):
user, _ = _seed_yt(db)
db.add(
VideoState(
user_id=user.id,
video_id="vmed",
status="new",
position_seconds=300,
progress_updated_at=NOW,
)
)
db.commit()
r = as_user(user).get("/api/discover?budget=medium")
assert r.status_code == 200
body = r.json()
assert body["budget"] == "medium"
assert body["hero"]["tier"] == "resume"
assert [c["id"] for c in body["continue_watching"]] == ["vmed"]
assert body["comfort"] == []
def test_an_unknown_budget_is_refused_rather_than_silently_widened(db, as_user):
user, _ = _seed_yt(db)
assert as_user(user).get("/api/discover?budget=whenever").status_code == 422
def test_pinning_twice_is_the_same_shelf(db, as_user):
"""The gesture is "make sure this is on my shelf", so a re-pin is a no-op, not a 409."""
user, _ = _seed_yt(db)
c = as_user(user)
first = c.post("/api/discover/pins", json={"kind": "video", "ref": "vmed"}).json()
second = c.post("/api/discover/pins", json={"kind": "video", "ref": "vmed"}).json()
assert first["created"] is True and second["created"] is False
assert first["id"] == second["id"]
assert len(c.get("/api/discover/pins").json()["items"]) == 1
def test_pinning_a_plex_show_resolves_to_the_episode_that_would_play(db, as_user):
"""Two of the five pinnable kinds are Plex, and the rating_key → internal-id translation is
theirs alone — a wrong column or a wrong model in either arm pins the wrong row, or 404s every
Plex pin. Neither arm had a test before the epic-end review."""
user, _ = _seed_yt(db)
_, _, ep1 = _seed_plex(db, user)
_enable_plex(db)
db.add(PlexState(user_id=user.id, item_id=ep1.id, status="watched", watched_at=NOW))
db.commit()
c = as_user(user)
assert c.post("/api/discover/pins", json={"kind": "plex_show", "ref": "ps1"}).status_code == 200
(tile,) = c.get("/api/discover/pins").json()["items"]
assert (tile["kind"], tile["id"]) == ("show", "ps1")
assert tile["next"]["id"] == "ps1e2" # ep1 is finished
def test_pinning_a_plex_movie_plays_itself_without_a_cycle(db, as_user):
"""The self-reference that once 500'd the whole shelf, on the other leaf kind: `next` has to be a
distinct dict, not the tile aliased into itself. Covered for a video since S1, never for a movie."""
user, _ = _seed_yt(db)
_seed_plex(db, user)
_enable_plex(db)
c = as_user(user)
assert c.post("/api/discover/pins", json={"kind": "plex_item", "ref": "pm1"}).status_code == 200
(tile,) = c.get("/api/discover/pins").json()["items"]
assert tile["id"] == "pm1" and tile["next"]["id"] == "pm1"
def test_the_reroll_endpoint_parses_its_whole_exclusion_list_and_its_press_count(db, as_user):
"""The reroll's behaviour is covered at the domain level; the ROUTE's query-string translation —
the comma split and the `start` passthrough — was not covered at all, so a change there could
hand back the same card on every press with the suite green. Excluding all three candidates is
what proves the whole list parsed: a delimiter change leaves one unsplit string in the set, and a
hero comes back.
NOT the `MAX_EXCLUDE` clamp, which this sends three keys against a cap of 32 and cannot see. The
docstring used to claim it did (round 2). The clamp is deliberately left unpinned: it guards
nothing the route would otherwise do — see the constant's own comment — and a test that sent 33
keys could only assert that the request still answers, which the comma split already proves."""
user, _ = _seed_yt(db)
db.add(
VideoState(
user_id=user.id,
video_id="vmed",
status="new",
position_seconds=300,
progress_updated_at=NOW,
)
)
db.add(ComfortPin(user_id=user.id, video_id="vlong", position=0))
db.commit()
c = as_user(user)
# `start` rotates which tier answers first: 0 leads with resume, 1 with the shelf.
assert c.get("/api/discover/pick?seed=1&start=0").json()["hero"]["tier"] == "resume"
assert c.get("/api/discover/pick?seed=1&start=1").json()["hero"]["tier"] == "comfort"
skip = ",".join(f"youtube:{v}" for v in ("vshort", "vmed", "vlong"))
assert c.get(f"/api/discover/pick?seed=2&exclude={skip}").json()["hero"] is None
assert c.get("/api/discover/pick?budget=whenever").status_code == 422
def test_pinning_something_that_does_not_exist_is_a_404(db, as_user):
user, _ = _seed_yt(db)
r = as_user(user).post("/api/discover/pins", json={"kind": "video", "ref": "nope"})
assert r.status_code == 404
def test_a_pin_whose_target_vanishes_mid_request_is_a_404_not_a_500(db, as_user, monkeypatch):
"""`add_pin`'s `except IntegrityError` arm was reached by no test at all, so the 500→404
narrowing could be reverted with the whole suite green (round 2). The 404 above comes from the
`resolve_ref` PRE-CHECK, a different branch that returns before the INSERT.
The race the arm exists for — a Plex sync dropping the row between `resolve_ref` confirming it
and the INSERT — needs two transactions, but the BRANCH does not: a `resolve_ref` pointing at an
id no row carries lets the pre-check pass and trips the same FK on commit, with the re-query
finding no winner. The sentinel has to FIT `comfort_pins.video_id` (varchar(16)) — a longer one
raises `DataError` on the way in, which this arm does not catch and never meant to. Its sibling arm (a concurrent duplicate, where the re-query DOES find a winner
and answers 200) genuinely needs a second connection and stays uncovered."""
user, _ = _seed_yt(db)
monkeypatch.setattr(pins, "resolve_ref", lambda *a, **k: ("video_id", "gone-mid-req"))
r = as_user(user).post("/api/discover/pins", json={"kind": "video", "ref": "vshort"})
assert r.status_code == 404
def test_another_users_playlist_is_not_pinnable(db, as_user):
"""404, not 403 — a 403 would confirm the id exists to a stranger."""
user, _ = _seed_yt(db)
other = User(email="other-e24@example.com")
db.add(other)
db.flush()
pl = Playlist(user_id=other.id, name="Theirs")
db.add(pl)
db.commit()
r = as_user(user).post("/api/discover/pins", json={"kind": "playlist", "ref": str(pl.id)})
assert r.status_code == 404
def test_reorder_ignores_ids_that_are_not_yours(db, as_user):
"""A stale tab replaying an order that includes a since-deleted pin should reorder what still
exists rather than fail wholesale."""
user, ch = _seed_yt(db)
c = as_user(user)
a = c.post("/api/discover/pins", json={"kind": "video", "ref": "vmed"}).json()["id"]
b = c.post("/api/discover/pins", json={"kind": "channel", "ref": ch.id}).json()["id"]
assert c.post("/api/discover/pins/reorder", json={"ids": [b, 9999, a]}).status_code == 200
assert [p["pin_id"] for p in c.get("/api/discover/pins").json()["items"]] == [b, a]
def test_deleting_a_pin_reports_what_went(db, as_user):
user, _ = _seed_yt(db)
c = as_user(user)
pin_id = c.post("/api/discover/pins", json={"kind": "video", "ref": "vmed"}).json()["id"]
r = c.delete(f"/api/discover/pins/{pin_id}")
assert r.status_code == 200 and r.json() == {"deleted": pin_id}
assert c.get("/api/discover/pins").json()["items"] == []
+190
View File
@@ -0,0 +1,190 @@
"""E10 S2 — the one-step "move this video to another list" endpoint, and the `updated_at` touch
the rail's "recently changed" sort reads.
Two properties are load-bearing and neither is visible from the happy path alone:
1. **The move is one transaction.** The client-side shape it replaces (add to the target, then
remove from the source) leaves the video in BOTH lists when the second call fails. So the
tests assert the *pair* of lists after every call, never just the destination.
2. **`updated_at` tracks item edits.** `UpdatedAtMixin.onupdate` only fires on an UPDATE of the
playlist row, and add/remove/reorder/move write `playlist_items` — so the timestamp is bumped
explicitly, and a regression there would silently degrade the sort to "renames only".
"""
from datetime import datetime, timedelta, timezone
from app.models import Channel, Playlist, PlaylistItem, Video
def _seed_videos(db, ids):
db.add(Channel(id="UCtest", title="Test"))
now = datetime.now(timezone.utc)
for v in ids:
db.add(Video(id=v, channel_id="UCtest", published_at=now))
db.commit()
def _playlist(db, user, name, video_ids, **kw):
pl = Playlist(user_id=user.id, name=name, **kw)
db.add(pl)
db.flush()
for i, vid in enumerate(video_ids):
db.add(PlaylistItem(playlist_id=pl.id, video_id=vid, position=i))
db.commit()
return pl
def _ids(db, pl_id):
return [
it.video_id
for it in db.execute(
PlaylistItem.__table__.select()
.where(PlaylistItem.playlist_id == pl_id)
.order_by(PlaylistItem.position)
).all()
]
def _move(client, src, video_id, dst):
return client.post(
f"/api/playlists/{src}/items/{video_id}/move", json={"target_playlist_id": dst}
)
def test_move_leaves_the_video_in_exactly_one_list(db, make_user, as_user):
user = make_user()
_seed_videos(db, ["v1", "v2"])
src = _playlist(db, user, "Source", ["v1", "v2"])
dst = _playlist(db, user, "Target", [])
r = _move(as_user(user), src.id, "v1", dst.id)
assert r.status_code == 200
assert r.json() == {
"ok": True,
"from_playlist_id": src.id,
"to_playlist_id": dst.id,
"video_id": "v1",
}
db.expire_all()
assert _ids(db, src.id) == ["v2"]
assert _ids(db, dst.id) == ["v1"]
def test_move_appends_to_the_end_of_the_target(db, make_user, as_user):
user = make_user()
_seed_videos(db, ["v1", "v2", "v3"])
src = _playlist(db, user, "Source", ["v3"])
dst = _playlist(db, user, "Target", ["v1", "v2"])
assert _move(as_user(user), src.id, "v3", dst.id).status_code == 200
db.expire_all()
assert _ids(db, dst.id) == ["v1", "v2", "v3"]
def test_move_into_a_list_that_already_has_it_is_a_removal_not_a_duplicate(db, make_user, as_user):
user = make_user()
_seed_videos(db, ["v1"])
src = _playlist(db, user, "Source", ["v1"])
dst = _playlist(db, user, "Target", ["v1"])
assert _move(as_user(user), src.id, "v1", dst.id).status_code == 200
db.expire_all()
assert _ids(db, src.id) == []
assert _ids(db, dst.id) == ["v1"] # exactly one row, not two
def test_move_to_the_same_list_is_refused(db, make_user, as_user):
user = make_user()
_seed_videos(db, ["v1"])
src = _playlist(db, user, "Source", ["v1"])
r = _move(as_user(user), src.id, "v1", src.id)
assert r.status_code == 400
assert r.json()["detail"] == {"code": "playlist_move", "reason": "same_playlist"}
db.expire_all()
assert _ids(db, src.id) == ["v1"]
def test_move_of_a_video_that_left_the_source_reports_it(db, make_user, as_user):
user = make_user()
_seed_videos(db, ["v1"])
src = _playlist(db, user, "Source", [])
dst = _playlist(db, user, "Target", [])
r = _move(as_user(user), src.id, "v1", dst.id)
assert r.status_code == 404
assert r.json()["detail"] == {"code": "playlist_move", "reason": "not_in_playlist"}
def test_move_into_someone_elses_list_404s_and_changes_nothing(db, make_user, as_user):
user, other = make_user(), make_user()
_seed_videos(db, ["v1"])
src = _playlist(db, user, "Mine", ["v1"])
theirs = _playlist(db, other, "Theirs", [])
assert _move(as_user(user), src.id, "v1", theirs.id).status_code == 404
db.expire_all()
assert _ids(db, src.id) == ["v1"]
assert _ids(db, theirs.id) == []
def test_move_marks_both_linked_sides_dirty(db, make_user, as_user):
"""A move edits two YouTube mirrors at once — the source lost an item, the target gained one.
Marking only the source is the asymmetric fix this repo keeps re-learning."""
user = make_user()
_seed_videos(db, ["v1"])
src = _playlist(db, user, "Src", ["v1"], yt_playlist_id="PLsrc", synced_video_ids='["v1"]')
dst = _playlist(db, user, "Dst", [], yt_playlist_id="PLdst", synced_video_ids="[]")
# Baseline: both are in sync with YouTube before the move.
src.dirty = False
dst.dirty = False
db.commit()
assert _move(as_user(user), src.id, "v1", dst.id).status_code == 200
db.expire_all()
assert db.get(Playlist, src.id).dirty is True
assert db.get(Playlist, dst.id).dirty is True
def test_item_edits_bump_updated_at(db, make_user, as_user):
"""The rail's "recently changed first" sort reads this. Adding, moving and reordering all
write `playlist_items` only, so nothing here is covered by the ORM's `onupdate`."""
user = make_user()
_seed_videos(db, ["v1", "v2"])
src = _playlist(db, user, "Source", ["v1", "v2"])
dst = _playlist(db, user, "Target", [])
old = datetime.now(timezone.utc) - timedelta(days=3)
src.updated_at = old
dst.updated_at = old
db.commit()
client = as_user(user)
assert client.post(f"/api/playlists/{dst.id}/items", json={"video_id": "v1"}).status_code == 200
db.expire_all()
assert db.get(Playlist, dst.id).updated_at > old
dst.updated_at = old
db.commit()
assert (
client.put(f"/api/playlists/{src.id}/order", json={"video_ids": ["v2", "v1"]}).status_code
== 200
)
db.expire_all()
assert db.get(Playlist, src.id).updated_at > old
src.updated_at = old
dst.updated_at = old
db.commit()
assert _move(client, src.id, "v2", dst.id).status_code == 200
db.expire_all()
# BOTH sides of a move are "recently changed" — the target is where the reader will look.
assert db.get(Playlist, src.id).updated_at > old
assert db.get(Playlist, dst.id).updated_at > old
def test_the_list_endpoint_publishes_updated_at(db, make_user, as_user):
user = make_user()
_playlist(db, user, "Solo", [])
rows = as_user(user).get("/api/playlists").json()
assert len(rows) == 1
assert rows[0]["updated_at"] # the rail cannot sort by a field the API never sends
+3 -3
View File
@@ -12,7 +12,7 @@ import app.plex.sync as syncmod
from app import sysconfig
from app.models import PlexItem, PlexLibrary
from app.plex.sync import _apply_item, _canonical_guid, _sync_overlay
from app.routes.plex._shared import _content_libs
from app.plex.query import content_libs
class _StubPlex:
@@ -155,7 +155,7 @@ def test_sync_designates_overlay_and_writes_hu(db, monkeypatch):
assert m.title_hu == "Öldöklés"
assert m.genres_hu == ["Akció"]
# The HU section is not browsable and mirrored no rows of its own.
movie_ids, _ = _content_libs(db)
movie_ids, _ = content_libs(db)
assert en.id in movie_ids and hu.id not in movie_ids
assert db.scalar(select(func.count(PlexItem.id)).where(PlexItem.library_id == hu.id)) == 0
assert stats["overlaid"] == 1
@@ -240,7 +240,7 @@ def test_content_libs_excludes_overlay_libraries(db):
db.add(overlay)
db.commit()
movie_ids, show_ids = _content_libs(db)
movie_ids, show_ids = content_libs(db)
assert en_lib.id in movie_ids
assert overlay.id not in movie_ids # the HU overlay is never browsable
assert show_ids == [en_shows.id]
+3 -3
View File
@@ -1,6 +1,6 @@
"""DB-backed regression (E5 S4): GET /api/plex/person/{name} returns a cast/crew member's
filmography — movies + shows in the ENABLED libraries whose cast OR directors include the name,
each carrying the caller's watch-state, plus the cached headshot. Also covers `_content_libs`
each carrying the caller's watch-state, plus the cached headshot. Also covers `content_libs`
scoping (disabled libraries excluded) and the kind filter (episodes are not movies).
Uses the DB lane (`db` fixture) — skipped when no Postgres is reachable.
@@ -20,7 +20,7 @@ from app.models import (
PlexState,
User,
)
from app.routes.plex._shared import _content_libs
from app.plex.query import content_libs
NAME = "Robert De Niro"
@@ -151,5 +151,5 @@ def test_content_libs_partitions_only_enabled(db):
]
)
db.commit()
movie_ids, show_ids = _content_libs(db)
movie_ids, show_ids = content_libs(db)
assert len(movie_ids) == 1 and len(show_ids) == 1 # the disabled movie lib is excluded
+42
View File
@@ -67,5 +67,47 @@ export default tseslint.config(
],
},
},
{
// E10 S1: `components/ui/` is the PLATFORM layer, and the rule that makes it a layer rather than
// a folder is enforced here, not in prose (see components/ui/README.md). A platform component is
// domain-free: it may import sibling primitives and generic `lib/` helpers, never a feature
// component (`../Feed`) and never a domain API slice. Without this rule the boundary erodes on
// the first "just this once" import and the directory silently becomes another components/.
files: ["src/components/ui/**/*.{ts,tsx}"],
rules: {
"no-restricted-imports": [
"error",
{
// NOTE the String.raw: these are REGEX sources, and in a plain "..." literal `\.` is an
// unknown escape that collapses to a bare `.` — the any-char wildcard. The first cut of
// this rule shipped that way and matched far less than it read like (E10 review round 1).
patterns: [
{
// `../X` (exactly one level up = a feature component). A `regex` rather than a glob:
// eslint matches `patterns` gitignore-style, where `../*` also swallows `../../lib/x`.
regex: String.raw`^\.\./(?!\.\./)`,
message:
"components/ui/ is domain-free: a platform component may not import a feature component. Invert the dependency (take a prop/render-prop) or keep the component outside ui/.",
},
{
// The same file by its OTHER spelling. `../../components/Feed` resolves exactly where
// `../Feed` does, so the rule above is only half the boundary; `ui/` is carved out
// because a sibling primitive is the one thing this path may legitimately name.
regex: String.raw`^\.\./\.\./components/(?!ui/)`,
message:
"components/ui/ is domain-free: a platform component may not import a feature component. Invert the dependency (take a prop/render-prop) or keep the component outside ui/.",
},
{
// `(/|$)` so the BARREL counts too: `../../lib/api` is the spelling every feature
// component uses, and a `/`-anchored pattern let exactly that one through.
regex: String.raw`^\.\./\.\./lib/api(/|$)`,
message:
"components/ui/ is domain-free: a platform component may not call a domain API slice. Take the data as props.",
},
],
},
],
},
},
prettier,
);
+33
View File
@@ -0,0 +1,33 @@
// The global Shift+W wiring, pinned at its call site.
//
// What this is: a source contract, the same idiom as uiBoundary.contract.test.ts — a structural rule
// asserted directly rather than left to prose. What it is NOT: a behaviour test. The behaviour lives
// in history.dom.test.ts and history.burial.dom.test.ts, which drive `navigateOverOverlays` against
// real overlay entries; the end-to-end gesture (Shift+W over an open player, then Back) is a browser
// check, because jsdom reproduces history ordering only as bookkeeping.
//
// It exists because the composition line itself was unpinned: App.tsx is the one module no test
// renders (main.tsx lazy-imports it and nothing else does), globalKeymap.dom.test.tsx drives the
// hook with its OWN handler, and history.dom.test.ts drives the helper with its own navigate — so
// dropping `navigateOverOverlays` from App left all 818 tests green while the user-visible bug came
// straight back (round 2, verified by running that revert).
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
const APP = readFileSync(fileURLToPath(new URL("./App.tsx", import.meta.url)), "utf8");
describe("App · global keymap wiring", () => {
it("routes the global home shortcut through navigateOverOverlays", () => {
const call = /useGlobalKeymap\(([\s\S]*?)\);/.exec(APP)?.[1];
// A rename or a move is a legitimate change — but then this test is the thing that has to be
// updated deliberately, which is the whole point of pinning it.
expect(
call,
"App.tsx no longer calls useGlobalKeymap in a shape this test can read",
).toBeTypeOf("string");
expect(call).toContain("navigateOverOverlays");
expect(call).toContain('setPage("home")');
});
});
+25 -4
View File
@@ -29,12 +29,16 @@ import Welcome from "./components/Welcome";
import SetupWizard from "./components/SetupWizard";
import Header from "./components/Header";
import NavSidebar from "./components/NavSidebar";
import BackToTop from "./components/BackToTop";
import PageShell from "./components/PageShell";
import BackToTop from "./components/ui/BackToTop";
import PageShell from "./components/ui/PageShell";
import GlassTuner from "./components/GlassTuner";
import ChatDock from "./components/ChatDock";
import Toaster from "./components/Toaster";
import { StateMessage } from "./components/QueryState";
import Toaster from "./components/ui/Toaster";
import KeyboardHelp from "./components/ui/KeyboardHelp";
import AppCommandPalette from "./components/AppCommandPalette";
import { useGlobalKeymap } from "./lib/keymap";
import { navigateOverOverlays } from "./lib/history";
import { StateMessage } from "./components/ui/QueryState";
import ErrorDialog from "./components/ErrorDialog";
import VersionBanner from "./components/VersionBanner";
import DemoBanner from "./components/DemoBanner";
@@ -134,6 +138,18 @@ export default function App() {
refetchInterval: (query) => (query.state.data ? 60_000 : false),
});
// The `global` key scope: `h` opens the cheat sheet, Mod+K the jump-to palette, Shift+W jumps to
// Home ("mit nézzek?"). Bound only once signed in, because that is where the overlays are mounted
// (bottom of this tree) and where the modules exist at all — see useGlobalKeymap on why binding it
// above the signed-out branch is worse than not binding it.
// Through `navigateOverOverlays`, because a global key can fire while an overlay is open and does
// not know it: the page change unmounts the player (it lives inside the module), whose reconcile
// then reclaimed the entry this push had just made — Shift+W over a playing video closed the
// player and snapped straight back to the feed. `setPage`'s boolean is load-bearing here, not
// decoration: pressed while ALREADY on Home (the default landing page) it navigates nowhere, and
// burying the open player's entry under a push that never happened orphaned it — see `buryUnder`.
useGlobalKeymap(!!meQuery.data, () => navigateOverOverlays(() => setPage("home")));
// Pin this tab to whatever account it first loaded as. Without this, a tab that never picked
// an account (it was riding the session's default) would silently swap identity when ANOTHER
// tab changes the default — e.g. that other tab adds/switches an account. Once pinned, this
@@ -412,6 +428,11 @@ export default function App() {
(collapsed or expanded) without tracking its width. */}
<Toaster />
</div>
{/* The two app-wide overlays: the keyboard cheat sheet (`h`) and the jump-to palette (Mod+K).
Mounted once, outside the page area, so every surface answers "what can I press here" and
"take me to X" the same way. Their keys are bound by useGlobalKeymap above. */}
<KeyboardHelp />
<AppCommandPalette />
{meQuery.data && !meQuery.data.is_demo && <ChatDock meId={meQuery.data.id} />}
<Suspense fallback={null}>
{aboutOpen && (
+4 -6
View File
@@ -4,7 +4,8 @@ import { useTranslation } from "react-i18next";
import { Info, Sparkles } from "lucide-react";
import { api } from "../lib/api";
import { FRONTEND_VERSION, FRONTEND_SHA, FRONTEND_BUILD_DATE } from "../lib/version";
import Modal from "./Modal";
import { Button } from "./ui/Button";
import Modal from "./ui/Modal";
function fmtDate(d?: string | null): string {
if (!d) return "—";
@@ -70,12 +71,9 @@ export default function About({
))}
</div>
<button
onClick={onOpenReleaseNotes}
className="mt-4 w-full inline-flex items-center justify-center gap-2 px-4 py-2 rounded-xl font-semibold btn-primary transition"
>
<Button variant="primary" size="lg" className="mt-4 w-full" onClick={onOpenReleaseNotes}>
<Sparkles className="w-4 h-4" /> {t("about.whatsNew")}
</button>
</Button>
</Modal>
);
}
+1 -1
View File
@@ -3,7 +3,7 @@ import { useQuery } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys";
import { api } from "../lib/api";
import ChannelAboutContent from "./ChannelAbout";
import { PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
import { PopoverSurface, useAnchoredPopover } from "./ui/AnchoredPopover";
/**
* A channel-description cell: one truncated line in the table, revealing the FULL channel "About"
+2 -2
View File
@@ -2,8 +2,8 @@ import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Check, Clock, Copy, Globe, KeyRound, Mail, Send, ShieldAlert } from "lucide-react";
import Modal from "./Modal";
import { LoadingState, StateMessage } from "./QueryState";
import Modal from "./ui/Modal";
import { LoadingState, StateMessage } from "./ui/QueryState";
import { api } from "../lib/api";
import { qk } from "../lib/queryKeys";
import { notify } from "../lib/notifications";
-78
View File
@@ -1,78 +0,0 @@
import { useState, type ReactNode } from "react";
import { MoreHorizontal } from "lucide-react";
import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
// A reusable "⋯ more actions" overflow menu: a compact trigger that opens a list of one-shot actions.
// Built on the R8 AnchoredPopover platform (arrows/typeahead/Escape/anchor-flip), same as ViewSwitcher
// and LanguageSwitcher. Use it to keep occasional actions out of a crowded toolbar. The items are
// fire-and-close (`onSelect`); a `disabled` item (e.g. a mutation already in flight) is inert.
export interface ActionMenuItem {
id: string;
label: string;
icon?: ReactNode; // an element (so a caller can swap in a spinning icon while pending)
onSelect: () => void;
disabled?: boolean;
title?: string; // optional native tooltip (carries a longer hint the label alone can't)
}
export default function ActionMenu({
items,
ariaLabel,
className,
trigger,
}: {
items: readonly ActionMenuItem[];
/** Accessible name for the trigger (e.g. "Catalog actions"). */
ariaLabel: string;
/** Trigger className override; omit for the default glass ⋯ icon button. */
className?: string;
/** Custom trigger content; omit for the default ⋯ icon. */
trigger?: ReactNode;
}) {
const [open, setOpen] = useState(false);
// A disabled item (a mutation already in flight) is skipped by roving keyboard nav automatically:
// PopoverItem renders it as a native `disabled` button, which useListNavigation reads and never
// focuses. No explicit disabledIndices needed. See ActionMenu.dom.test.tsx for the guard.
const pop = useAnchoredPopover({ open, onOpenChange: setOpen, placement: "bottom-end" });
return (
<>
<button
type="button"
ref={pop.refs.setReference}
{...pop.getReferenceProps()}
aria-label={ariaLabel}
title={ariaLabel}
className={
className ??
"glass-card glass-hover flex items-center justify-center rounded-xl p-2 text-sm transition"
}
>
{trigger ?? <MoreHorizontal className="h-5 w-5" aria-hidden="true" />}
</button>
<PopoverSurface
popover={pop}
ariaLabel={ariaLabel}
className="glass-menu min-w-52 rounded-xl p-1.5 animate-[popIn_0.16s_ease]"
>
{items.map((it) => (
<PopoverItem
key={it.id}
role="menuitem"
disabled={it.disabled}
label={it.label}
title={it.title}
onSelect={() => {
it.onSelect();
setOpen(false);
}}
className="flex w-full items-center gap-2.5 rounded-lg px-2 py-1.5 text-left text-sm outline-hidden transition disabled:opacity-50"
>
{it.icon && <span className="shrink-0 text-muted">{it.icon}</span>}
<span className="flex-1 truncate">{it.label}</span>
</PopoverItem>
))}
</PopoverSurface>
</>
);
}
@@ -3,8 +3,8 @@ import { useTranslation } from "react-i18next";
import { Filter, X } from "lucide-react";
import type { ActiveFilter } from "../lib/useActiveFilters";
import { decideChipLayout } from "../lib/chipOverflow";
import { useChipOverflowFit } from "../lib/useChipOverflowFit";
import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
import { chipLaneClass, useChipOverflowFit } from "../lib/useChipOverflowFit";
import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./ui/AnchoredPopover";
// The applied-filter row above the feed: every narrowing currently in force, each removable on its
// own. It exists because the filter panel now sits collapsed as a tab — without this you'd have to
@@ -172,7 +172,7 @@ export default function ActiveFilterChips({
<div
ref={laneRef}
data-testid="active-filter-chips"
className={`relative flex min-w-0 items-center gap-1.5 ${className ?? ""}`}
className={`${chipLaneClass} flex min-w-0 items-center gap-1.5 ${className ?? ""}`}
aria-label={t("feed.chips.label")}
>
{/* Hidden mirror of the full set, used only to measure natural widths. Kept out of the a11y tree
+1 -1
View File
@@ -6,7 +6,7 @@ import { Check, ListPlus, Plus, Youtube } from "lucide-react";
import { api, type Playlist } from "../lib/api";
import { useScrollFade } from "../lib/useScrollFade";
import { playlistName } from "../lib/playlistName";
import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./ui/AnchoredPopover";
// A small anchored menu for toggling a video's membership in the user's playlists, with inline
// "new playlist" creation. Same shape as the channel tag editor (R8 S1's pilot): radio-style
+74 -65
View File
@@ -26,16 +26,17 @@ import {
import { notify } from "../lib/notifications";
import { invalidateUserAccess, isEmailOff } from "../lib/adminUsers";
import { ADMIN_USERS_TAB_KEY } from "../lib/adminUsersTab";
import Tooltip from "./Tooltip";
import InlineNotice from "./InlineNotice";
import { Button } from "./ui/Button";
import Tooltip from "./ui/Tooltip";
import InlineNotice from "./ui/InlineNotice";
import { Section } from "./ui/form";
import SegmentedToggle from "./SegmentedToggle";
import SegmentedToggle from "./ui/SegmentedToggle";
import { LANGUAGES, type LangCode } from "../i18n";
import { LoadingState, StateMessage } from "./QueryState";
import { LoadingState, StateMessage } from "./ui/QueryState";
import AccountPanel from "./AccountPanel";
import { useConfirm } from "./ConfirmProvider";
import Tabs, { usePersistedTab } from "./Tabs";
import { PageToolbar } from "./PageShell";
import Tabs, { usePersistedTab } from "./ui/Tabs";
import { PageToolbar } from "./ui/PageShell";
import { useMe } from "../lib/useMe";
// Admin-only user & access management: roles, access requests (the Invite whitelist), and the
@@ -244,14 +245,16 @@ function UsersRoles({ me }: { me: Me }) {
: t("users.roles.makeAdmin")
}
>
<button
<Button
variant="glass"
size="xs"
className="shrink-0"
onClick={() => onToggle(u)}
disabled={u.is_demo || self || pendingIds.has(u.id)}
className="shrink-0 glass-card glass-hover inline-flex items-center gap-1 px-2.5 py-1.5 rounded-lg text-xs disabled:opacity-40 transition"
icon={<Shield className="w-3.5 h-3.5" />}
>
<Shield className="w-3.5 h-3.5" />
{u.role === "admin" ? t("users.roles.makeUser") : t("users.roles.makeAdmin")}
</button>
</Button>
</Tooltip>
<Tooltip
hint={
@@ -264,36 +267,36 @@ function UsersRoles({ me }: { me: Me }) {
: t("users.suspend.action")
}
>
<button
<Button
variant={u.is_suspended ? "outline-warn" : "outline"}
size="icon-sm"
className="shrink-0"
onClick={() => onSuspend(u)}
disabled={u.is_demo || self || pendingIds.has(u.id)}
aria-label={
u.is_suspended ? t("users.suspend.undoAction") : t("users.suspend.action")
}
className={`shrink-0 p-1.5 rounded-lg border disabled:opacity-40 transition ${
u.is_suspended
? "border-warn/40 text-warn hover:bg-warn/10"
: "border-border text-muted hover:text-warn"
}`}
>
{u.is_suspended ? (
<RotateCcw className="w-4 h-4" />
) : (
<Ban className="w-4 h-4" />
)}
</button>
icon={
u.is_suspended ? (
<RotateCcw className="w-4 h-4" />
) : (
<Ban className="w-4 h-4" />
)
}
/>
</Tooltip>
<Tooltip
hint={u.is_demo || self ? t("users.delete.locked") : t("users.delete.action")}
>
<button
<Button
variant="outline-danger"
size="icon-sm"
className="shrink-0"
onClick={() => onDelete(u)}
disabled={u.is_demo || self || pendingIds.has(u.id)}
aria-label={t("users.delete.action")}
className="shrink-0 p-1.5 rounded-lg border border-border text-muted hover:text-danger disabled:opacity-40 transition"
>
<Trash2 className="w-4 h-4" />
</button>
icon={<Trash2 className="w-4 h-4" />}
/>
</Tooltip>
</div>
);
@@ -435,25 +438,28 @@ function PendingAccessRowView({
</div>
{!row.email_verified && (
<Tooltip hint={t("users.pendingAccess.resendHint")}>
<button
<Button
variant="outline"
size="icon-sm"
className="shrink-0"
onClick={onResend}
disabled={busy}
className="shrink-0 p-1.5 rounded-lg border border-border text-muted hover:text-fg disabled:opacity-50 transition"
aria-label={t("users.pendingAccess.resend")}
>
<Send className="w-4 h-4" />
</button>
icon={<Send className="w-4 h-4" />}
/>
</Tooltip>
)}
{row.can_activate && (
<Tooltip hint={t("users.pendingAccess.activateHint")}>
<button
<Button
variant="outline-accent"
size="xs"
className="shrink-0"
onClick={onActivate}
disabled={busy}
className="shrink-0 px-2.5 py-1.5 rounded-lg border border-accent/40 text-accent text-xs font-medium hover:bg-accent/10 disabled:opacity-50 transition"
>
{t("users.pendingAccess.activate")}
</button>
</Button>
</Tooltip>
)}
</div>
@@ -673,12 +679,14 @@ function AdminInvites() {
placeholder={t("settings.invites.addPlaceholder")}
className="flex-1 min-w-0 bg-surface border border-border rounded-xl px-3 py-2 text-sm focus:outline-hidden focus:ring-2 focus:ring-accent/40"
/>
<button
<Button
type="submit"
className="shrink-0 glass-card glass-hover flex items-center gap-1.5 px-3 py-2 rounded-xl text-sm transition"
variant="glass"
className="shrink-0"
icon={<UserPlus className="w-4 h-4" />}
>
<UserPlus className="w-4 h-4" /> {t("settings.invites.add")}
</button>
{t("settings.invites.add")}
</Button>
</form>
{decided.length > 0 && (
@@ -820,14 +828,15 @@ function AdminDemo() {
)}
</div>
<Tooltip hint={t("settings.demo.removeHint")}>
<button
<Button
variant="outline-danger"
size="icon-sm"
className="shrink-0"
onClick={() => onRemove(r)}
disabled={remove.isPending}
className="shrink-0 p-1.5 rounded-lg border border-border text-muted hover:text-danger disabled:opacity-50 transition"
aria-label={t("settings.demo.remove")}
>
<Trash2 className="w-4 h-4" />
</button>
icon={<Trash2 className="w-4 h-4" />}
/>
</Tooltip>
</div>
))}
@@ -848,12 +857,9 @@ function AdminDemo() {
placeholder={t("settings.demo.addPlaceholder")}
className="flex-1 min-w-0 bg-surface border border-border rounded-xl px-3 py-2 text-sm focus:outline-hidden focus:ring-2 focus:ring-accent/40"
/>
<button
type="submit"
className="shrink-0 glass-card glass-hover flex items-center gap-1.5 px-3 py-2 rounded-xl text-sm transition"
>
<Button variant="glass" size="md" className="shrink-0" type="submit">
<UserPlus className="w-4 h-4" /> {t("settings.demo.add")}
</button>
</Button>
</form>
<div className="mt-4 pt-3 border-t border-border">
@@ -909,24 +915,26 @@ function InviteRow({
)}
</div>
<Tooltip hint={t("settings.invites.approveHint")}>
<button
<Button
variant="outline-accent"
size="icon-sm"
className="shrink-0"
onClick={onApprove}
disabled={busy}
className="shrink-0 p-1.5 rounded-lg border border-accent/40 text-accent hover:bg-accent/10 disabled:opacity-50 transition"
aria-label={t("settings.invites.approve")}
>
<Check className="w-4 h-4" />
</button>
icon={<Check className="w-4 h-4" />}
/>
</Tooltip>
<Tooltip hint={t("settings.invites.denyHint")}>
<button
<Button
variant="outline-danger"
size="icon-sm"
className="shrink-0"
onClick={onDeny}
disabled={busy}
className="shrink-0 p-1.5 rounded-lg border border-border text-muted hover:text-danger disabled:opacity-50 transition"
aria-label={t("settings.invites.deny")}
>
<X className="w-4 h-4" />
</button>
icon={<X className="w-4 h-4" />}
/>
</Tooltip>
{/* Deny records a decision and keeps the row; delete forgets the address. Once an account
exists for it the row is that account's approval gate, so it is managed from Users & roles
@@ -946,14 +954,15 @@ function InviteRow({
</button>
) : (
<Tooltip hint={t("settings.invites.deleteHint")}>
<button
<Button
variant="outline-danger"
size="icon-sm"
className="shrink-0"
onClick={onDelete}
disabled={busy}
className="shrink-0 p-1.5 rounded-lg border border-border text-muted hover:text-danger disabled:opacity-50 transition"
aria-label={t("settings.invites.deleteLabel", { email: inv.email })}
>
<Trash2 className="w-4 h-4" />
</button>
icon={<Trash2 className="w-4 h-4" />}
/>
</Tooltip>
)}
</div>
@@ -0,0 +1,174 @@
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { useQuery } from "@tanstack/react-query";
import { Keyboard, ListVideo, Palette, Sparkles, Tv } from "lucide-react";
import CommandPalette, { type CommandGroup } from "./ui/CommandPalette";
import { api } from "../lib/api";
import { qk } from "../lib/queryKeys";
import { useMe } from "../lib/useMe";
import { moduleDef, moduleOrder } from "../lib/modules";
import { SCHEMES, type Scheme } from "../lib/theme";
import { closePalette, toggleKeyboardHelp, usePaletteOpen } from "../lib/keymap";
import { requestSurprise } from "../lib/homeIntent";
import { useNavigationActions } from "./NavigationProvider";
import { usePlaylistsActions } from "./PlaylistsProvider";
import { usePrefs } from "./PrefsProvider";
import { useSavedViews } from "./SavedViewsProvider";
// What Ctrl/Cmd+K can reach. The generic list/filter/keyboard shell is `ui/CommandPalette`; this is
// the domain half — the layer boundary says a primitive may not import `lib/api` or a module.
//
// Scope decided with the product owner (2026-09-05): navigation + app-wide ACTIONS. The actions here
// are the reversible, local ones — the shortcuts sheet and the theme. Deliberately NOT here: "Sync
// from YouTube". 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.
//
// Saved views come from whichever module owns the current SavedViewsProvider source (feed on the
// feed, Plex on Plex), i.e. exactly the views that are applicable where you stand.
export default function AppCommandPalette() {
const { t } = useTranslation();
const open = usePaletteOpen();
const me = useMe();
const { setPage, openChannel } = useNavigationActions();
const { setSelectedId } = usePlaylistsActions();
const { theme, setTheme } = usePrefs();
const savedViews = useSavedViews();
// Only fetch the lists while the palette is actually open — it is a rarely-opened surface and
// these are otherwise the feed's and the playlist rail's own queries, shared through the cache.
const channelsQ = useQuery({
queryKey: qk.channels(),
queryFn: () => api.channels(),
enabled: open,
});
const playlistsQ = useQuery({
queryKey: qk.playlists(),
queryFn: () => api.playlists(),
enabled: open,
});
const groups = useMemo<CommandGroup[]>(() => {
const out: CommandGroup[] = [];
// Modules, in nav-rail order and gated by the account — the registry decides, not this file.
if (me) {
const pages = [...moduleOrder(me), "settings" as const];
out.push({
id: "modules",
label: t("palette.groups.modules"),
items: pages.map((page) => {
const def = moduleDef(page);
const Icon = def.icon;
return {
id: `page:${page}`,
label: t(def.labelKey),
icon: <Icon className="w-4 h-4" />,
// The longer tab title is a second name for the same place ("Channel manager" for
// "Channels"), so match on it even though the row shows the short one.
keywords: def.titleKey ? t(def.titleKey) : "",
run: () => setPage(page),
};
}),
});
}
const channels = channelsQ.data ?? [];
if (channels.length > 0) {
out.push({
id: "channels",
label: t("palette.groups.channels"),
items: channels
.filter((c) => !c.hidden)
.map((c) => ({
id: `channel:${c.id}`,
label: c.title ?? c.id,
hint: c.handle ?? undefined,
icon: <Tv className="w-4 h-4" />,
keywords: c.handle ?? "",
run: () => openChannel(c.id, c.title ?? undefined),
})),
});
}
const playlists = playlistsQ.data ?? [];
if (playlists.length > 0) {
out.push({
id: "playlists",
label: t("palette.groups.playlists"),
items: playlists.map((p) => ({
id: `playlist:${p.id}`,
label: p.name,
hint: t("palette.videoCount", { count: p.item_count }),
icon: <ListVideo className="w-4 h-4" />,
run: () => {
setSelectedId(p.id);
setPage("playlists");
},
})),
});
}
if (savedViews.views.length > 0) {
out.push({
id: "views",
label: t("palette.groups.views"),
items: savedViews.views.map((v) => ({
id: `view:${v.id}`,
label: v.name,
run: () => savedViews.apply(v),
})),
});
}
out.push({
id: "actions",
label: t("palette.groups.actions"),
items: [
// "Meglepetés" — Home's reroll, reachable from anywhere (E24). There is deliberately no
// second "Mit nézzek?" row beside it: the Home module row above ALREADY matches that phrase,
// because the palette searches a module's descriptive `titleKey` too and Home's is literally
// "Mit nézzek most?". A row that navigates where an existing row already navigates would be
// duplication in the one list whose whole job is to be scanned quickly.
{
id: "action:surprise",
label: t("palette.actions.surprise"),
icon: <Sparkles className="w-4 h-4" />,
keywords: t("home.title"),
run: () => {
setPage("home");
requestSurprise();
},
},
{
id: "action:shortcuts",
label: t("keys.title"),
icon: <Keyboard className="w-4 h-4" />,
run: toggleKeyboardHelp,
},
...SCHEMES.map((s) => ({
id: `action:theme:${s.id}`,
label: t("palette.theme", { name: s.name }),
icon: <Palette className="w-4 h-4" />,
keywords: s.id,
run: () => setTheme({ ...theme, scheme: s.id as Scheme }),
})),
],
});
return out;
}, [
t,
me,
channelsQ.data,
playlistsQ.data,
savedViews,
theme,
setTheme,
setPage,
openChannel,
setSelectedId,
]);
return <CommandPalette open={open} onClose={closePalette} groups={groups} />;
}
+8 -6
View File
@@ -6,10 +6,11 @@ import { Trash2 } from "lucide-react";
import { api } from "../lib/api";
import { notify } from "../lib/notifications";
import { LS } from "../lib/storage";
import { Button } from "./ui/Button";
import { Section } from "./ui/form";
import { useConfirm } from "./ConfirmProvider";
import DataTable from "./DataTable";
import { LoadingState, StateMessage } from "./QueryState";
import DataTable from "./ui/DataTable";
import { LoadingState, StateMessage } from "./ui/QueryState";
import { auditColumns } from "./auditColumns";
// Admin-only audit trail: an append-only who/what/when log of admin actions + scheduler run
@@ -72,14 +73,15 @@ export default function AuditLog() {
persistKey={LS.auditTable}
controlsPosition="top"
controlsLeading={
<button
<Button
variant="outline-danger"
size="xs"
onClick={onClear}
disabled={clear.isPending}
className="inline-flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded-lg border border-border text-muted hover:text-danger disabled:opacity-50 transition"
icon={<Trash2 className="w-3.5 h-3.5" />}
>
<Trash2 className="w-3.5 h-3.5" />
{t("audit.clear")}
</button>
</Button>
}
emptyText={t("audit.empty")}
/>
+6 -6
View File
@@ -12,15 +12,15 @@ import { useCardPager } from "../lib/useCardPager";
import { subsColumn } from "./channelColumns";
import { notify } from "../lib/notifications";
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
import Tooltip from "./Tooltip";
import Tooltip from "./ui/Tooltip";
import ChannelLink from "./ChannelLink";
import AboutCell from "./AboutCell";
import DataTable, { type Column } from "./DataTable";
import DataTable, { type Column } from "./ui/DataTable";
import ChannelLayoutGrid from "./ChannelLayoutGrid";
import { LoadingState, StateMessage } from "./QueryState";
import ColumnSortControl from "./ColumnSortControl";
import Pager from "./Pager";
import { PageToolbar } from "./PageShell";
import { LoadingState, StateMessage } from "./ui/QueryState";
import ColumnSortControl from "./ui/ColumnSortControl";
import Pager from "./ui/Pager";
import { PageToolbar } from "./ui/PageShell";
import { useConfirm } from "./ConfirmProvider";
import { useChannelLayout } from "./ChannelsProvider";
@@ -1,7 +1,7 @@
import { Fragment } from "react";
import { useTranslation } from "react-i18next";
import { type Column } from "./DataTable";
import VirtualGrid from "./VirtualGrid";
import { type Column } from "./ui/DataTable";
import VirtualGrid from "./ui/VirtualGrid";
import { CHANNEL_LAYOUT_SPEC } from "../lib/channelLayout";
// The channel manager's `cards` layout (E4 S3), built by reusing the SAME `Column<T>` render closures
@@ -24,6 +24,9 @@ interface ChannelLayoutGridProps<T> {
/** Column key rendered as the action cluster, pinned to the card's bottom edge. */
actionsKey?: string;
rowClassName?: (row: T) => string;
/** Extra DOM props per card — same escape hatch, same single consumer, as `DataTable.rowProps`, so
* a channel answers the right-click in the card layout exactly as it does in the table. */
rowProps?: (row: T) => Record<string, unknown>;
emptyText?: string;
}
@@ -35,6 +38,7 @@ export default function ChannelLayoutGrid<T>({
aboutKey,
actionsKey,
rowClassName,
rowProps,
emptyText,
}: ChannelLayoutGridProps<T>) {
const { t } = useTranslation();
@@ -50,6 +54,7 @@ export default function ChannelLayoutGrid<T>({
const renderCard = (row: T) => (
<div
{...rowProps?.(row)}
className={`glass-card rounded-xl p-3 h-full flex flex-col gap-2 ${rowClassName?.(row) ?? ""}`}
>
<div className="flex items-start justify-between gap-2">
@@ -84,7 +89,7 @@ export default function ChannelLayoutGrid<T>({
items={rows}
getKey={rowKey}
minCol={spec.minCol}
maxCol={null}
colMaxWidth={null}
rowEst={spec.rowEst}
renderItem={renderCard}
/>
+2 -2
View File
@@ -1,7 +1,7 @@
import { useTranslation } from "react-i18next";
import { ExternalLink } from "lucide-react";
import Avatar from "./Avatar";
import Tooltip from "./Tooltip";
import Avatar from "./ui/Avatar";
import Tooltip from "./ui/Tooltip";
import { channelYouTubeUrl } from "../lib/format";
// Avatar + channel name + "open on YouTube" link, shared by the Channel manager and the
+9 -9
View File
@@ -5,7 +5,7 @@ import { useLiveQuery } from "../lib/useLiveQuery";
import { channelDetailPollMs } from "../lib/channelDetailPoll";
import { qk } from "../lib/queryKeys";
import { useNavigationActions } from "./NavigationProvider";
import { PriorityStepper } from "./PriorityStepper";
import { PriorityStepper } from "./ui/PriorityStepper";
import { useChannelPriority } from "../lib/useChannelPriority";
import {
ArrowLeft,
@@ -20,11 +20,11 @@ import {
RefreshCw,
SlidersHorizontal,
} from "lucide-react";
import Avatar from "./Avatar";
import Tabs from "./Tabs";
import Avatar from "./ui/Avatar";
import Tabs from "./ui/Tabs";
import Feed from "./Feed";
import ChannelAboutContent from "./ChannelAbout";
import { PageToolbar, PageToolbarSlot } from "./PageShell";
import { PageToolbar, PageToolbarSlot } from "./ui/PageShell";
import { useFeedFilters } from "./FeedFiltersProvider";
import { useConfirm } from "./ConfirmProvider";
import { notify } from "../lib/notifications";
@@ -416,14 +416,14 @@ export default function ChannelPage({
<ExternalLink className="w-4 h-4" />
</a>
{ch?.subscribed && (
<button
<Button
variant="outline"
size="icon"
onClick={onShowInFeed}
title={t("channel.showInFeed")}
aria-label={t("channel.showInFeed")}
className="inline-flex items-center justify-center w-9 h-9 rounded-lg text-muted hover:text-accent border border-border hover:border-accent transition"
>
<SlidersHorizontal className="w-4 h-4" />
</button>
icon={<SlidersHorizontal className="w-4 h-4" />}
/>
)}
{ch?.subscribed && !me.is_demo && (
<div className="inline-flex items-center gap-1.5 h-9 px-2.5 rounded-lg border border-border">
+1 -1
View File
@@ -2,7 +2,7 @@ import { useTranslation } from "react-i18next";
import { useQuery } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys";
import { api, type FeedFilters } from "../lib/api";
import Combobox, { type ComboboxOption } from "./Combobox";
import Combobox, { type ComboboxOption } from "./ui/Combobox";
// Pick channels to narrow the feed to — several OR together, so every other filter then applies
// across all of them. Until this existed the filter was settable only via a shared "?channel=" link:
@@ -12,7 +12,7 @@ import {
import { useTranslation } from "react-i18next";
import { type MyStatus } from "../lib/api";
import { formatEta } from "../lib/format";
import { PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
import { PopoverSurface, useAnchoredPopover } from "./ui/AnchoredPopover";
// The channel-manager catalog stats, collapsed from a crowded inline row into a compact "Stats" chip
// that reveals the numbers on hover (reuses the AnchoredPopover hover mode). One row per stat, each
+4 -4
View File
@@ -2,10 +2,10 @@ import { useEffect, useRef, useState } from "react";
import { Check, Plus, Sparkles, X } from "lucide-react";
import { useTranslation } from "react-i18next";
import { type Tag } from "../lib/api";
import { useAnchoredPopover, PopoverSurface, PopoverItem } from "./AnchoredPopover";
import { useAnchoredPopover, PopoverSurface, PopoverItem } from "./ui/AnchoredPopover";
import { useScrollFade } from "../lib/useScrollFade";
import { fitChipsWithPill } from "../lib/chipOverflow";
import { useChipOverflowFit } from "../lib/useChipOverflowFit";
import { chipLaneClass, useChipOverflowFit } from "../lib/useChipOverflowFit";
// The channel tag surface, shared by the channel manager (table + cards) and the channel detail
// page. Built on the R8 popover platform. Two design goals it fixes over the old inline TagsCell:
@@ -129,7 +129,7 @@ function SuggestedChipsLane({
}, [hidden, overflowOpen]);
return (
<div className="relative flex items-center gap-1 min-w-0 flex-1">
<div className={`${chipLaneClass} flex items-center gap-1 min-w-0 flex-1`}>
{/* Hidden measurer — real ghost chips at natural width so the fit hook reads their right edges. */}
<div
aria-hidden
@@ -374,7 +374,7 @@ export function ChannelTagsControl({
// collapses the rest into "+N". (max-w-full lets a narrower card shrink it.) Unbounded, the row
// takes its container's width, so a wide header shows the tags inline instead of collapsing them.
<div
className={`relative flex items-center gap-1 min-w-0 ${bounded ? "w-48 max-w-full" : "w-full"}`}
className={`${chipLaneClass} flex items-center gap-1 min-w-0 ${bounded ? "w-48 max-w-full" : "w-full"}`}
>
{/* The add trigger LEADS the row — a stable left anchor that never moves as chips are added
(they grow to its right), so its editor popover can't drift. Compact icon in the width-
+34 -19
View File
@@ -10,26 +10,30 @@ import { notifyYouTubeActionError } from "../lib/youtubeErrors";
import { accountKey, LS } from "../lib/storage";
import { buildChannelColumns, applySyncFilter } from "./channelColumns";
import { notify } from "../lib/notifications";
import Tooltip from "./Tooltip";
import DataTable from "./DataTable";
import Tabs, { type TabDef } from "./Tabs";
import { Button } from "./ui/Button";
import Tooltip from "./ui/Tooltip";
import DataTable from "./ui/DataTable";
import Tabs, { type TabDef } from "./ui/Tabs";
import ChannelLayoutGrid from "./ChannelLayoutGrid";
import { LoadingState, StateMessage } from "./QueryState";
import { ComfortPinMenu } from "./ComfortPinMenu";
import { pinChannel } from "./comfortPin";
import { useContextMenu } from "./ui/useContextMenu";
import { LoadingState, StateMessage } from "./ui/QueryState";
import { useChannelTagActions } from "../lib/useChannelTagActions";
import { useTagSuggestions } from "./useTagSuggestions";
import { useChannelPriority } from "../lib/useChannelPriority";
import ViewSwitcher, { type ViewOption } from "./ViewSwitcher";
import ColumnSortControl from "./ColumnSortControl";
import FilterChipGroup from "./FilterChipGroup";
import ColumnSortControl from "./ui/ColumnSortControl";
import FilterChipGroup from "./ui/FilterChipGroup";
import TagFilterChips, { type FilterChip } from "./TagFilterChips";
import ActionMenu, { type ActionMenuItem } from "./ActionMenu";
import ActionMenu, { type ActionMenuItem } from "./ui/ActionMenu";
import ChannelStatsPopover from "./ChannelStatsPopover";
import Pager from "./Pager";
import Pager from "./ui/Pager";
import { CHANNEL_LAYOUTS, type ChannelLayout } from "../lib/channelLayout";
import { sortRows } from "../lib/columnSort";
import { filterRows } from "./tableHeader";
import { filterRows } from "./ui/tableHeader";
import { useCardPager } from "../lib/useCardPager";
import { PageToolbar } from "./PageShell";
import { PageToolbar } from "./ui/PageShell";
import ChannelDiscovery from "./ChannelDiscovery";
import TagManager from "./TagManager";
import { useConfirm } from "./ConfirmProvider";
@@ -411,6 +415,11 @@ export default function Channels() {
/>
);
const hasLayout = view === "subscribed" || view === "discovery";
// The comfort-shelf pin on a channel row (E24 S3). ONE menu for the list, shared by the table and
// the card layout — they are two paints of the same rows, so a right-click must mean the same
// thing in both. Only mounted under the SUBSCRIBED branch below: a discovery result is not a
// `Channel` row yet, so pinning one could only 404.
const pinMenu = useContextMenu<ManagedChannel>();
// In cards there are no column headers to sort by, so a dropdown drives the SAME shared sort state
// the table header uses (table ↔ cards stay in sync). Table mode keeps the header clicks.
const sortControl = (
@@ -489,13 +498,15 @@ export default function Channels() {
{/* The "TAGS" label IS the tags-editor trigger (pencil), replacing the separate
"Manage tags" button; the tooltip explains what tags are + that it's clickable. */}
<Tooltip hint={t("channels.tags.yourTagsHint")}>
<button
<Button
variant="outline"
size="xs"
className="shrink-0 uppercase tracking-wide"
onClick={() => setTagManagerOpen(true)}
className="shrink-0 inline-flex items-center gap-1.5 rounded-lg border border-border px-2 py-1 text-xs uppercase tracking-wide text-muted transition hover:border-accent hover:text-accent"
icon={<Pencil className="h-3 w-3" />}
>
<Pencil className="h-3 w-3" />
{t("channels.tags.label")}
</button>
</Button>
</Tooltip>
<TagFilterChips chips={tagFilterChips} className="flex-1" />
{tagFilter.length > 0 && (
@@ -518,12 +529,9 @@ export default function Channels() {
</span>
</Tooltip>
{highConfidence.length > 0 && (
<button
onClick={acceptAllHighConfidence}
className="text-xs px-2 py-1 rounded-lg border border-border text-muted hover:border-accent hover:text-accent transition"
>
<Button variant="outline" size="xs" onClick={acceptAllHighConfidence}>
{t("channels.tags.acceptAllHigh", { count: highConfidence.length })}
</button>
</Button>
)}
</div>
)}
@@ -613,6 +621,7 @@ export default function Channels() {
onFiltersChange={setFilters}
fixedLayout
emptyText={t("channels.empty")}
rowProps={pinMenu.triggerProps}
/>
) : (
<ChannelLayoutGrid
@@ -623,8 +632,14 @@ export default function Channels() {
aboutKey="about"
actionsKey="actions"
emptyText={t("channels.empty")}
rowProps={pinMenu.triggerProps}
/>
)}
<ComfortPinMenu
menu={pinMenu}
target={(c) => pinChannel(c.id)}
title={(c) => c.title ?? t("channels.row.thisChannel")}
/>
</div>
)}
</>
+2 -2
View File
@@ -22,8 +22,8 @@ import {
hasChannelsParams,
type ChannelsUrlState,
} from "../lib/channelsUrlState";
import { usePersistedTab } from "./Tabs";
import type { FilterMap } from "./tableHeader";
import { usePersistedTab } from "./ui/Tabs";
import type { FilterMap } from "./ui/tableHeader";
import { useNavigation, useNavigationActions } from "./NavigationProvider";
import type { ChannelsView } from "./Channels";
+2 -2
View File
@@ -13,9 +13,9 @@ import {
} from "../lib/chatDock";
import { onMessage, onUnread } from "../lib/messagesSocket";
import * as e2ee from "../lib/e2ee";
import Avatar from "./Avatar";
import Avatar from "./ui/Avatar";
import ChatThread from "./ChatThread";
import Overlay from "./Overlay";
import Overlay from "./ui/Overlay";
// The Messenger-style floating chat dock: open conversations live here, bottom-right, across
// page navigation (state persisted per user). Always mounted (for human users) so it also owns
+8 -6
View File
@@ -6,7 +6,8 @@ import { Send } from "lucide-react";
import { api, HttpError, type Message, type MessageUser } from "../lib/api";
import { useLiveQuery } from "../lib/useLiveQuery";
import { useScrollFade } from "../lib/useScrollFade";
import { LoadingState, StateMessage } from "./QueryState";
import { Button } from "./ui/Button";
import { LoadingState, StateMessage } from "./ui/QueryState";
import { relativeTime } from "../lib/format";
import { notify } from "../lib/notifications";
import { partnerPub, POLL_MS, refreshPartnerPub, useKeyState } from "../lib/messaging";
@@ -213,15 +214,16 @@ export default function ChatThread({
placeholder={t("messages.writePlaceholder")}
className="flex-1 resize-none max-h-28 bg-surface border border-border rounded-xl px-3 py-2 text-sm focus:outline-hidden focus:ring-2 focus:ring-accent/40"
/>
<button
<Button
variant="primary"
size="icon"
className="shrink-0"
onClick={submit}
disabled={!draft.trim() || send.isPending}
className="shrink-0 p-2.5 rounded-xl btn-primary transition disabled:opacity-40"
aria-label={t("messages.send")}
title={t("messages.send")}
>
<Send className="w-4 h-4" />
</button>
icon={<Send className="w-4 h-4" />}
/>
</div>
)}
</>
@@ -0,0 +1,50 @@
import { useTranslation } from "react-i18next";
import { type PlexCard, type Video } from "../lib/api";
import { pinPlex, pinVideo, useComfortPin, type PinTarget } from "./comfortPin";
import { ContextMenu } from "./ui/ContextMenu";
import { type ContextMenuController } from "./ui/useContextMenu";
// The menu SURFACE for the comfort-shelf pin (E24 S3). Split from `comfortPin.tsx` for the same
// reason `ContextMenu.tsx` is split from `useContextMenu.ts`: a file that exports components and
// nothing else keeps react-refresh working for every surface that imports it.
/**
* The whole right-click menu for a surface whose only context action is the pin — the card grids and
* rails. Render ONE per list, outside the rows (see `ui/ContextMenu`), and spread
* `menu.triggerProps` on each row.
*
* `target` maps the row the user right-clicked to its pin; `title` names it in the confirmation
* toast. Both come from the call site because only it knows what its rows are.
*/
export function ComfortPinMenu<T>({
menu,
target,
title,
}: {
menu: ContextMenuController<T>;
target: (row: T) => PinTarget | null;
title: (row: T) => string;
}) {
const { t } = useTranslation();
const pin = useComfortPin(menu.open);
const row = menu.target;
return (
<ContextMenu
menu={menu}
ariaLabel={t("home.pin.menu")}
items={row ? pin.item(target(row), title(row)) : []}
/>
);
}
/** The Plex poster grids and rails — five of them, all pinning the same way. One component so the
* show-vs-item decision and the toast's name are written once rather than five times. */
export function PlexPinMenu({ menu }: { menu: ContextMenuController<PlexCard> }) {
return <ComfortPinMenu menu={menu} target={pinPlex} title={(c) => c.title} />;
}
/** The YouTube video lists (the feed grid, the Home rails). */
export function VideoPinMenu({ menu }: { menu: ContextMenuController<Video> }) {
return <ComfortPinMenu menu={menu} target={(v) => pinVideo(v.id)} title={(v) => v.title ?? ""} />;
}
+5 -5
View File
@@ -5,12 +5,12 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Plug, RotateCcw, Send } from "lucide-react";
import { api, type ConfigItem, type PlexTestResult } from "../lib/api";
import { notify } from "../lib/notifications";
import Tooltip from "./Tooltip";
import InlineNotice from "./InlineNotice";
import Tooltip from "./ui/Tooltip";
import InlineNotice from "./ui/InlineNotice";
import { useMe } from "../lib/useMe";
import Tabs, { usePersistedTab } from "./Tabs";
import { LoadingState, StateMessage } from "./QueryState";
import { PageToolbar } from "./PageShell";
import Tabs, { usePersistedTab } from "./ui/Tabs";
import { LoadingState, StateMessage } from "./ui/QueryState";
import { PageToolbar } from "./ui/PageShell";
import { Switch } from "./ui/form";
import { DraftSaveBar, type SaveState } from "./ui/DraftSaveBar";
import { LS } from "../lib/storage";
+1 -1
View File
@@ -1,6 +1,6 @@
import { createContext, useCallback, useContext, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import Modal from "./Modal";
import Modal from "./ui/Modal";
import { Button } from "./ui/Button";
// App-styled, promise-based replacement for window.confirm. Wrap the tree in
+1 -1
View File
@@ -1,7 +1,7 @@
import { useLayoutEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { CalendarDays, Check, ChevronDown } from "lucide-react";
import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./ui/AnchoredPopover";
import { type FeedFilters } from "../lib/api";
import { deriveDateKey, selectDateChip, type DatePreset } from "../lib/dateFilter";
+1 -1
View File
@@ -1,6 +1,6 @@
import { useTranslation } from "react-i18next";
import { AlertTriangle } from "lucide-react";
import Banner from "./Banner";
import Banner from "./ui/Banner";
// Permanent, non-dismissible bar shown to the shared demo account so it's always clear the
// state is communal. (Replaces the old login-time toast, which re-popped on every reload.)
+3 -3
View File
@@ -19,9 +19,9 @@ import {
Trash2,
UploadCloud,
} from "lucide-react";
import Tabs from "./Tabs";
import { PageToolbar } from "./PageShell";
import { LoadingState, StateMessage } from "./QueryState";
import Tabs from "./ui/Tabs";
import { PageToolbar } from "./ui/PageShell";
import { LoadingState, StateMessage } from "./ui/QueryState";
// Lazy: these dialogs/editors open on demand, so they stay out of the Downloads page chunk until
// used (the video editor in particular is heavy — filmstrip + scrubber).
const DownloadDialog = lazy(() => import("./DownloadDialog"));
+1 -1
View File
@@ -2,7 +2,7 @@ import { lazy, Suspense, useState } from "react";
import { qk } from "../lib/queryKeys";
import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import Modal from "./Modal";
import Modal from "./ui/Modal";
import { Button } from "./ui/Button";
import { api } from "../lib/api";
import { invalidateDownloads } from "../lib/downloads";
+1 -1
View File
@@ -1,7 +1,7 @@
import { useSyncExternalStore } from "react";
import { useTranslation } from "react-i18next";
import { dismissError, getError, subscribeError } from "../lib/errorDialog";
import Modal from "./Modal";
import Modal from "./ui/Modal";
import { Button } from "./ui/Button";
// Renders the current app error (if any) as a modal the user must acknowledge.
+12 -13
View File
@@ -38,12 +38,13 @@ import { FEED_VIEWS, type FeedView } from "../lib/feedView";
import { clearedFilters, useActiveFilters } from "../lib/useActiveFilters";
import ActiveFilterChips from "./ActiveFilterChips";
import SelectedViewChip from "./SelectedViewChip";
import { LoadingState, StateMessage } from "./QueryState";
import { PageToolbar } from "./PageShell";
import { Button } from "./ui/Button";
import { LoadingState, StateMessage } from "./ui/QueryState";
import { PageToolbar } from "./ui/PageShell";
import { useWizard } from "./WizardProvider";
import SortControl from "./SortControl";
import FilterSelect from "./FilterSelect";
import ItemCount from "./ItemCount";
import SortControl from "./ui/SortControl";
import FilterSelect from "./ui/FilterSelect";
import ItemCount from "./ui/ItemCount";
import ViewSwitcher from "./ViewSwitcher";
import VirtualFeed from "./VirtualFeed";
// Lazy: the in-app YouTube player (IFrame API) loads only when a video is first opened.
@@ -530,20 +531,18 @@ export default function Feed({
{isDemo ? t("feed.demoEmptyBody") : t("feed.emptyBody")}
</p>
{isDemo ? (
<button
<Button
variant="primary"
size="lg"
onClick={() => setFilters({ ...filters, scope: "all" })}
className="inline-flex items-center justify-center gap-2 px-5 py-2.5 rounded-xl font-semibold btn-primary transition"
>
{t("feed.browseLibrary")}
</button>
</Button>
) : (
<>
<button
onClick={onOpenWizard}
className="inline-flex items-center justify-center gap-2 px-5 py-2.5 rounded-xl font-semibold btn-primary transition"
>
<Button variant="primary" size="lg" onClick={onOpenWizard}>
{t("feed.setUp")}
</button>
</Button>
<button
onClick={() => setFilters({ ...filters, scope: "all" })}
className="block mx-auto mt-3 text-sm text-muted hover:text-accent transition"
+1 -1
View File
@@ -1,6 +1,6 @@
import { useTranslation } from "react-i18next";
import { Library, User } from "lucide-react";
import SegmentedToggle from "./SegmentedToggle";
import SegmentedToggle from "./ui/SegmentedToggle";
import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider";
// The feed's Mine/Library scope, relocated from the filter sidebar to sit just right of the header
+2 -2
View File
@@ -1,8 +1,8 @@
import { useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { defaultLayout, type PanelId, type PanelLayout } from "../lib/panelLayout";
import SidePanel from "./SidePanel";
import PanelGroups, { type PanelItem } from "./PanelGroups";
import SidePanel from "./ui/SidePanel";
import PanelGroups, { type PanelItem } from "./ui/PanelGroups";
// The shared filter-rail shell, extracted from the three rails that all render the same SidePanel
// chrome around a PanelGroups body: the feed Sidebar, the PlexSidebar, and the PlaylistsRail. It owns
+1 -1
View File
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from "react";
import Overlay from "./Overlay";
import Overlay from "./ui/Overlay";
import { copyText } from "../lib/clipboard";
import { usePrefs } from "./PrefsProvider";
+676
View File
@@ -0,0 +1,676 @@
// @vitest-environment jsdom
//
// E24 S2: the Home surface. Covers the four behaviours the browser round actually caught, each of
// which is invisible to tsc and to a render-only check:
// • the hero's kicker comes from the SERVER's tier, never from the card's shape;
// • the hero is not printed twice (once as the hero, once as the first resume tile);
// • a budget change asks for the new budget and keeps the old answer on screen meanwhile;
// • the reroll sends the already-shown card as an exclusion — without it the resume tier hands the
// identical card straight back and the button looks dead.
// i18n is key-passthrough; the api is mocked so queries resolve synchronously. The rails live inside
// a Carousel (useHScroll → ResizeObserver), stubbed for jsdom.
import { StrictMode } from "react";
import { describe, it, expect, afterEach, beforeAll, beforeEach, vi } from "vitest";
import { act, render, screen, fireEvent, cleanup, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys";
beforeAll(() => {
class Stub {
observe() {}
unobserve() {}
disconnect() {}
takeRecords() {
return [];
}
}
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
});
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return {
...actual,
useTranslation: () => ({
t: (key: string, opts?: Record<string, unknown>) =>
opts?.time ? `${key}:${opts.time}` : key,
i18n: { language: "en" },
}),
};
});
const discover = vi.fn();
const discoverPick = vi.fn();
vi.mock("../lib/api", () => ({
api: {
discover: (...a: unknown[]) => discover(...a),
discoverPick: (...a: unknown[]) => discoverPick(...a),
setState: () => Promise.resolve(),
watchLaterAdd: () => Promise.resolve(),
watchLaterRemove: () => Promise.resolve(),
},
DISCOVER_BUDGETS: ["short", "medium", "long", "any"],
discoverCardKey: (c: { source: string; id: string }) => `${c.source}:${c.id}`,
}));
// The page hands Plex playback off to the Plex module. The provider itself is not under test, but
// WHAT is handed over is: the jump target decides both what plays and where Back goes.
const setPage = vi.fn();
const setJumpTo = vi.fn();
vi.mock("./NavigationProvider", () => ({
useNavigationActions: () => ({ setPage: (p: string) => setPage(p) }),
}));
vi.mock("./PlexProvider", () => ({
usePlexActions: () => ({ setJumpTo: (t: unknown) => setJumpTo(t) }),
}));
import Home from "./Home";
import { requestSurprise, takeSurprise } from "../lib/homeIntent";
function ytCard(id: string, title: string, extra: Record<string, unknown> = {}) {
return {
source: "youtube",
kind: "video",
id,
title,
subtitle: "A channel",
thumb: null,
aspect: "wide",
duration_seconds: 600,
position_seconds: 120,
remaining_seconds: 480,
status: "new",
plex: null,
video: {
id,
title,
channel_id: "UC1",
channel_title: "A channel",
channel_thumbnail: null,
channel_url: "https://youtube.com/channel/UC1",
published_at: "2026-01-01T00:00:00Z",
thumbnail_url: null,
duration_seconds: 600,
view_count: 10,
is_short: false,
live_status: "none",
status: "new",
position_seconds: 120,
saved: false,
watch_url: `https://www.youtube.com/watch?v=${id}`,
},
...extra,
};
}
/** A Plex SHOW row of the resume rail, carrying the episode that would actually start — what the
* backend now attaches (`rails.continue_watching`). */
const showCard = () => ({
source: "plex",
kind: "show",
id: "show-1",
title: "A Show",
subtitle: null,
thumb: null,
aspect: "poster",
duration_seconds: null,
position_seconds: 0,
remaining_seconds: null,
status: "in_progress",
video: null,
plex: { id: "show-1", type: "show", title: "A Show", thumb: "" },
next: {
source: "plex",
kind: "episode",
id: "ep-7",
title: "Ep 7",
subtitle: "A Show",
thumb: null,
aspect: "wide",
duration_seconds: 1500,
position_seconds: 0,
remaining_seconds: 1500,
status: "new",
video: null,
plex: { id: "ep-7", type: "episode", title: "Ep 7", thumb: "" },
},
});
const payload = (heroId: string, tier = "resume") => ({
budget: "any",
hero: { tier, card: ytCard(heroId, `Hero ${heroId}`) },
continue_watching: [ytCard(heroId, `Hero ${heroId}`), ytCard("v2", "Second thing")],
comfort: [],
});
function mount() {
return mountWithClient().result;
}
/** Same mount, handing back the QueryClient — the tests that need to drive an invalidation (i.e. a
* background refetch, the only way to reach the has-data-and-errored state) cannot reach it
* otherwise. */
function mountWithClient() {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const result = render(
<QueryClientProvider client={qc}>
<Home />
</QueryClientProvider>,
);
return { qc, result };
}
beforeEach(() => {
discover.mockReset();
discoverPick.mockReset();
setPage.mockReset();
setJumpTo.mockReset();
discover.mockResolvedValue(payload("v1"));
// Module-level state: a token left behind by one test would reroll the next one's first render.
takeSurprise();
});
afterEach(cleanup);
describe("Home", () => {
it("labels the hero from the server's tier, not from the card", async () => {
// The same card shape is served under a `surprise` tier: a client that inferred "where you left
// off" from the resume position would get this wrong, which is the dishonest state we forbid.
discover.mockResolvedValue(payload("v1", "surprise"));
mount();
expect(await screen.findByText("home.hero.surprise")).toBeTruthy();
expect(screen.queryByText(/home\.hero\.resume/)).toBeNull();
});
it("interpolates the remaining time into the resume kicker", async () => {
mount();
// 480s left → 8:00, passed through the key-passthrough t() as `<key>:<time>`.
expect(await screen.findByText("home.hero.resumeLeft:8:00")).toBeTruthy();
});
it("renders an error, never the empty state, when the payload cannot be loaded", async () => {
// The one thing this surface must not do: report a backend outage as "nothing to suggest yet"
// over a library full of things to suggest.
discover.mockRejectedValue(new Error("boom"));
mount();
expect(await screen.findByText("home.error")).toBeTruthy();
expect(screen.queryByText("home.hero.emptyAny")).toBeNull();
});
it("keeps the page on screen when a BACKGROUND refetch fails", async () => {
// react-query flips `status` to error on a failed refetch while keeping the cached payload, and
// every mutation here invalidates `qk.discover()` — so an unguarded `isError` blanked a fully
// populated landing page over one blip.
const { qc } = mountWithClient();
await screen.findByText("Hero v1");
discover.mockRejectedValue(new Error("blip"));
await act(async () => {
await qc.invalidateQueries({ queryKey: qk.discover() });
});
// The refetch must actually have been attempted, or this asserts nothing at all.
await waitFor(() => expect(discover).toHaveBeenCalledTimes(2));
expect(screen.queryByText("home.error")).toBeNull();
expect(screen.getByText("Hero v1")).toBeTruthy();
});
it("does not print the hero again as the first resume tile", async () => {
mount();
await screen.findByText("home.hero.resumeLeft:8:00");
// "Hero v1" is the hero's own heading; the rail below must not repeat it.
expect(screen.getAllByText("Hero v1")).toHaveLength(1);
expect(screen.getByText("Second thing")).toBeTruthy();
});
it("does not print a Plex series twice when the hero is the episode it resolves to", async () => {
// The commonest Plex resume case, and the one a single-key match misses: the rail row is the
// SHOW while the hero is the episode that show resolves to, so their `source:id` keys differ.
// Renamed on `plex` too, not only on the unified card: the rail tile is a `PlexPosterCard`, which
// takes its heading from the NATIVE payload — renaming just the outer title made this vacuous.
const s = showCard();
const show = { ...s, title: "The Series Tile", plex: { ...s.plex, title: "The Series Tile" } };
discover.mockResolvedValue({
budget: "any",
hero: { tier: "resume", card: show.next },
continue_watching: [show, ytCard("v2", "Second thing")],
comfort: [],
});
mount();
await screen.findByText("Second thing");
expect(screen.queryByText("The Series Tile")).toBeNull();
});
it("hides the resume rail rather than contradicting the hero that emptied it", async () => {
// One in-progress item: it becomes the hero, the dedupe filter takes it out of the rail, and the
// rail then announced "nothing half-finished right now" under a hero captioned "where you left
// off". The shelf's own empty state is untouched — it still has something to teach.
discover.mockResolvedValue({
budget: "any",
hero: { tier: "resume", card: ytCard("v1", "Hero v1") },
continue_watching: [ytCard("v1", "Hero v1")],
comfort: [],
});
mount();
await screen.findByText("Hero v1");
expect(screen.queryByText("home.continue.empty")).toBeNull();
expect(screen.getByText("home.comfort.empty")).toBeTruthy();
});
it("holds the hero it last found when a reroll fails", async () => {
// A failed pick has no data of its own (every press is a new key, and `keepPreviousData` is
// pending-only), so the hero used to rewind to the card the page LOADED with — several presses
// back — while `core.req`'s own dialog explained the failure.
// No resume rail: "Hero v1" then appears exactly where the hero is, so its reappearance is
// unambiguous evidence of a rewind rather than a rail tile that stopped being deduplicated.
discover.mockResolvedValue({
budget: "any",
hero: { tier: "resume", card: ytCard("v1", "Hero v1") },
continue_watching: [],
comfort: [],
});
discoverPick.mockResolvedValueOnce({
budget: "any",
hero: { tier: "surprise", card: ytCard("v9", "Rerolled") },
});
mount();
await screen.findByText("Hero v1");
fireEvent.click(screen.getByText("home.hero.reroll"));
await screen.findByText("Rerolled");
// Rejected on OUR schedule: asserting straight after the call goes out catches the query still
// pending, where `keepPreviousData` is legitimately holding the old hero — which is not the
// state under test and made this pass with or without the fix.
let failPick!: (e: Error) => void;
discoverPick.mockReturnValueOnce(
new Promise((_resolve, reject) => {
failPick = reject;
}),
);
fireEvent.click(screen.getByText("home.hero.reroll"));
await waitFor(() => expect(discoverPick).toHaveBeenCalledTimes(2));
await act(async () => {
failPick(new Error("boom"));
await new Promise((r) => setTimeout(r, 0));
});
expect(screen.getByText("Rerolled")).toBeTruthy();
expect(screen.queryByText("Hero v1")).toBeNull();
});
it("never answers a new budget with the previous budget's pick", async () => {
// Both halves of the hold outlive the question they answered: the chip row resets seed/seen/press,
// but it cannot reset an answer that already RESOLVED, and `keepPreviousData` chains straight
// through the disabled seed-0 query the chip click leaves behind. So the first reroll under a new
// budget offered the old budget's card — while it loaded, and permanently once it failed, since
// `retry: false` makes a failed pick terminal. Under a chip that promises ten minutes, that makes
// the page's one control meaningless (round 2).
discover.mockResolvedValue({
budget: "any",
hero: { tier: "resume", card: ytCard("v1", "Hero v1") },
continue_watching: [],
comfort: [],
});
discoverPick.mockResolvedValueOnce({
budget: "any",
hero: { tier: "surprise", card: ytCard("v9", "Long film") },
});
mount();
await screen.findByText("Hero v1");
fireEvent.click(screen.getByText("home.hero.reroll"));
await screen.findByText("Long film");
// A different question — whose own answer is already loaded.
discover.mockResolvedValue({
budget: "short",
hero: { tier: "resume", card: ytCard("v2", "Short thing") },
continue_watching: [],
comfort: [],
});
fireEvent.click(screen.getByText("home.budget.short"));
await screen.findByText("Short thing");
// Rejected on OUR schedule, as the sibling test above does, so the assertion cannot land while
// the query is still pending — that is a different state.
let failPick!: (e: Error) => void;
discoverPick.mockReturnValueOnce(
new Promise((_resolve, reject) => {
failPick = reject;
}),
);
fireEvent.click(screen.getByText("home.hero.reroll"));
await waitFor(() => expect(discoverPick).toHaveBeenCalledTimes(2));
// In flight: the placeholder must not reach across the budget change.
expect(screen.queryByText("Long film")).toBeNull();
await act(async () => {
failPick(new Error("boom"));
await new Promise((r) => setTimeout(r, 0));
});
// Failed: this budget's own hero, never the film picked for "any".
expect(screen.getByText("Short thing")).toBeTruthy();
expect(screen.queryByText("Long film")).toBeNull();
});
it("a mutation's invalidation reaches the rerolled hero too", async () => {
// The reroll has its own query key so pressing the button does not refetch the rails — but under
// the SAME root, or the prefix every mutation invalidates misses it and a rerolled hero outlives
// the page: play it, finish it, and it was still the answer, with a zeroed progress bar.
discoverPick.mockResolvedValue({
budget: "any",
hero: { tier: "surprise", card: ytCard("v9", "Rerolled") },
});
const { qc } = mountWithClient();
await screen.findByText("Hero v1");
fireEvent.click(screen.getByText("home.hero.reroll"));
await screen.findByText("Rerolled");
expect(discoverPick).toHaveBeenCalledTimes(1);
await act(async () => {
await qc.invalidateQueries({ queryKey: qk.discover() });
});
await waitFor(() => expect(discoverPick).toHaveBeenCalledTimes(2));
});
it("an unrelated mutation does not put the reroll button into a spinner", async () => {
// The other edge of the test above. Because the pick key sits under the `["discover"]` prefix,
// every mark-watched, bookmark, pin and shelf move on this page refetches it in the BACKGROUND —
// so binding the button's `loading` to raw `isFetching` made it spin and, since `ui/Button` turns
// `loading` into `disabled`, swallow clicks after actions the user never associated with a
// reroll (round 2). A press is outstanding only while THIS key has no answer of its own.
const rerollBtn = () =>
screen.getByText("home.hero.reroll").closest("button") as HTMLButtonElement;
let resolvePick!: (v: unknown) => void;
discoverPick.mockReturnValueOnce(
new Promise((resolve) => {
resolvePick = resolve;
}),
);
const { qc } = mountWithClient();
await screen.findByText("Hero v1");
fireEvent.click(screen.getByText("home.hero.reroll"));
await waitFor(() => expect(discoverPick).toHaveBeenCalledTimes(1));
// The positive control, and it is the half most easily broken while fixing the other: this is the
// FIRST press, so there is no previous pick for the placeholder to hold, and a predicate resting
// on `isPlaceholderData` alone would drop the spinner from exactly the press that needs it.
expect(rerollBtn().disabled).toBe(true);
expect(rerollBtn().getAttribute("aria-busy")).toBe("true");
await act(async () => {
resolvePick({ budget: "any", hero: { tier: "surprise", card: ytCard("v9", "Rerolled") } });
await new Promise((r) => setTimeout(r, 0));
});
await screen.findByText("Rerolled");
// Never settles, so the assertion lands squarely inside the refetch window rather than after it —
// and therefore the invalidation must NOT be awaited: its promise resolves only once every
// refetch it started has, which this one never will.
discoverPick.mockReturnValue(new Promise(() => {}));
act(() => {
void qc.invalidateQueries({ queryKey: qk.discover() });
});
await waitFor(() => expect(discoverPick).toHaveBeenCalledTimes(2));
expect(rerollBtn().disabled).toBe(false);
expect(rerollBtn().getAttribute("aria-busy")).toBeNull();
});
it("a press that FAILED does not leave the button hostage to the next mutation", async () => {
// Round 3's correction to the case above. A key whose request failed never gets data of its own
// (`retry: false` makes it terminal), so a predicate reading `data === undefined` stayed true
// forever and every later background refetch re-disabled the button — the same defect, one
// branch over. `isFetched` remembers that the key settled, an error counting as settled.
const rerollBtn = () =>
screen.getByText("home.hero.reroll").closest("button") as HTMLButtonElement;
let failPick!: (e: Error) => void;
discoverPick.mockReturnValueOnce(
new Promise((_resolve, reject) => {
failPick = reject;
}),
);
const { qc } = mountWithClient();
await screen.findByText("Hero v1");
fireEvent.click(screen.getByText("home.hero.reroll"));
await waitFor(() => expect(discoverPick).toHaveBeenCalledTimes(1));
// `waitFor`, not a fixed tick: react-query hands the rejection to the observer through
// `notifyManager`'s own `setTimeout(0)`, registered AFTER any timer this test schedules, so
// "await one macrotask then assert" races it. Round 4 measured that race as an intermittent red.
failPick(new Error("boom"));
await waitFor(() => expect(rerollBtn().disabled).toBe(false)); // the failure releases the button
// Now an unrelated action refetches that errored key in the background.
discoverPick.mockReturnValue(new Promise(() => {}));
act(() => {
void qc.invalidateQueries({ queryKey: qk.discover() });
});
await waitFor(() => expect(discoverPick).toHaveBeenCalledTimes(2));
expect(rerollBtn().disabled).toBe(false);
expect(rerollBtn().getAttribute("aria-busy")).toBeNull();
});
it("keeps the honest empty answer when a press fails after an empty one", async () => {
// The tagged slot is authoritative for its own budget, including when what it holds is "no hero
// at all". Under a `??` chain that read as "nothing remembered" and fell through to the loaded
// payload, so a failed press resurrected the card the page had already excluded as seen and just
// said it had nothing beyond (round 3).
discover.mockResolvedValue({
budget: "short",
hero: { tier: "resume", card: ytCard("v1", "Hero v1") },
continue_watching: [],
comfort: [],
});
mount();
await screen.findByText("Hero v1");
fireEvent.click(screen.getByText("home.budget.short")); // the empty-state branch needs a band
await waitFor(() => expect(discover).toHaveBeenLastCalledWith("short", 0));
// A press that RESOLVES with nothing left: the honest empty state.
discoverPick.mockResolvedValueOnce({ budget: "short", hero: null });
fireEvent.click(screen.getByText("home.hero.reroll"));
await screen.findByText("home.hero.emptyBudget");
expect(screen.queryByText("Hero v1")).toBeNull();
// …and a press from there that fails must not undo it.
let failPick!: (e: Error) => void;
discoverPick.mockReturnValueOnce(
new Promise((_resolve, reject) => {
failPick = reject;
}),
);
const rerollBtn = () =>
screen.getByText("home.hero.reroll").closest("button") as HTMLButtonElement;
fireEvent.click(screen.getByText("home.hero.reroll"));
await waitFor(() => expect(discoverPick).toHaveBeenCalledTimes(2));
expect(rerollBtn().disabled).toBe(true); // the press is outstanding
failPick(new Error("boom"));
// Wait for a signal that the REJECTION has landed, not for a fixed tick: react-query notifies
// the observer through its own `setTimeout(0)`, and the empty state is already on screen while
// the press is in flight — so asserting it straight away would pass with or without the fix.
// The button releasing is that signal (`isFetched` flips once the key settles, error included).
await waitFor(() => expect(rerollBtn().disabled).toBe(false));
expect(screen.getByText("home.hero.emptyBudget")).toBeTruthy();
expect(screen.queryByText("Hero v1")).toBeNull();
});
it("refreshes the payload the bookmark is read from", async () => {
mount();
await screen.findByText("Second thing");
expect(discover).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getAllByTitle("card.saveForLater")[0]!);
// `VideoCard` derives the filled/empty star straight from `video.saved` in this payload and
// keeps no optimistic state of its own, so without re-asking, the icon never changed and the
// next click re-sent the same call — the control looked dead.
await waitFor(() => expect(discover).toHaveBeenCalledTimes(2));
});
it("asks for the chosen budget and keeps the previous answer on screen meanwhile", async () => {
mount();
await screen.findByText("Hero v1");
// A never-resolving refetch: the point is what the page shows WHILE it waits.
discover.mockReturnValue(new Promise(() => {}));
fireEvent.click(screen.getByText("home.budget.short"));
await waitFor(() => expect(discover).toHaveBeenLastCalledWith("short", 0));
// Still the old answer — not a spinner that would take the chips away with it.
expect(screen.getByText("Hero v1")).toBeTruthy();
});
it("rerolls with the shown card excluded, and holds the hero while it loads", async () => {
discoverPick.mockResolvedValue({
budget: "any",
hero: { tier: "surprise", card: ytCard("v9", "Rerolled") },
});
mount();
await screen.findByText("Hero v1");
fireEvent.click(screen.getByText("home.hero.reroll"));
await waitFor(() =>
expect(discoverPick).toHaveBeenCalledWith("any", expect.any(Number), ["youtube:v1"], 1),
);
expect(await screen.findByText("Rerolled")).toBeTruthy();
});
it("never shows the empty state while a reroll is in flight", async () => {
// The regression the browser round caught: a new query key has no data for a beat, so the hero
// fell to null and the page rendered "nothing to suggest" over a perfectly good library.
discoverPick.mockReturnValue(new Promise(() => {}));
mount();
await screen.findByText("Hero v1");
fireEvent.click(screen.getByText("home.hero.reroll"));
await waitFor(() => expect(discoverPick).toHaveBeenCalled());
expect(screen.queryByText("home.hero.emptyAny")).toBeNull();
expect(screen.getByText("Hero v1")).toBeTruthy();
});
// E24 S4. The palette's "Meglepetés" is pressed on ANOTHER page, so Home's lazy chunk is not
// mounted yet — the request has to WAIT as a token rather than fire into an empty room. And it
// must be spent: a token that survived would make a later visit reroll on its own, which reads as
// the page overruling the answer you came back for.
it("acts on a pending surprise request once, and never replays it", async () => {
discoverPick.mockResolvedValue({
budget: "any",
hero: { tier: "surprise", card: ytCard("v9", "Rerolled") },
});
requestSurprise();
const first = mount();
expect(await screen.findByText("Rerolled")).toBeTruthy();
expect(discoverPick).toHaveBeenCalledTimes(1);
first.unmount();
discoverPick.mockClear();
mount();
await screen.findByText("Hero v1");
expect(discoverPick).not.toHaveBeenCalled();
});
// Measured in the browser (2026-09-07), before the fix: the palette's "Meglepetés" arrived at Home
// as `start=2` — two picks deep before the page had painted once — because StrictMode invokes a
// mount effect twice with the same render snapshot, and a reroll is not idempotent. The token is
// taken with a compare-and-clear against the LIVE store, which is what makes the second pass moot.
it("does not reroll twice under StrictMode's double-invoked mount effect", async () => {
discoverPick.mockResolvedValue({
budget: "any",
hero: { tier: "surprise", card: ytCard("v9", "Rerolled") },
});
requestSurprise();
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<StrictMode>
<QueryClientProvider client={qc}>
<Home />
</QueryClientProvider>
</StrictMode>,
);
expect(await screen.findByText("Rerolled")).toBeTruthy();
expect(discoverPick).toHaveBeenCalledTimes(1);
// …and on the press count the server reads as "which tier answers first".
expect(discoverPick).toHaveBeenCalledWith("any", expect.any(Number), [], 1);
});
it("says the shelf is empty rather than hiding it", async () => {
mount();
expect(await screen.findByText("home.comfort.empty")).toBeTruthy();
});
it("plays a half-watched SHOW's next episode instead of opening the show page", async () => {
// Reported 2026-09-06. A show tile that opens the show page hands back the very decision this
// surface exists to remove — six seasons to choose between. The rail row carries `next`, and the
// click follows it.
discover.mockResolvedValue({
budget: "any",
hero: null,
continue_watching: [showCard()],
comfort: [],
});
mount();
fireEvent.click(await screen.findByText("A Show"));
expect(setJumpTo).toHaveBeenCalledWith(expect.objectContaining({ id: "ep-7", type: "play" }));
});
it("hands off with `replace` so Back returns HERE, not to the Plex module's root", async () => {
// The hand-off is a continuation of the navigation that just pushed the Plex page entry, not a
// second step on top of it — without this, Back landed on the Plex Discover page the user never
// visited (and, under StrictMode, did nothing at all the first press).
discover.mockResolvedValue({
budget: "any",
hero: null,
continue_watching: [showCard()],
comfort: [],
});
mount();
fireEvent.click(await screen.findByText("A Show"));
expect(setJumpTo).toHaveBeenCalledWith(expect.objectContaining({ replace: true }));
expect(setPage).toHaveBeenCalledWith("plex");
});
it("sends the ⓘ affordance to the item's page, not to the player", async () => {
// `onInfo` and `onPlay` differ by one ternary arm, and confusing them is the defect the play
// test above was written for — but only `onPlay` was pinned, so swapping ⓘ's "movie" for "play"
// typechecked (both are members of `PlexJumpTarget["type"]`) and shipped green. ⓘ is the one
// action on this surface a user presses precisely because they do NOT want to start watching.
const movie = {
...ytCard("m1", "A Film"),
source: "plex",
kind: "movie",
aspect: "poster",
video: null,
plex: { id: "m1", type: "movie", title: "A Film", thumb: "" },
};
discover.mockResolvedValue({
budget: "any",
hero: null,
continue_watching: [movie],
comfort: [],
});
mount();
fireEvent.click(await screen.findByLabelText("plex.info.title"));
expect(setJumpTo).toHaveBeenCalledWith({ id: "m1", type: "movie", replace: true });
expect(setPage).toHaveBeenCalledWith("plex");
});
it("rotates the tier on each press so the button offers a different KIND of answer", async () => {
// Measured 2026-09-06: exclusion alone left it walking the resume rail and cycling nine cards,
// never once reaching the shelf. The press count travels to the server as the rotation.
discoverPick.mockResolvedValue({
budget: "any",
hero: { tier: "comfort", card: ytCard("v9", "Rerolled") },
});
mount();
await screen.findByText("Hero v1");
fireEvent.click(screen.getByText("home.hero.reroll"));
await waitFor(() =>
expect(discoverPick).toHaveBeenLastCalledWith("any", expect.any(Number), ["youtube:v1"], 1),
);
await screen.findByText("Rerolled");
fireEvent.click(screen.getByText("home.hero.reroll"));
await waitFor(() =>
expect(discoverPick).toHaveBeenLastCalledWith(
"any",
expect.any(Number),
// The exact list, not `expect.any(Array)`: the window has to ACCUMULATE. A one-element list
// is identical whether it accumulates or replaces, so the first press cannot tell them
// apart — and with the loose matcher both "replace, never grow" and SEEN_WINDOW = 1 (which
// reinstate the measured nine-card cycle) left the whole suite green.
["youtube:v1", "youtube:v9"],
2,
),
);
});
});
@@ -0,0 +1,269 @@
// @vitest-environment jsdom
//
// E24 S3: the comfort shelf's editor — the right-click menu on the Home rails. Its own file, not an
// addition to Home.dom.test.tsx, because the shelf query is module-level react-query state that a
// sibling test's mocks would leak into.
//
// What is asserted here is what tsc cannot see: that the menu the user gets over a SHELF tile is a
// different menu from the one over a resume tile, that a move row is disabled exactly when the move
// would go nowhere, and that a move writes the shelf's WHOLE new order rather than the pair it
// swapped.
//
// Same floating-ui recipe as ContextMenu.dom.test.tsx: stub the observers, wrap in a FloatingTree,
// and match menu items by text with `{ hidden: true }` (floating-ui never positions in jsdom, so the
// surface stays visibility:hidden and its contents have no accessible name).
import { describe, it, expect, afterEach, beforeAll, beforeEach, vi } from "vitest";
import { render, screen, fireEvent, cleanup, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { FloatingTree } from "@floating-ui/react";
beforeAll(() => {
class Stub {
observe() {}
unobserve() {}
disconnect() {}
takeRecords() {
return [];
}
}
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
});
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return {
...actual,
useTranslation: () => ({
t: (key: string) => key,
i18n: { language: "en" },
}),
};
});
const discover = vi.fn();
const comfortPins = vi.fn();
const addComfortPin = vi.fn();
const removeComfortPin = vi.fn();
const reorderComfortPins = vi.fn();
vi.mock("../lib/api", () => ({
api: {
discover: (...a: unknown[]) => discover(...a),
discoverPick: () => new Promise(() => {}),
setState: () => Promise.resolve(),
watchLaterAdd: () => Promise.resolve(),
watchLaterRemove: () => Promise.resolve(),
comfortPins: (...a: unknown[]) => comfortPins(...a),
addComfortPin: (...a: unknown[]) => addComfortPin(...a),
removeComfortPin: (...a: unknown[]) => removeComfortPin(...a),
reorderComfortPins: (...a: unknown[]) => reorderComfortPins(...a),
},
HttpError: class extends Error {},
DISCOVER_BUDGETS: ["short", "medium", "long", "any"],
discoverCardKey: (c: { source: string; id: string }) => `${c.source}:${c.id}`,
}));
vi.mock("./NavigationProvider", () => ({ useNavigationActions: () => ({ setPage: vi.fn() }) }));
vi.mock("./PlexProvider", () => ({ usePlexActions: () => ({ setJumpTo: vi.fn() }) }));
import Home from "./Home";
const video = (id: string, title: string) => ({
id,
title,
channel_id: "UC1",
channel_title: "A channel",
channel_thumbnail: null,
channel_url: "https://youtube.com/channel/UC1",
published_at: "2026-01-01T00:00:00Z",
thumbnail_url: null,
duration_seconds: 600,
view_count: 10,
is_short: false,
live_status: "none",
status: "new",
position_seconds: 0,
saved: false,
watch_url: `https://www.youtube.com/watch?v=${id}`,
});
const base = (id: string, title: string) => ({
source: "youtube",
kind: "video",
id,
title,
subtitle: null,
thumb: null,
aspect: "wide",
duration_seconds: 600,
position_seconds: 0,
remaining_seconds: 600,
status: "new",
plex: null,
video: video(id, title),
});
/** A pinned CHANNEL — the shelf tile with no native card, and the one that is easiest to address by
* its title in a test (the video tiles render a whole VideoCard). */
const channelPin = (id: string, title: string, pinId: number) => ({
...base(id, title),
kind: "channel",
video: null,
unwatched_count: 3,
pin_id: pinId,
next: base(`${id}-next`, `${title} next`),
});
const shelf = [
channelPin("UCa", "Comfort A", 3),
channelPin("UCb", "Comfort B", 7),
channelPin("UCc", "Comfort C", 9),
];
const payload = () => ({
budget: "any",
hero: null,
continue_watching: [base("v2", "Resume thing")],
comfort: shelf,
});
function mount() {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<FloatingTree>
<QueryClientProvider client={qc}>
<Home />
</QueryClientProvider>
</FloatingTree>,
);
}
const labels = () => screen.queryAllByRole("menuitem", { hidden: true }).map((b) => b.textContent);
const item = (label: string) => {
const found = screen
.getAllByRole("menuitem", { hidden: true })
.find((b) => b.textContent === label);
if (!found) throw new Error(`no menuitem "${label}" in [${labels().join(", ")}]`);
return found as HTMLButtonElement;
};
beforeEach(() => {
discover.mockReset().mockResolvedValue(payload());
comfortPins.mockReset().mockResolvedValue({ items: shelf });
addComfortPin.mockReset().mockResolvedValue({ ok: true, id: 1, created: true });
removeComfortPin.mockReset().mockResolvedValue({ deleted: 1 });
reorderComfortPins.mockReset().mockResolvedValue({ ok: true });
});
afterEach(cleanup);
describe("Home — the comfort shelf's context menu", () => {
it("offers the four moves and the unpin over a SHELF tile", async () => {
mount();
fireEvent.contextMenu(await screen.findByText("Comfort B"));
expect(labels()).toEqual([
"home.hero.play",
"home.shelf.moveFirst",
"home.shelf.moveLeft",
"home.shelf.moveRight",
"home.shelf.moveLast",
"home.pin.remove",
]);
});
it("disables exactly the moves that would go nowhere", async () => {
mount();
// The first tile can go right, but not left or to the front it is already at.
fireEvent.contextMenu(await screen.findByText("Comfort A"));
expect(item("home.shelf.moveFirst").disabled).toBe(true);
expect(item("home.shelf.moveLeft").disabled).toBe(true);
expect(item("home.shelf.moveRight").disabled).toBe(false);
expect(item("home.shelf.moveLast").disabled).toBe(false);
});
it("writes the WHOLE new order when a tile moves", async () => {
mount();
fireEvent.contextMenu(await screen.findByText("Comfort A"));
fireEvent.click(item("home.shelf.moveRight"));
// Not the swapped pair: the endpoint takes the shelf's complete order, and a short list would be
// the server reordering half the shelf.
await waitFor(() => expect(reorderComfortPins).toHaveBeenCalledWith([7, 3, 9]));
});
it("moves the tile under the pointer, not only in the request", async () => {
mount();
fireEvent.contextMenu(await screen.findByText("Comfort A"));
// Hold the refetch that follows the write: it would answer with this mock's ORIGINAL order,
// which is not what is under test. The claim here is that the tile moves under the pointer,
// before any round trip — `keepPreviousData` keeps the patched cache on screen meanwhile.
discover.mockReturnValue(new Promise(() => {}));
fireEvent.click(item("home.shelf.moveRight"));
await waitFor(() => expect(reorderComfortPins).toHaveBeenCalled());
// The RENDERED order, not just the payload sent: drop the optimistic cache write and the request
// is still perfectly right while the shelf visibly does not move, so the user clicks again.
expect(screen.getAllByText(/^Comfort [ABC]$/).map((e) => e.textContent)).toEqual([
"Comfort B",
"Comfort A",
"Comfort C",
]);
});
it("re-asks the server when a move fails instead of restoring a snapshot", async () => {
// Writing a captured `before` back was wrong twice: two overlapping moves each reverted to their
// own snapshot and the shelf settled on an order neither side held, and `before` is the WHOLE
// payload, so the rollback also discarded any rail refresh that landed mid-flight.
reorderComfortPins.mockRejectedValue(new Error("nope"));
mount();
await screen.findByText("Comfort A");
expect(discover).toHaveBeenCalledTimes(1);
fireEvent.contextMenu(screen.getByText("Comfort A"));
fireEvent.click(item("home.shelf.moveRight"));
await waitFor(() => expect(discover).toHaveBeenCalledTimes(2));
});
it("unpins with the pin id the shelf card carries", async () => {
mount();
fireEvent.contextMenu(await screen.findByText("Comfort C"));
fireEvent.click(item("home.pin.remove"));
await waitFor(() => expect(removeComfortPin).toHaveBeenCalledWith(9));
});
it("offers the plain PIN over a resume tile, and never a move", async () => {
// The rail below the shelf is not orderable, so its menu must not grow rows that would reorder
// something the tile is not part of.
mount();
await screen.findByText("Comfort A");
fireEvent.contextMenu(screen.getByText("Resume thing"));
await waitFor(() => expect(labels()).toContain("home.pin.add"));
expect(labels().some((l) => l?.startsWith("home.shelf.move"))).toBe(false);
fireEvent.click(item("home.pin.add"));
await waitFor(() => expect(addComfortPin).toHaveBeenCalledWith("video", "v2"));
});
it("still opens the menu over a shelf tile that has nothing left to play", async () => {
// The tile with nothing left to play is the one you most want to take OFF the shelf, so its
// menu still has to open and its Play row still has to be honest about being dead.
// NOTE what this does NOT prove: jsdom delivers `contextmenu` to a real `<button disabled>`
// too, so swapping the tile's `aria-disabled` back to `disabled` keeps this test green. That
// choice was settled in a real browser instead (see the tile's own comment) — the difference
// is focus, which jsdom's event dispatch never exercises.
discover.mockResolvedValue({
...payload(),
comfort: [{ ...channelPin("UCd", "Nothing left", 11), next: null }],
});
mount();
fireEvent.contextMenu(await screen.findByText("Nothing left"));
expect(item("home.hero.play").disabled).toBe(true);
fireEvent.click(item("home.pin.remove"));
await waitFor(() => expect(removeComfortPin).toHaveBeenCalledWith(11));
});
it("says so rather than guessing while the shelf is still loading", async () => {
// "Not pinned" and "not known yet" must not render as the same row: the pin label is only
// trustworthy once the shelf has answered.
comfortPins.mockReturnValue(new Promise(() => {}));
mount();
await screen.findByText("Comfort A");
fireEvent.contextMenu(screen.getByText("Resume thing"));
expect(item("home.pin.add").disabled).toBe(true);
});
});
+736
View File
@@ -0,0 +1,736 @@
import { lazy, Suspense, useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { keepPreviousData, useQuery, useQueryClient } from "@tanstack/react-query";
import {
ChevronLeft,
ChevronRight,
ChevronsLeft,
ChevronsRight,
PinOff,
Play,
RotateCcw,
} from "lucide-react";
import { qk } from "../lib/queryKeys";
import {
api,
DISCOVER_BUDGETS,
discoverCardKey,
type DiscoverBudget,
type DiscoverCard,
type DiscoverPayload,
type HeroTier,
type PlexCard,
type Video,
type VideoStatus,
} from "../lib/api";
import { notifyActionError } from "../lib/actionErrors";
import { takeSurprise, useSurpriseRequest } from "../lib/homeIntent";
import { formatDuration } from "../lib/format";
import { toggleWatchState } from "../lib/plexWatch";
import { useComfortPin, pinTargetForCard, shelfMoveTarget, type ShelfMove } from "./comfortPin";
import { ComfortPinMenu } from "./ComfortPinMenu";
import { type ActionMenuItem } from "./ui/ActionMenu";
import { Button } from "./ui/Button";
import { Carousel } from "./ui/Carousel";
import { ContextMenu } from "./ui/ContextMenu";
import { useContextMenu } from "./ui/useContextMenu";
import SegmentedToggle from "./ui/SegmentedToggle";
import { LoadingState, StateMessage } from "./ui/QueryState";
import VideoCard from "./VideoCard";
import { PlexPosterCard } from "./plexCards";
import { useNavigationActions } from "./NavigationProvider";
import { usePlexActions } from "./PlexProvider";
// The Home surface (E24) — "Mit nézzek most?".
//
// Layout C+, chosen from mockups: the time-budget chips sit ABOVE a single large hero, so the
// question comes first and the hero is its answer; the shelves follow, compact. The page's whole
// promise is that it ANSWERS rather than offers, which is why there is one hero and not a grid of
// three: a shortlist is still a comparison, and comparing is the thing the user cannot do on the
// evenings this page exists for.
//
// Everything domain-shaped is already built: the cards are the feed's `VideoCard` and Plex's
// `PlexPosterCard`, rendered from the native payload each unified card carries, and the rails are
// the platform `Carousel`. The two PLAYABLE card faces are reused rather than re-drawn; the only
// card this file defines is `ShelfTile`, because a pinned channel or playlist has no card anywhere
// in the app and `PosterTile`'s art is hardcoded to a Plex image URL.
// Lazy, exactly as the feed does it: the YouTube IFrame player loads only once something is played.
const PlayerModal = lazy(() => import("./PlayerModal"));
// From the api slice's own list, not a second copy: `readonly DiscoverBudget[]` accepts any
// SUBSET, so a band added server-side and to `DISCOVER_BUDGETS` typechecked green while this row
// silently kept offering four.
// A rail tile's slot width. `cardsSmall` is the feed's own dense card (its column bottoms out at
// 180px), so a 200px slot is the same object the feed shows — not a Home-only variant that would
// drift from it.
const YT_SLOT = 200;
// How many already-offered cards the reroll remembers. Matched to the server's own cap
// (`routes/discover.MAX_EXCLUDE`) rather than to a smaller number of our own: at 8 the window rolled
// before the resume rail (20 rows) was exhausted, so the button cycled the same nine cards forever
// (measured, 2026-09-06). It is bounded at all only because the server will not scan an unbounded
// exclusion list.
const SEEN_WINDOW = 32;
// The four shelf moves, in the order they appear in the menu.
const SHELF_MOVES: readonly [ShelfMove, string, typeof ChevronLeft][] = [
["first", "home.shelf.moveFirst", ChevronsLeft],
["left", "home.shelf.moveLeft", ChevronLeft],
["right", "home.shelf.moveRight", ChevronRight],
["last", "home.shelf.moveLast", ChevronsRight],
];
function Rail({
title,
ariaLabel,
cards,
emptyLabel,
onPlay,
onInfo,
onState,
onToggleSave,
onToggleWatched,
onMove,
}: {
title: string;
ariaLabel: string;
cards: DiscoverCard[];
emptyLabel: string;
onPlay: (card: DiscoverCard, startAt?: number | null) => void;
onInfo: (card: DiscoverCard) => void;
onState: (id: string, status: VideoStatus) => void;
onToggleSave: (id: string, saved: boolean) => void;
onToggleWatched: (card: PlexCard) => void;
/** Only the comfort shelf is orderable. Given, this rail's menu grows the four move rows; omitted,
* its tiles offer the plain pin instead. */
onMove?: (card: DiscoverCard, to: ShelfMove) => void;
}) {
const { t } = useTranslation();
// ONE right-click menu for the rail (see ui/ContextMenu). It is the shelf's whole editor: menu
// rows rather than drag & drop, because dragging inside a horizontal, scrolling Carousel fights
// the scroll, and because these rows are keyboard-reachable (Shift+F10) for free.
const menu = useContextMenu<DiscoverCard>();
const pin = useComfortPin(menu.open);
const target = menu.target;
const shelfIdx = target?.pin_id != null ? cards.findIndex((c) => c.pin_id === target.pin_id) : -1;
const items: ActionMenuItem[] = !target
? []
: [
{
id: "play",
label: target.position_seconds > 0 ? t("home.hero.continue") : t("home.hero.play"),
icon: <Play className="w-4 h-4" />,
// A pinned channel with nothing unwatched left, or an empty playlist, resolves to no
// `next` — the same condition that stands its tile down. Kept visible and disabled rather
// than dropped, so the menu does not change shape from tile to tile.
disabled: !(target.next ?? target).video && !(target.next ?? target).plex,
onSelect: () => onPlay(target),
},
...(onMove && target.pin_id != null
? [
...SHELF_MOVES.map(([to, key, Icon], i) => ({
id: `shelf-${to}`,
label: t(key),
icon: <Icon className="w-4 h-4" />,
separatorBefore: i === 0,
// The same rule the move itself runs, so a row is never enabled onto a no-op.
disabled: shelfMoveTarget(shelfIdx, to, cards.length) < 0,
onSelect: () => onMove(target, to),
})),
{
id: "shelf-unpin",
label: t("home.pin.remove"),
icon: <PinOff className="w-4 h-4" />,
separatorBefore: true,
// The only row here that removes a curated entry, and it sat in the same colour as
// the four harmless reorders. `danger` matches the shared unpin row in
// `comfortPin.item()`, which is what every OTHER surface (including the hero and the
// rail below) composes for the same command — the two must not disagree.
danger: true,
// The pin id travels on the shelf card, so this needs no lookup — and unlike every
// other surface it can never be in the "not known yet" state.
onSelect: () => pin.remove(target.pin_id as number, target.title),
},
]
: pin.item(pinTargetForCard(target), target.title)),
];
// An empty rail says why it is empty rather than vanishing: a shelf that silently disappears when
// you have not pinned anything teaches nothing about how to fill it.
if (!cards.length) {
return (
<section className="mb-6">
<h2 className="text-sm font-semibold mb-2">{title}</h2>
<StateMessage compact>{emptyLabel}</StateMessage>
</section>
);
}
return (
<section className="mb-6">
<Carousel
title={<span className="text-sm font-semibold">{title}</span>}
ariaLabel={ariaLabel}
>
{cards.map((c) => (
<div
key={`${c.source}:${c.kind}:${c.id}`}
className={c.aspect === "poster" ? "w-[150px] shrink-0" : "shrink-0"}
style={c.aspect === "poster" ? undefined : { width: YT_SLOT }}
>
{c.video ? (
<VideoCard
video={c.video}
view="cardsSmall"
width={YT_SLOT}
onState={onState}
onToggleSave={onToggleSave}
onOpen={(v, startAt) => onPlay({ ...c, video: v }, startAt)}
// Bound to the UNIFIED card, not the native payload the card component holds: the
// menu acts on the shelf ENTRY (its pin id, its place in the order), which the
// `Video` inside it does not carry.
menuProps={() => menu.triggerProps(c)}
/>
) : c.plex ? (
<PlexPosterCard
card={c.plex}
onOpen={() => onPlay(c)}
onInfo={() => onInfo(c)}
onToggleWatched={onToggleWatched}
menuProps={() => menu.triggerProps(c)}
/>
) : (
<ShelfTile card={c} onPlay={onPlay} menuProps={menu.triggerProps} />
)}
</div>
))}
</Carousel>
<ContextMenu menu={menu} ariaLabel={ariaLabel} items={items} />
</section>
);
}
// A pinned CHANNEL or PLAYLIST has no native card of its own — neither is a playable thing, so
// neither VideoCard nor PlexPosterCard fits. It advertises what is waiting behind it instead of a
// runtime it does not have.
function ShelfTile({
card,
onPlay,
menuProps,
}: {
card: DiscoverCard;
onPlay: (card: DiscoverCard) => void;
menuProps?: (c: DiscoverCard) => Record<string, unknown>;
}) {
const { t } = useTranslation();
const count = card.kind === "channel" ? card.unwatched_count : card.item_count;
const countLabel =
card.kind === "channel"
? t("home.shelf.unwatchedCount", { count: count ?? 0 })
: t("home.shelf.itemCount", { count: count ?? 0 });
return (
<button
type="button"
onClick={() => card.next && onPlay(card)}
{...menuProps?.(card)}
// `aria-disabled`, not `disabled`: a pinned channel with nothing unwatched left is not
// playable, but it IS the tile you most want to take off the shelf, and the only way to do
// that is its context menu. MEASURED in Chromium (2026-09-06), because the reason is narrower
// than it looks: a real `disabled` button DOES still receive `contextmenu` and `pointerdown`,
// so mouse and long-press would both survive it — what it loses is FOCUS, and with it
// Shift+F10, the only keyboard route to the menu. Inert to the click (the handler guards),
// alive to all three input paths.
aria-disabled={!card.next}
className="block w-full text-left rounded-xl overflow-hidden border border-border bg-surface transition-colors aria-disabled:opacity-60 [&:not([aria-disabled='true'])]:hover:border-accent"
>
<span className="block aspect-video bg-card overflow-hidden">
{card.thumb ? (
<img src={card.thumb} alt="" loading="lazy" className="w-full h-full object-cover" />
) : null}
</span>
<span className="block p-2">
<span className="block text-xs font-medium truncate">{card.title}</span>
<span className="block text-2xs text-muted">{countLabel}</span>
</span>
</button>
);
}
function Hero({
tier,
card,
onPlay,
onReroll,
rerolling,
}: {
tier: HeroTier;
card: DiscoverCard;
onPlay: (card: DiscoverCard) => void;
onReroll: () => void;
rerolling: boolean;
}) {
const { t } = useTranslation();
// The hero is the likeliest thing on the page to be comfort media — it is what the surface just
// answered with — so it answers the same right-click every card in the app does.
const pinMenu = useContextMenu<DiscoverCard>();
const started = card.position_seconds > 0;
const left = card.remaining_seconds;
// The kicker is driven by the tier the SERVER chose, never guessed from the card's shape: "where
// you left off" over something never opened is exactly the quiet lie the house rules forbid.
const kicker =
tier === "resume"
? left != null
? t("home.hero.resumeLeft", { time: formatDuration(left) })
: t("home.hero.resume")
: tier === "comfort"
? t("home.hero.comfort")
: t("home.hero.surprise");
return (
<section
{...pinMenu.triggerProps(card)}
className="mb-6 rounded-xl border border-border bg-surface p-4 grid gap-4 sm:grid-cols-[minmax(0,18rem)_1fr]"
>
<button
type="button"
onClick={() => onPlay(card)}
className={`relative block w-full overflow-hidden rounded-lg border border-border bg-card ${
card.aspect === "poster" ? "aspect-[2/3] max-w-[12rem]" : "aspect-video"
}`}
aria-label={`${started ? t("home.hero.continue") : t("home.hero.play")}: ${card.title}`}
>
{card.thumb ? <img src={card.thumb} alt="" className="w-full h-full object-cover" /> : null}
{card.duration_seconds != null && (
<span className="absolute bottom-1.5 right-1.5 rounded-sm bg-black/80 px-1.5 py-0.5 text-2xs font-medium text-white tabular-nums">
{formatDuration(card.duration_seconds)}
</span>
)}
{started && card.duration_seconds ? (
<span className="absolute inset-x-0 bottom-0 h-1 bg-black/60">
<span
className="block h-full bg-accent"
style={{
width: `${Math.min(100, (card.position_seconds / card.duration_seconds) * 100)}%`,
}}
/>
</span>
) : null}
</button>
<div className="flex min-w-0 flex-col">
<p className="text-2xs font-semibold uppercase tracking-wider text-accent">{kicker}</p>
<h2 className="mt-1 text-lg font-semibold leading-tight">{card.title}</h2>
{card.subtitle && <p className="mt-0.5 text-xs text-muted truncate">{card.subtitle}</p>}
<div className="mt-auto flex flex-wrap items-center gap-2 pt-3">
<Button icon={<Play className="h-4 w-4" />} onClick={() => onPlay(card)}>
{started ? t("home.hero.continue") : t("home.hero.play")}
</Button>
<Button
variant="outline"
icon={<RotateCcw className="h-4 w-4" />}
loading={rerolling}
onClick={onReroll}
>
{t("home.hero.reroll")}
</Button>
</div>
</div>
<ComfortPinMenu menu={pinMenu} target={pinTargetForCard} title={(c) => c.title} />
</section>
);
}
export default function Home() {
const { t } = useTranslation();
const qc = useQueryClient();
const { setPage } = useNavigationActions();
const { setJumpTo } = usePlexActions();
const [budget, setBudget] = useState<DiscoverBudget>("any");
// 0 = "the hero the page loaded with". Any other value is a reroll, answered by its own query so
// pressing the button does not refetch the rails under the user's cursor.
const [seed, setSeed] = useState(0);
// Which press this is. It rotates the tier the server leads with, so "Mást kérek" offers a
// different KIND of answer each time (where you left off → your shelf → a surprise) rather than
// the next row of the same rail. Reset with the budget: a new question starts the rotation over.
const [press, setPress] = useState(0);
// The cards the reroll has already offered this session, newest last. A ROLLING window rather than
// the whole history: an unbounded list would eventually exclude everything and leave the button
// with nothing to give, whereas a bounded one cannot. Its size is `SEEN_WINDOW` — see there for
// why it is 32 and not the 8 this comment used to quote. Kept here (not in the URL or a pref)
// because it is a property of this sitting.
const [seen, setSeen] = useState<string[]>([]);
const [player, setPlayer] = useState<{ video: Video; startAt: number | null } | null>(null);
const q = useQuery({
queryKey: qk.discover(budget),
queryFn: () => api.discover(budget, 0),
// Keep the previous budget's answer on screen while the new one loads. Without this the page
// falls back to `isPending` and replaces itself — chips included — with a spinner, so the
// control you just clicked vanishes under your cursor. (Same reason the feed does it.)
placeholderData: keepPreviousData,
});
const pickQ = useQuery({
queryKey: qk.discoverPick(budget, seed, seen, press),
queryFn: () => api.discoverPick(budget, seed, seen, press),
enabled: seed !== 0,
staleTime: 0,
// Hold the previous hero while the next one is in flight. Without this the reroll's new query key
// has no data for a beat, `hero` falls to null, and the page renders "nothing to suggest yet" —
// an EMPTY state standing in for a loading one, which is the exact dishonesty the house rules
// forbid. Measured in the browser (2026-09-06); the button's own spinner is the loading signal.
//
// But only WITHIN a budget. Plain `keepPreviousData` chains through the disabled seed-0 query a
// chip click leaves behind, so the first reroll under a new budget held up the card the PREVIOUS
// question was answered with — under a chip promising something else (round 2). Returning
// undefined instead falls the hero back to this budget's own loaded answer, which is already in
// hand; the spinner still covers the wait.
placeholderData: (prev) => (prev?.budget === budget ? prev : undefined),
});
// Is a PRESS outstanding? NOT `pickQ.isFetching`. The pick key deliberately sits under the
// `["discover"]` prefix so a mutation's invalidation reaches the rerolled hero — which also means
// every mark-watched, bookmark, pin/unpin and shelf move on this page refetches it in the
// background, and `ui/Button` turns `loading` into `disabled`. So the reroll spun and swallowed
// clicks after actions the user never associated with it (round 2). A press is outstanding exactly
// while THIS key has never settled — which is what `isFetched` means (react-query counts both a
// success and an error as settled), and every press mints a fresh seed, so a fresh key.
//
// Round 3 corrected this: `isPlaceholderData || data === undefined` looked equivalent and was not.
// A pick that FAILED leaves its key data-less forever (`retry: false`), so the second disjunct
// stayed true and the next unrelated mutation re-disabled the button — the same defect, surviving
// in the error branch. Guarding on `!isError` does NOT help either: query-core resets a data-less
// query to status "pending" for the duration of a refetch, so the error is invisible exactly when
// it matters. `isFetched` is the only flag that remembers.
const rerolling = pickQ.isFetching && !pickQ.isFetched;
// The last pick that actually RESOLVED, tagged with the budget it answered. Every press is a new
// query key, so a failed one has no data of its own and `keepPreviousData` (pending-only) cannot
// cover it — without this the hero silently rewound to the card the page loaded with, several
// presses back, while `core.req`'s own dialog was explaining the failure. A reroll that fails
// leaves the answer on screen alone.
//
// The TAG is load-bearing, not bookkeeping: the chip row resets `seed`/`seen`/`press` but cannot
// reset an answer that already resolved, so an untagged slot outlived the question it answered —
// switch to "10 perc", press reroll, let that request fail (`retry: false`, so it is terminal),
// and the two-hour film picked under "Bármennyi" was still the hero, under a chip promising ten
// minutes (round 2). Taken from the SERVER's `budget` echo rather than this render's state, so the
// tag cannot be minted from a budget the pick never answered.
const [lastPick, setLastPick] = useState<{
budget: DiscoverBudget;
hero: DiscoverPayload["hero"];
} | null>(null);
useEffect(() => {
if (pickQ.data !== undefined) setLastPick({ budget: pickQ.data.budget, hero: pickQ.data.hero });
}, [pickQ.data]);
const data = q.data;
// Which hero is on screen. The middle branch is the load-bearing one: `keepPreviousData` holds the
// PREVIOUS reroll, but the FIRST reroll has no previous pick to hold, so without falling back to
// the loaded payload the page renders "nothing to suggest" over a perfectly good library for a
// beat. `pickQ.data !== undefined` (not `?? null`) is what keeps a genuinely empty answer honest:
// once the pick has RESOLVED, a null hero really means nothing is left and the empty state is the
// truth.
const hero =
seed === 0
? (data?.hero ?? null)
: pickQ.data !== undefined
? pickQ.data.hero
: // A ternary, not `??`: the tagged slot is AUTHORITATIVE for its own budget, and "the last
// pick for this budget resolved to no hero" is a real answer, not a missing one. Under
// `??` the two collapsed, so a failed press after an empty pick resurrected the card the
// page loaded with — which it had already excluded as seen and just said it had nothing
// beyond (round 3). A mismatched or absent tag still falls through to the loaded hero,
// which is what the FIRST press of a budget needs.
lastPick?.budget === budget
? lastPick.hero
: (data?.hero ?? null);
// The hero is the top of the resume rail by construction, so leaving it in the rail below prints
// the same card twice, a hand's width apart. Matched on source+id rather than on the tier alone:
// a rerolled hero can be a surprise pick that happens to be nothing to do with this rail.
//
// Against BOTH of a row's keys, the way `hero.pick`'s own exclusion does. For a Plex series the
// rail row is the SHOW while the hero is the episode that show RESOLVES to, so a single-key match
// never fires and the most common Plex resume case printed the series twice.
const heroKey = hero ? discoverCardKey(hero.card) : null;
const resumeRail = (data?.continue_watching ?? []).filter(
(c) => discoverCardKey(c) !== heroKey && discoverCardKey(c.next ?? c) !== heroKey,
);
// Empty because the hero ATE the rail, not because nothing is half-finished. An empty rail earns
// its empty state by teaching you how to fill it; here there is nothing to teach, and "Nothing
// half-finished right now" under a hero captioned "Where you left off" is the page contradicting
// itself on one screen.
const resumeConsumed = !resumeRail.length && (data?.continue_watching ?? []).length > 0;
const onState = useCallback(
(id: string, status: VideoStatus) => {
// A full refetch, unlike the feed's stale-mark: these rails are two dozen rows, and marking
// something watched genuinely changes what the page should answer (it can retire the hero).
// The feed avoids this because it would refetch ten infinite-query pages per click.
void api
.setState(id, status)
.then(() => qc.invalidateQueries({ queryKey: qk.discover() }))
// Without this the rejection escapes unhandled and the click just does nothing — the
// feed's equivalents both catch, and the reorder on this same page already does.
// A WRITE key, not `home.error`: that string is the page's LOAD failure ("could not load
// your suggestions"), and it is what a detail-less rejection actually shows — an offline
// click raises a bare fetch TypeError, so there is no server reason to prefer over it. It
// would describe a state the user can see is untrue, over a fully populated page whose
// invalidations never even ran (they sit inside `.then`).
.catch((e: unknown) => notifyActionError(e, t("home.actionFailed")));
},
[qc, t],
);
const onToggleSave = useCallback(
(id: string, saved: boolean) => {
void (saved ? api.watchLaterAdd(id) : api.watchLaterRemove(id))
.then(() => {
qc.invalidateQueries({ queryKey: qk.playlists() });
qc.invalidateQueries({ queryKey: qk.playlist() });
// And the payload the star is READ from. `VideoCard` derives the filled/empty bookmark
// straight from `video.saved` and keeps no optimistic state of its own (the feed instead
// holds a `savedOverrides` map), so without this the icon never changed and a second click
// re-sent the same call — the control looked dead.
qc.invalidateQueries({ queryKey: qk.discover() });
})
// The write key, for the same reason as `onState` above.
.catch((e: unknown) => notifyActionError(e, t("home.actionFailed")));
},
[qc, t],
);
// One click plays. A shelf tile resolves through its `next` (a show → the episode that would
// start, a channel → its newest unwatched video); everything else plays itself. Plex hands off to
// its own module, which owns the hls.js player and its whole provider tree — the alternative would
// be a second player mounted on the landing page.
const onPlay = useCallback(
(card: DiscoverCard, startAt?: number | null) => {
const target = card.next ?? card;
if (target.video) {
setPlayer({ video: target.video, startAt: startAt ?? null });
return;
}
if (target.plex) {
// `replace`: the Plex module's sub-view rewrites the page entry `setPage` is about to push,
// so browser-Back from the player returns HERE — not to the Plex Discover root, which this
// navigation passed through and the user never saw.
setJumpTo({
id: target.plex.id,
type: target.plex.type === "show" ? "show" : "play",
replace: true,
});
setPage("plex");
}
},
[setJumpTo, setPage],
);
// The ⓘ affordance on a Plex tile: the item's own page in the Plex module, exactly where the same
// click goes from the Plex grid. A show tile's card click lands there too — a show is not playable.
const onInfo = useCallback(
(card: DiscoverCard) => {
if (!card.plex) return;
setJumpTo({
id: card.plex.id,
type: card.plex.type === "show" ? "show" : "movie",
replace: true,
});
setPage("plex");
},
[setJumpTo, setPage],
);
// Watched-toggle on a Plex tile. No optimistic cache patch here (unlike the Plex Discover
// surface): marking something watched can retire the hero as well as the tile, so this page
// re-asks the server rather than trying to predict a cross-rail consequence.
const onToggleWatched = useCallback(
(card: PlexCard) =>
void toggleWatchState(card, t, {
optimistic: () => {},
resync: () => qc.invalidateQueries({ queryKey: qk.discover() }),
}),
[qc, t],
);
// Shelf reorder, from the comfort rail's menu. Optimistic on THIS budget's cached payload so the
// tile moves under the pointer, then written; a failure re-asks the server rather than leaving the
// page showing an order the server never accepted (the same lesson the playlist reorder learned).
const moveInShelf = useCallback(
(card: DiscoverCard, to: ShelfMove) => {
const key = qk.discover(budget);
const before = qc.getQueryData<DiscoverPayload>(key);
const cards = before?.comfort ?? [];
const from = cards.findIndex((c) => c.pin_id === card.pin_id);
const target = shelfMoveTarget(from, to, cards.length);
if (target < 0) return;
const next = [...cards];
const [moved] = next.splice(from, 1);
if (!moved) return; // unreachable: `from` is in range whenever `target` is
next.splice(target, 0, moved);
qc.setQueryData<DiscoverPayload>(key, (old) => (old ? { ...old, comfort: next } : old));
// `pin_id` is present on every shelf card by construction; the filter is what proves it to the
// type, and a short list would be rejected by the server rather than silently reordering half
// the shelf.
const ids = next.map((c) => c.pin_id).filter((id): id is number => id != null);
void api
.reorderComfortPins(ids)
.catch((e: unknown) => notifyActionError(e, t("home.shelf.moveFailed")))
.finally(() => {
// The shelf is also the pin menus' source of truth everywhere else in the app.
void qc.invalidateQueries({ queryKey: qk.comfortPins() });
// And the PREFIX, not this budget's key — which is what actually re-asks the server, and
// is what the comment above this callback promises. Restoring the captured snapshot
// instead was wrong twice over: two overlapping moves each reverted to their own
// snapshot, so the shelf settled on an order neither side ever held; and `before` is the
// whole payload, so a rollback also threw away any rail refresh that had landed while the
// request was in flight. On the success path the prefix matters for a second reason — the
// shelf is budget-independent server-side but cached per budget, so without it the next
// chip click renders the pre-move order and a move from there writes it back.
void qc.invalidateQueries({ queryKey: qk.discover() });
});
},
[budget, qc, t],
);
const reroll = useCallback(() => {
// Remember what is on screen so the next pick cannot be the same card, then take a fresh seed —
// never 0 (which means "the hero the page loaded with") and never the current one (a cache hit
// would look like a dead button).
if (heroKey)
setSeen((prev) => [...prev.filter((k) => k !== heroKey), heroKey].slice(-SEEN_WINDOW));
setPress((n) => n + 1);
setSeed((prev) => {
let next = prev;
while (next === prev || next === 0) next = Math.floor(Math.random() * 1_000_000) + 1;
return next;
});
}, [heroKey]);
// "Meglepetés" from the command palette: the request is made on another page, so it waits as a
// token until this module's chunk has actually mounted (see lib/homeIntent). The subscribed value
// is what WAKES this effect; `takeSurprise()` is what decides — it clears the token and answers
// false to every later pass, including StrictMode's second mount invocation and the re-runs
// `reroll`'s changing identity causes.
const surpriseRequest = useSurpriseRequest();
useEffect(() => {
if (surpriseRequest && takeSurprise()) reroll();
}, [surpriseRequest, reroll]);
if (q.isPending) return <LoadingState />;
// `&& !data`, the codebase-wide convention (and the one `ui/QueryState` documents): react-query
// flips `status` to error on a FAILED REFETCH while keeping the cached payload, and every mutation
// on this page invalidates `qk.discover()` — so without the guard, marking one card watched over a
// blip replaced a fully populated landing page with an error line.
if (q.isError && !data)
return (
<StateMessage tone="error" onRetry={() => void q.refetch()}>
{t("home.error")}
</StateMessage>
);
return (
<div className="p-4">
{/* The caption sits NEXT TO the chips, not opposite them: SegmentedToggle's own `label` prop
renders a justify-between row, which is right for a narrow filter rail and wrong here — on
an 80rem page it throws the answers to the far edge, away from the question they answer. */}
<div className="mb-4 flex flex-wrap items-center gap-x-3 gap-y-2">
<span className="text-sm text-muted">{t("home.budget.ask")}</span>
<SegmentedToggle
size="md"
ariaLabel={t("home.budget.ask")}
value={budget}
onChange={(v) => {
setBudget(v);
setSeed(0); // the payload for the new budget carries its own hero
setSeen([]); // a new question deserves the whole library again
setPress(0); // …and the tier rotation starts from the page's own priority order
}}
options={DISCOVER_BUDGETS.map((b) => ({ value: b, label: t(`home.budget.${b}`) }))}
/>
</div>
{hero ? (
<Hero
tier={hero.tier}
card={hero.card}
onPlay={onPlay}
onReroll={reroll}
rerolling={rerolling}
/>
) : (
// Honest: nothing in the library fits what was asked. Offering something that does not fit
// would make the one control on this page meaningless.
<section className="mb-6 rounded-xl border border-border bg-surface p-4">
{/* Not `StateMessage`'s `onRetry` slot: that button is labelled "Retry", which promises
a re-run of something that failed. Nothing failed — the library simply has no answer
this short, and the honest offer is to ask a different question. */}
<StateMessage compact>
{budget === "any" ? t("home.hero.emptyAny") : t("home.hero.emptyBudget")}
</StateMessage>
{budget !== "any" && (
<div className="flex justify-center pb-4">
{/* The same action as the Hero's own reroll, so it carries the same in-flight signal.
Without it the press was unacknowledged — `hero` stays null for the whole round
trip in this branch, so nothing on the page moved — and a second click spent
another pick AND skipped a tier in the server's `start=` rotation. The icon comes
along because `loading` swaps it for the spinner: with no icon the button would
grow by a spinner's width mid-press. */}
<Button
variant="outline"
size="xs"
type="button"
icon={<RotateCcw className="h-4 w-4" />}
loading={rerolling}
onClick={reroll}
>
{t("home.hero.reroll")}
</Button>
</div>
)}
</section>
)}
<Rail
title={t("home.comfort.title")}
ariaLabel={t("home.comfort.title")}
cards={data?.comfort ?? []}
emptyLabel={t("home.comfort.empty")}
onPlay={onPlay}
onInfo={onInfo}
onState={onState}
onToggleSave={onToggleSave}
onToggleWatched={onToggleWatched}
onMove={moveInShelf}
/>
{!resumeConsumed && (
<Rail
title={t("home.continue.title")}
ariaLabel={t("home.continue.title")}
cards={resumeRail}
emptyLabel={t("home.continue.empty")}
onPlay={onPlay}
onInfo={onInfo}
onState={onState}
onToggleSave={onToggleSave}
onToggleWatched={onToggleWatched}
/>
)}
{player && (
<Suspense fallback={null}>
<PlayerModal
video={player.video}
startAt={player.startAt}
onClose={() => {
setPlayer(null);
// The player saves progress on close, so the rails and the hero are stale by
// definition once it shuts.
void qc.invalidateQueries({ queryKey: qk.discover() });
}}
onState={onState}
/>
</Suspense>
)}
</div>
);
}
+9 -16
View File
@@ -5,6 +5,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
import { AlertTriangle, Lock, ShieldCheck } from "lucide-react";
import { api, HttpError } from "../lib/api";
import * as e2ee from "../lib/e2ee";
import { Button } from "./ui/Button";
import { inputCls } from "./ui/form";
// Secure-messaging key gate: first-run setup (choose a passphrase) or unlock on this device.
@@ -107,21 +108,17 @@ export default function KeyGate({ meId, compact = false }: { meId: number; compa
)}
{err && <div className="text-sm text-danger">{err}</div>}
<div className="flex gap-2">
<button
onClick={cancelReset}
disabled={busy}
className="px-4 py-2 rounded-xl text-sm border border-border hover:border-accent transition disabled:opacity-40"
>
<Button variant="outline" onClick={cancelReset} disabled={busy}>
{t("common.cancel")}
</button>
<button
</Button>
<Button
variant="danger"
onClick={doReset}
disabled={busy}
className="inline-flex items-center gap-2 px-4 py-2 rounded-xl font-semibold bg-danger/90 text-danger-fg hover:bg-danger transition disabled:opacity-40"
icon={<AlertTriangle className="w-4 h-4" />}
>
<AlertTriangle className="w-4 h-4" />
{t("messages.resetButton")}
</button>
</Button>
</div>
</div>
);
@@ -157,14 +154,10 @@ export default function KeyGate({ meId, compact = false }: { meId: number; compa
/>
)}
{err && <div className="text-sm text-danger">{err}</div>}
<button
onClick={submit}
disabled={busy || !pass}
className="inline-flex items-center gap-2 px-4 py-2 rounded-xl font-semibold btn-primary transition disabled:opacity-40"
>
<Button variant="primary" size="md" onClick={submit} disabled={busy || !pass}>
<Lock className="w-4 h-4" />
{configured ? t("messages.unlock") : t("messages.setUp")}
</button>
</Button>
{configured && (
<button onClick={toReset} className="block text-xs text-muted hover:text-fg">
{t("messages.forgotPass")}
+1 -1
View File
@@ -1,7 +1,7 @@
import { useState } from "react";
import { Check, Globe } from "lucide-react";
import { LANGUAGES, type LangCode } from "../i18n";
import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./ui/AnchoredPopover";
// Compact language picker (globe + current code). Presentational: the parent decides what
// changing the language does (set it; persist server-side when signed in).
+1 -1
View File
@@ -1,6 +1,6 @@
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import Modal from "./Modal";
import Modal from "./ui/Modal";
import { api, type DownloadJob } from "../lib/api";
import { jobTitle } from "./downloadRow";
+21 -17
View File
@@ -5,13 +5,14 @@ import { useQuery } from "@tanstack/react-query";
import { ArrowLeft, ShieldCheck, SquarePen, ExternalLink } from "lucide-react";
import { api, type Conversation, type MessageUser } from "../lib/api";
import { useLiveQuery } from "../lib/useLiveQuery";
import { LoadingState, StateMessage } from "./QueryState";
import { Button } from "./ui/Button";
import { LoadingState, StateMessage } from "./ui/QueryState";
import { relativeTime } from "../lib/format";
import { partnerPub, POLL_MS, SYSTEM_ID, useKeyState } from "../lib/messaging";
import { useHistorySubview } from "../lib/history";
import { openChat } from "../lib/chatDock";
import * as e2ee from "../lib/e2ee";
import Avatar from "./Avatar";
import Avatar from "./ui/Avatar";
import KeyGate from "./KeyGate";
import { inputCls } from "./ui/form";
import ChatThread from "./ChatThread";
@@ -123,13 +124,10 @@ function ConversationList({
{t("messages.conversations")}
</div>
{ready && (
<button
onClick={onNew}
className="inline-flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded-lg btn-primary transition"
>
<Button variant="primary" size="xs" onClick={onNew}>
<SquarePen className="w-4 h-4" />
{t("messages.new")}
</button>
</Button>
)}
</div>
{(keyState === "setup" || keyState === "unlock") && <KeyGate meId={meId} />}
@@ -213,12 +211,15 @@ function Directory({ onPick, onBack }: { onPick: (u: MessageUser) => void; onBac
return (
<div className="space-y-3">
<div className="flex items-center gap-2">
<button
<Button
variant="ghost"
size="icon-sm"
// An icon-only button needs a name: both of these announced themselves as a bare "button",
// and they are the only way out of the sub-view (E10 review round 1).
label={t("channel.back")}
onClick={onBack}
className="p-1.5 rounded-lg text-muted hover:text-fg hover:bg-card transition"
>
<ArrowLeft className="w-4 h-4" />
</button>
icon={<ArrowLeft className="w-4 h-4" />}
/>
<div className="text-xs uppercase tracking-wide text-muted">{t("messages.newTitle")}</div>
</div>
<input
@@ -280,12 +281,15 @@ function PageThread({
return (
<div className="flex flex-col h-[65vh] min-h-80">
<div className="flex items-center gap-3 pb-3 border-b border-border">
<button
<Button
variant="ghost"
size="icon-sm"
// An icon-only button needs a name: both of these announced themselves as a bare "button",
// and they are the only way out of the sub-view (E10 review round 1).
label={t("channel.back")}
onClick={onBack}
className="p-1.5 rounded-lg text-muted hover:text-fg hover:bg-card transition"
>
<ArrowLeft className="w-4 h-4" />
</button>
icon={<ArrowLeft className="w-4 h-4" />}
/>
<Avatar
src={seedAvatar}
fallback={name}
+14 -13
View File
@@ -12,10 +12,11 @@ import type { Page } from "../lib/urlState";
import { moduleDef, moduleLabelKey, moduleOrder, SYSTEM_PAGES } from "../lib/modules";
import { useScrollFade } from "../lib/useScrollFade";
import { type LangCode } from "../i18n";
import AvatarImg from "./Avatar";
import Tooltip from "./Tooltip";
import { Button } from "./ui/Button";
import AvatarImg from "./ui/Avatar";
import Tooltip from "./ui/Tooltip";
import LanguageSwitcher from "./LanguageSwitcher";
import { PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
import { PopoverSurface, useAnchoredPopover } from "./ui/AnchoredPopover";
// Primary app navigation: a thin icon-only left rail. The modules used to hide under the avatar
// dropdown; they now live here, each revealing its name in a hover/focus flyout label. Account
@@ -221,22 +222,22 @@ export default function NavSidebar({
<div className="mt-2 pt-2 border-t border-border flex flex-col gap-1">
<div className="flex flex-col items-center justify-center gap-2 pb-2 mb-1 border-b border-border">
<LanguageSwitcher variant="rail" value={language} onChange={onChangeLanguage} />
<button
<Button
variant="ghost"
size="icon"
onClick={onOpenAbout}
title={t("header.account.about")}
aria-label={t("header.account.about")}
className="p-2 rounded-lg text-muted hover:text-fg hover:bg-card transition"
>
<Info className="w-5 h-5" />
</button>
<button
icon={<Info className="w-5 h-5" />}
/>
<Button
variant="ghost"
size="icon"
onClick={logout}
title={t("header.account.signOut")}
aria-label={t("header.account.signOut")}
className="p-2 rounded-lg text-muted hover:text-fg hover:bg-card transition"
>
<LogOut className="w-5 h-5" />
</button>
icon={<LogOut className="w-5 h-5" />}
/>
</div>
<Tooltip hint={t("header.account.settings")} side="right" always openDelay={350}>
<button
@@ -0,0 +1,111 @@
// @vitest-environment jsdom
//
// `setPage`'s no-op contract — the PRODUCER side of the history-burial fix, which nothing rendered
// before this. `setPage` early-returns when the requested page is already current with no channel
// page over it, and reports that by returning `false`; `lib/history.buryUnder` reads the boolean to
// decide whether the overlay entries beneath a navigation are still reclaimable. Get it wrong and
// the round-2 bug is back: Shift+W over the player on Home buries an entry nothing pushed past, and
// the user's next browser Back does nothing.
//
// The type does NOT fence this. `NavigationActions` pins the SHAPE (`=> boolean`), so deleting one
// `return` fails tsc — but round 3 measured that swapping the two values (early return `true`, tail
// `false`) passes typecheck and all 822 tests. Hence assertions on the value AND on the effects the
// early return skips, which is what the boolean actually stands for.
import { describe, it, expect, afterEach, beforeEach, vi } from "vitest";
import { renderHook, act, cleanup } from "@testing-library/react";
import type { ReactNode } from "react";
// Partial: the provider reads `t` AND `i18n.language` (both feed the document-title effect), and the
// import graph pulls in `src/i18n`, which initialises against the real `initReactI18next` — so a
// wholesale mock throws before any test runs.
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return {
...actual,
useTranslation: () => ({ t: (k: string) => k, i18n: { language: "en" } }),
};
});
import { NavigationProvider, useNavigation, useNavigationActions } from "./NavigationProvider";
const wrapper = ({ children }: { children: ReactNode }) => (
<NavigationProvider>{children}</NavigationProvider>
);
const render = () =>
renderHook(() => ({ ...useNavigation(), ...useNavigationActions() }), { wrapper });
beforeEach(() => {
localStorage.clear();
sessionStorage.setItem("siftlode.activeAccount", "1"); // account-scoped page memory
window.history.replaceState({ sfPage: "feed" }, "", "/");
});
// Not automatic: vitest runs without `globals`, so RTL never registers its own afterEach — leaving a
// provider (and its popstate listener) mounted for the next case. Same reason Home.dom.test.tsx does.
afterEach(cleanup);
describe("NavigationProvider · setPage reports whether it navigated", () => {
it("returns true and pushes an entry for a real page change", () => {
const { result } = render();
const push = vi.spyOn(window.history, "pushState");
try {
let reported: boolean | undefined;
act(() => {
reported = result.current.setPage("plex");
});
expect(reported).toBe(true);
expect(push).toHaveBeenCalledTimes(1);
expect(result.current.page).toBe("plex");
} finally {
push.mockRestore();
}
});
it("returns false and pushes nothing when it is already that page", () => {
const { result } = render();
act(() => {
result.current.setPage("plex");
});
const push = vi.spyOn(window.history, "pushState");
try {
let reported: boolean | undefined;
act(() => {
reported = result.current.setPage("plex");
});
// Both halves matter: `false` is what `buryUnder` reads, and "pushed nothing" is what makes
// that answer true. A value-only assertion would pass against an inverted pair.
expect(reported).toBe(false);
expect(push).not.toHaveBeenCalled();
} finally {
push.mockRestore();
}
});
it("returns true even for the current page when a channel page is over it", () => {
// The nav rail's own module while a channel page is open over it is a real navigation: it closes
// the channel page. The `&& !channelViewRef.current` half of the guard is what makes it one.
const { result } = render();
// The page under test must be the one that is ALREADY current, or the guard's first conjunct is
// false and this never reaches the clause it is named for — the mistake round 4 caught here: a
// bare URL with cleared storage lands on "home" (navigationInit's E24 default), not "feed", so
// asking for "feed" was just an ordinary page change and deleting the clause left it green.
const current = result.current.page;
expect(current).toBe("home");
act(() => {
result.current.openChannel("UC1", "A channel");
});
expect(result.current.channelView).not.toBeNull();
const push = vi.spyOn(window.history, "pushState");
try {
let reported: boolean | undefined;
act(() => {
reported = result.current.setPage(current);
});
expect(reported).toBe(true);
expect(push).toHaveBeenCalledTimes(1);
expect(result.current.channelView).toBeNull();
} finally {
push.mockRestore();
}
});
});
@@ -37,7 +37,10 @@ export interface NavigationState {
}
export interface NavigationActions {
setPage: (next: Page) => void;
/** Switch modules. Returns whether it actually navigated — it is a no-op when the requested page
* is already current with no channel page over it, and a caller that buries overlay history
* entries under the push has to know that (see `lib/history.navigateOverOverlays`). */
setPage: (next: Page) => boolean;
openChannel: (id: string, name?: string) => void;
closeChannel: () => void;
enterYtSearch: (q: string) => void;
@@ -50,7 +53,7 @@ const StateContext = createContext<NavigationState>({
ytSearch: null,
});
const ActionsContext = createContext<NavigationActions>({
setPage: () => {},
setPage: () => false,
openChannel: () => {},
closeChannel: () => {},
enterYtSearch: () => {},
@@ -118,7 +121,7 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
(next: Page) => {
// A channel page overlays the content column, so a nav click must close it even when the
// underlying page is unchanged (e.g. "Feed" while a channel opened from the feed).
if (next === pageRef.current && !channelViewRef.current) return;
if (next === pageRef.current && !channelViewRef.current) return false;
markDirty();
setChannelView(null); // leave any open channel page when navigating via the rail
setPageState(next);
@@ -147,6 +150,7 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
}),
);
notifyNav();
return true;
},
[markDirty],
);
+74 -61
View File
@@ -37,9 +37,10 @@ import {
type VideoWatchedMeta,
} from "../lib/notifications";
import { channelYouTubeUrl, relativeFromMs, relativeTime } from "../lib/format";
import { LEVEL_STYLE } from "./Toaster";
import { PageToolbar } from "./PageShell";
import { LoadingState, StateMessage } from "./QueryState";
import { Button } from "./ui/Button";
import { LEVEL_STYLE } from "./ui/Toaster";
import { PageToolbar } from "./ui/PageShell";
import { LoadingState, StateMessage } from "./ui/QueryState";
import { useNavigationActions } from "./NavigationProvider";
import { useChannelsActions } from "./ChannelsProvider";
import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider";
@@ -132,22 +133,24 @@ export default function NotificationsPanel() {
</div>
{hasAny && (
<div className="flex items-center gap-2">
<button
<Button
variant="ghost"
size="xs"
onClick={markAll}
disabled={!hasUnread || readAllMut.isPending}
className="inline-flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded-lg text-muted hover:text-fg hover:bg-card transition disabled:opacity-40"
icon={<CheckCheck className="w-4 h-4" />}
>
<CheckCheck className="w-4 h-4" />
{t("inbox.markAllRead")}
</button>
<button
</Button>
<Button
variant="ghost"
size="xs"
onClick={clearEverything}
disabled={clearMut.isPending}
className="inline-flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded-lg text-muted hover:text-fg hover:bg-card transition disabled:opacity-40"
icon={<Trash2 className="w-4 h-4" />}
>
<Trash2 className="w-4 h-4" />
{t("inbox.clearAll")}
</button>
</Button>
</div>
)}
</div>
@@ -305,43 +308,45 @@ function NotificationRow({
</ul>
)}
{isScheduler && (
<button
<Button
variant="link"
className="text-sm mt-1.5"
onClick={onOpenScheduler}
className="flex items-center gap-1 text-accent text-sm font-semibold hover:underline mt-1.5"
icon={<Activity className="w-3.5 h-3.5" />}
>
<Activity className="w-3.5 h-3.5" />
{t("inbox.openScheduler")}
</button>
</Button>
)}
{isDownloadDone && (
<button
<Button
variant="link"
className="text-sm mt-1.5"
onClick={onOpenDownloads}
className="flex items-center gap-1 text-accent text-sm font-semibold hover:underline mt-1.5"
icon={<Download className="w-3.5 h-3.5" />}
>
<Download className="w-3.5 h-3.5" />
{t("inbox.openDownloads")}
</button>
</Button>
)}
</div>
<div className="flex items-center gap-1 shrink-0">
{!n.read && (
<button
<Button
variant="ghost"
size="icon-sm"
onClick={onRead}
title={t("inbox.markRead")}
aria-label={t("inbox.markRead")}
className="p-1.5 rounded-lg text-muted hover:text-fg hover:bg-card transition"
>
<Check className="w-4 h-4" />
</button>
icon={<Check className="w-4 h-4" />}
/>
)}
<button
<Button
variant="ghost"
size="icon-sm"
onClick={onDismiss}
title={t("inbox.dismiss")}
aria-label={t("inbox.dismiss")}
className="p-1.5 rounded-lg text-muted hover:text-fg hover:bg-card transition"
>
<X className="w-4 h-4" />
</button>
icon={<X className="w-4 h-4" />}
/>
</div>
</div>
);
@@ -396,47 +401,52 @@ function ClientActivityRow({
</div>
{hidden ? (
<div className="flex items-center gap-3 mt-1">
<button
<Button
variant="link"
className="text-sm"
onClick={() => onFind(hidden)}
className="flex items-center gap-1 text-accent text-sm font-semibold hover:underline"
icon={<Search className="w-3.5 h-3.5" />}
>
<Search className="w-3.5 h-3.5" />
{t("notifications.findInFeed")}
</button>
<button
</Button>
<Button
variant="link"
className="text-sm"
onClick={() => onRevert(hidden)}
className="flex items-center gap-1 text-accent text-sm font-semibold hover:underline"
icon={<Eye className="w-3.5 h-3.5" />}
>
<Eye className="w-3.5 h-3.5" />
{t("notifications.unhide")}
</button>
</Button>
</div>
) : watched ? (
<div className="flex items-center gap-3 mt-1">
<button
<Button
variant="link"
className="text-sm"
onClick={() => onFind(watched)}
className="flex items-center gap-1 text-accent text-sm font-semibold hover:underline"
icon={<Search className="w-3.5 h-3.5" />}
>
<Search className="w-3.5 h-3.5" />
{t("notifications.findInFeed")}
</button>
<button
</Button>
<Button
variant="link"
className="text-sm"
onClick={() => onRevert(watched)}
className="flex items-center gap-1 text-accent text-sm font-semibold hover:underline"
icon={<RotateCcw className="w-3.5 h-3.5" />}
>
<RotateCcw className="w-3.5 h-3.5" />
{t("notifications.unwatch")}
</button>
</Button>
</div>
) : subscribed ? (
<div className="flex items-center gap-3 mt-1">
<button
<Button
variant="link"
className="text-sm"
onClick={() => onFocusChannel(subscribed.channelName)}
className="flex items-center gap-1 text-accent text-sm font-semibold hover:underline"
icon={<Tv className="w-3.5 h-3.5" />}
>
<Tv className="w-3.5 h-3.5" />
{t("notifications.openInManager")}
</button>
</Button>
<a
href={channelYouTubeUrl(subscribed.channelId)}
target="_blank"
@@ -448,36 +458,39 @@ function ClientActivityRow({
</a>
</div>
) : access ? (
<button
<Button
variant="link"
className="text-sm mt-1"
onClick={() => onReviewAccess()}
className="flex items-center gap-1 text-accent text-sm font-semibold hover:underline mt-1"
icon={<UserPlus className="w-3.5 h-3.5" />}
>
<UserPlus className="w-3.5 h-3.5" />
{t("common.accessRequestsReview")}
</button>
</Button>
) : (
n.action &&
!n.dismissed && (
<button
<Button
variant="link"
className="mt-1 text-sm"
onClick={() => {
n.action!.onClick();
dismissClient(n.id);
}}
className="mt-1 text-accent text-sm font-semibold hover:underline"
>
{n.action.label}
</button>
</Button>
)
)}
</div>
<button
<Button
variant="ghost"
size="icon-sm"
className="shrink-0"
onClick={onRemove}
title={t("inbox.dismiss")}
aria-label={t("inbox.dismiss")}
className="p-1.5 rounded-lg text-muted hover:text-fg hover:bg-card transition shrink-0"
>
<X className="w-4 h-4" />
</button>
icon={<X className="w-4 h-4" />}
/>
</div>
);
}
+8 -9
View File
@@ -5,7 +5,8 @@ import { qk } from "../lib/queryKeys";
import { AlertTriangle, Check, Loader2, RefreshCw, ShieldCheck, X, Youtube } from "lucide-react";
import { api, type Me } from "../lib/api";
import { beginGrant, endOnboarding } from "../lib/onboarding";
import Overlay from "./Overlay";
import { Button } from "./ui/Button";
import Overlay from "./ui/Overlay";
// A first-login wizard that walks the user through granting YouTube access one scope at a
// time, each with a plain explanation and an up-front heads-up about Google's "unverified
@@ -159,12 +160,9 @@ export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: ()
>
<Youtube className="w-4 h-4" /> {t("onboarding.read.connect")}
</button>
<button
onClick={close}
className="px-5 py-2 rounded-xl text-sm text-muted hover:text-fg transition"
>
<Button variant="ghost" size="md" onClick={close}>
{t("onboarding.read.skip")}
</button>
</Button>
</div>
</>
)}
@@ -225,15 +223,16 @@ export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: ()
>
<ShieldCheck className="w-4 h-4" /> {t("onboarding.write.enableUnsubscribe")}
</button>
<button
<Button
variant="ghost"
size="md"
onClick={() => {
endOnboarding(false);
onClose();
}}
className="px-5 py-2 rounded-xl text-sm text-muted hover:text-fg transition"
>
{t("onboarding.write.keepReadOnly")}
</button>
</Button>
</div>
</>
)}
@@ -17,13 +17,13 @@ import {
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { coercePlexFilters, type FeedFilters, type PlexFilters, type SavedView } from "../lib/api";
import Modal from "./Modal";
import Modal from "./ui/Modal";
import { Button } from "./ui/Button";
import { useSavedViews, asFilters } from "./SavedViewsProvider";
import { useActiveFilters } from "../lib/useActiveFilters";
import { buildPlexActiveFilters } from "./usePlexActiveFilters";
import { useUndoable } from "../lib/useUndoable";
import UndoToolbar from "./UndoToolbar";
import UndoToolbar from "./ui/UndoToolbar";
// Master–detail manager for saved ("personal") views: the left column lists the views (drag to
// reorder) plus a "save the current filters" input; the right column shows the selected view's name
+75 -75
View File
@@ -28,9 +28,9 @@ import {
VolumeX,
X,
} from "lucide-react";
import Avatar from "./Avatar";
import Avatar from "./ui/Avatar";
import AddToPlaylist from "./AddToPlaylist";
import { PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
import { PopoverSurface, useAnchoredPopover } from "./ui/AnchoredPopover";
import { pushLayer, removeLayer, anyLayerAbove, escapeHandled } from "../lib/layerStack";
import DownloadButton from "./DownloadButton";
import { api, type Video, type VideoStatus } from "../lib/api";
@@ -44,7 +44,8 @@ import {
} from "../lib/format";
import { renderDescription } from "../lib/descriptionLinks";
import { fullscreenElement } from "../lib/fullscreen";
import { useBackToClose } from "../lib/history";
import { dispatch, isActivationTarget, useKeyScope } from "../lib/keymap";
import { closeThenNavigate, useBackToClose } from "../lib/history";
import {
useNativeControlsYield,
useYtBigView,
@@ -329,72 +330,76 @@ export default function PlayerModal({
};
}, []);
// Keyboard shortcuts (Esc close / F fullscreen / Space play-pause) + background scroll lock.
useKeyScope("ytPlayer");
// Keyboard shortcuts + background scroll lock. The chords live in the shared keymap (`ytPlayer`
// scope) so the help sheet lists them; only the behaviour behind each id is here.
// These fire only while focus is on our page, not inside the cross-origin player iframe — so
// we focus the modal card on open and again on player-ready (autoplay can grab focus).
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") {
// Defer to any layer opened above the player FIRST (a Modal like the download dialog, or an
// anchored popover): it takes the Escape and we do nothing — so we don't also step out of
// maximise or close the player underneath it. The consumed-event flag is the reliable signal
// (UX-8): the layer's `document` listener runs before ours and its close can pop it off the
// stack synchronously, so `anyLayerAbove` alone would miss it and close the player too.
if (escapeHandled(e)) return;
if (layerIdRef.current != null && anyLayerAbove(layerIdRef.current)) return;
// In fullscreen, let the browser's Esc exit fullscreen only — don't also close the modal.
if (fullscreenElement()) return;
// Same for our own maximise: Esc steps back out of it before it closes the player.
if (maximizedRef.current) {
e.preventDefault();
setMaximized(false);
return;
}
onClose();
// Volume by keyboard (YouTube/Plex-style ±5). The only volume control that survives fullscreen,
// where the wheel goes to YouTube's own iframe. But the modal card scrolls (a long description)
// and Up/Down is how you scroll it from the keyboard — so defer whenever the focused element can
// still scroll that way, exactly like Space defers to a focused button. NOT while the video fills
// the screen though — in fullscreen OR in our own maximise — because the card is behind/off-screen
// there: deferring would scroll something invisible and leave the viewer with no volume control at
// all. Both flags, not just fullscreen: since F stopped taking the browser's lock, maximise is the
// common case and checking only `isFullscreen` made the keys dead exactly where they matter most.
const volume = (e: KeyboardEvent, up: boolean) => {
const covered = isFullscreenRef.current || maximizedRef.current;
if (!covered && canScrollBy(document.activeElement as HTMLElement | null, up ? -1 : 1))
return;
}
const el = document.activeElement as HTMLElement | null;
const tag = (el?.tagName || "").toLowerCase();
const typing =
tag === "input" || tag === "textarea" || tag === "select" || !!el?.isContentEditable;
if (e.key === "f" || e.key === "F") {
if (typing) return;
e.preventDefault();
toggleFullscreen();
} else if (e.key === " " || e.code === "Space") {
// Let Space activate a focused button/link; otherwise toggle playback (and stop the
// page from scrolling).
if (typing || tag === "button" || tag === "a") return;
e.preventDefault();
togglePlay();
} else if (e.key === "ArrowUp" || e.key === "ArrowDown") {
// Volume by keyboard (YouTube/Plex-style ±5). The only volume control that survives
// fullscreen, where the wheel goes to YouTube's own iframe. But the modal card scrolls
// (a long description) and Up/Down is how you scroll it from the keyboard — so defer
// whenever the focused element can still scroll that way, exactly like Space defers to a
// focused button. NOT while the video fills the screen though — in fullscreen OR in our own
// maximise — because the card is behind/off-screen there: deferring would scroll something
// invisible and leave the viewer with no volume control at all. Both flags, not just
// fullscreen: since F stopped taking the browser's lock, maximise is the common case and
// checking only `isFullscreen` made the keys dead exactly where they matter most.
const up = e.key === "ArrowUp";
const covered = isFullscreenRef.current || maximizedRef.current;
if (typing || (!covered && canScrollBy(el, up ? -1 : 1))) return;
e.preventDefault();
nudgeVolume(up ? 5 : -5);
} else if (e.key === "ArrowLeft" || e.key === "ArrowRight") {
// Plain arrows seek within the video (YouTube-style ±5s); Shift+arrow steps to the
// previous/next video in the queue (the feed's order or a playlist).
if (typing) return;
e.preventDefault();
const forward = e.key === "ArrowRight";
if (e.shiftKey) {
if (forward) goNext();
else goPrev();
} else {
nudgeSeek(forward ? 5 : -5);
}
}
e.preventDefault();
nudgeVolume(up ? 5 : -5);
};
const seek = (e: KeyboardEvent, secs: number) => {
e.preventDefault();
nudgeSeek(secs);
};
const onKey = (e: KeyboardEvent) => {
dispatch(e, "ytPlayer", {
close: (ev) => {
// Defer to any layer opened above the player FIRST (a Modal like the download dialog, or an
// anchored popover): it takes the Escape and we do nothing — so we don't also step out of
// maximise or close the player underneath it. The consumed-event flag is the reliable signal
// (UX-8): the layer's `document` listener runs before ours and its close can pop it off the
// stack synchronously, so `anyLayerAbove` alone would miss it and close the player too.
if (escapeHandled(ev)) return;
if (layerIdRef.current != null && anyLayerAbove(layerIdRef.current)) return;
// In fullscreen, let the browser's Esc exit fullscreen only — don't also close the modal.
if (fullscreenElement()) return;
// Same for our own maximise: Esc steps back out of it before it closes the player.
if (maximizedRef.current) {
ev.preventDefault();
setMaximized(false);
return;
}
onClose();
},
fullscreen: (ev) => {
ev.preventDefault();
toggleFullscreen();
},
playPause: (ev) => {
// Let Space activate a focused button/link; otherwise toggle playback (and stop the
// page from scrolling).
if (isActivationTarget(ev)) return;
ev.preventDefault();
togglePlay();
},
volumeUp: (ev) => volume(ev, true),
volumeDown: (ev) => volume(ev, false),
seekBack: (ev) => seek(ev, -5),
seekFwd: (ev) => seek(ev, 5),
prevVideo: (ev) => {
ev.preventDefault();
goPrev();
},
nextVideo: (ev) => {
ev.preventDefault();
goNext();
},
});
};
window.addEventListener("keydown", onKey);
focusModal();
@@ -751,16 +756,11 @@ export default function PlayerModal({
onClick={() => {
const cid = active.channel_id;
const cname = active.channel_title ?? undefined;
// Closing the player pops its own history entry (useBackToClose → history.back()).
// Open the channel only AFTER that pop's popstate — pushing the channel entry
// before it would let the back remove it again (the bug: clicking the name only
// closed the player). One-shot listener fires after the popstate handler.
const open = () => {
window.removeEventListener("popstate", open);
openChannel(cid, cname);
};
window.addEventListener("popstate", open);
onClose();
// Close first, open the channel only once this player's own Back-to-close
// entry has been reclaimed — otherwise that reclaim undoes the push and
// clicking the name merely closes the player. The rule lives in history.ts
// now; the command palette needs exactly the same one.
closeThenNavigate(onClose, () => openChannel(cid, cname));
}}
className="font-medium hover:text-accent truncate text-left"
>
+184 -70
View File
@@ -21,8 +21,13 @@ import {
import { CSS } from "@dnd-kit/utilities";
import {
AlertTriangle,
ArrowDown,
ArrowDownToLine,
ArrowUp,
ArrowUpToLine,
Check,
ExternalLink,
FolderInput,
GripVertical,
ListPlus,
Pencil,
@@ -31,9 +36,10 @@ import {
RotateCcw,
Trash2,
Upload,
X,
Youtube,
} from "lucide-react";
import { api, HttpError, type Video } from "../lib/api";
import { api, HttpError, type Playlist, type Video } from "../lib/api";
import { notify } from "../lib/notifications";
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
import { playlistName } from "../lib/playlistName";
@@ -42,10 +48,16 @@ import { cycleSort, type SortState } from "../lib/columnSort";
import { formatRuntime } from "../lib/format";
import { applyView, sameOrder } from "../lib/playlistView";
const PlayerModal = lazy(() => import("./PlayerModal"));
import UndoToolbar from "./UndoToolbar";
import { PageToolbar } from "./PageShell";
import { useChoice, useConfirm } from "./ConfirmProvider";
import { usePlaylists, usePlaylistsActions } from "./PlaylistsProvider";
import { Button } from "./ui/Button";
import { type ActionMenuItem } from "./ui/ActionMenu";
import { useComfortPin, pinVideo } from "./comfortPin";
import { ContextMenu } from "./ui/ContextMenu";
import { useContextMenu } from "./ui/useContextMenu";
import UndoToolbar from "./ui/UndoToolbar";
import { PageToolbar } from "./ui/PageShell";
import { useConfirm } from "./ConfirmProvider";
import { usePlaylistActions } from "./usePlaylistActions";
import { usePlaylists } from "./PlaylistsProvider";
import { useMe } from "../lib/useMe";
import {
ColumnHead,
@@ -54,7 +66,7 @@ import {
isActive,
type Column,
type FilterMap,
} from "./tableHeader";
} from "./ui/tableHeader";
import { GRIP_KEY, playlistColumns } from "./playlistColumns";
// The playlist detail is a HYBRID table (E4 S4): real sortable/filterable column headers over a
@@ -87,11 +99,16 @@ function Row({
columns,
dragDisabled,
dragHint,
menuProps,
}: {
video: Video;
columns: Column<Video>[];
dragDisabled: boolean;
dragHint: string;
/** useContextMenu's trigger props. On the WHOLE row, not a handle: a right-click anywhere in the
* row opens the menu, the way a native list does. The row's own buttons keep their left-click
* behaviour — a right-click just isn't a button press. */
menuProps: Record<string, unknown>;
}) {
const { t } = useTranslation();
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
@@ -111,6 +128,7 @@ function Row({
// transition-COLORS, not `transition` — the bare utility animates transform too, which is
// the same lag by another route (150ms behind the pointer, on every row).
className="border-b border-border/50 hover:bg-card/40 transition-colors"
{...menuProps}
>
{columns.map((col) => (
<td
@@ -152,11 +170,10 @@ export default function Playlists() {
// The selected playlist lives in PlaylistsProvider, shared with the PlaylistsRail (which owns the
// list + its rail controls). This detail pane reacts to the selection.
const { selectedId } = usePlaylists();
const { setSelectedId } = usePlaylistsActions();
const { t } = useTranslation();
const qc = useQueryClient();
const confirm = useConfirm();
const choose = useChoice();
const plActions = usePlaylistActions();
const [renaming, setRenaming] = useState(false);
const [renameValue, setRenameValue] = useState("");
// The stored order is undoable: drag and "save this order" both go through `order.set`, so each
@@ -201,6 +218,12 @@ export default function Playlists() {
// a change of view would otherwise slide a different video under the index — or unmount the player
// mid-playback. PlayerModal already tracks its own queue by id; this is the parent half of that.
const [playingId, setPlayingId] = useState<string | null>(null);
// ONE context menu for the whole table, not one popover per <tr> (see ui/ContextMenu): the row it
// belongs to is `rowMenu.target`, and the panel anchors to the pointer.
const rowMenu = useContextMenu<Video>();
// The comfort-shelf pin composes into the row's existing menu (E24 S3) rather than mounting a
// second one on the same <tr>.
const comfortPin = useComfortPin(rowMenu.open);
const listQuery = useQuery({
queryKey: qk.playlists(),
@@ -482,58 +505,65 @@ export default function Playlists() {
const removeRef = useRef(removeItem);
removeRef.current = removeItem;
async function moveItemTo(video: Video, target: Playlist) {
if (selectedId == null) return;
// Optimistic, exactly like removeItem: from THIS list's point of view a move is a removal, and
// rebasing keeps the undo stack valid for what's left instead of throwing it away.
const next = items.filter((v) => v.id !== video.id);
order.rebase((list) => list.filter((v) => v.id !== video.id));
lastSetRef.current = `${selectedId}:${idsKey(next)}`;
try {
// ONE request. The two-call version (add there, remove here) leaves the video in BOTH lists
// whenever the second call fails — see the endpoint's docstring.
await api.moveToPlaylist(selectedId, video.id, target.id);
notify({
level: "success",
message: t("playlists.movedToast", { title: video.title, name: plName(target) }),
});
} catch (e) {
// NOT a silent revert. `req` deliberately leaves 401/403/404 to the caller as control flow, and
// every move failure a user can actually reach is a 404 (the 400 `same_playlist` case is already
// impossible — the submenu never offers the current list). So the row used to snap back with no
// explanation and the shipped moveErrors strings were unreachable (E10 review round 1).
// ONLY the statuses `req` deliberately leaves to the caller. It already opens the blocking
// error dialog for 400/409/422 and 5xx and raises the connectivity notice for a bad gateway,
// so toasting unconditionally reported a transport fault twice (E10 review round 2).
if (e instanceof HttpError && (e.status === 404 || e.status === 403))
notify({ level: "error", message: e.detail || t("playlists.moveFailed") });
}
// BOTH sides changed, and so did this video's membership. `qk.playlist(selectedId)` is an exact
// key, so it cannot cover the target — invalidate the whole `["playlist"]` prefix, exactly as
// AddToPlaylist does for the same reason.
qc.invalidateQueries({ queryKey: qk.playlists() });
qc.invalidateQueries({ queryKey: qk.playlist() });
qc.invalidateQueries({ queryKey: qk.playlistMembership(video.id) });
}
// Keyboard/menu reordering, for the same stored order the drag handle writes — so it goes through
// `order.set` (undoable + persisted), not a direct API call. Gated by the same rule as the drag
// handle: a view (sort/group/filter) is not the stored order, so moving "up" inside one would
// write something the user never saw.
function moveInOrder(video: Video, to: "top" | "up" | "down" | "bottom") {
const from = items.findIndex((v) => v.id === video.id);
if (from < 0) return;
const target =
to === "top" ? 0 : to === "bottom" ? items.length - 1 : to === "up" ? from - 1 : from + 1;
if (target === from || target < 0 || target >= items.length) return;
order.set(arrayMove(items, from, target)); // onApply persists
}
async function saveRename() {
if (selectedId == null) return;
const name = renameValue.trim();
setRenaming(false);
if (name && name !== detail?.name) {
try {
await api.renamePlaylist(selectedId, name);
} catch (e) {
notifyYouTubeActionError(e, t("playlists.renameFailed"));
}
refreshAll();
}
if (name && name !== detail?.name) await plActions.rename(selectedId, name);
}
async function deletePlaylist() {
if (selectedId == null || !detail) return;
// ONE dialog with every outcome. It used to be two chained confirms, where the second one's
// Cancel/Escape/backdrop meant "delete here only" — dismissing the dialog deleted the playlist.
const offerYoutube = linked && canWrite;
const picked = await choose({
title: t("playlists.delete"),
message: offerYoutube
? t("playlists.confirmDeleteLinked", { name: plName(detail) })
: t("playlists.confirmDelete", { name: plName(detail) }),
choices: offerYoutube
? [
{ id: "here", label: t("playlists.deleteHereOnly"), danger: true },
{ id: "both", label: t("playlists.deleteBoth"), danger: true },
]
: [{ id: "here", label: t("playlists.delete"), danger: true }],
});
if (picked == null) return;
const onYoutube = picked === "both";
const deletedId = selectedId;
// Pick the next selection from what's left *now* (don't go via null, or the
// auto-select effect would re-pick the just-deleted id from the stale list).
const remaining = playlists.filter((p) => p.id !== deletedId);
setSelectedId(remaining[0]?.id ?? null);
try {
await api.deletePlaylist(deletedId, onYoutube);
} catch (err) {
// Prefer the server's specific reason (e.g. a local vs YouTube-side failure) over a blanket
// "couldn't delete on YouTube" that misleads when the failure wasn't the YouTube side.
notify({
level: "warning",
message:
(err instanceof HttpError && err.detail) ||
(onYoutube ? t("playlists.deleteYtFailed") : t("playlists.deleteFailed")),
});
}
qc.removeQueries({ queryKey: qk.playlist(deletedId) });
qc.invalidateQueries({ queryKey: qk.playlists() });
if (!detail) return;
// The dialog, the "delete on YouTube too?" branch and the re-selection live in
// usePlaylistActions — the rail's context menu deletes through the SAME code.
await plActions.remove(detail);
}
function playerState(id: string, status: string) {
@@ -573,6 +603,86 @@ export default function Playlists() {
: t("playlists.dragBlockedSort");
const playingIndex = playingId ? shown.findIndex((v) => v.id === playingId) : -1;
// The row menu, built from the row the user right-clicked. Same item shape (and therefore same
// rows, same paint, same keyboard model) as any ⋯ ActionMenu in the app.
const moveTargets = playlists.filter((p) => p.id !== selectedId);
const storedOrderEditable = canEdit && !viewActive;
const ctxRow = rowMenu.target;
const ctxItems: ActionMenuItem[] = !ctxRow
? []
: [
{
id: "play",
label: t("card.play"),
icon: <Play className="w-4 h-4" />,
onSelect: () => setPlayingId(ctxRow.id),
},
...comfortPin.item(pinVideo(ctxRow.id), ctxRow.title ?? ""),
...(canEdit
? ([
moveTargets.length
? {
id: "move",
label: t("playlists.moveTo"),
icon: <FolderInput className="w-4 h-4" />,
separatorBefore: true,
items: moveTargets.map((pl) => ({
id: `move-${pl.id}`,
label: plName(pl),
icon:
pl.source === "youtube" || pl.yt_playlist_id ? (
<Youtube className="w-4 h-4" />
) : (
<ListPlus className="w-4 h-4" />
),
onSelect: () => void moveItemTo(ctxRow, pl),
})),
}
: // Kept visible and disabled rather than dropped: an action that vanishes teaches
// nobody why. KNOWN GAP: the `title` that carries the why currently reaches nobody
// either — see ActionMenu's `title` prop, where the two failed attempts at fixing
// that are recorded. The row is honest about being off; it is not yet honest about
// the reason.
{
id: "move",
label: t("playlists.moveTo"),
icon: <FolderInput className="w-4 h-4" />,
separatorBefore: true,
disabled: true,
title: t("playlists.moveNoTargets"),
onSelect: () => {},
},
...(
[
["top", t("playlists.orderTop"), <ArrowUpToLine className="w-4 h-4" />],
["up", t("playlists.orderUp"), <ArrowUp className="w-4 h-4" />],
["down", t("playlists.orderDown"), <ArrowDown className="w-4 h-4" />],
["bottom", t("playlists.orderBottom"), <ArrowDownToLine className="w-4 h-4" />],
] as const
).map(([to, label, icon], i) => ({
id: `order-${to}`,
label,
icon,
separatorBefore: i === 0,
// Same gate as the drag handle. NOTE the drag handle explains itself and this row
// does not: the handle is `aria-disabled` (focusable, tooltip-capable) while a menu
// row cannot be — see ActionMenu's `title` prop for why.
disabled: !storedOrderEditable,
title: storedOrderEditable ? undefined : dragHint,
onSelect: () => moveInOrder(ctxRow, to),
})),
{
id: "remove",
label: t("playlists.removeItem"),
icon: <X className="w-4 h-4" />,
separatorBefore: true,
danger: true,
onSelect: () => void removeItem(ctxRow),
},
] satisfies ActionMenuItem[])
: []),
];
if (selectedId == null) {
return <div className="text-muted text-sm p-4">{t("playlists.pickOne")}</div>;
}
@@ -585,12 +695,9 @@ export default function Playlists() {
<AlertTriangle className="w-4 h-4 text-danger" />
{t("playlists.loadFailed")}
</span>
<button
onClick={() => detailQuery.refetch()}
className="px-3 py-1.5 rounded-lg border border-border text-xs text-muted hover:text-fg hover:border-accent transition"
>
<Button variant="outline" size="xs" onClick={() => detailQuery.refetch()}>
{t("common.retry")}
</button>
</Button>
</div>
);
}
@@ -703,25 +810,26 @@ export default function Playlists() {
</button>
)}
{linked && detail.dirty && (
<button
<Button
variant="outline"
size="icon-sm"
onClick={revertToYoutube}
disabled={reverting}
title={t("playlists.revert")}
aria-label={t("playlists.revert")}
className="inline-flex items-center justify-center p-2 rounded-lg border border-border text-muted hover:text-fg hover:border-accent transition disabled:opacity-40"
>
<RotateCcw className={`w-3.5 h-3.5 ${reverting ? "animate-spin" : ""}`} />
</button>
</Button>
)}
{editable && (
<button
<Button
variant="outline-danger"
size="icon-sm"
onClick={deletePlaylist}
title={t("playlists.delete")}
aria-label={t("playlists.delete")}
className="inline-flex items-center justify-center p-2 rounded-lg border border-border text-muted hover:text-danger hover:border-danger/50 transition"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
icon={<Trash2 className="w-3.5 h-3.5" />}
/>
)}
</div>
</div>
@@ -755,12 +863,14 @@ export default function Playlists() {
</button>
)}
{canSaveOrder && (
<button
<Button
variant="outline-accent"
size="xs"
onClick={saveOrder}
className="inline-flex items-center gap-1.5 text-xs font-semibold px-2.5 py-1 rounded-lg border border-accent text-accent hover:bg-accent/10 transition"
icon={<Check className="w-3.5 h-3.5" />}
>
<Check className="w-3.5 h-3.5" /> {t("playlists.saveOrder")}
</button>
{t("playlists.saveOrder")}
</Button>
)}
{/* ONE sentence that names the state. Three loose texts used to pile up here
(reset link + save hint + "reordering is off"), and none of them answered the
@@ -829,6 +939,7 @@ export default function Playlists() {
columns={columns}
dragDisabled={!canEdit || viewActive}
dragHint={dragHint}
menuProps={rowMenu.triggerProps(v)}
/>
))}
</tbody>
@@ -843,6 +954,9 @@ export default function Playlists() {
</DndContext>
)}
{/* One surface for the whole table, outside the rows — it anchors to the pointer, not the row. */}
<ContextMenu menu={rowMenu} ariaLabel={t("playlists.rowMenu")} items={ctxItems} />
{playingIndex >= 0 && (
<Suspense fallback={null}>
<PlayerModal
+170 -40
View File
@@ -2,7 +2,7 @@ import { useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys";
import { ListVideo, Pin, Plus, RefreshCw, Youtube } from "lucide-react";
import { ListVideo, Pencil, Pin, Plus, RefreshCw, Trash2, Youtube } from "lucide-react";
import { api, type Playlist } from "../lib/api";
import { formatRuntime } from "../lib/format";
import { notify } from "../lib/notifications";
@@ -10,20 +10,30 @@ import { notifyYouTubeActionError } from "../lib/youtubeErrors";
import { LS, readAccountMerged, writeAccount } from "../lib/storage";
import { type PanelLayout } from "../lib/panelLayout";
import FilterRail from "./FilterRail";
import { type PanelItem } from "./PanelGroups";
import SortControl from "./SortControl";
import { Button } from "./ui/Button";
import { type ActionMenuItem } from "./ui/ActionMenu";
import { useComfortPin, pinPlaylist } from "./comfortPin";
import { ContextMenu } from "./ui/ContextMenu";
import { useContextMenu } from "./ui/useContextMenu";
import { type PanelItem } from "./ui/PanelGroups";
import SortControl from "./ui/SortControl";
import { type SortOption } from "../lib/columnSort";
import { usePlaylists, usePlaylistsActions } from "./PlaylistsProvider";
import { useMe } from "../lib/useMe";
import { playlistName } from "../lib/playlistName";
import { usePlaylistActions } from "./usePlaylistActions";
type PlSort = {
key: "custom" | "name" | "count" | "duration";
key: "custom" | "name" | "count" | "duration" | "recent";
dir: "asc" | "desc";
dirtyFirst: boolean;
};
const PL_SORT_DEFAULT: PlSort = { key: "custom", dir: "asc", dirtyFirst: false };
// One geometry for the row in both of its shapes (the selectable button, and the rename field it
// becomes) — the row must not resize the moment you rename it.
const ROW_BASE = "w-full flex items-center gap-2.5 p-2 rounded-lg border transition";
// The Playlists module's left rail — lifted to App level (like the filter rails) so it floats in the
// shared SidePanel with the same collapse-to-tab behavior. It owns the list query + its own rail
// controls (sort / new / sync); the selected playlist is App state so the module's detail pane
@@ -52,12 +62,26 @@ export default function PlaylistsRail({
{ key: "name", label: t("playlists.railSortName"), defaultDir: "asc" },
{ key: "count", label: t("playlists.railSortCount"), defaultDir: "desc" },
{ key: "duration", label: t("playlists.railSortDuration"), defaultDir: "desc" },
// E10 S2 — "the one I just touched, first". Reads `updated_at`, which the API bumps for item
// adds/removes/moves/reorders too, not just renames (see the backend's _touch). NOT the
// default: a rail that reshuffles itself while you look at it costs more than it saves.
{ key: "recent", label: t("playlists.railSortRecent"), defaultDir: "desc" },
];
useEffect(() => {
writeAccount(LS.plSort, plSort);
}, [plSort]);
const [newName, setNewName] = useState("");
const [syncing, setSyncing] = useState(false);
const plActions = usePlaylistActions();
// Right-click / long-press anywhere on a rail row. ONE menu for the whole list (see ui/ContextMenu).
const railMenu = useContextMenu<Playlist>();
// The comfort-shelf pin composes INTO this menu rather than mounting a second one: a row can only
// have one right-click menu, and rename/delete/pin are all things you do to the same playlist.
const comfortPin = useComfortPin(railMenu.open);
// Renaming happens IN the row that was right-clicked, not in the detail header — the point of the
// context menu is that the action lands where the gesture did.
const [renamingId, setRenamingId] = useState<number | null>(null);
const [renameValue, setRenameValue] = useState("");
const selectedRef = useRef<HTMLButtonElement | null>(null);
const scrolledRef = useRef(false);
@@ -95,6 +119,8 @@ export default function PlaylistsRail({
return sign * (a.item_count - b.item_count);
case "duration":
return sign * ((a.total_duration_seconds ?? 0) - (b.total_duration_seconds ?? 0));
case "recent":
return sign * (Date.parse(a.updated_at) - Date.parse(b.updated_at));
default:
return 0;
}
@@ -141,20 +167,94 @@ export default function PlaylistsRail({
},
});
// KNOWN GAP: Enter and Escape unmount the focused <input> and re-render the row as a <button> that
// nothing focuses, so a keyboard user is dropped on <body> and loses their place in the rail (E10
// review round 1). A refocus-the-row fix was written in round 1 and REMOVED in round 3 because a
// real-browser check showed it never worked: Escape's handler does run (typing then Escape leaves
// the name unchanged, i.e. it cancels rather than blur-commits) and the row button is focusable
// when focused from outside — yet activeElement is still BODY afterwards. Three explanations were
// tried and none survived contact, so the machinery went rather than a fourth guess. Whoever picks
// this up: instrument the effect in a real browser first, do not reason about the commit order.
const renameInputRef = useRef<HTMLInputElement | null>(null);
// UNRESOLVED — do not "simplify" this back to `autoFocus` without reading the whole story.
//
// Opening this field from the context menu is a focus race: `MenuItemList` runs `onSelect()` and
// `onClose()` in one batch, so the field mounts in the same commit that unmounts the popover, and
// floating-ui's FloatingFocusManager hands focus back from a `queueMicrotask` queued in its
// layout-effect cleanup. If that microtask wins, the field is blurred straight back out and its
// own onBlur commits and closes it — "the rename field flashes and vanishes" (E10 review round 1).
//
// Three answers have been tried and none is VERIFIED. Round 1 turned floating-ui's returnFocus off
// platform-wide and broke two other surfaces (a non-focusable <tr> trigger; a scroll-dismiss that
// scrolled the list back). Round 2 replaced it with this effect, on the reasoning that a passive
// effect is scheduled after the microtask. Round 3 disputed that (React flushes passive effects
// synchronously at the end of a SyncLane commit, which would put the microtask LAST) — and a jsdom
// harness built to settle it reproduced no failure for ANY of autoFocus / this effect / a
// microtask, i.e. jsdom does not exercise the race, because focus never enters the menu there.
//
// So: this is the shape that is deliberate rather than accidental, and the question is open. It
// needs ONE real-browser check (open the rail's context menu, pick Rename, see whether the field
// survives) — not a fourth guess. Logged in the E10 plan.
useEffect(() => {
if (renamingId !== null) renameInputRef.current?.focus();
}, [renamingId]);
async function commitRename(pl: Playlist) {
const name = renameValue.trim();
setRenamingId(null);
if (name && name !== pl.name) await plActions.rename(pl.id, name);
}
const ctxPl = railMenu.target;
// The built-in Watch later list is renameable/deletable nowhere else in the UI either; the rows
// stay visible and disabled so the menu doesn't change shape from list to list.
const ctxBuiltin = ctxPl?.kind === "watch_later";
// Interpolated, not spelled out: the HU copy said "Watch later", a name the Hungarian UI never
// shows for this list ("Megnézem később") — so the message named a list the reader had never seen
// (E10 review round 1). Passing the localized name makes the two impossible to drift.
const lockedTitle = ctxBuiltin
? t("playlists.builtinLocked", { name: t("playlists.watchLater") })
: undefined;
const railItems: ActionMenuItem[] = !ctxPl
? []
: [
{
id: "rename",
label: t("playlists.rename"),
icon: <Pencil className="w-4 h-4" />,
disabled: ctxBuiltin,
title: lockedTitle,
onSelect: () => {
setRenameValue(ctxPl.name);
setRenamingId(ctxPl.id);
},
},
...comfortPin.item(pinPlaylist(ctxPl.id), plName(ctxPl)),
{
id: "delete",
label: t("playlists.delete"),
icon: <Trash2 className="w-4 h-4" />,
separatorBefore: true,
danger: true,
disabled: ctxBuiltin,
title: lockedTitle,
// The same dialog (and the same "what gets selected next" rule) as the detail header's
// trash button — one implementation, two entry points.
onSelect: () => void plActions.remove(ctxPl),
},
];
const items: PanelItem[] = [
{
id: "manage",
title: t("playlists.manageGroup"),
body: (
<div className="flex flex-col gap-2">
<button
onClick={syncYoutube}
disabled={syncing}
className="w-full inline-flex items-center justify-center gap-1.5 rounded-lg border border-border px-2.5 py-1.5 text-xs text-muted hover:border-accent hover:text-accent disabled:opacity-40 transition"
>
<Button variant="outline" size="xs" fullWidth onClick={syncYoutube} disabled={syncing}>
<RefreshCw className={`w-3.5 h-3.5 ${syncing ? "animate-spin" : ""}`} />
{t("playlists.syncYoutube")}
</button>
</Button>
{playlists.length > 1 && (
<SortControl
label={t("playlists.sortLabel")}
@@ -219,40 +319,70 @@ export default function PlaylistsRail({
<div className="text-xs text-muted px-1 py-1">{t("playlists.noMatches")}</div>
)}
<div className="space-y-1">
{visiblePlaylists.map((pl) => (
<button
key={pl.id}
ref={pl.id === selectedId ? selectedRef : null}
onClick={() => setSelectedId(pl.id)}
className={`w-full flex items-center gap-2.5 p-2 rounded-lg border transition text-left ${
pl.id === selectedId
? "bg-accent/15 border-accent"
: "border-transparent hover:bg-card/60"
}`}
>
<div className="w-[34px] h-[34px] rounded-md bg-card overflow-hidden shrink-0">
{pl.cover_thumbnail && (
<img src={pl.cover_thumbnail} alt="" className="w-full h-full object-cover" />
)}
</div>
<div className="min-w-0">
<div className="flex items-center gap-1 text-[0.8125rem] text-fg">
<span className="truncate">{plName(pl)}</span>
{(pl.source === "youtube" || pl.yt_playlist_id) && (
<Youtube
className={`w-3 h-3 shrink-0 ${pl.dirty ? "text-accent" : "text-muted"}`}
/>
{visiblePlaylists.map((pl) =>
renamingId === pl.id ? (
// The row becomes its own rename field. A <button> can't contain an <input>, so the
// whole row swaps rather than the label alone — but it KEEPS the cover, because
// that is what holds the row at its two-line height. Without it the rail jumps by
// ~12px the moment you start renaming, and every row below it moves under the
// pointer.
<div key={pl.id} className={`${ROW_BASE} bg-accent/10 border-accent`}>
<div className="w-[34px] h-[34px] rounded-md bg-card overflow-hidden shrink-0">
{pl.cover_thumbnail && (
<img src={pl.cover_thumbnail} alt="" className="w-full h-full object-cover" />
)}
</div>
<div className="text-2xs text-muted">
{t("playlists.itemCount", { count: pl.item_count })}
{pl.total_duration_seconds > 0 &&
` · ${formatRuntime(pl.total_duration_seconds)}`}
</div>
<input
ref={renameInputRef}
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onBlur={() => void commitRename(pl)}
onKeyDown={(e) => {
if (e.key === "Enter") void commitRename(pl);
// Unmounting the input fires no blur in React, so Escape really does cancel.
if (e.key === "Escape") setRenamingId(null);
}}
aria-label={t("playlists.rename")}
className="flex-1 min-w-0 bg-surface border border-border rounded-md px-2 py-1 text-xs focus:outline-hidden focus:ring-2 focus:ring-accent/40"
/>
</div>
</button>
))}
) : (
<button
key={pl.id}
ref={pl.id === selectedId ? selectedRef : null}
onClick={() => setSelectedId(pl.id)}
{...railMenu.triggerProps(pl)}
className={`${ROW_BASE} text-left ${
pl.id === selectedId
? "bg-accent/15 border-accent"
: "border-transparent hover:bg-card/60"
}`}
>
<div className="w-[34px] h-[34px] rounded-md bg-card overflow-hidden shrink-0">
{pl.cover_thumbnail && (
<img src={pl.cover_thumbnail} alt="" className="w-full h-full object-cover" />
)}
</div>
<div className="min-w-0">
<div className="flex items-center gap-1 text-[0.8125rem] text-fg">
<span className="truncate">{plName(pl)}</span>
{(pl.source === "youtube" || pl.yt_playlist_id) && (
<Youtube
className={`w-3 h-3 shrink-0 ${pl.dirty ? "text-accent" : "text-muted"}`}
/>
)}
</div>
<div className="text-2xs text-muted">
{t("playlists.itemCount", { count: pl.item_count })}
{pl.total_duration_seconds > 0 &&
` · ${formatRuntime(pl.total_duration_seconds)}`}
</div>
</div>
</button>
),
)}
</div>
<ContextMenu menu={railMenu} ariaLabel={t("playlists.railMenu")} items={railItems} />
</>
),
},
+49 -30
View File
@@ -25,20 +25,23 @@ import { useDebounced } from "../lib/useDebounced";
import { useHistorySubview } from "../lib/history";
import { PLEX_SUB_URL, type Sub } from "./plexSubUrl";
import { toggleWatchState } from "../lib/plexWatch";
import SortControl from "./SortControl";
import FilterSelect from "./FilterSelect";
import SortControl from "./ui/SortControl";
import FilterSelect from "./ui/FilterSelect";
import PlexDiscover from "./PlexDiscover";
import { PlexCollectionsBrowse, PlexPlaylistsBrowse } from "./plexBrowseLists";
import { PageToolbar } from "./PageShell";
import { PageToolbar } from "./ui/PageShell";
import ActiveFilterChips from "./ActiveFilterChips";
import SelectedViewChip from "./SelectedViewChip";
import ItemCount from "./ItemCount";
import { StateMessage } from "./QueryState";
import ItemCount from "./ui/ItemCount";
import { StateMessage } from "./ui/QueryState";
import { usePlexActiveFilters, usePlexClearFilters } from "./usePlexActiveFilters";
import { usePlex, usePlexActions } from "./PlexProvider";
import { usePublishPlexView } from "./PlexViewBridge";
import { EpisodeCard, PlexPosterCard } from "./plexCards";
import { PlexPinMenu } from "./ComfortPinMenu";
import { useContextMenu } from "./ui/useContextMenu";
import { PlexInfoView, PlexPersonView, PlexSeasonView, PlexShowView } from "./plexViews";
import { dispatch, useKeyScope } from "../lib/keymap";
// Lazy: the rich player (pulls in hls.js) loads only when something is first played.
const PlexPlayer = lazy(() => import("./PlexPlayer"));
@@ -64,6 +67,20 @@ const LASTWATCHED_SORT: NonNullable<SortState> = Object.freeze({
// I looking at" control now, like the feed's Watch-state pill, not a rail widget).
const SHOW_OPTS = ["all", "unwatched", "in_progress", "watched"] as const;
// The drill-down views Backspace can step out of. "player" is deliberately absent: the player owns
// its own Backspace (stop & back to the grid), and this handler must not fire underneath it.
const BACKABLE_VIEWS: readonly string[] = [
"grid",
"playlists",
"collections",
"show",
"season",
"info",
"person",
"playlist",
"collection",
];
export default function PlexBrowse() {
const { t } = useTranslation();
const qc = useQueryClient();
@@ -119,7 +136,16 @@ export default function PlexBrowse() {
if (jumpTo != null) {
if (jumpTo.type !== "show") infoScrollRef.current = 0; // a jumped-to info page starts at the top
sub.open(
jumpTo.type === "show" ? { kind: "show", id: jumpTo.id } : { kind: "info", id: jumpTo.id },
jumpTo.type === "show"
? { kind: "show", id: jumpTo.id }
: jumpTo.type === "play"
? { kind: "player", id: jumpTo.id }
: { kind: "info", id: jumpTo.id },
// A cross-module hand-off continues the navigation that brought us here rather than adding
// to it. It also makes this effect idempotent, which matters more than it looks: under
// StrictMode the effect body runs twice before `setJumpTo(null)` re-renders, and two pushes
// of the SAME sub-view left a duplicate entry whose Back visibly did nothing.
{ replace: jumpTo.replace },
);
setJumpTo(null);
}
@@ -153,6 +179,8 @@ export default function PlexBrowse() {
// (down to a collection strip) instead of snapping to the top. Reset on a fresh info open so a
// different title's info starts at the top.
const infoScrollRef = useRef(0);
// One comfort-pin menu for the titles grid (see ui/ContextMenu).
const pinMenu = useContextMenu<PlexCard>();
const scroller = () => document.querySelector("main");
useLayoutEffect(() => {
const el = scroller();
@@ -163,35 +191,24 @@ export default function PlexBrowse() {
}, [sub.view.kind]);
// Backspace steps back one drill-down level (grid ← show ← season, and out of info/playlist),
// matching the browser/mouse Back. The player has its OWN Backspace handling, so we skip it here;
// and never hijack Backspace while typing in a field.
// matching the browser/mouse Back; the chord and the typing guard come from the shared keymap.
// BACKABLE_VIEWS is the ONE list behind both the guard and the scope registration, so the cheat
// sheet can never promise a "Back one level" the handler declines to perform.
const canStepBack = BACKABLE_VIEWS.includes(sub.view.kind);
useKeyScope("plexBrowse", canStepBack);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key !== "Backspace") return;
const el = document.activeElement as HTMLElement | null;
const tag = (el?.tagName || "").toLowerCase();
if (tag === "input" || tag === "textarea" || tag === "select" || el?.isContentEditable)
return;
if (
[
"grid",
"playlists",
"collections",
"show",
"season",
"info",
"person",
"playlist",
"collection",
].includes(sub.view.kind)
) {
e.preventDefault();
sub.back();
}
dispatch(e, "plexBrowse", {
back: (ev) => {
if (!canStepBack) return;
ev.preventDefault();
sub.back();
},
});
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [sub]);
}, [sub, canStepBack]);
// D3: entering the in-progress watch-state view auto-applies the "last watched" sort (most recently
// watched first); leaving restores the sort that was active before entering. Only the ENTER edge
@@ -598,6 +615,7 @@ export default function PlexBrowse() {
onOpen={() => onCard(c)}
onInfo={() => onInfo(c)}
onToggleWatched={toggleWatched}
menuProps={pinMenu.triggerProps}
/>
))}
</div>
@@ -623,6 +641,7 @@ export default function PlexBrowse() {
</>
)}
<PlexPinMenu menu={pinMenu} />
<div ref={setSentinelEl} className="h-8" />
{isFetchingNextPage && (
<p className="text-center text-xs text-muted pb-4">{t("plex.loading")}</p>
@@ -6,7 +6,8 @@ import { Check, Plus } from "lucide-react";
import { api } from "../lib/api";
import { notifyCollectionEditError } from "../lib/actionErrors";
import { useScrollFade } from "../lib/useScrollFade";
import Modal from "./Modal";
import { Button } from "./ui/Button";
import Modal from "./ui/Modal";
import { useConfirm } from "./ConfirmProvider";
// Admin-only "manage this movie's collections" dialog (Collections P2). Lists the library's EDITABLE
@@ -138,14 +139,16 @@ export default function PlexCollectionEditor({
placeholder={t("plex.collEditor.newPlaceholder")}
className="glass-card min-w-0 flex-1 rounded-lg px-3 py-2 text-sm focus:outline-hidden focus:ring-2 focus:ring-accent/40"
/>
<button
<Button
variant="glass"
size="md"
className="shrink-0"
onClick={createNew}
disabled={!newName.trim() || !!busy}
className="glass-card glass-hover inline-flex shrink-0 items-center gap-1.5 rounded-lg px-3 py-2 text-sm font-medium disabled:opacity-50"
>
<Plus className="h-4 w-4" />
{t("plex.collEditor.create")}
</button>
</Button>
</div>
{/* Filter */}
@@ -11,9 +11,11 @@ import { showEpisodeKeys } from "../lib/plexQueue";
import { useConfirm } from "./ConfirmProvider";
import { BackBtn, ActionBtn, DetailHero } from "../lib/plexDetailUi";
import { PlexPosterCard } from "./plexCards";
import { PlexPinMenu } from "./ComfortPinMenu";
import { useContextMenu } from "./ui/useContextMenu";
import PlexLibraryPicker from "./PlexLibraryPicker";
import { toggleWatchState } from "../lib/plexWatch";
import { LoadingState, StateMessage } from "./QueryState";
import { LoadingState, StateMessage } from "./ui/QueryState";
// The Collection info page (ask B): a hero (art/title/cert/year-range/count/runtime/summary) + the
// ordered member grid, a Play-all / Shuffle over the playable leaves, and — for a NON-external,
@@ -47,6 +49,8 @@ export default function PlexCollectionView({
const { t } = useTranslation();
const qc = useQueryClient();
const confirm = useConfirm();
// One comfort-pin menu for the member grid (see ui/ContextMenu).
const pinMenu = useContextMenu<PlexCard>();
const isAdmin = qc.getQueryData<{ role?: string }>(qk.me())?.role === "admin";
const q = useQuery({
queryKey: qk.plexCollections("detail", collectionId),
@@ -348,6 +352,7 @@ export default function PlexCollectionView({
onOpen={() => onOpenCard(c)}
onInfo={() => onInfoCard(c)}
onToggleWatched={toggleWatched}
menuProps={pinMenu.triggerProps}
/>
{canEdit && (
<button
@@ -366,6 +371,7 @@ export default function PlexCollectionView({
)}
</>
)}
<PlexPinMenu menu={pinMenu} />
</div>
);
}
+8 -2
View File
@@ -8,9 +8,11 @@ import PosterTile from "./PosterTile";
import { plexLang } from "../i18n";
import { toggleWatchState } from "../lib/plexWatch";
import { usePlex } from "./PlexProvider";
import { Carousel } from "./Carousel";
import { Carousel } from "./ui/Carousel";
import { PlexPosterCard } from "./plexCards";
import { LoadingState, StateMessage } from "./QueryState";
import { PlexPinMenu } from "./ComfortPinMenu";
import { useContextMenu } from "./ui/useContextMenu";
import { LoadingState, StateMessage } from "./ui/QueryState";
// The Plex "Discover" landing (E5 S4): a stack of horizontal Carousel rails served from ONE
// /api/plex/discover call — Continue watching, Recently added, Browse by genre (tiles), Because you
@@ -40,6 +42,8 @@ function CardRail({
onToggleWatched: (c: PlexCard) => void;
action?: ReactNode; // optional header control (e.g. the Surprise-me reshuffle button)
}) {
// One comfort-pin menu per rail (see ui/ContextMenu), not one per poster.
const pinMenu = useContextMenu<PlexCard>();
const heading =
action != null ? (
<span className="inline-flex items-center gap-2">
@@ -59,10 +63,12 @@ function CardRail({
onOpen={() => onOpenCard(c)}
onInfo={() => onInfoCard(c)}
onToggleWatched={onToggleWatched}
menuProps={pinMenu.triggerProps}
/>
</div>
))}
</Carousel>
<PlexPinMenu menu={pinMenu} />
</section>
);
}
+1 -1
View File
@@ -16,7 +16,7 @@ import {
} from "../lib/plexDetailUi";
import { usePref } from "../lib/usePref";
import i18n from "../i18n";
import { Carousel } from "./Carousel";
import { Carousel } from "./ui/Carousel";
import PlexCollectionEditor from "./PlexCollectionEditor";
import PlexPlaylistAdd from "./PlexPlaylistAdd";
import { PLAYABLE_TINT, playableClass } from "./plexCards";
@@ -4,7 +4,7 @@ import { useQuery } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys";
import { api, type PlexCard } from "../lib/api";
import { useDebounced } from "../lib/useDebounced";
import { LoadingState } from "./QueryState";
import { LoadingState } from "./ui/QueryState";
// A debounced library search-and-pick list, shared by the "Add title" panel (PlexCollectionView) and
// the "New collection" first-title panel (plexBrowseLists) — both search the library the same way and
+145 -146
View File
@@ -1,4 +1,4 @@
import { Fragment, lazy, Suspense, useCallback, useEffect, useRef, useState } from "react";
import { lazy, Suspense, useCallback, useEffect, useRef, useState } from "react";
import { qk } from "../lib/queryKeys";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
@@ -25,7 +25,6 @@ import {
Square,
Volume2,
VolumeX,
X,
} from "lucide-react";
import { api, HttpError } from "../lib/api";
import {
@@ -53,6 +52,16 @@ import {
useWallClock,
} from "../lib/plexPlayerHooks";
import { fullscreenElement } from "../lib/fullscreen";
import {
dispatch,
isActivationTarget,
isTypingTarget,
toggleKeyboardHelp,
useHelpConditions,
useKeyboardHelpOpen,
useKeyScope,
usePaletteOpen,
} from "../lib/keymap";
import { LS, useAccountPersistedObject } from "../lib/storage";
import { useDismiss } from "../lib/useDismiss";
import { useScrollFade } from "../lib/useScrollFade";
@@ -176,10 +185,33 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
},
[tracksFade.ref],
);
// Keyboard-shortcut cheat sheet (toggled with "h") + a live wall clock for the top bar.
const [helpOpen, setHelpOpen] = useState(false);
// The keyboard cheat sheet is app-wide now (ui/KeyboardHelp, opened by the global "h"), not this
// player's own overlay. Mirror its open state into a ref because usePlayerVolume still needs to
// know an overlay is up before it steals a wheel scroll for volume.
const helpOpen = useKeyboardHelpOpen();
const helpOpenRef = useRef(false);
helpOpenRef.current = helpOpen;
// Read for the Backspace guard below: these two overlays open OVER the player and own the key.
const paletteOpenRef = useRef(false);
paletteOpenRef.current = usePaletteOpen();
// On-screen confirmation for a KEYBOARD track switch. A/S have cycled audio and subtitles since
// E5, but silently — you changed the track and the only way to see what you landed on was to open
// the tracks menu, which is exactly the modal round-trip the shortcut exists to avoid. The menu
// itself needs no OSD: it shows the selection where you clicked.
const [osd, setOsd] = useState<string | null>(null);
const osdTimer = useRef<number | null>(null);
const flashOsd = useCallback((text: string) => {
setOsd(text);
if (osdTimer.current) window.clearTimeout(osdTimer.current);
osdTimer.current = window.setTimeout(() => setOsd(null), 2200);
}, []);
useEffect(
() => () => {
if (osdTimer.current) window.clearTimeout(osdTimer.current);
},
[],
);
// "I" toggles a rich info overlay (poster/cast/ratings) over the running video.
const [infoOpen, setInfoOpen] = useState(false);
const infoOpenRef = useRef(false);
@@ -577,15 +609,15 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
useEffect(() => setUpNextOpen(false), [id]);
// "Back" cascade shared by the on-screen Back arrow AND the keyboard/remote Backspace·Escape: peel
// off whatever is layered on top (a tuning/tracks menu, then the shortcuts sheet, then the info
// overlay) before leaving the player. The arrow used to call onClose() unconditionally, so Back
// with the info/shortcuts panel open jumped straight to the feed instead of just closing the panel.
// off whatever is layered on top (a tuning/tracks menu, then the info overlay) before leaving the
// player. The arrow used to call onClose() unconditionally, so Back with the info panel open
// jumped straight to the feed instead of just closing the panel. The shortcuts sheet is no longer
// in this cascade: it is a Modal above us now, and owns its own Escape layer and Back-to-close.
const handleBack = useCallback(() => {
if (menuOpenRef.current) {
setMenuOpen(false);
setTracksOpen(false);
} else if (helpOpenRef.current) setHelpOpen(false);
else if (infoOpenRef.current) setInfoOpen(false);
} else if (infoOpenRef.current) setInfoOpen(false);
else if (statsOpenRef.current) setStatsOpen(false);
else if (upNextOpenRef.current) setUpNextOpen(false);
else onClose();
@@ -603,6 +635,11 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
handleBack();
}, [handleBack, skipProgressRef, cancelAutoSkipRef]);
// Tell the shared cheat sheet which keys are live here, and whether ⇧← / ⇧→ apply (they step
// episodes, so they are listed only inside a series).
useKeyScope("plexPlayer");
useHelpConditions({ episode: detail?.kind === "episode" });
// This player is a dismissable layer: register it so a Modal or popover opened ABOVE it (its own
// settings/tracks menus included) takes Escape first and this player defers (R8 S3).
const layerIdRef = useRef<number | null>(null);
@@ -617,92 +654,101 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
// --- keyboard --------------------------------------------------------------------------------
useEffect(() => {
const seekBy = (e: KeyboardEvent, dir: -1 | 1, fine: boolean) => {
e.preventDefault();
seekTo(absRef.current + dir * (fine ? fineSeekStepRef.current : seekStepRef.current));
};
const step = (e: KeyboardEvent, id: string | null | undefined) => {
e.preventDefault();
go(id);
};
const onKey = (e: KeyboardEvent) => {
const tag = (e.target as HTMLElement)?.tagName;
if (tag === "INPUT" || tag === "TEXTAREA") return;
wake(); // keyboard use also reveals the controls
switch (e.key) {
case " ":
// Keyboard use also reveals the controls — but typing in a player field is not "using the
// remote", and `dispatch` still lets Escape through from inside a field (the only key that
// opts in), which is why this is a guard around `wake` rather than an early return.
if (!isTypingTarget(e)) wake();
dispatch(e, "plexPlayer", {
playPause: (ev) => {
// Let Space activate a focused button/link natively (e.g. "Skip intro"); only toggle
// playback when the focus isn't on a control — matching PlayerModal's guard.
if (tag === "BUTTON" || tag === "A") return;
e.preventDefault();
// playback when the focus isn't on a control — the same shared rule PlayerModal uses.
if (isActivationTarget(ev)) return;
ev.preventDefault();
togglePlay();
break;
case "k":
e.preventDefault();
togglePlay();
break;
case "f":
toggleFs();
break;
case "ArrowLeft":
e.preventDefault();
if (e.shiftKey) go(prevId);
else seekTo(absRef.current - (e.ctrlKey ? fineSeekStepRef.current : seekStepRef.current));
break;
case "ArrowRight":
e.preventDefault();
if (e.shiftKey) go(nextId);
else seekTo(absRef.current + (e.ctrlKey ? fineSeekStepRef.current : seekStepRef.current));
break;
case "ArrowUp":
e.preventDefault();
},
fullscreen: () => toggleFs(),
seekBack: (ev) => seekBy(ev, -1, false),
seekFwd: (ev) => seekBy(ev, 1, false),
fineBack: (ev) => seekBy(ev, -1, true),
fineFwd: (ev) => seekBy(ev, 1, true),
prevEpisode: (ev) => step(ev, prevId),
nextEpisode: (ev) => step(ev, nextId),
volumeUp: (ev) => {
ev.preventDefault();
nudgeVolume(0.05);
break;
case "ArrowDown":
e.preventDefault();
},
volumeDown: (ev) => {
ev.preventDefault();
nudgeVolume(-0.05);
break;
case "m":
toggleMute();
break;
case "a":
case "A":
cycleAudio();
break;
case "s":
case "S":
cycleSubtitle();
break;
case "h":
case "H":
setHelpOpen((v) => !v);
break;
case "i":
case "I":
setInfoOpen((v) => !v);
break;
case "d":
case "D":
setStatsOpen((v) => !v);
break;
case "Backspace":
},
mute: () => toggleMute(),
audio: () => {
const ord = cycleAudio();
if (ord === undefined) return; // one track (or none): nothing to say
const label = detail?.audio_streams.find((a) => a.ord === (ord ?? 0))?.label;
flashOsd(`${t("plex.player.audio")} · ${label ?? "—"}`);
},
subtitle: () => {
const ord = cycleSubtitle();
if (ord === undefined) return;
const label =
ord == null
? t("plex.player.subOff")
: (detail?.subtitle_streams.find((x) => x.ord === ord)?.label ?? "—");
flashOsd(`${t("plex.player.subtitles")} · ${label}`);
},
info: () => setInfoOpen((v) => !v),
stats: () => setStatsOpen((v) => !v),
back: (ev) => {
// HTPC-style "stop & back to the feed" — mirrors the mouse Back button (shared cascade).
e.preventDefault();
// Who owns ⌫ when something is open over the player. Modal stopPropagation()s Escape ONLY
// and its focused close button is not a typing target, so the key reaches us regardless.
// Two wrong answers so far: deferring to nothing at all (round 1's bug — the cheat sheet
// took ⌫ and the player stopped underneath it), then deferring to `anyLayerAbove` outright
// (round 1's fix, round 2's bug — the player's OWN gear/tracks popovers push layers too, so
// ⌫ stopped closing them), then naming just the sheet and palette (round 2's fix, round 3's
// bug — the app-wide ErrorDialog is neither, so ⌫ tore the player down under it).
// The rule that holds: the player's own panels answer first, because `handleBack`'s cascade
// is what closes them; anything else layered above us takes the key.
const ownPanelOpen =
menuOpenRef.current ||
infoOpenRef.current ||
statsOpenRef.current ||
upNextOpenRef.current;
if (!ownPanelOpen && layerIdRef.current != null && anyLayerAbove(layerIdRef.current))
return;
ev.preventDefault();
if (skipProgressRef.current != null)
cancelAutoSkipRef.current(); // cancel auto-skip, don't go back
else handleBack();
break;
case "Escape":
},
escape: (ev) => {
// Defer to any layer opened above the player (Modal or anchored popover) — let it take the
// Escape, not us. A player MENU registers as a layer too, so it closes first and the next
// Escape reaches the auto-skip-cancel below (the old blanket-stopPropagation bug). Check the
// consumed-event flag first (UX-8): a layer's `document` listener runs before this `window`
// one and its close can pop it off the stack synchronously, so `anyLayerAbove` alone misses it.
if (escapeHandled(e)) break;
if (layerIdRef.current != null && anyLayerAbove(layerIdRef.current)) break;
if (escapeHandled(ev)) return;
if (layerIdRef.current != null && anyLayerAbove(layerIdRef.current)) return;
if (skipProgressRef.current != null) cancelAutoSkipRef.current();
else if (menuOpenRef.current) {
setMenuOpen(false);
setTracksOpen(false);
} else if (helpOpenRef.current) setHelpOpen(false);
else if (infoOpenRef.current) setInfoOpen(false);
} else if (infoOpenRef.current) setInfoOpen(false);
else if (statsOpenRef.current) setStatsOpen(false);
else if (upNextOpenRef.current) setUpNextOpen(false);
else if (!fullscreenElement()) onClose();
break;
}
},
});
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
@@ -715,6 +761,8 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
toggleMute,
cycleAudio,
cycleSubtitle,
flashOsd,
t,
detail,
prevId,
nextId,
@@ -846,15 +894,30 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
</div>
)}
{/* Keyboard track-switch OSD. Sits ABOVE the control bar's own space so it reads even while the
bar is showing, and inside the player element so it survives fullscreen. `aria-live` because
the whole point is confirming something you cannot otherwise see.
The REGION is always mounted and only the bubble is conditional: a polite region has to
pre-exist in the accessibility tree for a later insertion to be announced, so one created
together with its own first message is unreliably read — and since `osd` clears after 2.2s,
conditioning the wrapper re-created it for every switch, not just the first (E10 review
round 1). Toaster.tsx carries the same rule and the same shape. */}
<div
className="pointer-events-none absolute inset-x-0 bottom-24 z-menu flex justify-center px-4"
aria-live="polite"
>
{osd && (
<div className="max-w-full truncate rounded-full border border-white/15 bg-black/75 px-4 py-2 text-sm text-white shadow-2xl">
{osd}
</div>
)}
</div>
{/* While a panel/menu is open or an auto-skip countdown is running, take over the browser/mouse
Back button (history popstate) so it closes/cancels that instead of dropping to the grid. */}
{(menuOpen ||
tracksOpen ||
helpOpen ||
infoOpen ||
statsOpen ||
upNextOpen ||
skipProgress != null) && <BackClose onBack={backIntent} />}
{(menuOpen || tracksOpen || infoOpen || statsOpen || upNextOpen || skipProgress != null) && (
<BackClose onBack={backIntent} />
)}
{/* Top bar */}
<div
@@ -1197,7 +1260,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
<Ctrl label={t("plex.player.infoBtn")} onClick={() => setInfoOpen((v) => !v)}>
<Info className="w-5 h-5" />
</Ctrl>
<Ctrl label={t("plex.player.help")} onClick={() => setHelpOpen((v) => !v)}>
<Ctrl label={t("plex.player.help")} onClick={toggleKeyboardHelp}>
<Keyboard className="w-5 h-5" />
</Ctrl>
<Ctrl label={t("plex.player.download")} onClick={download}>
@@ -1236,70 +1299,6 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
/>
)}
{/* Keyboard-shortcut cheat sheet (toggle with "h" or the keyboard button). */}
{helpOpen && (
<div
className="absolute inset-0 z-menu grid place-items-center bg-black/60 p-4"
onClick={(e) => {
e.stopPropagation();
setHelpOpen(false);
}}
>
<div
className="w-[min(92vw,26rem)] rounded-2xl border border-white/15 bg-neutral-900/95 p-5 text-white shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
<div className="mb-4 flex items-center justify-between">
<div className="flex items-center gap-2 font-semibold">
<Keyboard className="w-5 h-5" /> {t("plex.player.help")}
</div>
<button
onClick={() => setHelpOpen(false)}
className="p-1 rounded-lg hover:bg-white/10"
aria-label={t("plex.player.close")}
>
<X className="w-5 h-5" />
</button>
</div>
<dl className="grid grid-cols-[auto_1fr] items-center gap-x-4 gap-y-2.5 text-sm">
{(
[
[["Space", "K"], "plex.player.keys.playPause"],
[["←", "→"], "plex.player.keys.seek"],
[["Ctrl ←", "Ctrl →"], "plex.player.keys.fineSeek"],
...(detail?.kind === "episode"
? [[["⇧ ←", "⇧ →"], "plex.player.keys.episode"] as [string[], string]]
: []),
[["↑", "↓"], "plex.player.keys.volume"],
[["M"], "plex.player.keys.mute"],
[["A"], "plex.player.keys.audio"],
[["S"], "plex.player.keys.subtitle"],
[["F"], "plex.player.keys.fullscreen"],
[["I"], "plex.player.keys.info"],
[["D"], "plex.player.keys.stats"],
[["⌫", "Esc"], "plex.player.keys.back"],
[["H"], "plex.player.keys.help"],
] as [string[], string][]
).map(([keys, labelKey]) => (
<Fragment key={labelKey}>
<dt className="flex flex-wrap gap-1">
{keys.map((k) => (
<kbd
key={k}
className="rounded-sm bg-white/15 px-1.5 py-0.5 text-xs font-mono whitespace-nowrap"
>
{k}
</kbd>
))}
</dt>
<dd className="text-white/80">{t(labelKey)}</dd>
</Fragment>
))}
</dl>
</div>
</div>
)}
{/* Rich info overlay (poster/cast/ratings) — "i" or the info button; video keeps playing. */}
{infoOpen && detail && (
<div
+7 -4
View File
@@ -5,7 +5,8 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Check, Minus, Plus } from "lucide-react";
import { api, type PlexPlaylist } from "../lib/api";
import { useScrollFade } from "../lib/useScrollFade";
import Modal from "./Modal";
import { Button } from "./ui/Button";
import Modal from "./ui/Modal";
// What we're adding: a single leaf (movie/episode) or a whole group (a season or an entire show, as a
// set of episode rating_keys). Groups get a tri-state (none / some / all) per playlist.
@@ -102,14 +103,16 @@ export default function PlexPlaylistAdd({
placeholder={t("plex.playlistAdd.newPlaceholder")}
className="glass-card min-w-0 flex-1 rounded-lg px-3 py-2 text-sm focus:outline-hidden focus:ring-2 focus:ring-accent/40"
/>
<button
<Button
variant="glass"
size="md"
className="shrink-0"
onClick={createNew}
disabled={!newName.trim() || busy}
className="glass-card glass-hover inline-flex shrink-0 items-center gap-1.5 rounded-lg px-3 py-2 text-sm font-medium disabled:opacity-50"
>
<Plus className="h-4 w-4" />
{t("plex.playlistAdd.create")}
</button>
</Button>
</div>
<div
+4 -6
View File
@@ -35,7 +35,8 @@ import { api, type PlexCard } from "../lib/api";
import { formatRuntime } from "../lib/format";
import { getAccountRaw, LS, setAccountRaw } from "../lib/storage";
import { useConfirm } from "./ConfirmProvider";
import { LoadingState, StateMessage } from "./QueryState";
import { Button } from "./ui/Button";
import { LoadingState, StateMessage } from "./ui/QueryState";
import ViewSwitcher, { type ViewOption } from "./ViewSwitcher";
// A playlist's items, grouped for readability: movies stay standalone, while a run of episodes from the
@@ -514,13 +515,10 @@ export default function PlexPlaylistView({
return (
<div className="mx-auto w-[90%] max-w-[1100px] p-4">
<button
onClick={onBack}
className="glass-card glass-hover mb-4 inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-xs"
>
<Button variant="glass" size="xs" className="mb-4" onClick={onBack}>
<ArrowLeft className="h-4 w-4" />
{t("plex.backToLibrary")}
</button>
</Button>
<div className="glass rounded-2xl p-4 sm:p-6">
<div className="mb-4 flex flex-wrap items-center gap-3">
+16 -3
View File
@@ -28,9 +28,22 @@ import { useNavigation } from "./NavigationProvider";
// search term is deliberately its OWN state, NOT the feed's `filters.q`, so a Plex search never leaks
// into (and is never greeted by a stale value from) the feed.
// A "jump straight to this title" hand-off from the header typeahead: PlexBrowse opens a movie's
// info page (like the card's ⓘ) or a show's drill-down (like clicking a show card) by type.
export type PlexJumpTarget = { id: string; type: "movie" | "show" };
// A "jump straight to this title" hand-off: PlexBrowse opens a movie's info page (like the card's ⓘ),
// a show's drill-down (like clicking a show card), or — for E24's Home hero, whose whole promise is a
// single Play button — the PLAYER for a movie/episode rating_key. `play` exists because the player
// lives inside this module with its lazy hls.js and its provider tree; mounting a second one on the
// landing page to avoid one hand-off would be the expensive way round.
export type PlexJumpTarget = {
id: string;
type: "movie" | "show" | "play";
/** The jump ARRIVED WITH a page change — another module set this and sent the user here in one
* gesture (the Home surface plays a Plex title). The opened sub-view then REPLACES the page entry
* that hand-off just pushed, instead of stacking on top of it, so Back returns to where the user
* actually came from rather than to a module root they never visited. Omitted for a jump made
* from INSIDE the Plex module (the header typeahead), where the entry being replaced would be a
* sub-view the user really was looking at. */
replace?: boolean;
};
type PlexState = {
scope: string; // movie | show | both (unified cross-library scope)
+1 -1
View File
@@ -1,5 +1,5 @@
import { useTranslation } from "react-i18next";
import SegmentedToggle from "./SegmentedToggle";
import SegmentedToggle from "./ui/SegmentedToggle";
import { usePlex, usePlexActions } from "./PlexProvider";
import { usePlexViewControls } from "./PlexViewBridge";
+1 -1
View File
@@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next";
import { useQuery } from "@tanstack/react-query";
import { ChevronDown, Film, Tv } from "lucide-react";
import SearchBar from "./SearchBar";
import ActionMenu from "./ActionMenu";
import ActionMenu from "./ui/ActionMenu";
import { usePlex, usePlexActions } from "./PlexProvider";
import { api } from "../lib/api";
import { qk } from "../lib/queryKeys";
+4 -4
View File
@@ -9,10 +9,10 @@ import { type PanelLayout } from "../lib/panelLayout";
import { type MediaSourceKind } from "../lib/mediaSource";
import { useDebounced } from "../lib/useDebounced";
import FilterRail from "./FilterRail";
import { type PanelItem } from "./PanelGroups";
import SegmentedToggle from "./SegmentedToggle";
import FilterChipGroup from "./FilterChipGroup";
import Combobox, { type ComboboxOption } from "./Combobox";
import { type PanelItem } from "./ui/PanelGroups";
import SegmentedToggle from "./ui/SegmentedToggle";
import FilterChipGroup from "./ui/FilterChipGroup";
import Combobox, { type ComboboxOption } from "./ui/Combobox";
import SavedViewsPopover from "./SavedViewsPopover";
import PersonalViewsModal from "./PersonalViewsModal";
import { useSavedViews } from "./SavedViewsProvider";
+1 -1
View File
@@ -1,5 +1,5 @@
import { useTranslation } from "react-i18next";
import SegmentedToggle from "./SegmentedToggle";
import SegmentedToggle from "./ui/SegmentedToggle";
import { usePlexViewControls } from "./PlexViewBridge";
// The Discover ⟷ Browse switch, hosted in the header next to the library-scope pill (All/Movies/Shows).
+1 -1
View File
@@ -3,7 +3,7 @@ import { qk } from "../lib/queryKeys";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Lock, Pencil, Plus, Trash2 } from "lucide-react";
import Modal from "./Modal";
import Modal from "./ui/Modal";
import { useConfirm } from "./ConfirmProvider";
import { api, type DownloadProfile, type DownloadSpec } from "../lib/api";
import { inputCls } from "./ui/form";
+1 -1
View File
@@ -1,7 +1,7 @@
import { useTranslation } from "react-i18next";
import { Bug, Sparkles } from "lucide-react";
import { RELEASE_NOTES } from "../lib/releaseNotes";
import Modal from "./Modal";
import Modal from "./ui/Modal";
function fmtDate(d: string): string {
const t = new Date(d);
@@ -1,7 +1,7 @@
import { useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Bookmark, Check, Settings2 } from "lucide-react";
import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./ui/AnchoredPopover";
import { useSavedViews } from "./SavedViewsProvider";
// The saved-views quick-picker. The filter-panel header's icon (and the toolbar's view chip) use it as
+5 -8
View File
@@ -19,11 +19,12 @@ import {
} from "lucide-react";
import { api, type SchedulerJob, type SchedulerStatus } from "../lib/api";
import { useLiveQuery } from "../lib/useLiveQuery";
import { LoadingState, StateMessage } from "./QueryState";
import { Button } from "./ui/Button";
import { LoadingState, StateMessage } from "./ui/QueryState";
import { useConfirm } from "./ConfirmProvider";
import { relativeTime } from "../lib/format";
import { notify } from "../lib/notifications";
import Tooltip from "./Tooltip";
import Tooltip from "./ui/Tooltip";
const POLL_MS = 4000;
// While any job is running, poll faster so short scheduled runs aren't missed between ticks.
@@ -283,13 +284,9 @@ function MaintenanceSettings({
aria-label={t("scheduler.maintenance.batchLabel")}
className="w-28 bg-surface border border-border rounded-sm px-2 py-1 text-sm tabular-nums focus:outline-hidden focus:ring-2 focus:ring-accent/40"
/>
<button
onClick={save}
disabled={!dirty || saving}
className="glass-card glass-hover px-3 py-1.5 rounded-lg text-sm disabled:opacity-40 transition"
>
<Button variant="glass" size="sm" onClick={save} disabled={!dirty || saving}>
{t("common.save")}
</button>
</Button>
</div>
<div className="text-2xs text-muted mt-1.5">
{t("scheduler.maintenance.batchNote", {
+33 -18
View File
@@ -7,17 +7,18 @@ import { Bell, Check, Loader2, Monitor, RefreshCw, Trash2, User } from "lucide-r
import { SCHEMES, type Scheme, type ThemePrefs } from "../lib/theme";
import { api, HttpError, type Me } from "../lib/api";
import { LS, useAccountPersistedState } from "../lib/storage";
import Avatar from "./Avatar";
import Avatar from "./ui/Avatar";
import { notify, type NotifSettings } from "../lib/notifications";
import Tooltip from "./Tooltip";
import Tooltip from "./ui/Tooltip";
import { inputCls, Section, SettingRow, Switch } from "./ui/form";
import { Button } from "./ui/Button";
import { type SaveState } from "./ui/DraftSaveBar";
import { useConfirm } from "./ConfirmProvider";
import { StateMessage } from "./QueryState";
import { StateMessage } from "./ui/QueryState";
import { useWizard } from "./WizardProvider";
import { START_ON_HOME } from "./navigationInit";
import { usePrefs } from "./PrefsProvider";
import Tabs from "./Tabs";
import Tabs from "./ui/Tabs";
import { useMe } from "../lib/useMe";
// The Settings page edits server-persisted preferences. Each change applies locally for instant
@@ -129,6 +130,11 @@ function Appearance({ prefs }: { prefs: PrefsController }) {
// Controlled by App's prefs draft: each change applies locally for preview but is only
// persisted on an explicit Save (handled by the panel's Save/Discard bar).
const { theme, setTheme, perf, setPerf, hints, setHints } = prefs;
// The landing page (E24). NOT a server preference like the rest of this tab: `loadInitialPage`
// has to answer before /api/me lands, so the value must be readable synchronously from
// localStorage — a landing module decided after the first paint would be a visible jump. The two
// stored values come from navigationInit, which is what reads them back at boot.
const [startOnHome, setStartOnHome] = useAccountPersistedState(LS.startOnHome, START_ON_HOME.on);
return (
<>
@@ -214,6 +220,19 @@ function Appearance({ prefs }: { prefs: PrefsController }) {
</SettingRow>
</Section>
<Section title={t("settings.appearance.startup")}>
<SettingRow
label={t("settings.appearance.startOnHome")}
hint={t("settings.appearance.startOnHomeHint")}
>
<Switch
label={t("settings.appearance.startOnHome")}
checked={startOnHome !== START_ON_HOME.off}
onChange={(v) => setStartOnHome(v ? START_ON_HOME.on : START_ON_HOME.off)}
/>
</SettingRow>
</Section>
<Section title={t("settings.appearance.textSize")}>
<input
type="range"
@@ -324,12 +343,9 @@ function AccessRow({
</span>
) : (
<Tooltip hint={t("settings.account.enableHint")}>
<button
onClick={onEnable}
className="shrink-0 glass-card glass-hover px-3 py-1.5 rounded-xl text-sm transition"
>
<Button variant="glass" size="sm" className="shrink-0" onClick={onEnable}>
{t("settings.account.enable")}
</button>
</Button>
</Tooltip>
)}
</div>
@@ -498,13 +514,15 @@ function SignInMethods({ me }: { me: Me }) {
{t("settings.account.sessions.hint")}
</p>
</div>
<button
<Button
variant="glass"
size="sm"
className="shrink-0"
onClick={logoutOthers}
disabled={sessBusy}
className="shrink-0 glass-card glass-hover px-3 py-1.5 rounded-xl text-sm transition disabled:opacity-40"
>
{t("settings.account.sessions.action")}
</button>
</Button>
</div>
</div>
</Section>
@@ -676,7 +694,7 @@ export function DownloadAuth() {
<div className="flex items-center gap-2 shrink-0">
{h.configured && (
<Button
variant="danger-outline"
variant="danger-soft"
size="sm"
onClick={() => onRemove(h.host_key)}
disabled={remove.isPending}
@@ -833,13 +851,10 @@ function Account({ me, onOpenWizard }: { me: Me; onOpenWizard: () => void }) {
<p className="text-xs text-muted leading-relaxed mb-2">
{t("settings.account.deleteHint")}
</p>
<button
onClick={onDeleteAccount}
className="inline-flex items-center gap-1.5 px-3 py-2 rounded-xl text-sm border border-danger/40 text-danger hover:bg-danger/10 transition"
>
<Button variant="danger-soft" size="md" onClick={onDeleteAccount}>
<Trash2 className="w-4 h-4" />
{t("settings.account.deleteAccount")}
</button>
</Button>
</Section>
)}
</>
+6 -3
View File
@@ -4,6 +4,7 @@ import { Check, ChevronLeft, ChevronRight, Loader2, Send } from "lucide-react";
import { api, HttpError } from "../lib/api";
import { setLanguage, type LangCode } from "../i18n";
import LanguageSwitcher from "./LanguageSwitcher";
import { Button } from "./ui/Button";
import { inputCls } from "./ui/form";
// First-run install wizard. Shown by App.tsx while the instance is unconfigured. The one-time
@@ -239,15 +240,17 @@ export default function SetupWizard() {
placeholder={t("setup.smtp.testTo")}
className={inputCls}
/>
<button
<Button
variant="glass"
size="md"
className="shrink-0"
type="button"
onClick={sendTest}
disabled={testState === "sending" || !testTo.trim()}
className="shrink-0 glass-card glass-hover inline-flex items-center gap-1.5 px-3 py-2 rounded-xl text-sm disabled:opacity-50 transition"
>
<Send className={`w-4 h-4 ${testState === "sending" ? "animate-pulse" : ""}`} />
{t("setup.smtp.test")}
</button>
</Button>
</div>
)}
{testState === "ok" && (
+25 -19
View File
@@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Check, Copy, Link2, Lock, Trash2, UserPlus, Eye } from "lucide-react";
import clsx from "clsx";
import Modal from "./Modal";
import Modal from "./ui/Modal";
import { useConfirm } from "./ConfirmProvider";
import { notify } from "../lib/notifications";
import { copyText } from "../lib/clipboard";
@@ -138,31 +138,39 @@ function LinkRow({ link, onChanged }: { link: ShareLink; onChanged: () => void }
value={fullUrl}
className="flex-1 min-w-0 bg-transparent text-xs text-muted truncate outline-hidden"
/>
<button
<Button
variant="ghost"
size="icon-sm"
onClick={copy}
aria-label={t("downloads.share.copy")}
title={t("downloads.share.copy")}
className="p-1.5 rounded-md hover:bg-surface text-muted hover:text-fg transition"
>
{copied ? <Check className="w-4 h-4 text-success" /> : <Copy className="w-4 h-4" />}
</button>
<button
</Button>
{/* All three on the same rung. Only Copy was migrated in S1, which left one 2rem button
between two 1.75rem ones with a different radius and hover fill — and, on a coarse
pointer, `.touch-target` grew ONLY the migrated one to 2.75rem while the destructive
Revoke stayed small (E10 review round 1). */}
<Button
variant="ghost"
size="icon-sm"
onClick={() => {
setNewPass("");
setEditPass((v) => !v);
}}
title={
label={
link.has_password
? t("downloads.share.changePassword")
: t("downloads.share.setPassword")
}
className={clsx(
"p-1.5 rounded-md hover:bg-surface transition",
link.has_password ? "text-accent" : "text-muted hover:text-fg",
)}
>
<Lock className="w-4 h-4" />
</button>
<button
// On the ICON, not the button: `clsx` does not merge Tailwind, so a colour className loses
// to the variant's own `text-muted`/`hover:text-fg` by stylesheet order and the state was
// simply invisible (E10 review round 2). The Copy button above already does it this way.
icon={<Lock className={`w-4 h-4 ${link.has_password ? "text-accent" : ""}`} />}
/>
<Button
variant="ghost"
size="icon-sm"
onClick={async () => {
if (
await confirm({
@@ -174,11 +182,9 @@ function LinkRow({ link, onChanged }: { link: ShareLink; onChanged: () => void }
)
revoke.mutate();
}}
title={t("downloads.share.revoke")}
className="p-1.5 rounded-md hover:bg-surface text-muted hover:text-danger transition"
>
<Trash2 className="w-4 h-4" />
</button>
label={t("downloads.share.revoke")}
icon={<Trash2 className="w-4 h-4 text-danger" />}
/>
</div>
<div className="flex items-center gap-2 text-xs text-muted flex-wrap">
<span className="inline-flex items-center gap-1">
+2 -2
View File
@@ -11,12 +11,12 @@ import TagManager from "./TagManager";
import SavedViewsPopover from "./SavedViewsPopover";
import { useSavedViews } from "./SavedViewsProvider";
import FilterRail from "./FilterRail";
import { type PanelItem } from "./PanelGroups";
import { type PanelItem } from "./ui/PanelGroups";
import { type MediaSourceKind } from "../lib/mediaSource";
import ChannelPicker from "./ChannelPicker";
import DateFilterSelect from "./DateFilterSelect";
import YearFilter from "./YearFilter";
import Combobox, { type ComboboxOption } from "./Combobox";
import Combobox, { type ComboboxOption } from "./ui/Combobox";
import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider";
import { useChannelsActions } from "./ChannelsProvider";
import { useMe } from "../lib/useMe";

Some files were not shown because too many files have changed in this diff Show More