Commit Graph
23 Commits
Author SHA1 Message Date
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 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 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 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 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 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 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 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 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 9d7a2a75d8 feat(ux): card unification, slim icon nav, filter/plex fixes (7-item batch)
1A ChannelPage "Full history" button no longer wraps (whitespace-nowrap).
1B Extract the manager's press-and-hold priority stepper into a shared
   PriorityStepper and add it to the channel detail header (subscribed, non-demo);
   backend channel_detail now returns the per-subscription `priority`.
2  Unify YouTube + Plex media cards onto one look/behaviour: drop the whole-card
   hover LIFT (VideoCard) — which also fixes the clipped-top bug — and instead the
   inner thumbnail/poster ZOOMS on hover (both); give the flat Plex posters the same
   glass-card 3D depth + accent hover border as the feed cards (shadow-md is a
   transparent no-op here, so it's glass-card that provides the depth); clamp Plex
   card titles to a fixed 2-line height so 1- vs 2-line titles align.
3  Nav rail is now always a slim icon-only column, flush to the left edge; each icon
   reveals a floating name label on hover (Tooltip gains `always` + `openDelay`, and
   a right side). Removed the pin/unpin toggle, the expand-on-hover mode and the
   navCollapsed pref plumbing.
4  Scope the side-panel peek-open HOVER to the tab handle + the card (not the whole
   full-height slot next to the rail), so approaching the nav no longer pops the
   filter open; focus stays container-wide. useHoverFocusWithin gains a hover-out
   grace period + split hover/focus handler groups.
5  Plex art backdrop fills the whole viewport (a fixed inset-0 layer behind the
   transparent app) instead of only the inset <main> box.
6  Plex season binge continues across seasons: the season view's play queue now
   spans the whole show (allKeys), so a season's last episode rolls into the next
   season's first (seasonKeys stays for "add season to playlist").
7  When a Plex video/episode ends with nothing queued next, the player returns to
   the previous page (onEnded → onClose) instead of stranding a black screen.
2026-08-11 02:20:52 +02:00
peter 56f95fa626 feat(channels): tag editing on the channel detail page (R8 S1 Part 2)
Bring the same tag control to the channel page, next to Subscribe:
- backend: GET /api/channels/{id} now returns this user's `tag_ids` for the channel
  (added to _channel_detail_dict + the ChannelDetail interface), matching the manager
  list shape;
- ChannelPage renders the shared ChannelTagsControl, wired to attach/detach/create with
  an optimistic update on the channel-detail cache (reverting on failure) and a manager
  list invalidation so a tag added here shows there too.

Reuses the R8 popover platform + ChannelTagsControl — no new tag UI.
2026-07-30 23:33:50 +02:00
peter cfa8be950d feat(channels): fixed-width tag cell with "+N" overflow + inline create (R8 S1)
Redesign the channel tag cell (the popover pilot's first real consumer) to fix two
UAT-reported bugs and two UX gaps:

- Jumping editor (bug 1): the "+" edit trigger now sits in a STABLE slot at the end,
  so toggling a tag (which grows the chip row) no longer moves the anchor — the editor
  popover stays put (verified dx/dy = 0).
- Whole-table reflow (bug 2): the cell is width-bounded (max-w inside the cell, since
  table-layout:auto ignores the column width — the documented channel/About trap) and
  single-line, collapsing overflow into a hoverable "+N" pill. Rows no longer wrap and
  reflow (verified uniform row heights).
- The "+" is ALWAYS present (even with zero tags), and its menu can CREATE a tag inline
  (type + Enter → create + attach) — tagging no longer requires the top-of-page dialog.

New shared ChannelTagsControl (used by table + cards; the detail page is Part 2) over the
R8 popover platform: a `menu` editor popover + a `hover` reveal-all popover. Greedy rem-safe
fit via ResizeObserver. `api.createTag` now returns Promise<Tag>.
2026-07-30 23:33:50 +02:00
peter 1fd4c2f7b3 docs(api): note the façade guard's intended scope boundary (type-name clashes are tsc-caught) 2026-07-28 23:14:32 +02:00
peter 95db151565 test(api): guard the spread-only façade against interleave regrowth (R7.5 S3)
Add facade.test.ts asserting the two invariants that keep lib/api.ts safe:
no method name is defined in two slices (a silent last-wins spread override),
and the `api` object is exactly the union of the slices (nothing added inline
in the barrel, nothing lost). Verified to have teeth — an injected duplicate
key fails the collision test with a named message.

Closes R7.5: the api layer is now per-domain modules behind a mechanically
guarded spread-only façade.
2026-07-28 23:04:39 +02:00
peter 8eb6f13288 refactor(api): extract account/auth/setup/admin; façade is now spread-only (R7.5 S2)
Move the last four inline domains out of lib/api.ts into per-domain slices,
each owning its types + method slice. The barrel now holds ZERO endpoint logic
and zero types of its own — it only re-exports core's HTTP machinery, re-exports
each domain's types, and spreads the 15 slices into the single `api` object.

A new endpoint therefore has nowhere to land but a domain module. The `api`
shape and all call sites are unchanged (every req/beacon call byte-identical
to dev; no method dropped, no key collision).
2026-07-28 22:59:09 +02:00
peter 8ce1e0230a refactor(api): extract 8 content domains into api/*.ts slices (R7.5 S1)
Move feed, channels, sync, playlists, tags, notifications, saved-views and
quota out of the lib/api.ts god object into per-domain slice modules, each
owning its own types + method slice and spread into the façade. The `api`
object shape and all 54 call sites are unchanged; account/auth/setup/admin
stay inline until S2.

Reunites the interleaved tags read/CRUD that motivated the epic.
2026-07-28 22:41:34 +02:00
peter cdcffb8c91 feat(downloads): categorize download failures + a readable error dialog (S2)
Failed downloads stored the raw yt-dlp/ffmpeg text and dumped it as a truncated red
one-liner in the queue. Classify the failure into a category (unavailable / login_required /
geo_blocked / format_unavailable / drm / postprocess / source_missing / network / unknown)
and surface it properly:

- backend: app/downloads/errors.classify maps the raw message to a category code; the worker
  stores it in the new download_jobs.error_code (migration 0061) at both failure sites; resume
  clears it; the serializer exposes it. Raw text kept as technical detail.
- frontend: the queue row shows a clean localized reason + a "details" link opening a modal
  with the explanation, the raw text behind a disclosure, and a Retry only for transient
  categories (network/postprocess/unknown).
- tests: classify() unit coverage over verbatim yt-dlp/ffmpeg message shapes.
2026-07-28 01:40:22 +02:00
peter 0d655e6f88 fix(api): un-export types used only within their own module (knip gate)
The S3b api.ts split introduced 5 exported-but-never-imported types
(PlaylistKind/PlaylistSource, DownloadJobKind, YT internal option/data shapes).
The publish gate's knip lane flagged them (my per-step checks ran tsc/eslint/
prettier/vitest but not knip). They're used inside their own modules by the
interfaces, so make them local (consumers compare against literals, never name
the type).
2026-07-28 00:24:38 +02:00
peter 1223df6ccc refactor(api): code-review fixes (R7 S3b review round 1)
- barrel re-exports domains with `export type *` (not `export *`) so the
  internal downloadsApi/plexApi/messagingApi slices stay behind the façade;
  plex's runtime value exports (EMPTY_PLEX_FILTERS/plexFilterCount) re-exported
  explicitly
- App.tsx: drop the now-redundant `prefs as Record<string, unknown>` self-cast
  (Me.preferences is already that type) and its `prefsRec` alias
- plex.ts: comment why plexSetState keeps its status union inline (reusing
  VideoStatus would make an api.ts↔plex.ts type cycle)

Behaviour-identical. Gate green: typecheck, eslint 0, prettier, 74 vitest.
2026-07-27 23:45:02 +02:00
peter 926ebe7ffa refactor(api): extract the messaging domain to api/messaging.ts (R7 S3b·4)
The E2EE direct-message types (MessageUser/Message/Conversation/MyKeyBundle/
KeySetup) + the ~9 messaging methods move into a self-contained
api/messaging.ts exporting a `messagingApi` slice (imports only core's req).
api.ts spreads the slice and re-exports the types. api.ts 882 -> 819 lines; no
runtime change. Gate green: typecheck, eslint, prettier, 74 vitest.
2026-07-27 23:26:23 +02:00
peter 3232a53c46 refactor(api): extract the plex domain to api/plex.ts (R7 S3b·3)
All Plex types (browse cards, show/season/item details, collections, native
playlists, filters/facets, watch-link) + EMPTY_PLEX_FILTERS/plexFilterCount/
appendPlexFilters + the ~40 plex methods move into a self-contained api/plex.ts
exporting a `plexApi` slice (imports only core's req/beacon). api.ts spreads the
slice and re-exports the types via `export *`. api.ts 1352 -> 882 lines (halved
from the original 1793); no runtime change. Gate green: typecheck, eslint,
prettier, 74 vitest.
2026-07-27 23:23:31 +02:00
peter cc38d8bdfa refactor(api): extract the downloads domain to api/downloads.ts (R7 S3b·2)
The download-center types (DownloadJob/Spec/Profile/EditSpec/ShareLink/... and
the admin storage/quota shapes) + all ~30 download methods move into a
self-contained api/downloads.ts exporting a `downloadsApi` slice. api.ts spreads
the slice into the `api` façade and re-exports the types via `export *`, so the
53 call sites are unchanged. Cleanly separable — the domain's types reference
only each other and its methods only touch core's req/getActiveAccount. api.ts
1583 -> 1352 lines; no runtime change. Gate green: typecheck, eslint, prettier,
74 vitest.
2026-07-27 23:17:12 +02:00
peter 49f8c57c89 refactor(api): extract the shared HTTP machinery to api/core.ts (R7 S3b·1)
First step of splitting the api.ts god module behind its façade. Move req,
HttpError, localizeDetail, the connectivity toast, ReqConfig, the per-tab
active-account plumbing (getActiveAccount/setActiveAccount/clearActiveAccount/
beacon), setupHeaders, and setUnauthorizedHandler into lib/api/core.ts. api.ts
imports the machinery it uses and re-exports the public surface, so the 53 call
sites importing from "./api" are unchanged. api.ts 1793 -> 1583 lines; no
runtime change (pure move). Gate green: typecheck, eslint, prettier, 74 vitest.
2026-07-27 23:08:48 +02:00