fdf80760139b675959ef4a03aeae3ae703dbc19a
18
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
94418a087e |
fix(auth): a tab acts only as the account it was built for, and the review's other findings
A tab names the account it was claimed for on every request, beacon and upload (X-Siftlode-Expect-Account); current_user and /auth/logout refuse a mismatch with 401 account_changed and keep the session, so a tab left showing the account signed in before reloads instead of writing to the new one. /auth/login clears a connect marker left by an abandoned connect, and a link completes only on a session that still holds. The prefs merge reads the row under a lock. A tab's sessionStorage records its own owner. A page the account loses is given up, address bar and remembered page included. The panel layouts are preferences; the storage contract also refuses a key handed to the Storage API or to usePref outside its kind. A refused sign-in as someone else and a failed sign-out say so. The chromium-only e2e specs are in the gate register, the member walk waits for its requests, and devices.spec checks the save itself. |
||
|
|
4aaaaa7860 |
chore(comments): drop epic, sprint and review-round ids from the code
The code cited its plan instead of saying why: `E28 review J4`, `(R6 S3b)`, `S1 justified…`,
`deep-review round 5`. That plan lives in the private ops repo, so for every reader of this one the
citation was noise, and a sentence whose subject was the plan step ("round 2 showed…") hid its
lesson behind an id. Each one is gone and the "why" kept: a bare citation is deleted, a sentence
carrying a lesson is reworded around it ("a later attempt showed…").
Measured (`scripts/audit_metrics.py`, product code): 652 lines in 270 files carried an epic code by
the definition the next commit holds (507 / 241 by the old, two-digit-only one) -> 0. Across all
code, tests, scripts and configs: ~1100 epic-code lines plus ~320 sprint/round ids.
Behaviour unchanged, checked mechanically: every changed .py file has the same AST once docstrings
are blanked; css/sh/toml/yml/json files are identical once comments are stripped; the only
non-comment changes in TS/JS are test titles, one description string in a contract table, and five
ESLint messages, whose text is all the gates snapshot diff holds. Not touched: Markdown (docs and
the changelog keep their history), Alembic migrations, TV episode notation (`S2 · E5`), DESIGN.md
ids (`C1`, `B4`, `S3h`), tracker ids (`SEC-16`, `DX-17`).
One stale claim fixed on the way: test_hls_reaper's docstring said the player checkpoints "every
10 s whether or not it is playing", which plex/stream.py calls wrong; it now says what the code does.
|
||
|
|
c3d88d9b20 |
refactor: component files export only components; lint fails on a warning
The 43 react-refresh/only-export-components warnings the gate let through meant an edit to a provider (or AnchoredPopover, Toaster, plexDetailUi, …) could not be hot-swapped: Fast Refresh reloads a module that also exports hooks or helpers. - Each provider's contexts, the types its hooks return and the hooks themselves live in a sibling context module (navigationContext.ts, channelsContext.ts, …); the provider file keeps the component. loadAccountFilters moved next to the feed filters' defaults (feedFiltersInit.ts), following navigationInit/playlistsInit. - Helpers that sat in component files have their own modules: welcomeMode, downloadTitle, plexPlayable, channelSync, ui/anchoredPopoverHooks, ui/pageToolbarSlot, ui/pagerContent, ui/toastLevels, ui/overlayOrder, ui/tableAlign, lib/plexDetail; the cells of the channel columns and the registry's page wrappers are components files of their own (channelCells, modulePages). - usePersistedTab, an alias of useAccountPersistedState, is gone; its callers use the original. npm run lint is now eslint --max-warnings 0, so a warning fails the gate like an error does. |
||
|
|
5883ea509d |
feat: Settings → Diagnostics, a consented log level and the tab's own log
A user can raise their diagnostics level to Részletes (DEBUG) or Teljes (TRACE) for 24 hours from a new Settings tab, after a consent box that says what is recorded, that the server keeps it and for how long (the instance's own retention), and that it lapses. Alap is the base and needs no consent. - users.diagnostics_level/_until (migration 0087), written only by PUT /api/me/diagnostics, which computes the expiry itself; the demo account cannot raise it. /api/me reports the level in force. - The server logs an elevated user's requests at that level and stores every line of them (logs.LevelGate on the stdout handler and the sink; the root is lowered only while an elevation is active). Teljes adds the redacted query string to the request line. - POST /api/diagnostics/events stores the browser's log as source=web rows: account from the session, body capped while streaming, events/attrs capped, re-redacted, events under the consented level dropped, timestamps clamped, 30 batches a minute, 409 once the level lapsed. - lib/diagnostics.ts applies the level to the tab and streams only what happens after consent, in batches, never its own request lines, with a keepalive flush on pagehide; stops on lapse (its own timer), on withdrawal and on 409. - Detail hooks at DEBUG: which control was clicked (test id, icon, app copy in English — never user text, input values or a link's query), visibility/bfcache/resize, filter changes (lib/filterLog.ts; typed search only at TRACE), player events (lib/mediaLog.ts; media errors are warnings at every level). Offline/online at the base level. - "Saját napló" viewer: live, newest first, level groups with counts, search, row details, copy the filtered events as JSON lines, clear. - Privacy policy and Terms exist in Hungarian and English (a contract test holds both languages to the same sections); the policy now describes the server logs and diagnostic logging, self-service deletion and password sign-in. Fixed on the way: - A sync auth dependency ran logs.set_user on a copied context, so the account never reached a handler's own log lines (measured); a per-request mutable scope carries it now. - The Settings tab rail left a phone no room (clipped labels, buttons over text): below `sm` it is a scrolling row above the content. - PUT /api/me/preferences took a bare dict body; it is a Pydantic model now. |
||
|
|
e795d191c5 |
fix(boot): review round 1 — reconcile the tab pin, one loading clock, honest probe failures
Deep-review round 1 over main..improvement/watch-background-audio. BootGate - The tab's account pin is corrected to the account the server answered as. The server honours the pin only for an account still signed in to this browser and otherwise answers as the default, so a tab pinned to an account signed out in another tab ran as the default on the server while reading and writing the gone account's local state. - A mounted tab whose identity changes (its session ended, or the case above) reloads instead of rebuilding the tree in place, like every account switch; that also drops the previous account's query cache. The account key on the tree goes: the readyFor gate already unmounted it on every change. - An unanswered setup probe shows the error with Retry, like an unanswered account probe, instead of the signed-out landing to a signed-in user. - Comments: /api/me never 401s; the ended-session path is identity → nobody. Loading states - LoadingState's reveal delay is one clock across loading states that hand over to each other (boot screen → page-chunk fallback → the page's own fetching state), instead of a CSS delay restarted by each, which blanked a visible spinner for another 300 ms at every hand-off. BootScreen is now the shared loading visual, full screen, counting from navigation start. Plex shared link - Persists only the dimensions the link carries again. Writing the default filters for an omitted `pf` created the stored-filters key, which is what tells SavedViewsProvider the account picked filters, so the account's default Plex saved view was never applied again. Watch page - The Repeat toggle uses the shared pressed-pill paint (a solid fill when on) instead of Button variants that differ only in colour, and its flag goes through storage.ts readJSON/writeJSON. - The actions wrap under the title when the row is too narrow, instead of squeezing the title into a column a word wide on a phone; the title breaks long words. Comments that still justified code by the removed adopt effects (usePref, PrefsProvider, FeedViewProvider) are rewritten against the seed-once model. Tests: BootGate error/retry for both probes, setup mode, a failed background refetch keeping the app, the pin correction and the reload on an identity change; the shared reveal clock; the Plex link persist; the audio signature actually moved off version 4; a video download (av and v) still remuxed to an mp4. Each mutation-checked RED for its own reason. |
||
|
|
5346efcd10 |
fix(boot): a new tab knows its account before anything renders
With Settings -> Appearance -> Startup turned off, every NEW tab or window still opened on Home; only a same-tab reload honoured it. The provider tree mounted before /api/me answered, so NavigationProvider's initializer could not read the per-account opt-out, landed on Home and mirrored ?page=home into the address bar. When App pinned the account a moment later, the re-hydrate re-read that mirror and stayed on Home. The same mount-then-patch shape made a new tab start on default filters, the default theme and the browser language, and correct each after the first paint. BootGate (main.tsx, above the providers) now owns the setup-status and /api/me queries and mounts the tree only once the identity is settled: it pins the tab's account and adopts the account's language in a layout effect, so the first frame of the app is already right. The tree is keyed on the account id, which makes "per-account state is read once, for this account" a structural guarantee. With that, the whole re-hydrate machinery is gone: useAccountRehydrate, the reactive active-account store, the markDirty guards, the pin-deferred saved-view seed and the present-dimension bookkeeping of the shared Channels/Plex links. PrefsProvider, FeedFiltersProvider, usePref and App's panel layouts seed from the me cache instead of adopting it in an effect; the theme is applied in layout effects. The app chunk is fetched in parallel with the probe, and the boot screen only fades its label in after 400 ms, on one clock across the gate and the Suspense fallback. Module switches no longer flash a placeholder either. Measured frame by frame in Chromium and WebKit, every first visit to a module showed "Loading..." for 17-70 ms: the lazy chunk's Suspense fallback, then often the first fetch's LoadingState. Pages are now lazyWithPreload components, and App fetches the chunks of every module the account can reach (plus the channel page) once the first page is idle; a preloaded page renders synchronously and never suspends. LoadingState, the one shared loading visual, fades in only after 300 ms, so a fast load shows nothing in between and a slow one still gets its spinner. |
||
|
|
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.
|
||
|
|
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. |
||
|
|
f80ed115ad |
fix(plex,channels): deep-review fixes — durable watch-sync + no shared-link clobber
Deep-review of the quickfix batch surfaced two data/preference-loss bugs plus smaller items; all fixed with tests. - watch_sync _pull_apply: don't let the Plex-authoritative pull overwrite a DIRTY local row (a change whose immediate push failed) — that stranded the value forever since _repush_dirty only retries dirty rows. Leave it dirty for re-push. - PlexProvider / ChannelsProvider: a partial shared link opened in a fresh tab persisted mount-time DEFAULTS over the account's stored scope/sort/filters (base read before the account was pinned). Persist/keep only URL-present dims; re-hydrate the omitted ones from storage. - playback resume write-back: guard against out-of-order delivery (a superseded older push now drops) and evict the throttle map so it can't grow unbounded. - PlexPlayer: announce mid-playback buffering to assistive tech (role=status + sr-only label); new plex.player.buffering (HU+EN). - columnSort: hoist the shared key:dir sort codec used by both URL-state modules. - Tests: dirty-guard + flag-settle + per-key throttle scoping + superseded-push (backend); hold-repeat/suppress-click/unmount + partial-link no-clobber + refetch-doesn't-clobber-undo (frontend). Backend pytest 475, FE vitest 519. |
||
|
|
56a2ecdec3 |
feat(plex): shareable Plex-browse state in the URL — scope+filters+sort+show (S4)
Mirror the Plex-browse content state into the address bar so a browse view is bookmarkable/shareable, the Plex analogue of the feed's urlState. Keys are NAMESPACED (pscope/psort/pshow/pf) rather than reusing the feed's bare scope/sort/show: FeedFiltersProvider actively strips its own KEYS from the URL whenever off the feed page, so a shared key would be deleted out from under the Plex page. The namespaced keys are Plex-unique (like ?v=/?pl), so no other provider ever reads or clears them. - New lib/plexUrlState.ts: plexParamUpdates / plexParamsToState / hasPlexParams. The pf filter blob reuses the SAME appendPlexFilters wire serializer (new public plexFiltersToParams + inverse parsePlexFilters, kept next to each other so a new wire key can't be added on one side only). - PlexProvider: URL-seed wins over localStorage at mount; the account-pin rehydrate persists the seed instead of clobbering it (mirrors feed's initialHadUrlFilters); useUrlMirror mirrors/clears the 4 keys, gated on page===plex. q/field stay out of the shareable contract. - NavigationProvider: a page switch now also nulls the 4 plex keys. Tests: 8 new round-trip cases in plexUrlState.dom.test.ts (FE vitest 483→491). |
||
|
|
a20e27800c |
feat(plex): port the saved-views (personal views) UI to Plex (frontend)
Plex now has the same personal-views system as the feed — the rail header icon opens the quick-pick popover, the rail title opens the manager modal (list / create / rename / delete / reorder / set-default / apply), and the active view leads the chip row with a "modified" dot. One SavedViewsProvider serves both modules: it switches `source` (feed ⟷ plex) by the active page (both filter providers are global, so it reads whichever the page owns) and runs the same logic on per-source wiring — the live filters, apply, a stable signature (feed filtersToParams / plex plexFiltersSignature), coercion (asFilters / coercePlexFilters), and what to strip on save. Moved below PlexProvider in the tree so it can read plex state; resets sticky/modal on a source switch. The shared popover/modal/chip are unchanged; the modal describes a plex view's captured filters via a pure buildPlexActiveFilters (extracted from usePlexActiveFilters — no duplication). api.savedViews/createView take a source; `SavedView.filters` is now an opaque blob. New coercePlexFilters/plexFilters- Signature (also dedups PlexProvider's collection migration). Empty-state copy genericised (no longer says "feed"). A Plex view captures the rail filters (not watch-state/sort/scope). Verified end-to-end on localdev: create/apply/preview/default/delete on plex, feed views stay separate + unregressed (402 vitest), no console errors. |
||
|
|
a60164edc4 |
feat(plex): make the Collection filter multi-select with Any/All
A title can belong to several Plex collections, so the collection filter becomes multi-select combining with Any (OR, default) / All (AND) — the same shape as the genre/actor filters. Backend (query.py): `collection` (single) → `collections` CSV + `collection_mode` on LibraryFilters; meta_filter_conds ORs/ANDs `collection_keys.contains` per id. Frontend: PlexFilters `collection`/`collectionTitle` → `collections` + optional `collectionMode` + `collectionTitles` map; CollectionPicker is now a multi-select Combobox with the any/all match footer; one chip per collection (usePlexActive- Filters); the info-page "Browse collection" strip unions into the list; applyFilter now shallow-merges label maps so adding one keeps the others' titles. PlexProvider migrates a legacy persisted single `collection` into the new shape on read. Test: test_plex_collections_filter (single / any-union / all-intersection). |
||
|
|
f9681deecf |
feat(plex): field-scoped search (S2) + instant search typeahead
Add a search scope-pill to the Plex header search box (GitHub-style, one field-scope at a time) and an instant typeahead of the top matches below it. Backend (unified_library): - New `field` param: all | title | people | summary | episode. `all` is byte-identical to the pre-scope behaviour (untouched weighted search_vector); a specific field keeps the GIN `search_vector @@ tsq` prefilter AND ANDs a per-column tsvector recheck, so scoped hits are a strict subset of Everywhere. Per-field expressions mirror the search_vector Computed def (same TS config, same left(summary,1000) truncation). - `episode` scope empties the movie/show grid and title-only-matches the grouped Episodes section; the title/people/summary scopes suppress that section. - Unknown field falls back to `all`. New DB-lane characterization tests (test_plex_field_scoped_search.py): each scope narrows correctly, field=all is identical to today, episode empties the grid and needs shows in scope. Frontend: - PlexSearchBox wraps the shared SearchBar with the scope-pill (reusing ActionMenu) and the typeahead; SearchBar gains generic leading/below/focus/ keydown slots so other modules' search boxes are untouched. - Episode scope offered only when shows are in scope (movie-only hides it), healed at the point of use like the sort state. - Typeahead reuses /api/plex/library (limit=10), honours scope+field (not the sidebar filters), shows poster+title+year, keyboard nav (arrows/Enter/Esc), and jumps straight to a movie's info page or a show's drill-down via a new provider jumpTo hand-off (mirrors the playlistOpen pattern). - `field` persisted per-account; en/hu labels added. |
||
|
|
9917d908eb |
fix(nav,plex,playlists): extend account-pin re-hydrate to every affected provider
The fresh-tab restore bug wasn't Channels-specific: any provider that mounts above <App> and reads per-account state at useState-init hit it, since <App> pins the tab's account only after /api/me. Audited all of them — FeedFilters/FeedView/Prefs/App already self-correct via their own me?.id server-prefs adopt effects, so the still-broken ones were the pure-localStorage providers with no server adopt: NavigationProvider (landing page), PlexProvider (scope/show/sort/filters/q), PlaylistsProvider (selected playlist). Each now adopts the shared useAccountRehydrate hook (markDirty in the setters; page uses replaceState so the restore doesn't add a phantom Back step). Also baked re-hydrate into useAccountPersistedState/Object so their consumers (persisted tabs, player prefs) are robust even if one ever mounts before the pin. Self-E2E'd on a genuinely fresh tab: it lands on Plex with Movies/In-progress/Year/search all restored on the FIRST load. |
||
|
|
02c8c71c53 |
fix(sort): address R13 S1 review findings
- PlexSidebar activeCount now compares the HEALED sort, so an out-of-scope key (movie "duration" after switching to shows) no longer inflates the active-filter badge while the control + grid show the default. - PlexProvider strips the legacy `sortDir` from the filters blob, so it stops leaking into the facets query key and being re-persisted on every change. - PlexProvider sort migration guards the fall-through: valid JSON that isn't a SortState now falls back instead of using the raw JSON string as the key. - New shared healPlexSort(sort, scope) is the single heal both PlexBrowse and PlexSidebar use (removes the noop-label plexSortOptions call + the duplication). - Tests: useSortState.dom.test.ts (hydrate/persist/heal round-trip via renderHook) and SortControl.dom.test.tsx (options, pick, direction toggle, directionless, trailing). vitest 94 -> 105. Gate green: tsc/eslint(0 err)/prettier/knip clean, vitest 105. |
||
|
|
e4bdc5ea6a |
refactor(plex): unify Plex sort onto SortControl + a single SortState (R13 S1)
Plex sort was a bespoke chip-pill row plus a separate asc/desc chip pair, with
the key in PlexState.sort and the direction hidden in PlexFilters.sortDir. Now:
- sort is one SortState {key,dir}; sortDir is dropped from PlexFilters (and from
the facet-key destructure). The wire call still splits it into sort + sort_dir
at the server boundary, so the backend is untouched.
- The chip sort-row is replaced by the shared SortControl (select + arrow) — the
same idiom the feed and channel manager use. New lib/plexSort.ts owns the
scope-dependent vocabulary (movies add duration) + the title/asc fallback.
- PlexProvider migrates the old storage in place: a legacy bare-key plexSort
string plus the old filters.sortDir become the {key,dir} state, so an existing
sort preference survives the upgrade.
- PlexBrowse and PlexSidebar heal the stored sort against the current scope's
options, so a movie-only key (duration) left over after switching to shows
can't ask the server for an unavailable sort.
Gate green: tsc clean, eslint 0 errors, prettier clean, vitest 93.
|
||
|
|
e257e2aca0 |
style(frontend): Prettier config + one-time repo-wide format pass
Prettier 3 (pinned), printWidth 100, double quotes — chosen to match the existing hand-formatting and minimise reflow. This commit is ONLY the mechanical format pass (`prettier --write`) plus the config/ignore/scripts, isolated so it never pollutes a feature diff. Verified behaviour-neutral: typecheck, eslint (0 errors), and vitest all green before and after. See .git-blame-ignore-revs — `git blame` skips this sha. |
||
|
|
6c32e5c8d6 |
refactor(plex): scope module state in PlexProvider
Lift the Plex module's shared local state (scope / watch-state / sort / expanded filters / search term / open-playlist hand-off) out of App into a split-context PlexProvider with stable useCallback setters. PlexSidebar, PlexBrowse, and the header search box read it via usePlex()/usePlexActions() instead of props drilled through App; PlexBrowse is now prop-free. App loses six useStates + the filters JSON memo/serialise helpers. (Platform S6b.2) |