Commit Graph
434 Commits
Author SHA1 Message Date
peter 634e9bb17c chore(release): 0.69.2 — fix multi-account Library thumbnails 2026-08-19 02:00:24 +02:00
peter ce2954cf2b fix(downloads): carry the active account on the Library poster URL
An <img> can't send the X-Siftlode-Account header, so the poster.jpg request
resolved to the session-default account and _accessible_job 404'd it for any
download owned by a different (per-tab) account — every card then fell back to
the source's remote thumbnail, which for Facebook is an expiring fbcdn URL, so
the Library showed blank thumbnails in a multi-account session. The 0.69.1
local-poster-primary change made this the primary image, exposing it broadly.

Append ?account= to the poster URL via a new api.posterImageUrl(), the same
wallet-gated header-less selection downloadFileUrl / storyboardImageUrl already
use (resolved_user_id reads ?account= for exactly these contexts).
2026-08-19 02:00:24 +02:00
peter b17b47f54e chore(release): 0.69.1 — feed read-ahead, download thumbnails, About popover
Three prod bug fixes: local-poster-primary Library thumbnails, viewport-scaled
feed read-ahead (fixes the fast-scroll "Load more" stall), and the channel About
popover fitting/blank-line cleanup.
2026-08-19 01:42:35 +02:00
peter d9677e0e3a chore(release): 0.69.0 — E3 backend architecture consolidation
Bump VERSION + user-facing release notes for the E3 epic (Plex god-module split,
downloads owner-unification, backend contract & conventions). Observable wins:
Plex catalog prune, download reliability (no false stalls, fair queue, no
file-delete-under-live-download). 20 commits, four clean /code-review high rounds.
2026-08-19 00:13:00 +02:00
peter e8771f4d6b chore(release): 0.68.0 — E2 Feed & Search performance epic
Bump VERSION 0.67.0 -> 0.68.0 and add the user-facing release-notes entry for
the E2 epic (feed sort index, keyset/NOT NULL, search GIN + state inner-joins,
single /feed/summary pass, no card-action refetch storm, job partial indexes).
2026-08-18 19:20:18 +02:00
peter 6116ad4bf4 refactor(feed): address /code-review high findings on the E2 stack
Review round on the E2 stack (S1-S5). No correctness bugs; the surviving
low/medium findings addressed:

- altitude (#1): extract the shared `tags_by_category` helper used by BOTH
  filtered_query (SQL tag WHERE) and feed_summary (Python facet/count), removing
  the category-grouping duplication, and add bidirectional "keep in lockstep —
  equivalence-tested by test_feed_summary_matches_count_plus_facets" comments so
  the SQL<->Python tag rule can't silently drift.
- test-coverage (#2): the /feed/summary equivalence test seeded only system tags,
  leaving the `ChannelTag.user_id == user.id` visibility branch untested. Add a
  user-owned Tag + link + two combos; the suite is now 346 (was 344) and the new
  cases pass (feed_summary agrees with /feed/count + /facets on user-visible tags).
- dead-code (#3): annotate get_feed_count, get_facets, and the FE api.feedCount/
  facets as "SUPERSEDED by /feed/summary — kept only as the equivalence oracle;
  retire together" so the intentionally-retained surface reads as deliberate.

Not changed (confirmed no-op): the search channel-name resolve (#4) is
once-per-request on a 633-row table and a deliberate pre-resolve for PERF-12's
BitmapOr plan; the facet loop (#5) already computes passes() once per
(channel, category) with a distinct exclude each, so there is no recomputation.

Gate green: ruff + 346 pytest; frontend tsc/knip/prettier (FE change is a comment).
2026-08-18 16:39:04 +02:00
peter 21a3aae700 perf(feed): collapse count + facets into one /feed/summary pass (E2 S4, PERF-13)
The FE fired /feed/count (1 pass) + /facets (3 passes) on every filter change =
4 full scans of the subscribed set. New /api/feed/summary does ONE grouped query
(the base filter with tags stripped, GROUP BY channel_id -> per-channel video
counts) and derives the total count + every tag's distinct-channel facet count in
Python on the ~few-hundred-row channel map, faithfully mirroring filtered_query's
channel-based tag logic + the /facets drill-down (own category dropped, unless a
conjunctive topic-AND). Returns {count, facets} — the same numbers the two old
endpoints returned.

FE: a single shared query (Feed reads .count, Sidebar reads .facets); same key +
filters, so React Query dedupes to ONE request. Invalidations repointed to
qk.feedSummary. The old /feed/count + /facets endpoints (and their now-unused FE
api/qk) are kept as the equivalence-test oracle + safety net; a later cleanup can
drop them.

New equivalence test (test_feed_views, 8 filter combos incl. cross-category,
disjunctive/conjunctive topic modes, search): /feed/summary count == /feed/count
and facets == /facets, byte-identical. Structural win: 2 requests / 4-6 queries
-> 1 request / 2-3 queries (the review measured 530 -> 144 ms). Gate green:
344 pytest, frontend tsc/eslint/knip/prettier/222 vitest.
2026-08-18 05:01:27 +02:00
peter cace9a004d chore(release): 0.67.0 — E1 Foundation epic
Bump VERSION 0.66.0 -> 0.67.0 and add the user-facing release-notes entry for
the E1 Foundation epic (data-safety fixes, account-switch + search-error fixes,
graceful key-mismatch degrade; plus the security/test-net/CI/repro chores).
2026-08-18 03:41:28 +02:00
peter 18edbecb1e fix(auth): close the account pre-emption + wrong-account link holes (E1 S5, SEC-10/11)
SEC-10 (account pre-emption / "pre-hijack"): a password set on an account whose
email was never verified is untrusted — whoever registered the address (to
pre-empt the real owner) could have chosen it. Both the Google-adoption callback
and admin activation (_activate_user_by_email) now drop that pre-set password and
bump the session epoch when the account isn't email_verified, so a later Google
login / admin approval can't hand the pre-set password a working login. No-op for
a verified account (the legit no-SMTP flow). +2 tests.

SEC-11 (link/upgrade bound to the session default): the OAuth link/upgrade GETs
resolved to the session DEFAULT account, so a tab acting as a secondary account
would link Google / the YouTube grant to the wrong account. The frontend now
threads the active account through the navigation URL (withActiveAccount ->
?account=<active>), and _complete_link accepts any wallet account, not only the
default, so the identity lands on the account that started the flow.
2026-08-18 03:02:34 +02:00
peter f54dd98129 test(release): lock the VERSION file and RELEASE_NOTES[0] together (E1 S4, DX-6)
gate / backend (push) Successful in 1m27s
gate / frontend (push) Successful in 1m9s
A vitest that fails the gate when the newest release-notes entry's version
diverges from the repo-root VERSION file (the two hand-edited version sources),
plus a no-duplicate-versions check. Catches the "bumped one, forgot the other"
release mistake.
2026-08-18 02:34:45 +02:00
peter b605c28b32 release: 0.66.0 — E5 Plex epic (scoped search, HU localization, toolbar/header)
Ships the E5 sprint stack (S1–S7): field-scoped Plex search + typeahead, cast &
crew pages, the HU-overlay Hungarian metadata + language-aware serving, the
IMDb/audience rating split, the toolbar relocation, and the narrow/mobile header
fix — plus the underlying Plex-module refactors (feedquery/plex.cards/plex.query
extraction, db.query→2.0 select).

- VERSION 0.65.2 → 0.66.0
- releaseNotes.ts: 0.66.0 user-facing entry
- prettier-format the E5-touched frontend files (release hygiene)

Merged main (0.65.2 download-gc hotfix) in first; no code conflicts (E5 is
Plex-only, the hotfix was notifications/downloads).
2026-08-17 22:18:07 +02:00
peter cee0bc95b6 Merge remote-tracking branch 'gitea/main' into e5-s3-toolbar-relocation 2026-08-17 22:14:50 +02:00
peter 9fb4db2546 fix(plex): address S6 code-review — 5 findings
1. (correctness) Show hero rating regression: PlexBrowse's show hero rendered a
   rating only when show.imdb_rating was set, so after the S6 IMDb-only change a
   show with no imdb:// entry showed no rating. Now falls back to the audience
   `rating` like PlexInfo.
2. (correctness) ratingMin column mismatch: the rating filter and the /facets
   rating_max now key off coalesce(imdb_rating, rating) — the SAME "best score"
   the badge displays — so a ratingMin picked from a shown IMDb score narrows the
   right column instead of the raw audience `rating`.
3. (altitude) HU search gap: new query.vector_match() matches EITHER vector in HU
   (or_(search_vector_hu, search_vector)) so a row with no HU twin stays findable
   by its English title; ranking still uses the HU vector. field_recheck unions
   the HU+EN per-field tsvectors in HU, staying a strict subset of the prefilter.
4. (test-coverage) Extracted the badge logic to lib/plexRating.pickRatingBadge
   (shared by PlexInfo + the show hero) with a unit test (imdb-preferred, audience
   fallback, 0-score, none). Backend tests added for the HU union search + the
   IMDb-preferred rating filter.
5. (efficiency) Language switch no longer double-fetches the grid: browseQ is
   gated on a committedLang state that lags `lang` by one render, so the
   genre-clear effect settles into a single fetch instead of one throwaway fetch
   with stale-language genres plus a second.

DB-lane suite 295 passed / 2 skip; FE tsc/knip/lint clean, 217 vitest pass.
2026-08-17 20:41:13 +02:00
peter 4169cfe4ff feat(plex): S6 frontend — language threading, rating badge, i18n parity
E5 S6 frontend half.

Language threading (?lang=hu serves the HU overlay; en is the default):
- api/plex.ts: `lang` on plexLibrary / plexFacets / plexItem / plexShow (only
  sent for hu, so EN URLs stay byte-stable); PlexItemDetail gains `rating`.
- queryKeys: plexItem / plexShow take an optional trailing lang so a switch caches
  HU/EN separately; the ...rest keys (library/facets) already carry it. Prefix
  invalidations (qk.plexShow(id) / qk.plexItem(id)) still match either.
- i18n: plexLang() collapses the UI language to en|hu for non-component code.
- PlexBrowse / PlexSidebar: thread lang into the grid, facets, item and show
  queries (useTranslation re-renders on switch → the key changes → refetch).
- plexPlayerHooks: the optimistic resume setQueryData now keys by plexLang() so
  it still lands on the language-keyed detail query.
- Clear the genre-filter selection on a language switch — genre chips are
  language-specific strings, so a stale one silently empties the grid.

IMDb-only rating badge (folded-in S5b review #5):
- PlexInfo.tsx shows the IMDb badge + imdb.com link only when imdb_rating is
  present; otherwise it renders the Plex audience `rating` (no IMDb link/branding),
  so a non-IMDb score is never mislabelled. ratingMin filter keys off whichever
  score is shown.

Tests: i18n parity.test.ts (en/hu key sets identical per namespace — currently
green, no drift). FE gate green: tsc / knip / lint clean, 213 vitest tests pass.
2026-08-17 19:19:03 +02:00
peter c361fc2581 fix(gate): green the frontend gate at HEAD
- PlexPersonView.dom.test.tsx: variadic vi.fn mock for plexSetState so the
  spread call typechecks (TS2556 — zero-arg mock couldn't take ...args).
- plexSearch.ts: drop the unused `export` on PLEX_SEARCH_FIELDS (module-local
  only; knip flagged it as an unused export).

tsc / knip / lint all green; ruff clean.
2026-08-17 17:30:12 +02:00
peter 8b8c077a29 feat(plex): S4 actor detail page — filmography endpoint + person subview
Backend: new GET /api/plex/person/{name} returns a cast/crew member's cached headshot plus
the movies (PlexItem, kind=movie) and shows (PlexShow) in the enabled libraries whose
cast_names OR directors include the name — the inverse of the cast → filter link. Introduce a
shared _content_libs(db) helper (enabled-library partition) and reuse it in people_cards;
S5 will extend it with the HU-overlay exclusion. Watch-state comes from PlexState / rollup
(reusing _movie_card / _show_card / state_map). DB-lane tests cover cast+director credits,
library scoping, the kind filter, unknown-name empties, and the partition helper.

Frontend: a new `person` history-subview. A cast-row click (PlexInfo cast row + the show
page's CastStrip) now opens the person's filmography page instead of filtering; the filter
gesture moves to a "Filter library by {name}" button on the page itself. PlexPersonView
mirrors PlexInfoView's fetch-wrapper + glass hero (headshot + name + credit counts) and
renders Movies / Shows as PlexPosterCard grids, with an optimistic per-card watch toggle on
the person cache. New plexPerson query key + api client + PlexPersonDetail type; HU/EN copy.
2026-08-16 21:26:58 +02:00
peter 6465f0ce75 style: prettier-format the 0.65.2 release-notes entry
The release gate's prettier --check flagged the new entry; no content change.
2026-08-16 20:13:09 +02:00
peter 588a1a82a1 release: bump VERSION to 0.65.2 + release notes (download-cleanup title-truncation fix) 2026-08-16 20:08:23 +02:00
peter d0f5559c49 feat(plex): S3 toolbar relocation — scope to header, sort above the cards
Move the two "what am I browsing" controls out of the filter sidebar to where
they're always in reach, matching the Feed's layout:

- Library scope (both/movie/show) → a compact SegmentedToggle in the header next
  to the search box (new PlexScopeToggle, reads the same provider state).
- Sort → a SortControl in the row above the cards (into the PageToolbar band,
  count on the left, sort on the right), like the Feed.

Sidebar cleanup: drop the `scope` and `sort` island cards; the panel badge now
counts only the filters still in the panel (expanded filters + watch-state);
remove the now-unused sort imports. panelLayout drops both from the default plex
order (stale saved-layout keys are auto-filtered, so existing layouts degrade
cleanly). usePlexActiveFilters no longer emits a sort chip — sort is an
always-visible control now, not a removable narrowing (same as the Feed).

FE-only, no backend/migration. typecheck + eslint clean; 171 vitest green;
self-E2E verified (header scope drives the grid, sort control reorders, sidebar
no longer shows the two cards, no sort chip).
2026-08-16 18:30:48 +02:00
peter 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.
2026-08-16 08:05:18 +02:00
peter 9105c4db3d release: 0.65.1 — immediate full-history finishes large channels
Bumps VERSION to 0.65.1 and adds the release note for the backfill fix
(immediate "get full history" now completes channels over ~3000 videos in
one run) plus the per-page durability + centralized reserve-guard hardening.
2026-08-16 03:22:12 +02:00
peter 7dc9f712a0 release: 0.65.0 — R14 channel-table redesign + immediate full-history backfill
Bump VERSION to 0.65.0 and add the user-facing release notes for R14
(zoom-fit manager table, ✓ charm, N/S/L moved to the channel page,
live-refresh, immediate "get full history").
2026-08-15 07:26:09 +02:00
peter 5720356191 fix(channels): address R14 deep-review findings
- Make the immediate-backfill guard SYMMETRIC: run_deep_backfill now also
  registers the channel it pages in _backfill_in_progress, so it and
  backfill_channel_now mutually exclude per-channel and never advance the
  same cursor concurrently (whichever started first). The interactive cap
  moves to a separate _immediate_running counter so the scheduler's channels
  don't false-trip it.
- Attribute the immediate backfill's spend: wrap it in
  quota.attribute(user_id, VIDEOS_BACKFILL_FULL) (route passes user.id), so
  it shows under the user's full-backfill action, not the OTHER/system bucket.
- Tests: the update_channel->background-task wiring + the demo guard
  (scheduled for a real user, NOT for demo); distinct N/S/L counts (2 short
  + 1 live) so a short/live swap fails; extracted channelDetailPollMs with a
  pure unit test of the poll-or-stop predicate.
- a11y: role="img" on the fully-synced charm so screen readers announce it.
2026-08-15 07:11:50 +02:00
peter 2cc77757d9 fix(channels): address R14 code-review findings
- list_channels no longer computes the Normal/Shorts/Live aggregation the
  frontend stopped reading when R14 dropped the N/S/L table column; the dead
  ManagedChannel.count_* fields are gone too.
- Immediate "get full history" backfill now coordinates with the paced
  scheduler via a small in-flight set: capped at 2 concurrent + per-channel
  dedup, and run_deep_backfill skips a channel an immediate backfill owns —
  killing the cursor race and bounding the quota-drain burst, without losing
  the speed win.
- Add test_channel_backfill.py: the channelDetail N/S/L counts + the guard's
  no-op / cap / scheduler-skip behaviour.
- Inline the syncedChannels useMemo (its dep is a fresh array each render, so
  it never cached); fix the stale "~2-min ticks" comment (it's 10 minutes).
2026-08-15 06:38:19 +02:00
peter be738d3791 feat(channel): live-refresh the channel page while full-history backfills
The channel detail page now polls (via useLiveQuery) while THIS user's
full-history backfill is running (deep_requested && !backfill_done), so
the stored count and the N/S/L breakdown climb on their own as the
scheduler adds videos — no manual F5 — and it stops polling once the
back-catalogue is in. Widen useLiveQuery's interval callback to allow
returning `false` (stop) so "poll while active, stop when done" is a
first-class, reusable cadence.
2026-08-15 05:50:11 +02:00
peter 7ee0e9cd02 feat(channels): R14 zoom-fit table — fluid columns + column fogyókúra
- DataTable: opt-in `fixedLayout` (colgroup + table-layout:fixed) so the
  table fits its container at any zoom/text-size; only the channel manager
  opts in — playlists/discovery/Plex keep auto-sizing.
- Remove the N/S/L and Sync columns from the manager table. Fully-synced
  status becomes a ✓ charm on the Stored count; the sync filter moves to an
  `applySyncFilter` pre-filter still driven by the top chips.
- Channel column is now fluid: ChannelLink truncates with the full name on
  hover (title). Data columns get fixed widths so only Channel/About flex.
- N/S/L (normal/short/live) breakdown moves to the channel page: add the
  three counts to the channelDetail endpoint (one grouped query, same
  filters as the list) + the ChannelDetail type + a meta-line entry.
- Drop the now-dead sync/types i18n keys (en+hu).
2026-08-15 05:28:59 +02:00
peter 19f1905da9 release: 0.64.0 — R13 sort/filter idiom unification + Hidden tab
Bump VERSION to 0.64.0 and add the user-facing release-notes entry for
the R13 sprint (unified sort/filter controls, Hidden channels tab,
channel-manager toolbar declutter, filter-rail jitter fix).
2026-08-15 04:53:08 +02:00
peter 003043a989 test(sidebar): cover the date preset/custom selection logic (R13 S2c)
The deep-review flagged that the feed sidebar's new date single-select
derivation was the one bit of migrated logic without a test, while its sibling
primitives (FilterChipGroup/SegmentedToggle/SortControl) all gained one. Lift
the pure logic into lib/dateFilter (deriveDateKey + applyDateChip), same split
as columnSort, and cover it: dateKey for none/preset/custom/off-preset-value,
the preset toggle-off, the preset-not-active-while-custom-open case, and the
Custom open/close + maxAgeDays clearing. Sidebar now calls the helpers; the
onChange is behaviour-identical (self-E2E'd: preset on/off, Custom editor).
11 new tests (vitest 136 -> 147). gate green. Branch not merged.
2026-08-15 02:27:22 +02:00
peter 735d08c91d refactor(channels): adopt shared Tabs + usePersistedTab for the manager tab row (R13 S2b)
Retire the hand-rolled channel-manager view pills for the shared <Tabs> component (given an
optional className so it embeds in the justify-between header without the default mb-4), and
back view + layout on usePersistedTab instead of manual getAccountRaw/setAccountRaw useState.
That subsumes the account-pin re-hydrate (usePersistedTab now re-reads on the pin), so
ChannelsProvider's bespoke markManagerDirty/useAccountRehydrate is gone; setView keeps the
history-tab logic (_chTab pushState + popstate) layered on the stable raw setter, and
useAccountPersistedState's setter is now memoized so that stable action holds.

Per the user's call, unify every channel-manager pill on the rounded-lg idiom: the view tabs
via <Tabs>, the sync chips and tag chips + the "manage tags" affordance from rounded-full to
rounded-lg — no more odd-one-out rounded-full.

gate-green: tsc + vitest 120 + eslint-0 + knip + prettier. Self-E2E'd: tab switch, browser
Back across tabs (Blocked->Hidden->Subscriptions with localStorage in sync), and a fresh tab
restoring view=hidden on the FIRST load; zero console errors.
2026-08-14 03:50:43 +02:00
peter e6688edbcc refactor(nav): drop redundant sub-view markDirty; add useFilterState re-hydrate tests
Addresses the 2 findings from /code-review high on 726c382:
- NavigationProvider: openChannel/enterYtSearch no longer markDirty. A sub-view is an
  overlay, not a page change — the page re-hydrate's replaceState already spreads the
  _chan/_yt marker, so the overlay survives on top of the RESTORED page. Removing the
  markDirty lets a fresh tab still restore the stored landing page under a pre-pin overlay
  (previously it stuck on the default). setPage keeps markDirty (a real navigation).
- useFilterState.dom.test.ts: model a mutable pinned account and add 3 integration tests —
  null->id re-hydrate adopts the stored map, the migrate seed carries a legacy value forward
  on re-hydrate when the key is empty, and a pre-pin edit is preserved (markDirty guard).

gate-green: vite build + vitest 120 + eslint-0 + knip + prettier. Fresh-tab restore re-E2E'd
(lands on Plex with full state, zero console errors).
2026-08-14 03:26:30 +02:00
peter 726c382524 refactor(account): address account-pin re-hydrate review — extract module, harden guards
Fixes the 5 findings from /code-review high on the account-pin work:
- Extract the reactive-account primitives (ACTIVE_ACCOUNT_KEY, activeAccountId,
  notifyActiveAccountChange, useActiveAccountId) into a new dependency-free
  lib/activeAccount.ts, breaking the storage.ts <-> useAccountRehydrate.ts import cycle.
  storage.ts / api/core.ts / useAccountRehydrate.ts now import from it.
- NavigationProvider: the page re-hydrate now PRESERVES existing history.state markers
  (spread, not overwrite), and openChannel/enterYtSearch markDirty so a sub-view opened
  before the account pins isn't clobbered by the page override.
- useAccountRehydrate: reset the dirty flag after each account transition, so a future
  non-reloading account switch can't skip the re-hydrate and leak the prior account's state.
- Doc-link useAccountRehydrate to the pre-existing useEffect([me?.id]) server-adopt pattern.
- Tests: model a mutable pinned account and add useSortState integration tests for the
  null->id re-hydrate + the markDirty no-clobber guard (vitest 115->117).

gate-green: vite build + vitest 117 + eslint-0 + knip + prettier. Self-E2E'd on a fresh
tab: still lands on Plex with Movies/In-progress/Year restored, zero console errors.
2026-08-14 03:12:50 +02:00
peter 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.
2026-08-14 02:56:55 +02:00
peter b57e4fb9bc fix(channels): re-hydrate per-account manager state once the account is pinned
The provider tree mounts above <App>, which pins this tab's account only after /api/me
resolves — so on a genuinely fresh tab (empty sessionStorage) the manager's sort, filters,
view and layout initialised from an unknown account and stayed at defaults until a second
reload. Make the active account reactive (a subscribable store + useActiveAccountId via
useSyncExternalStore; setActiveAccount/clearActiveAccount notify) and re-read the stored
value when the account transitions null→id, via a shared useAccountRehydrate hook. A
markDirty guard (called by each owning setter) prevents clobbering a value the user changed
in the brief pre-pin window; the effect is keyed on the account id only, so the user's own
writes can't loop it. useSortState, useFilterState and ChannelsProvider (view/layout) all
adopt it. Affects sort identically — this was a long-standing caveat, not new to R13.
2026-08-14 02:37:28 +02:00
peter ca9dd54923 fix(channels,datatable): address R13 S2a review + unify tag chips with the Tags column filter
Review fixes (user /code-review high):
- DataTable: hoist a stable EMPTY_FILTERS fallback so a controlled caller that omits
  `filters` doesn't churn a fresh {} each render.
- DataTable: the persistKey blob now stores only the slices this table OWNS — a
  controlled sort/filter is written as null/{} instead of a stale never-read duplicate.
- useFilterState: optional `migrate` seed (used only when storage is empty);
  ChannelsProvider carries the retired LS.channelFilter sync chip forward via migrateSyncChip.
- Channels: guard the sync-chip read with typeof instead of an `as string` cast.

Also unify the tag filter, same as the sync one: the top "YOUR TAGS" chip row is now a
controlled view over the Tags COLUMN filter (`filters.tags`), replacing its own tagFilter
state — so the chips and the Tags funnel share one state bidirectionally, the card layout
applies it via filterRows, and it persists with the rest of the map.
2026-08-14 02:19:44 +02:00
peter 837fdcd408 feat(channels,datatable): unify sync-status into one controlled DataTable filter (R13 S2a)
The top sync chips and the Sync column's own select filter were two independent
states with different vocab (all/needs_full/fully_synced vs fully/needsFull) and
subtly different semantics. DataTable gains a controlled filters/onFiltersChange
pair (symmetric to the existing controlled sort); ChannelsProvider now owns the
filter map via a new useFilterState hook, so the top chips, the Sync column
funnel, the card layout, and the header "full history" deep-link all drive ONE
filters.sync. The card layout reuses the exported filterRows predicate instead of
its own status pre-filter. Vocab unified on the column's fully = recent_synced &&
backfill_done, which now matches the Sync cell's own "fully synced" badge.
2026-08-14 02:00:17 +02:00
peter ccd7170a20 fix(sort): harden shared sort primitives (R13 S1 review round 2)
- SortControl no longer renders a blank select when an always-sorted view
  (no defaultLabel) is handed a key its options don't offer: it falls back to
  the first option instead of value="" (which also tripped React's controlled
  warning). Robustness now lives in the primitive, not each caller.
- PLEX_SORT_FALLBACK is Object.frozen: it's handed out by reference from
  healSort/healPlexSort and stored as provider state, so freezing turns any
  accidental in-place SortState mutation into a loud error instead of silently
  corrupting the shared fallback.
- Test: SortControl falls back to the first option for an unavailable key.

Gate green: tsc/eslint(0 err)/prettier/knip clean, vitest 106.
2026-08-14 00:10:13 +02:00
peter 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.
2026-08-14 00:00:31 +02:00
peter 45bd588af7 refactor(channels): share useSortState across the manager + discovery (R13 S1)
ChannelsProvider and ChannelDiscovery each hand-rolled the same sort plumbing
(useState<SortState> + parseSortState + a persist key). Both now use the shared
useSortState hook. hydrateSort gains an empty-options rule: with no vocabulary
passed it parses without healing (these owners heal for display via
ColumnSortControl against the live columns), so the un-healed provider store is
preserved. plexSortKeys made module-private (knip).

Closes R13 S1: feed, Plex, and the two channel tables all sort through one
SortControl + one SortState model. Gate green: tsc/eslint(0 err)/prettier/knip
clean, vitest 94.
2026-08-13 23:41:12 +02:00
peter 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.
2026-08-13 23:36:34 +02:00
peter 7d9da80795 refactor(sort): source-agnostic SortControl + SortOption model (R13 S1 foundation)
Introduce the one sort control every content surface will share:
- columnSort.ts gains the SortOption vocabulary + pure pickSort/healSort/
  hydrateSort helpers (direction lives in SortState, never in the key).
- SortControl.tsx: the generic dropdown + asc/desc toggle, with an optional
  "no sort" choice (tables) and a trailing adornment slot (the feed's reshuffle).
- ColumnSortControl is now a thin wrapper over SortControl (API unchanged, so
  Channels + ChannelDiscovery are untouched); optionsFromColumns moved to
  lib/sortOptions.ts to keep the component file fast-refresh clean.
- useSortState.ts: the per-account persist/hydrate/heal plumbing the channel
  manager and discovery each hand-rolled, ready to replace both.

Pure logic is fully unit-tested (columnSort.test.ts 10 -> 23). Gate green:
tsc/eslint/prettier clean, vitest 93 pass. Feed/Plex/channels migration follows.
2026-08-13 23:24:59 +02:00
peter 8ff28dcc69 release: 0.63.3 — shared player volume core + PlexPlayerSettings extraction (R12 S5) 2026-08-13 03:45:13 +02:00
peter 92ef6116f0 fix(player): hold last-known Plex volume on a corrupt read, normalise once (review)
- applyVolume/setVol now pass the element's CURRENT level as the normalize
  fallback instead of undefined, so a corrupt persisted volume holds the
  playing level rather than jumping to PLEX_SCALE.default=1 (full) — the "full
  blast" failure mode playerVolume.ts's header explicitly warns against. (The
  old bare clamp muted instead; both are wrong for corrupt data.) readMerged
  shallow-merges stored prefs without per-field validation, so a corrupt
  volume is reachable.
- setVol normalises the level ONCE and both applies + persists that same value,
  so the <video> element and the stored pref can never disagree.
- Test documents the hold-last-known contract (corrupt raw + valid fallback ->
  fallback; only an unusable fallback lands on the scale default).

Gate green (tsc / eslint-0 / prettier / knip / vitest 83 / pytest 234).
2026-08-13 03:43:02 +02:00
peter cba992379c refactor(player): share the volume core across both players + extract PlexPlayerSettings (R12 S5 frontend)
Volume-consolidation (conservative — no behaviour or prefs-format change):
- playerVolume.ts is now scale-generic (a VolumeScale parameterises range /
  default / integer rounding). YT_SCALE (0-100 ints) and PLEX_SCALE (0..1
  float) bind the same tested core; the YouTube wrappers keep the original API
  so the existing tests are unchanged.
- The Plex usePlayerVolume hook drops its bespoke clamp01 arithmetic for the
  shared normalizeVolumeIn / stepVolumeIn at PLEX_SCALE. Side effect (a latent
  bug fix): a corrupt persisted volume now falls back to the default instead of
  a bare clamp silently muting it. The separate .muted flag stays Plex-only.
- clamp01 stays for the seek-bar fraction (its non-volume use).
- Tests: the generic core is covered at both scales (float no-round, corrupt ->
  default, step/clamp) — the Plex-side arithmetic finally has unit coverage.

PlexPlayerSettings extraction (verbatim JSX move, behaviour-equivalent):
- The ~240-line gear/tuning panel (sync / playback / subtitle / clock tabs)
  moves out of PlexPlayer into PlexPlayerSettings.tsx (a pure prefs+patchPrefs
  view owning no state). PlexPlayer shrinks 1402 -> 1173 lines; Slider/Toggle
  imports it no longer uses are dropped.

Gate green (tsc / eslint-0 / prettier / knip / vitest 83 / pytest 234). Dev
server smoke: app builds + loads clean, no console errors. The interactive
settings panel needs a playing Plex video (unreachable on localdev) -> owed to
the user's UAT.
2026-08-13 03:20:01 +02:00
peter b5f889aa33 release: 0.63.2 — download queue fairness + backend residue cleanup (R12 S5) 2026-08-13 02:48:18 +02:00
peter ee8aa72abe release: 0.63.1 — SourceAdapter refactor + sidecar/nfo fixes 2026-08-13 01:21:22 +02:00
peter 099d6d1301 chore: bump VERSION 0.63.0 + release notes (supervised download-subprocess watchdog) 2026-08-12 23:43:15 +02:00
peter 4af919ac4d chore: bump VERSION 0.62.2 + release notes (download ReDoS + Reddit thumbnail hotfix) 2026-08-12 18:49:42 +02:00
peter d9a12144dc chore: bump VERSION 0.62.1 + release notes (R12 S4a PlexPlayer refactor) 2026-08-12 01:58:48 +02:00
peter f86313eaa9 refactor(plex): extract PlexPlayer track selection into usePlexTracks
S4a-4a of the PlexPlayer decomposition. Move audio/subtitle track
selection + subtitle rendering into usePlexTracks: the selected-ordinal
state, change/cycle audio+subtitle, the debounced sub-offset, the audio
A/V-sync offset, and the two subtitle effects (force-showing the mounted
track + positioning cues at the user's subPos). The shared media refs
(video/hls/audio/aoff/abs/multiAudio) stay component-owned and are passed
in, since loadSession + the detail effect write audioRef too; absRef is
hoisted above the hook call for that. No behaviour change; verified by
self-E2E (subtitle on/off via 'S' mounts/removes the <track>, mode
'showing', VTT 804 cues all positioned to line=subPos, subLang persists;
playback uninterrupted). Audio switch is single-track here so cycleAudio
correctly no-ops.
2026-08-12 01:21:20 +02:00
peter 521d2aeaa8 refactor(plex): extract PlexPlayer auto-skip into useAutoSkip
S4a-3b of the PlexPlayer decomposition. Move the intro/credits auto-skip
machinery (activeMarker detection, the skipProgress countdown, doSkip,
cancelAutoSkip + the running-interval and reset-on-id effects) into
useAutoSkip. The hook returns skipProgressRef / cancelAutoSkipRef — the
forward-declaration bridge the earlier keyboard/back handlers read to
cancel a running countdown; those two stable refs are now listed in the
backIntent/keyboard dep arrays (they were provably-stable local refs
before, params now). No behaviour change; verified by self-E2E (credits
marker detected -> countdown fill renders + advances while playing ->
auto-skip fires -> item marked Watched + player closes).
2026-08-12 00:46:54 +02:00