Compare commits

...
54 Commits
Author SHA1 Message Date
peter 6b4febcc95 release: 0.71.0 (improvement/e5-s1-plex-platform)
gate / frontend (push) Failing after 1m9s
gate / backend (push) Successful in 1m57s
2026-08-21 08:26:31 +02:00
peter cb5ac09a80 chore(e5): bump to 0.71.0 + release notes for the Plex Experience epic 2026-08-21 08:21:21 +02:00
peter bad2aae753 fix(e5): deep-review round — combobox Enter, parse_person collision, enrich hardening + coverage
Full-epic /deep-review (11 finders + 12 adversarial verifiers) surfaced no HIGH; every
mechanism finding calibrated to LOW on verification. Fix all 13:

Correctness / reliability:
- Combobox: Enter now commits the VISUALLY highlighted row (highlightIndex ?? activeIndex),
  not the bare keyboard activeIndex — so arrow-then-hover-then-Enter no longer toggles a
  different option than the one painted, and hover-with-no-arrow is no longer a silent no-op.
- parse_person: dedup the known_for pool by (id, media_type), not the bare id — TMDB movie
  and tv id spaces are independent, so a same-id movie+show pair is two distinct credits
  (matches the collision-safe key the rest of the module already uses).
- person-enrich: a per-person in-flight + 60s cooldown guard (people._should_schedule_enrich)
  dedups concurrent opens and rate-limits re-triggering while TMDB is degraded (a transient
  TmdbError leaves the watermark NULL), so bursts can't pile 8s tasks onto the shared threadpool.
- PlexInfo compact()/money(): format with i18n.language (keeping currency USD) instead of a
  hardcoded en/en-US, matching the app's Intl convention.
- delete the dead `plex.filter.add` i18n key (both locales) — the +Add button that used it is gone.

Reuse:
- _continue_watching reuses rollup.agg_subquery(include_resumed=True) + query.apply_show_state_filter
  instead of a third hand-inlined copy of the per-show rollup + in_progress predicate, so the
  Discover rail can't drift from the grid's definition.

Test coverage (the gaps a regression would ship through):
- resolve_library_guids scope-exclusion (a credit in a non-scoped/disabled library is dropped)
  + the []-vs-None distinction; parse_person cross-media-id retention; enrich_person transient
  (NULL watermark) and 404 (stamp) branches; _enrich_person_bg body (fresh session close/rollback,
  None-guard) + the cooldown dedup; continue_watching fully-watched-show exclusion; Combobox
  Enter-commits-highlighted + async/controlled-query mode; PopoverItem aria-checked contract;
  RatingsRow missing-IMDb + imdb==audience branches.

Backend lane 413→420, FE vitest 351→357; all gates green. No migration.
2026-08-21 08:11:18 +02:00
peter 8f6714e678 refactor(e5): deferred review-tail (frontend) — Carousel reuse + shared scroll core
The last of the deep-review deferred reuse items:

- extract the scroll-affordance machinery (watch the scroller AND its swappable/
  lazily-growing children, diff two edge booleans) that useScrollFade (vertical) and
  Carousel's useHScroll (horizontal) each hand-duplicated into one shared
  lib/useScrollEdges(axis); both hooks now just map start/end to their own shape.

- migrate PlexInfo's two remaining hand-rolled `flex overflow-x-auto` strips — the
  cast row and the collection strips — to the S1 <Carousel> primitive, so they get the
  edge fade + hover/focus scroll buttons (only on the clipped side) for free, matching
  the Discover rails / CastStrip / RelatedStrip.

Pure refactor, no behaviour change. Gate green (tsc, vitest 351, eslint, prettier,
knip). Browser-verified on a movie info page: cast row + collection strip render as
Carousels with the edge fade and the right-scroll button on the clipped cast row.
2026-08-21 06:16:15 +02:00
peter 13fb2b4e61 refactor(e5): deferred review-tail (backend) — enrich off request path, reuse, tests
Address the deep-review's deferred backend items so E5 is review-complete:

- person-enrich OFF the request path (ABUSE finding): person_detail no longer makes
  the TMDB call inline (even bounded to 8s it sat on the serve thread, un-throttled,
  demo-reachable — it violated the module's "no live TMDB call at serve time"
  invariant). It now schedules a FastAPI BackgroundTask (people._enrich_person_bg,
  fresh session, best-effort) after the response; the first open serves the row as-is
  and the bio lands by the next open. The inline try/rollback recovery is gone with it.

- reuse: a shared tmdb.resolve_library_guids owns the collision-safe guid→library
  bucketing (movie vs tv ids are independent sequences) that resolve_recs and
  people._resolve_known_for each hand-duplicated; a new rollup.item_state_map dedups
  the "PlexState-by-item_id" fetch across the person + discover paths.

- test gaps: enrich_batch transient TmdbError leaves the watermark NULL to retry;
  guid_to_tmdb_id resolves a tvdb source + tv media_type via /find's tv_results;
  _continue_watching includes a NULL-status started movie and excludes a hidden one;
  /api/search/youtube returns 422 (not 5xx) when ingest fails (route-level, B-core-1).

Backend lane 403 → 413, ruff clean. No migration; serve contracts unchanged.
2026-08-21 06:09:17 +02:00
peter 5316f48af2 feat(e5): first-class combobox platform mode + universal Combobox (A11Y-1)
The E5 filter pickers were a hand-rolled floating-ui combobox (MultiSelectChips)
with no combobox ARIA, no arrow-nav, and options in a body-portal — keyboard/SR
users could not operate it, and the bare role=listbox/option wiring actively
misled screen readers (deep-review A11Y-1, CONFIRMED 3/3 medium). Rather than
one-off-hack the a11y, add combobox as a first-class mode of the AnchoredPopover
platform and rebuild the pickers on a reusable component.

Platform (AnchoredPopover):
- new mode "combobox": reuses the whole positioning core (offset/flip/shift/size/
  hide, isPositioned + transform-clobber flash guards, FloatingPortal + fullscreen
  re-parent, topmost-only Escape layer). Differs only in the focus model — no
  useClick (open is field-driven), useRole role="combobox" so Floating UI wires the
  full ARIA tree (input role=combobox + aria-expanded/controls/autocomplete/
  activedescendant, listbox surface, option ids), and useListNavigation virtual:true
  so ↑/↓ move an aria-activedescendant pointer while DOM focus STAYS on the input.
- PopoverSurface: combobox surfaces host the item registry but are NOT focus-trapped.
- PopoverItem: virtual mode carries a stable id (the activedescendant target), never
  takes roving DOM focus, keeps the input focused on click (mousedown preventDefault),
  and its checked/selected truth wins over getItemProps' single-select guess.

Component (Combobox.tsx, new): the universal react-select/Overseerr combi-input —
chips-in-field + typed search + checklist. Supports multi/single select, client OR
server (async) filtering, custom rows, the big-vocabulary render cap + refine footer,
and a selected-label cache so a server-narrowed single-select still names its chip.

Rollout: MultiSelectChips (genre/age-rating/studio) → Combobox; the CollectionPicker
"+Add" popover → a single-select async Combobox (also fixes its arrow-keys-eaten
story), unifying the two filter idioms. PlexSearchBox is a documented future consumer.

Gate green (tsc, vitest 351, eslint, prettier, knip). Browser E2E on localdev:
role=combobox confirmed on both pickers; ↑/↓ moves aria-activedescendant with focus
staying on the input; Enter + click select; multi-select chips accumulate; Backspace
removes the last chip; async collection server-search picks + closes. No backend change.
2026-08-21 05:43:36 +02:00
peter 0c746f5bcd fix(e5): deep-review survivors — TMDB key log leak, person-enrich rollback + timeout
Three verified deep-review findings (the 4th, the filter combobox a11y gap, is
deferred to a dedicated session on the combobox architecture):

- SEC (medium): TmdbClient sent the api_key as a URL query param, so an HTTP
  error's httpx exception string embedded the full URL incl. the key, which
  enrich_batch logged. _get now raises status+path only (never str(e)/the URL)
  and severs the secret-bearing cause with `from None`. Regression test asserts
  neither the status nor the network error path contains the key.
- STATE (low): person_detail's lazy-enrich except now db.rollback()s, so a
  failed enrich_person commit can't poison the session and 500 the subsequent
  SELECTs (SQLAlchemy 2.x deactivates the tx on a commit-time flush failure and
  does not auto-reset it). Test locks that the page renders despite a poisoned
  enrich.
- ABUSE (low): the request-path lazy person enrich now caps its TMDB client at
  an 8s timeout (was the 30s default) so a slow TMDB can't tie up a worker;
  TmdbClient gained a `timeout` param (batch keeps 30s). The fuller fix (move
  person enrichment off the request path) is logged for later.
2026-08-21 04:42:07 +02:00
peter caced8664c fix(e5): stop the filter dropdown flashing at the origin before it positions
The combi-input popover flashed in the top-left corner for a frame, then
jumped into place. Two causes, both confirmed with a per-frame capture in
the browser:

1. The panel painted before floating-ui computed a position — gated its
   visibility on `isPositioned` (hidden until positioned), same as
   PopoverSurface.
2. The bigger one: the `popIn` entrance animation animates `transform`
   (scale/translateY), and it sat on the SAME element floating-ui positions
   via `transform` — so during the 0.16s animation the keyframes OVERRODE
   the translate(x,y) and the panel rendered at the origin. Moved the glass
   styling + popIn onto an inner child, leaving the positioned element's
   transform untouched.

Frame capture after the fix: frame 0 hidden, first visible frame already at
translate(82,474) — never visible at the origin. Also gave the size
middleware a min width for a narrow rail.
2026-08-21 04:00:54 +02:00
peter 29f2c9059e fix(e5): rebuild the filter combi-input on floating-ui primitives (focus, dismiss, z-order)
The AnchoredPopover-based version had three UAT-caught bugs: the field
showed an ugly "Add" placeholder; the first click couldn't type (the
platform's FloatingFocusManager + focusItemOnOpen pulled focus onto the
list, blurring the field until a second click); and an outside click didn't
close the list. A visual pass also caught the dropdown rendering BEHIND the
sidebar panel (z-menu 20 < z-panel 35).

Rebuilt on floating-ui primitives directly — no focus manager, so focus
stays in the field; open is driven by field focus; useDismiss closes on
outside-press/Escape; the list is a z-popover (60) FloatingPortal sized to
the field width. Placeholder is now a plain "Search" (empty once chips
exist). Verified in the browser: type-on-first-click, filter, chip add,
outside-close, and the list painting above the sidebar.
2026-08-21 03:37:14 +02:00
peter b83248c510 fix(e5): Plex detail/UI polish from review (backdrop reset, layout, combobox, labels)
User-reported issues after the E5 build:

- useCrossfadeBackdrop: the static branch (one image / crossfade off /
  reduced motion) painted #root but returned NO cleanup, so the backdrop
  persisted after leaving the page (e.g. switching modules). Every painted
  branch now returns clear() so unmount always resets the default backdrop.
- scheduler: give the tmdb_enrich job a proper label + description (was the
  raw "tmdb_enrich" id) in EN/HU.
- CastStrip: cap crew names to ONE line (truncate) so the row of circular
  photos sits on one baseline instead of going wavy on 2-line names.
- PlexInfo: lay Details / Keywords / Crew out as side-by-side boxes
  (items-start grid) instead of full-width stacked panels with dead space;
  narrower Crew grid for the boxed width; each panel independently hideable
  from the customize menu (new Details/Keywords/Crew toggles). Tighter
  vertical spacing.
- MultiSelectChips: reshape the trigger from a "+Add" button into an
  Overseerr-style combi input — removable chips inside a typeable field that
  filters the checklist, opening on focus (backspace removes the last chip).
- Discover / person rails: reduce the vertical gap between rails.
2026-08-21 03:16:40 +02:00
peter 2b5de89eda fix(e5): address code-review findings (TMDB guid media_type, search ingest guard, discover keying)
Follow-ups from the epic-end /code-review of the E5 branch:

- tmdb.resolve_recs + people._resolve_known_for: match recommendations/
  credits to the library bucketed by media_type. TMDB movie ids and tv ids
  are independent sequences, so a bare `tmdb:<id>` match could deep-link a
  movie rec to an unrelated show (or vice-versa) on a numeric collision.
  Regression test added (test_resolve_recs_matches_by_media_type_not_bare_id).
- routes/search: wrap the _ingest_candidates call so a DB/enrich failure
  degrades to a 422 with a message instead of a bare 500 hidden behind the
  FE's generic "search failed" (extends the B-core-1 rationale); rollback on
  failure. Stamp enriched_at with its own timestamp taken after the enrich,
  not the stub-insert placeholder.
- routes/plex/discover: key serialized cards back by rating_key (globally
  unique in Plex) instead of id(obj); fix the stale rows 3-tuple annotation.
2026-08-21 03:16:24 +02:00
peter ff45db70ae fix: live YouTube search 500 on the scrape source (published_at NOT NULL)
The scrape source exposes no publish date, so its search stub carried
published_at=None. Migration 0068 (PERF-15, E2/0.68.0) made
videos.published_at NOT NULL, so insert_stubs raised IntegrityError —
outside the route's page-fetch try/except — and the whole
/api/search/youtube request 500'd ("YouTube search failed."). Every
scrape search (the default source) had been broken since 0.68.0.

- routes/search._ingest_candidates: stub published_at falls back to
  now() (mirrors stub_from_playlist_item), satisfying the constraint.
- sync.videos.apply_video_details: prefer the enrich snippet's real
  date whenever present, so the now() placeholder is corrected instead
  of becoming the video's permanent date (the guard only filled a NULL,
  which a non-null placeholder no longer is). Never clobbers a real
  date with now().

Regression tests (DB lane): dateless scrape stub ingests and takes the
real enrich date; an enrich miss keeps a non-null placeholder, unkept.
2026-08-21 02:15:09 +02:00
peter 7d40a8780d E5 S8: crossfading art backdrop (detail + person pages)
The faint HTPC art backdrop now slowly cross-dissolves through a pool of images
instead of showing one static art — the last E5 sprint.

- useCrossfadeBackdrop (replaces useArtBackdrop): a double buffer — the SETTLED
  image lives on #root's background (the proven full-viewport layer); the next
  image cross-fades in on a z-index:-1 overlay layer, then is committed and the
  overlay reset. ⭐ #root is given `isolation: isolate` so it's a stacking context
  — without it the z:-1 overlay paints BEHIND #root's own background and is
  invisible (found + fixed live: a red test layer + a real TMDB backdrop both
  only showed once #root was isolated).
- Art pool: the item's own art + its TMDB backdrops (E5 S2, rendered straight
  from the TMDB CDN — the CSP allows it) + its collection siblings' art; the show
  page adds related art; the PERSON page cross-dissolves through the person's
  films' art (the roadmap's signature touch).
- HARD-OFF under prefers-reduced-motion (static, no overlay) + a new usePref
  "Crossfade backdrop" toggle in the detail customize menu (fade 1.6s / 7s cycle).

Wired into PlexInfo, PlexShowView, PlexSeasonView, PlexPersonView. Tests:
plexBackdrop.dom.test.tsx (5: paint+isolate+overlay, reduced-motion off, single
image, animate=false, disable/unmount cleanup). Browser-E2E'd (isolation +
overlay render + a real backdrop crossfade + the toggle). Gate: vitest 347,
backend lane 403 (unchanged — FE only).

▶ E5 · Plex Experience epic COMPLETE (S1–S8).
2026-08-21 01:00:11 +02:00
peter d56a791c04 E5 S7: Plex filter UI — listbox-select pickers + studio filter
Reworks the always-expanded filter chip walls into compact, Seerr-style
"+ Add" → searchable-popover pickers (the user's space-saving ask), and adds
the missing studio filter.

- New MultiSelectChips: selected values as removable chips + a "+ Add" trigger
  opening a searchable checklist (AnchoredPopover platform — menuitemcheckbox,
  arrow/Escape, anchor flip). Options carry facet counts; a large vocabulary
  renders a 50-row cap + "N more — refine with search" footer (studios run to
  ~1500). Replaces the flat Genre + Age-rating chip lists.
- New Studio filter via the same picker. Backend: /facets now aggregates the
  studio vocabulary (distinct studio + count, self-excluded like genres). The
  studio filter itself already existed (only addable from the detail page before).
- Collection converted to the same "+ Add" pattern (CollectionPicker): a
  single-select popover with server-side search + the 50-cap; the selected
  collection still shows as a removable chip. Retires the always-inline search+list.

New i18n plex.filter.studio/add/moreRefine + common.search/remove/noResults
(en+hu). Tests: MultiSelectChips.dom.test.tsx (5, incl. the cap) +
test_plex_facets studio-vocabulary. Browser-E2E'd: Genre→Horror(402),
Studio→A24(14, capped/searched), Collection→James Bond(26)/harry-search.
Gate: vitest 342, backend lane 403.
2026-08-21 00:40:31 +02:00
peter 187b8007f8 E5 S6: enriched person page (TMDB bio + birth facts + Known-for rail)
The person page now leads with a TMDB bio, lazily fetched the first time it's
opened (off the S2-linked `tmdb_person_id`) and then served from the row:

- Migration 0072: ADD-only nullable person columns on plex_people — biography,
  birthday, deathday, place_of_birth, known_for_department, also_known_as,
  known_for ([{tmdb_id, media_type, title}]), tmdb_enriched_at.
- tmdb.py: TmdbClient.fetch_person (/person/{id}+combined_credits), parse_person
  (bio + a popularity-ranked, deduped known_for), apply_person, enrich_person
  (lazy one-shot, key+id guarded, commits, 404 stamps the watermark).
- people.py person_detail: loads the full person row, lazily enriches (best-effort
  — never fails the page), serves the bio fields + resolves known_for to library
  rows by guid (in-library only, popularity order).
- FE PlexPersonView: bio/birth-facts/aka hero + a "Known for" Carousel rail above
  the existing Movies/Shows filmography; PlexPersonDetail type + i18n (en+hu).

Tests: test_plex_person_bio.py (6: parse / lazy-enrich guards / serve) + extended
PlexPersonView.dom.test.tsx + the unknown-name contract in test_plex_person.py.
Browser-E2E'd on Jim Carrey (live TMDB enrich → bio 1937 chars, born 1962-01-17
Newmarket, Known-for resolved). Gate: vitest 337, backend lane 402.
2026-08-21 00:08:19 +02:00
peter f10915c223 E5 S5: enriched Plex detail page (ratings + facts + keywords + crew)
Consumes the TMDB block the S2 engine already persists + serves (movie/show
detail's `tmdb`), turning the movie info page into a Seerr-style enriched detail
(page variant only — the player overlay stays lean):

- Multi-source ratings row: IMDb (links out) · TMDb (with vote count) · Plex
  audience — whichever are present. Only the primary score (IMDb, else audience)
  carries the ratingMin filter, mirroring the backend rating column.
- Facts panel: release · runtime · cert · budget · revenue · studios · countries.
- Keyword chips → tap to filter the library. Backend: a new `keywords` filter on
  LibraryFilters + meta_filter_conds (`keywords @> [kw]`, both models), wired
  through PlexFilters (appendPlexFilters / plexFilterCount / active-chip / clear).
- Full crew (TMDB key-crew) → tap a name to open their person page.

Tests: test_plex_keyword_filter.py (3) + PlexInfo.dom.test.tsx (5, incl. the
overlay-stays-lean guard). New i18n plex.info.facts.* / keywords / crew / audience
(en+hu). Gate: vitest 337, backend lane 396.
2026-08-20 23:50:50 +02:00
peter f9b63745f9 E5 S4: Plex Discover rail-surface (default landing)
A dedicated Plex "Discover" landing — a stack of horizontal Carousel rails
served from ONE consolidated read (serve-from-DB, no live Plex/TMDB call):

- GET /api/plex/discover → continue_watching (in-progress movies + shows, most
  recently progressed) · recently_added · genres (top-N tiles) ·
  because_you_watched (recs of the most-recent watched, TMDB-enriched movie,
  resolved to library rows by guid via serve_block's resolver).
- GET /api/plex/surprise → a fresh random, scope-bounded sample for the ↻ rail.

Frontend: PlexDiscover.tsx (rails reuse the S1 Carousel + S3 PlexPosterCard;
colored genre tiles → tap to filter the grid; ↻ reshuffles the surprise rail
alone). Discover is now the root sub-view; the flat grid becomes the bookmarkable
"Browse all" secondary view (?v=browse) reached by a header search/filter (a
browse-intent flip) or the new Discover⟷Browse toggle.

resolve_recs is promoted from _resolve_recs (now cross-module). Tests:
test_plex_discover.py (rail shape/order, continue-watching, because-you-watched
+ null, surprise bounds, scope) and PlexDiscover.dom.test.tsx; plexSubUrl
round-trip updated for discover-root/grid-browse. Backend lane 393, vitest 332.
2026-08-20 22:15:54 +02:00
peter 5b5e76b73d E5 S3: unified Plex card — scrim-reveal hover + rest polish
The poster card (grid + person page) gets the Seerr-style hover: on hover a
bottom scrim slides up with the title, a "year · runtime · ★rating" meta line,
and a 3-line synopsis — the info you'd otherwise click through for — plus a
less-flat play ring (radial-gradient + ring shadow) and a top-right quick-action
cluster (Info + watched-toggle, revealed on hover / always on touch). Rest-state
badges (season / type tag) yield to the scrim on hover; the playable-cost tint
stays. (§1 A + C from the enrichment study.)

Backend (the data the scrim needs, mirroring S1's `art`): the grid
(browse.unified_library) now emits a lang-coalesced, truncated `summary` (new
query.summary_col) + the Plex `rating`; movie_card/show_card do the same (EN) for
the detail/related/person cards. PlexCard.rating type added (summary already
existed for episodes).

Gate: tsc x3 / eslint / prettier / knip / vitest 326 green; ruff green; backend
lane full suite green. Self-E2E on the live dev server (rebuilt on this branch,
S2-enriched): the grid serves rating+summary (Shawshank 9.3, Godfather 9.2, real
synopses) and the card renders the play ring + scrim (The Expanse: ring, synopsis,
★8.1) — verified via the DOM (the pane wouldn't composite for a screenshot).
2026-08-20 20:30:30 +02:00
peter 91be3d0535 E5 S2: prioritise TMDB crew, and capture the TV showrunner
A live smoke against the real TMDB API showed two crew problems the mocked
tests missed: (1) a TV show's creator/showrunner lives in the payload's
top-level `created_by`, not the crew list, so it was dropped entirely; and
(2) TMDB's crew order is arbitrary — a TV show's per-episode Directors (dozens)
flooded the key-crew list and buried the show-level roles.

Fix: split the accepted crew jobs by media type — movies key on
Director/Writer/…; TV keys on Creator (from created_by) + the show-level
Executive Producer/Writer/Producer/Composer, deliberately excluding the
per-episode Director/Editor/DP noise. Stored crew is sorted by display
priority and capped, so the lead roles come first.

Verified live: Breaking Bad -> [Gilligan (Creator), Johnson (EP), … , Writer];
The Godfather -> [Coppola (Director), Puzo (Screenplay), …]. test_plex_tmdb
green (9).
2026-08-20 09:24:25 +02:00
peter 3bde114b14 E5 S2: TMDB enrichment engine (Plex catalog)
Matches each mirrored Plex movie/show to TMDB by its stored canonical guid,
fetches once (append_to_response), and PERSISTS the enrichment onto the row —
served from the DB, never a live TMDB call at serve time. Additive: the
Plex-sourced imdb_rating/rating are untouched; tmdb_rating is the distinct
TMDb community score (ratings scope = TMDb + Plex's IMDb/audience, no OMDb/RT).

- config.py: tmdb_api_key + api_base/language/interval/batch defaults.
- sysconfig SPECS: tmdb_api_key (secret, Fernet) + tmdb_language, auto-rendered
  in the admin Config page; read like the Plex token.
- migration 0071 (ADD-only nullable, reversible): plex_items + plex_shows gain
  tmdb_id / tmdb_enriched_at (watermark) / tmdb_rating / tmdb_vote_count /
  budget / revenue / keywords / crew / studios / production_countries /
  recommendations / backdrops; plex_people gains tmdb_person_id (the S2
  foundation for S6 person bios). No watermark reset needed.
- app/plex/tmdb.py: TmdbClient (mirrors PlexClient; throttled; 404->None,
  transient->TmdbError), parse_tmdb / apply_tmdb_detail (one-parser rule),
  guid_to_tmdb_id (tmdb: direct, imdb:/tvdb: via /find), enrich_batch (movies
  then shows, transient error leaves the watermark NULL to retry, definitive
  no-match still stamps so it isn't re-fetched), _link_people (upserts
  tmdb_person_id by name), serve_block (recommendations resolved to library
  rows by guid), run_enrich (no-op without a key).
- scheduler: tmdb_enrich nightly job (default 120 min, no YouTube quota),
  runtime-tunable + "run now" from the Scheduler dashboard.
- detail endpoints emit `tmdb` (movies + shows) so it's curl-verifiable now;
  the FE render lands in S5.

Note: _link_people inserts plex_people rows for TMDB cast/crew; unreferenced
crew (composers etc., not in cast_names/directors) get pruned by the orphan
sweep — acceptable, the person page is cast-driven and cast rows survive.

Gate: ruff + py_compile green; backend test lane FULL suite 387 passed
(incl. migration drift downgrade->re-upgrade + test_plex_tmdb 9 tests).
Frontend unchanged since S1. Not merged (epic branch, stacks on S1).
2026-08-20 09:17:43 +02:00
peter fca3fa13ef E5 S1: shared Carousel primitive + Plex card-art serializer
Wave 5a building blocks under the Seerr-inspired Plex enrichment (E5).

Carousel (frontend/src/components/Carousel.tsx): the one horizontal-rail
component, replacing the hand-rolled `flex overflow-x-auto` strips and the
home of the future Plex-Discover rows. Edge-fade mask, hover/focus scroll
buttons that appear only on the clipped side (useHScroll — the X-axis twin
of useScrollFade, same watch-the-children discipline), native scrollbar
hidden behind .no-scrollbar, reduced-motion-aware paging. First consumers:
CastStrip + RelatedStrip now scroll inside it (dedup + free affordances).
New i18n: common.seeAll / scrollLeft / scrollRight (en+hu).

Card-art serializer (backend/app/plex/cards.py): movie_card + show_card now
emit `art` (the wide backdrop, /api/plex/image/{rk}?variant=art) so rails and
the crossfading hero get a landscape image with no per-item fetch — gated on
art_key so an art-less item stays null and falls back to the poster. FE type
PlexCard.art?.

Tests: Carousel.dom.test (children/header/see-all, clipped-edge buttons +
paging), test_plex_cards (art present/absent by art_key). Added a
ResizeObserver stub to PlexPersonView.dom.test (it renders CastStrip, now a
Carousel consumer).

Gate: tsc x3 / eslint / prettier / knip / vitest 324 green; ruff green;
backend test lane (docker-compose.test.yml) green on the plex card tests.
2026-08-20 08:50:47 +02:00
peter df60bc5ff5 merge: E4 test-coverage backfill (deferred deep-review gaps + 2 review rounds)
Closes every ▶ DEFERRED test-coverage item from the E4 deep-review (Modal
focus-trap, useHistorySubview popstate URL-fallback, PLEX_SUB_URL round-trip,
loadInitialPage precedence + channelViewFrom, readSelectedId ?pl guard,
useUrlMirror dep-keying, usePref flush-on-unmount) + the a11y sweep, and the
findings from two /code-review high rounds. Test-only + behaviour-preserving
seams (parseId/navigationInit/playlistsInit/plexSubUrl extractions); no
user-facing change, no version bump. vitest 257→321. Not deployed.
2026-08-20 05:56:16 +02:00
peter 99d63d272e test(e4): lock accessible names on the Plex card buttons
Closes the re-review's test-coverage finding: a plexCards.dom.test asserting the
icon-only card controls (watched-toggle, Info, add-season/episode) each expose
their aria-label as an accessible name via getByRole({name}). A future edit that
drops a label now fails the gate instead of silently reopening the unnamed-button
gap the a11y sweep closed.

4 tests; full FE gate green (tsc/eslint/prettier/knip, 321 vitest).
2026-08-20 05:52:36 +02:00
peter 482dd28006 fix(e4): address code-review findings (a11y sweep + dedup guards)
Resolves the 5 /code-review high findings on this branch:
- a11y sweep: extend aria-label to the remaining icon-only Plex card buttons the
  first pass missed — PlexPosterCard Info, SeasonCard watched-toggle + add-season
  (EpisodeCard already had them). "Fix a principle everywhere, in one sweep."
- new shared lib/parseId.ts `parsePositiveIntId`, used by BOTH readSelectedId
  (now guarding the stored ?pl fallback the same as the URL — was unguarded) and
  plexSubUrl's ?v=playlist decode; removes the hand-duplicated guard.
- PAGE_KEY exported from navigationInit and imported by NavigationProvider (was
  defined in both).
- hoist a `watchedLabel` const per card so title + aria-label can't drift.

+8 tests (parseId unit + guarded-stored ?pl); full FE gate green (tsc/eslint/
prettier/knip, 317 vitest).
2026-08-20 05:40:59 +02:00
peter 0b7180ce8f a11y(e4): aria-label on the icon-only Plex watched-toggle buttons
The hover watched-toggle on Plex movie/episode cards is icon-only (WatchedIcon,
no text), so it carried a `title` but no accessible name for screen readers —
and the empty-Circle unwatched glyph could read like a radio. Mirror the title
into `aria-label` (the Modal close-button idiom) so the control announces its
action. The PlexInfo watched buttons already have visible text labels, so they
need nothing.

FE gate green (tsc/eslint/prettier/knip, 309 vitest).
2026-08-20 05:25:48 +02:00
peter d2f2085f1f test(e4): readSelectedId ?pl guard, useUrlMirror dep-keying, usePref flush
The remaining E4 deep-review deferred gaps, in one sprint:
- readSelectedId split into playlistsInit.ts (react-refresh-clean, testable) —
  ?pl accepted only as a positive integer; empty/0/negative/fractional/non-numeric
  fall back to the stored selection; null when neither is present.
- useUrlMirror: mounts write the param, a same-serialized rerender does NOT
  re-fire (stable dep), a null value clears the key, other params preserved.
- usePref: a pending debounced write is FLUSHED on unmount (not dropped); an
  unmount with nothing pending stays silent.

20 new test cases; full FE gate green (tsc/eslint/prettier/knip, 309 vitest).
2026-08-20 05:24:11 +02:00
peter 062015ba27 test(e4): loadInitialPage precedence + channelViewFrom (S1 fix)
Split the two URL-derived nav seeds out of NavigationProvider into
navigationInit.ts so they're unit-testable and the provider stays
react-refresh-clean (component-only exports). channelViewFrom also dedups the
identical _chan/?chan resolve the channelView seed and the onPop handler each
hand-rolled — locking the S1 fix (a popstate on a URL-only bookmark entry must
restore the channel from ?chan, not close it). Pure code motion.

Tests: the full loadInitialPage precedence matrix (?page > ?v > ?pl >
filters/?chan > stored landing > feed, with the beats-a-lower-param cases) and
channelViewFrom (_chan+name, ?chan fallback, state-over-url, closed → null).

15 tests; full FE gate green (tsc/eslint/prettier/knip).
2026-08-20 05:21:13 +02:00
peter 0401dae689 test(e4): extract PLEX_SUB_URL for round-trip coverage
Move `type Sub` + `PLEX_SUB_URL` out of the 538-line PlexBrowse into a small
plexSubUrl.ts so the encode/decode round-trip and its guards can be unit-tested
without dragging the component (and its lazy hls.js player) into the test graph.
Pure code motion — PlexBrowse imports the two symbols; the `SubviewUrl` import
moves with them.

Tests lock: encode of each bookmarkable kind, grid/player → null, encode→decode
identity, a person name containing ':' round-tripping, and the decode guards
(positive-integer playlist id; season needs both ids; non-empty show/info/person
arg; unknown kind/garbage → null).

12 tests; full FE gate green (tsc/eslint/prettier/knip).
2026-08-20 05:16:06 +02:00
peter e8a1c252a9 test(e4): useHistorySubview seed + popstate URL-fallback (S2 fix)
Backfills the E4 deep-review's deferred coverage for the subview history
primitive. The keeper case is the S2 bookmark fix: a popstate that lands on a
URL-only entry (no history.state._sub) must restore the sub-view from the ?v
param via the SAME fallback the mount seed uses, not collapse to root.

Covers: seed from URL vs state._sub vs root, the no-SubviewUrl state-only
path, open() push+mirror + encode→null key-clear, and popstate restore from
state / from URL (the fix) / collapse-to-root.

9 tests, gate green.
2026-08-20 05:14:12 +02:00
peter fa8bdf83a8 test(e4): Modal focus-trap coverage (FE-9 deferred gap)
Backfills the E4 deep-review's deferred Modal focus-management test. jsdom
returns null for every offsetParent (no layout), so focusablesIn() would see
zero controls; the test stubs HTMLElement.prototype.offsetParent to report
"visible" for connected nodes (what a real browser reports for the shown
dialog), and a second describe documents the raw-jsdom empty-guard path.

Covers: initial focus on open, Tab/Shift+Tab edge-wrap, pull-back on escaped
focus, the active===panel leak guard, no-trap in the interior, Escape closes
the topmost only, and restore-to-trigger on unmount. useBackToClose is mocked
(its history stack is covered by history.dom.test.ts).

10 tests; full FE gate green (tsc/eslint/prettier/knip, 257 vitest).
2026-08-20 05:12:33 +02:00
peter 868ff533ee style(e4): prettier-format two S6-touched files to unblock the 0.70.0 gate
gate / frontend (push) Successful in 1m2s
gate / backend (push) Successful in 2m1s
The reveal-class swap in S6 shortened two className lines (VideoCard action row,
SavedViewsWidget default-star), which prettier wants collapsed to one line. The
local `npm run lint` doesn't run prettier --check, so it slipped past the sprint
gate and only the release `siftlode check` caught it. Formatting only, no behavior
change. Fixes the pre-publish gate for the already-merged 0.70.0 on main.
2026-08-20 03:52:11 +02:00
peter 00bf9a39f7 release: 0.70.0 (improvement/e4-frontend-platform)
gate / frontend (push) Failing after 38s
gate / backend (push) Successful in 2m51s
2026-08-20 03:50:02 +02:00
peter 640a8efeed chore(release): 0.70.0 — E4 Frontend Platform (bookmarkable URLs + touch a11y)
Bump VERSION to 0.70.0 and add the user-facing release-notes entry for the E4
Frontend Platform epic: full bookmarkable/shareable address-bar URLs (retiring the
"Share view" button), touch-visible card action buttons with a shape (not
colour-only) watched marker, and the bookmark-page-closes-on-modal-close fix — over
a large internal front-end refactor (god-component splits, usePref, useUrlMirror,
FilterRail, Modal focus). No DB migration.
2026-08-20 03:49:07 +02:00
peter 23ac884b42 fix(e4): address deep-review findings — bookmark popstate, focus trap, touch a11y
Deep-review (6 dimensions, 3-verifier adversarial pass) surfaced 7 findings, all
verified; fixed here.

- NavigationProvider + history.ts (medium, correctness): the popstate handlers
  restored the open channel / Plex sub-view from history.state ONLY, but a fresh
  ?chan / ?v bookmark lives in the URL with no marker in state — so any overlay's
  programmatic history.back() (closing a modal/player) silently closed the
  bookmarked channel or collapsed the sub-view to the grid. Give onPop the same
  URL fallback the initializers already use (history.ts extracts a shared readView
  so seed and popstate can't diverge again).
- plexCards SeasonCard (medium, a11y): the "In progress" season badge was a
  reveal-idle swap-badge → display:none on touch, and a season has no progress bar,
  so an in-progress season was indistinguishable from unwatched on touch. Moved it
  to the top-left (the toggle owns top-right) and made it always visible.
- plexCards watched toggle (low, a11y / WCAG 1.4.1): watched state was color-only
  on touch (green vs white CheckCircle2). New WatchedIcon distinguishes by SHAPE —
  CheckCircle2 (watched) vs an empty Circle (not) — used by all three card toggles.
- Modal.tsx (low, a11y): the focus trap leaked on Shift+Tab when focus rested on
  the panel container itself (Node.contains(self) is true, so the pull-back branch
  never ran). Guard now also checks `active === panel`.
- index.css (low, a11y): .reveal-idle faded only on .group:hover though .reveal is
  revealed on hover AND focus-within → the badge overlapped the control on keyboard
  focus. Added the focus-within fade.
- PlexBrowse.tsx (low, correctness): the ?v=playlist decoder accepted a non-integer
  id (Number.isFinite); tightened to Number.isInteger(n) && n > 0, matching the ?pl
  guard.

Gate green (tsc, knip, lint 0-err, 247 vitest). Self-E2E (localdev): ?v=show and
?chan bookmarks survive a modal open+close (were collapsing/closing before); Modal
Shift+Tab from the panel stays inside; in-progress season badge visible on touch;
watched toggle shows an empty-circle-vs-check shape difference; 0 console errors.
2026-08-20 03:26:19 +02:00
peter 1579a81395 fix(e4): address code-review findings — usePref flush, ?pl guard, dedup (E4 review)
Applies the /code-review high findings on the E4 branch:

- usePref: flush the pending debounced server write on unmount instead of
  clearing it, so the last change isn't lost (and can't later be overwritten by
  the stale server value via the adopt effect). [correctness]
- PlaylistsProvider.readSelectedId: accept ?pl only when it's a positive integer
  — "?pl=" gave Number("")===0 (finite) and "?pl=1.5" a fractional id, both of
  which selected a non-existent playlist. [correctness]
- usePref: extract mergePrefsCache() so useSavePref and usePref.set share the
  one me-cache merge shape instead of duplicating it. [reuse]
- New lib/useUrlMirror: the shared replaceState(urlWithParams(...)) mirror
  effect, so FeedFiltersProvider and PlaylistsProvider stop hand-rolling the
  identical block (keyed on the serialized delta so it only fires on change).
  [reuse]
- plexCards: swapBadgeReveal() helper for the repeated
  reveal-idle/group-hover:opacity-0 swap-badge ternary. [simplification]

Gate green (tsc, knip, lint 0-err, 247 vitest). Self-E2E (localdev): feed filter
mirrors to ?show and clears on leaving the feed / re-mirrors on return via
useUrlMirror; ?pl= (empty) and ?pl=abc no longer select a bogus playlist (fall
back to the stored selection); Plex reveal-idle badges still display:none on
touch after the helper refactor; 0 console errors.
2026-08-20 02:13:49 +02:00
peter 4fe6f38f1b refactor(rail): extract shared FilterRail shell + name the MediaSource seam (E4 S7)
The three filter rails — feed Sidebar, PlexSidebar, PlaylistsRail — each rendered
the same SidePanel chrome (title bar, customize `editing` state, edit-toggle,
reset-to-layout-default, edit hint) around a PanelGroups body, differing only in
identity + filter content. Extracted that shell into components/FilterRail.tsx:
each rail now supplies only what differs (title/icon, active-filter count +
Clear-all action, its `items`, and optional content above/below the groups —
`top` for the feed's scope toggle, `children` for its tag manager). FilterRail
owns the `editing` state, so all three drop their duplicated useState + SidePanel
wiring.

MediaSource facade (lib/mediaSource.ts): a thin `MediaSourceKind = "feed" | "plex"`
naming the two browsable, filterable sources (a PanelId minus "playlists", which
is a rail but not a source). Already unified in practice — one SortControl, the
source-agnostic ActiveFilter[] chips, PanelId layouts, now the shared shell — so
this is formalization, not new capability: a single named seam for a future third
source or a cross-source feature. The two media-source rails annotate their `kind`
with it; PlaylistsRail passes the plain PanelId. Per the recon, the playlist-dialog
merge was deliberately NOT forced (it proved forced + low value). UX-3 control-vocab
sweep deliberately deferred — the rails already use the canonical primitives.

Behaviour-preserving. Gate green (tsc, knip, lint 0-err, 247 vitest). Self-E2E
(localdev): all three rails render via FilterRail — feed (scope toggle + panel
groups + Customize→editing with reset + 56 reorder affordances, exits clean), Plex
(filter groups + customize), Playlists (sections + customize); 0 JS console errors.
2026-08-20 00:47:09 +02:00
peter 94a608053a feat(a11y): touch-visible card controls + modal focus trap/restore (E4 S6)
UX-7 — hover-only card actions were `opacity-0 group-hover:opacity-100`: fine on
a mouse, but invisible AND still tappable on a touch device (no hover). New
`.reveal` primitive (index.css) gates the hide on `@media (hover: hover)`, so a
touch device keeps the control visible; its partner `.reveal-idle` is the idle
badge a control replaces (watched/in-progress pill under the watched-toggle),
faded on hover and dropped on touch so the two don't collide at the same corner.
Migrated the true controls — feed card action toolbar (VideoCard), Plex poster
card watched-toggle/info/add across all card variants + their swap badges
(plexCards), message pop-out (Messages), edit-interval pencil (Scheduler), and
saved-view star/rename/delete (SavedViewsWidget). Pure play-scrim overlays (the
whole card is the tap target) stay hover-only by design.

FE-9 — Modal.tsx had aria-modal but no focus management. Adds: a Tab/Shift+Tab
focus trap (topmost layer only, wraps at the edges and pulls escaped focus back);
initial focus into the dialog unless an autoFocus child already placed it; focus
restore to the trigger on close (captured in a useState initializer during render,
before commit runs autoFocus and moves activeElement inside); and aria-labelledby
wiring the dialog to its title.

Gate green (tsc, knip, lint 0-err, 247 vitest). Self-E2E (localdev): FE-9 trap
both directions + pull-back + Escape-restore-to-trigger + aria-labelledby verified;
UX-7 feed toolbar opacity 0 on desktop / 1 under mobile (hover:none) emulation,
Plex reveal buttons opacity 1 + reveal-idle badges display:none on touch.
2026-08-19 21:35:52 +02:00
peter aea99cc71a refactor(downloads): split DownloadCenter god-file into row + modals + admin (E4 S5)
Pure code-motion, DownloadCenter 1088->359 L. Group by kind, mirroring the S4
plexCards/plexViews split:

- components/downloadRow.tsx (309 L): the user-list presentation + shared row
  primitives — StatusPill, Thumb, IconBtn, SourceRef, ChannelLine, DownloadRow,
  UsageBar (+ jobTitle/jobChannel/displayUrl helpers, status/phase constants).
- components/downloadModals.tsx (178 L): the per-item user modals — MetaEditModal
  (rename/metadata) and ErrorModal (failure detail + retry).
- components/downloadAdmin.tsx (267 L): the admin "System" tab — AdminSystem plus
  QuotaModal and QuotaUserPicker.

Every extracted piece is prop-driven with no shell-state reach-back, so the shell
just imports them back; the tab logic, mutations and modal state stay in
DownloadCenter. Behaviour-preserving.

Gate green (tsc first try, knip, lint 0-err, 247 vitest). Self-E2E (localdev):
Queue/Library/Shared/System tabs render, UsageBar, admin storage/footprint/job
list, MetaEditModal open/close, 0 console errors.
2026-08-19 21:14:49 +02:00
peter 180ab56379 refactor(plex): split PlexBrowse god-file into cards + views + shared UI (E4 S4)
Pure behaviour-preserving code-motion — PlexBrowse 1751 -> 538 lines. The ~11 inlined
components are self-contained (every view is invoked with shell callbacks as props; no
view reaches back into shell state), so they just move to sibling files.

New files:
- lib/plexWatch.ts: toggleWatchState (shared by the grid + person page)
- components/plexCards.tsx: PlexPosterCard, SeasonCard, CastStrip, RelatedStrip, EpisodeCard
- components/plexViews.tsx: PlexInfoView, PlexPersonView, PlexShowView, PlexSeasonView
- lib/plexDetailUi.tsx (extended): BackBtn, ActionBtn, WatchedToggle, DetailHero, epLabel
  (the file's own remit — "shared detail-page UI")

PlexBrowse keeps the Sub router + PLEX_SUB_URL + PAGE + PlexBrowse() shell. Repointed
the only external importer (PlexPersonView.dom.test) to plexCards/plexViews.

Self-E2E (localdev): Plex grid renders (poster cards + episodes); The Expanse show-drill
renders the hero + action buttons + season cards; ?v=show URL-mirroring intact; 0 console
errors. Gate green: typecheck, knip, eslint (0 err), prettier, 247 vitest.
2026-08-19 06:10:48 +02:00
peter 0b592345e6 fix(player): remove the leaked window "blur" listener in useNativeControlsYield
The native-controls-yield effect registered addEventListener("blur", onBlur) but its
cleanup removed "blur-sm" (a stray Tailwind-class find-replace artifact), so the blur
listener was never removed — leaking one per player open/close. Match the names.
2026-08-19 05:56:20 +02:00
peter 4800df4208 refactor(player): split PlayerModal god-component into hooks + controls (E4 S3)
Mirror the Plex player split (plexPlayerHooks.ts / plexPlayerControls.tsx). The shell
keeps owning the shared refs and passes them into each hook; the once-bound keyboard
handler + layer-stack registration stay in the shell (they orchestrate every cluster).
Behaviour-preserving code-motion — PlayerModal 1332 -> 890 lines.

New lib/playerModalHooks.ts:
- useYtVolume (per-account wheel/arrow volume, on shared playerVolume.ts)
- useNativeControlsYield (iframe-focus overlay yield)
- useYtBigView (F-maximise + our true-fullscreen; YT-specific, not Plex's useFullscreen)
- useYtProgress (player create + resume + 5s checkpoint + auto-watch + auto-advance)

New components/playerModalControls.tsx: PlayerTransportControls (moved as-is) + the
AutoMode/LoopMode types.

Self-E2E (localdev): play a feed video -> plays; transport bar (Auto/Loop); wheel volume
persists ({"volume":100}); F maximises + docked bar; exit big view; Close destroys the
player. Gate green: typecheck, knip, eslint (0 err), prettier, 247 vitest.

Noted a pre-existing bug (kept byte-identical here, flagged separately): the native-yield
effect removes a "blur-sm" listener but adds "blur" — the blur listener leaks.
2026-08-19 05:41:52 +02:00
peter 8bfc01684f feat(nav): full bookmarkable URL model — retire minimalist-URL + Share-view (E4 S2)
The query string is now the source of truth for the current view, so every URL is
bookmarkable/shareable. Reverses the early minimalist-URL rule (user's call).

- urlState: urlWithParams (read-modify-write delta on the live query, each provider
  owns its keys) + clearParams (strip only transient auth params) replace the whole-
  query stripUrlParams; shareUrl deleted; filterParamUpdates helper.
- NavigationProvider: page + ?chan mirrored to the address bar (pushState URL arg;
  history.state mechanics untouched); loadInitialPage resolves deep links
  (?v->plex, ?pl->playlists, filter/?chan->feed, bare->stored landing).
- FeedFiltersProvider: feed filters mirrored to the URL, gated on page===feed.
- useHistorySubview: optional SubviewUrl mapping; PlexBrowse mirrors the sub-view to
  ?v=<kind>:<args> (show/season/info/person/playlist; grid+player stay ephemeral).
- PlaylistsProvider: selected playlist mirrored to ?pl on the Playlists page.
- App: the 5 stripUrlParams calls become targeted clearParams (link/oauth/admin/
  access/verify/reset); the share-link hydrate effect removed.
- Retire the "Share view" feature: Sidebar + SavedViewsWidget buttons + i18n keys.

Browser tab title stays the short module/channel name (document.title untouched) —
the query never leaks into it, per the user's request.

Gate green: typecheck, knip, eslint (0 err), prettier, 247 vitest (urlState.dom.test
+9). No browser-E2E yet (most visual sprint — flagged for the epic-end review).
2026-08-19 04:47:36 +02:00
peter 48aa1a25f5 feat(prefs): usePref — one per-account+server preference hook (E4 S1)
Fold the hand-rolled server-pref plumbing (savePrefs + local mirror + me-cache
merge + adopt-on-login) into a single usePref<T>(serverKey, default, opts) hook,
plus useSavePref for dynamic multi-key patches.

Migrated (6 savePrefs call sites + 2 duplicated adopt effects -> the hook):
- FeedViewProvider: view
- App: filterCollapsed, playlistsCollapsed (+ their adopt block removed)
- PlayerModal: playerAutoAdvance, playerLoop
- plexDetailUi: plexInfoArtBg/Cast/Related (savePref via useSavePref)

Left as-is by design: panel layouts (a dynamically-indexed Record, not one key),
language (i18n store, no React state), PrefsProvider (debounced multi-field
auto-save + baseline echo-suppression - a different model).

Behaviour-equivalent (net -84 LOC at the call sites). usePref.dom.test covers
the three seed sources, persist path, debounce, adopt, parse, and useSavePref.
Frontend gate green: typecheck, knip, eslint (0 err), prettier, 238 vitest.
2026-08-19 03:07:25 +02:00
peter c82cf25bcf release: 0.69.2 (bug/download-poster-account-scope)
gate / frontend (push) Successful in 56s
gate / backend (push) Successful in 1m43s
2026-08-19 02:00:44 +02:00
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 2229b01a91 release: 0.69.1 (bug/feed-scroll-thumbnails-about)
gate / frontend (push) Successful in 56s
gate / backend (push) Failing after 38s
2026-08-19 01:42:54 +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 1e2b3c5d29 docs(sync): scope normalize_description to channels + lock line-ending behavior
Review follow-up. The docstring said "channel/video description" but the helper is
wired only to the channel store sites; clarify it is deliberately channel-only
(video descriptions are user-facing prose where paragraph breaks carry meaning,
unlike a channel's metadata block) so it doesn't read as an asymmetric fix. Also
document that str.splitlines() folds CR/CRLF and Unicode line separators to "\n",
and lock it with a test.
2026-08-19 01:32:26 +02:00
peter d065902404 refactor(feed): cap the read-ahead lookahead and extract it to a tested helper
Review follow-up. The viewport-scaled lookahead was unbounded, so a tall window
prefetched several feed pages on every mount / filter change before any scroll —
wasted work on the app's heaviest query. Clamp it to MAX_LOOKAHEAD_ROWS, and move
the policy into a pure readAhead.ts (readAheadLookahead / shouldPrefetchNext) with
unit tests covering the floor, the scaling, the cap boundary, and the fetch trigger
— the core of the feed-scroll fix now has coverage instead of only manual E2E.
2026-08-19 01:32:26 +02:00
peter a300977ed4 fix(feed): read further ahead so fast scrolling doesn't stall at "Load more"
Keyset pagination is strictly serial (a page needs the previous page's cursor)
and the read-ahead only fired within a fixed 4 rows of the loaded end, pulling
one 60-item page per cycle. A fast free-spin flick (MX Master & co.) out-scrolls
that tiny lookahead, lands on an empty bottom, and waits out a chain of serial
round-trips — the multi-second "Load more" hang. Scale the lookahead to the
rendered row count (PREFETCH_VIEWPORTS screenfuls); since the effect re-runs when
a page settles it chains, keeping a couple of screens buffered ahead of the fold
so the content is usually already there. Bounded, and a no-op for finite lists.
2026-08-19 01:13:57 +02:00
peter ddb7818d38 fix(sync): strip blank lines from channel descriptions before storing
YouTube channel descriptions pad their contact/link blocks with runs of blank
lines, which the About popover renders verbatim (whitespace-pre-wrap) — wasted
space and storage, and noise in the search_vector. Add normalize_description()
and apply it at both channel store sites (full details + initial import) so the
DB holds the clean text. New channels are clean immediately; existing rows heal
on their next metadata re-sync. Unit-tested in test_utils.
2026-08-19 01:13:56 +02:00
peter 48787af11c fix(channels): keep the About popover on-screen at high browser zoom
At elevated zoom the CSS viewport shrinks; a long channel About wanted its fixed
max-h-112 (448px) height, so when floating-ui flipped it above the trigger its
top rendered above the viewport edge, clipped and unreachable (the inner
overflow-y-auto can't scroll past its own top). Enable the popover's fitHeight so
the `size` middleware caps the panel to the available space. Because the surface
splits into an outer positioned element and an inner styled/scroll wrapper, the
middleware now also publishes the available height as a --fit-max-h custom
property the inner scroller consumes via maxHeight: min(28rem, var(--fit-max-h)),
keeping the 28rem design cap on a tall viewport and shrinking to fit on a short one.
2026-08-19 01:13:40 +02:00
peter b733ee84fa fix(downloads): use the local poster as the Library thumbnail primary
The Library card used the source's remote thumbnail_url as the primary <img>
src and only fell back to our self-hosted poster on error. For Facebook
downloads (the bulk of a typical library) that URL is an fbcdn signed link that
expires within days, and is also blocked by ad-blockers / Brave Shields on the
facebook domain — so the thumbnail broke even though the local poster was
present on disk. Flip the priority: serve /api/downloads/{id}/poster.jpg first
(always present, never expires, same-origin) and keep the remote as the fallback
for the rare poster-less asset.
2026-08-19 01:13:40 +02:00
108 changed files with 9588 additions and 3152 deletions
+1 -1
View File
@@ -1 +1 @@
0.69.0
0.71.0
@@ -0,0 +1,52 @@
"""plex TMDB enrichment columns (E5 S2)
Additive TMDB-sourced fields on plex_items + plex_shows — keywords, full crew, budget/revenue, TMDb
community score, production companies/countries, recommendations, and a backdrop-path pool — plus a
`tmdb_id` cache and a `tmdb_enriched_at` watermark. Filled by the new TMDB enrichment job (matched by
the stored canonical `guid`), SERVED FROM THE ROW (never a live TMDB call). Distinct from the existing
Plex-sourced `imdb_rating`/`rating`, which are left untouched. Plus `plex_people.tmdb_person_id` (the S2
foundation for S6's person bios). ADD-only, all nullable → no table rewrite, no ACCESS EXCLUSIVE lock.
No watermark reset needed: `tmdb_enriched_at` starts NULL, so every eligible row is picked up once.
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision: str = "0071_plex_tmdb_fields"
down_revision: Union[str, None] = "0070_fold_maintenance_batch"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
_TABLES = ("plex_items", "plex_shows")
# (name, type) — same set on both movie/leaf rows and show rows. budget/revenue stay NULL for shows.
_COLS: tuple[tuple[str, sa.types.TypeEngine], ...] = (
("tmdb_id", sa.Integer()),
("tmdb_enriched_at", sa.DateTime(timezone=True)),
("tmdb_rating", sa.Float()),
("tmdb_vote_count", sa.Integer()),
("budget", sa.BigInteger()),
("revenue", sa.BigInteger()),
("keywords", postgresql.JSONB()),
("crew", postgresql.JSONB()),
("studios", postgresql.JSONB()),
("production_countries", postgresql.JSONB()),
("recommendations", postgresql.JSONB()),
("backdrops", postgresql.JSONB()),
)
def upgrade() -> None:
for t in _TABLES:
for name, type_ in _COLS:
op.add_column(t, sa.Column(name, type_, nullable=True))
op.add_column("plex_people", sa.Column("tmdb_person_id", sa.Integer(), nullable=True))
def downgrade() -> None:
op.drop_column("plex_people", "tmdb_person_id")
for t in _TABLES:
for name, _ in reversed(_COLS):
op.drop_column(t, name)
@@ -0,0 +1,40 @@
"""plex person TMDB bio columns (E5 S6)
Additive TMDB-sourced person fields on plex_people — biography, birth/death dates, place of birth,
known-for department, also-known-as, and a `known_for` credit pool ([{tmdb_id, media_type, title}]
resolved to library rows by guid at serve time) — plus a `tmdb_enriched_at` watermark. Filled the
first time a person page is opened (from /person/{tmdb_person_id}), then SERVED FROM THE ROW. The
`tmdb_person_id` link itself already exists (0071). ADD-only, all nullable → no table rewrite, no
ACCESS EXCLUSIVE lock. `tmdb_enriched_at` starts NULL, so every person is enriched once on first view.
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision: str = "0072_plex_person_bio"
down_revision: Union[str, None] = "0071_plex_tmdb_fields"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
_COLS: tuple[tuple[str, sa.types.TypeEngine], ...] = (
("biography", sa.Text()),
("birthday", sa.String(length=10)),
("deathday", sa.String(length=10)),
("place_of_birth", sa.Text()),
("known_for_department", sa.String(length=64)),
("also_known_as", postgresql.JSONB()),
("known_for", postgresql.JSONB()),
("tmdb_enriched_at", sa.DateTime(timezone=True)),
)
def upgrade() -> None:
for name, type_ in _COLS:
op.add_column("plex_people", sa.Column(name, type_, nullable=True))
def downgrade() -> None:
for name, _ in reversed(_COLS):
op.drop_column("plex_people", name)
+14
View File
@@ -245,6 +245,20 @@ class Settings(BaseSettings):
# local disk, so the default is fine there.
plex_hls_dir: str = ""
# --- TMDB enrichment (E5 S2; optional — enriches the Plex catalog from TMDB) ---
# TMDB API key (v3 api_key, 32 hex chars). Server-side only; stored encrypted when set via the
# admin Config page. Empty → the TMDB enrichment job no-ops. Feeds keywords / crew / budget /
# revenue / TMDb score / recommendations / backdrops onto the mirrored Plex rows, matched by the
# stored canonical guid, and SERVED FROM THE DB — never a live TMDB call at serve time.
tmdb_api_key: str = ""
tmdb_api_base: str = "https://api.themoviedb.org/3"
# Metadata language for TMDB fetches (keyword/overview locale). Stored EN by default.
tmdb_language: str = "en-US"
# How often the TMDB enrichment job fetches a batch of not-yet-enriched Plex titles, in minutes.
tmdb_enrich_interval_min: int = 120
# Titles fetched per TMDB enrichment run (TMDB is fast + generously rate-limited).
tmdb_enrich_batch: int = 100
@property
def allowed_email_set(self) -> set[str]:
return {e.strip().lower() for e in self.allowed_emails.split(",") if e.strip()}
+47
View File
@@ -1067,6 +1067,20 @@ class PlexShow(Base, TimestampMixin, UpdatedAtMixin):
imdb_id: Mapped[str | None] = mapped_column(String(16)) # tt… → the clickable IMDb link
tagline: Mapped[str | None] = mapped_column(Text)
full_cast: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) # ordered [{name, role, thumb}]
# --- TMDB enrichment (E5 S2): see the identical block on plex_items for the full rationale. Additive;
# served from the row; NULL until enriched. (`budget`/`revenue` stay NULL for shows — TV has none.) ---
tmdb_id: Mapped[int | None] = mapped_column(Integer)
tmdb_enriched_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
tmdb_rating: Mapped[float | None] = mapped_column(Float) # TMDb vote_average (0–10)
tmdb_vote_count: Mapped[int | None] = mapped_column(Integer)
budget: Mapped[int | None] = mapped_column(BigInteger)
revenue: Mapped[int | None] = mapped_column(BigInteger)
keywords: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) # [str]
crew: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) # [{name, job, tmdb_id, thumb}]
studios: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) # TMDB production companies [str]
production_countries: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) # [str]
recommendations: Mapped[list | None] = mapped_column(JSONB(none_as_null=True))
backdrops: Mapped[list | None] = mapped_column(JSONB(none_as_null=True))
# Hungarian-metadata twin fields, written by the overlay sync (E5 S5). NULL = no HU twin matched,
# so serving falls back to the EN columns. `people_text` is language-neutral, reused for both vectors.
title_hu: Mapped[str | None] = mapped_column(String(512))
@@ -1184,6 +1198,24 @@ class PlexItem(Base, TimestampMixin, UpdatedAtMixin):
imdb_id: Mapped[str | None] = mapped_column(String(16)) # tt… → the clickable IMDb link
tagline: Mapped[str | None] = mapped_column(Text)
full_cast: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) # ordered [{name, role, thumb}]
# --- TMDB enrichment (E5 S2): matched by the canonical `guid` ("tmdb:12345"), filled by the TMDB
# backfill job, SERVED FROM THIS ROW (never a live TMDB call). Additive — imdb_rating/rating
# above stay Plex-sourced; `tmdb_rating` is the distinct TMDb community score. NULL until enriched;
# `tmdb_enriched_at` is the "tried" watermark (stamped on a definitive no-match too, so an
# unresolvable title isn't re-fetched every run; left NULL only on a transient fetch error). ---
tmdb_id: Mapped[int | None] = mapped_column(Integer)
tmdb_enriched_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
tmdb_rating: Mapped[float | None] = mapped_column(Float) # TMDb vote_average (0–10)
tmdb_vote_count: Mapped[int | None] = mapped_column(Integer)
budget: Mapped[int | None] = mapped_column(BigInteger) # USD (movies; TV has none)
revenue: Mapped[int | None] = mapped_column(BigInteger)
keywords: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) # [str]
crew: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) # [{name, job, tmdb_id, thumb}]
studios: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) # TMDB production companies [str]
production_countries: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) # [str]
# [{tmdb_id, title, poster, media_type}] — resolved to library rows (by guid) at serve time.
recommendations: Mapped[list | None] = mapped_column(JSONB(none_as_null=True))
backdrops: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) # TMDB backdrop file_paths [str] (crossfade pool, S8)
# Hungarian-metadata twin fields, written by the overlay sync (E5 S5). NULL = no HU twin matched
# (serving falls back to the EN columns). `people_text` is language-neutral, reused for both vectors.
title_hu: Mapped[str | None] = mapped_column(String(512))
@@ -1224,6 +1256,21 @@ class PlexPerson(Base, TimestampMixin, UpdatedAtMixin):
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(255), unique=True, index=True)
photo_thumb: Mapped[str | None] = mapped_column(Text) # proxied /person-image URL, or None
# TMDB person id, linked by name from a title's TMDB credits (E5 S2 lays the foundation; the /person
# biography fetch + person-page render land in S6). NULL until a TMDB-enriched title names this person.
tmdb_person_id: Mapped[int | None] = mapped_column(Integer)
# --- TMDB person enrichment (E5 S6): fetched from /person/{tmdb_person_id} the first time the person
# page is opened, then SERVED FROM THE ROW. `tmdb_enriched_at` is the watermark (set even on a
# no-op fetch so it isn't retried every open). `known_for` = top combined-credits [{tmdb_id,
# media_type, title}], resolved to library rows by guid at serve time (like recommendations). ---
biography: Mapped[str | None] = mapped_column(Text)
birthday: Mapped[str | None] = mapped_column(String(10)) # "YYYY-MM-DD", served as-is
deathday: Mapped[str | None] = mapped_column(String(10))
place_of_birth: Mapped[str | None] = mapped_column(Text)
known_for_department: Mapped[str | None] = mapped_column(String(64))
also_known_as: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) # [str]
known_for: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) # [{tmdb_id, media_type, title}]
tmdb_enriched_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
class PlexCollection(Base, TimestampMixin, UpdatedAtMixin):
+21
View File
@@ -6,14 +6,32 @@ router. Pure functions — no DB, no request state."""
from app.models import PlexItem, PlexShow, PlexState
# A card's wide backdrop, for a rail's landscape tile or a crossfading hero — but ONLY when the item
# actually has art. `thumb` is always emitted (its endpoint placeholders a missing poster), but an
# `art` URL for an item with no `art_key` would resolve to a broken/blank image on every card that
# tried to use it, so gate it on the stored key and let the client fall back to the poster.
def _art_url(rating_key: str, art_key: str | None) -> str | None:
return f"/api/plex/image/{rating_key}?variant=art" if art_key else None
# Card hover-overview: a short synopsis for the reveal panel, trimmed so a strip/related row of cards
# stays a few hundred bytes each rather than carrying full synopses. (These non-grid card paths serve
# the EN summary/rating; the main grid coalesces the HU twin in browse.unified_library.)
def _card_summary(summary: str | None) -> str | None:
return (summary or "")[:300] or None
def movie_card(it: PlexItem, st: PlexState | None) -> dict:
return {
"id": it.rating_key,
"type": "movie",
"title": it.title,
"year": it.year,
"rating": it.rating,
"summary": _card_summary(it.summary),
"duration_seconds": it.duration_s,
"thumb": f"/api/plex/image/{it.rating_key}",
"art": _art_url(it.rating_key, it.art_key),
"playable": it.playable,
"status": st.status if st else "new",
"position_seconds": st.position_seconds if st else 0,
@@ -26,7 +44,10 @@ def show_card(sh: PlexShow, status: str = "new") -> dict:
"type": "show",
"title": sh.title,
"year": sh.year,
"rating": sh.rating,
"summary": _card_summary(sh.summary),
"thumb": f"/api/plex/image/{sh.rating_key}",
"art": _art_url(sh.rating_key, sh.art_key),
"season_count": sh.child_count,
# Aggregate watch-state across the show's episodes (new | in_progress | watched) → grid badge.
"status": status,
+9
View File
@@ -29,6 +29,11 @@ def title_col(model, lang: str):
return func.coalesce(model.title_hu, model.title) if lang == "hu" else model.title
def summary_col(model, lang: str):
"""DISPLAY summary, HU-coalesced (EN fallback) — the card hover-overview + info page read this."""
return func.coalesce(model.summary_hu, model.summary) if lang == "hu" else model.summary
def genres_col(model, lang: str):
# type_coerce so the JSONB comparator (`.contains` / `@>`, GIN-backed) survives the coalesce.
if lang == "hu":
@@ -135,6 +140,8 @@ def meta_filter_conds(model, p: dict, lang: str = "en") -> list:
stds = split_csv(p.get("studios"))
if stds: # OR
conds.append(model.studio.in_(stds))
for kw in split_csv(p.get("keywords")): # AND — TMDB keyword chips from the detail page (E5 S5)
conds.append(model.keywords.contains([kw]))
if p.get("collection"):
conds.append(model.collection_keys.contains([p["collection"]]))
return conds
@@ -166,6 +173,7 @@ class LibraryFilters:
"any" # how multiple actors combine: any (OR, default) | all (AND)
)
studios: str | None = None
keywords: str | None = None # TMDB keyword chips (E5 S5), CSV; language-neutral
collection: str | None = None
def as_dict(self) -> dict:
@@ -183,6 +191,7 @@ class LibraryFilters:
"actors": self.actors,
"actor_mode": self.actor_mode,
"studios": self.studios,
"keywords": self.keywords,
"collection": self.collection,
}
+41 -18
View File
@@ -42,27 +42,32 @@ def rollup(cards: list[dict]) -> dict:
}
def agg_subquery(db: Session, user_id: int):
def agg_subquery(db: Session, user_id: int, include_resumed: bool = False):
"""Per-show episode counts for a user: total, watched, in-progress. Reused to (a) filter the show
grid by aggregate watch-state and (b) compute each card's badge."""
grid by aggregate watch-state, (b) compute each card's badge, and (c) drive the Discover
continue-watching rail. `include_resumed` adds `resumed_at` = max(progress_updated_at) across the
show's episodes, so the rail can order shows by how recently they were last progressed."""
ps = aliased(PlexState)
return (
select(
PlexItem.show_id.label("show_id"),
func.count(PlexItem.id).label("total"),
func.count(case((ps.status == "watched", 1))).label("watched"),
func.count(
case(
(
and_(
ps.position_seconds > 0,
or_(ps.status.is_(None), ps.status != "watched"),
),
1,
)
cols = [
PlexItem.show_id.label("show_id"),
func.count(PlexItem.id).label("total"),
func.count(case((ps.status == "watched", 1))).label("watched"),
func.count(
case(
(
and_(
ps.position_seconds > 0,
or_(ps.status.is_(None), ps.status != "watched"),
),
1,
)
).label("inprog"),
)
)
).label("inprog"),
]
if include_resumed:
cols.append(func.max(ps.progress_updated_at).label("resumed_at"))
return (
select(*cols)
.outerjoin(ps, and_(ps.item_id == PlexItem.id, ps.user_id == user_id))
.where(PlexItem.kind == "episode", PlexItem.show_id.isnot(None))
.group_by(PlexItem.show_id)
@@ -76,3 +81,21 @@ def state_map(db: Session, user_id: int, show_ids: list[int]) -> dict[int, str]:
agg_subquery(db, user_id).where(PlexItem.show_id.in_(show_ids))
).all()
return {r.show_id: show_status(r.total, r.watched, r.inprog) for r in rows}
def item_state_map(
db: Session, user_id: int, item_ids: list[int]
) -> dict[int, PlexState]:
"""A user's PlexState rows for a set of leaf items (movies or episodes), keyed by item_id — the
leaf analogue of `state_map` (which rolls a show's episodes up to a status string). One batched
fetch, so the caller serializes cards without a per-card query. Empty in → empty dict (no `IN ()`)."""
if not item_ids:
return {}
return {
s.item_id: s
for s in db.execute(
select(PlexState).where(
PlexState.user_id == user_id, PlexState.item_id.in_(item_ids)
)
).scalars()
}
+492
View File
@@ -0,0 +1,492 @@
"""TMDB enrichment for the mirrored Plex catalog (E5 S2).
A thin synchronous TMDB v3 client plus the parse/persist of the enrichment fields onto plex_items /
plex_shows, matched by the stored canonical `guid` ("tmdb:12345", or imdb/tvdb resolved via /find).
Server-side ONLY (the api_key from sysconfig). Everything fetched is PERSISTED and served from the DB
row — there is never a live TMDB call at serve time (the user's explicit rule). Additive: the existing
Plex-sourced `imdb_rating` / `rating` are left untouched; `tmdb_rating` is the distinct TMDb score.
One-parser rule (mirrors app/plex/detail.py): `parse_tmdb` is the pure parser, `apply_tmdb_detail` writes
the row. The enrich batch + scheduler entry live here too so the whole TMDB integration is one module.
"""
import logging
import time
from datetime import datetime, timezone
import httpx
from sqlalchemy import func, select
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.orm import Session
from app import sysconfig
from app.config import settings
from app.models import PlexItem, PlexPerson, PlexShow
log = logging.getLogger("siftlode.tmdb")
# Politeness throttle: TMDB tolerates ~50 req/s; ~33/s leaves comfortable headroom for a shared host.
_MIN_INTERVAL = 0.03
# The crew jobs worth surfacing on an info page, in DISPLAY-priority order (the stored crew is sorted by
# this — TMDB's own crew order is arbitrary and buries the lead roles). MOVIE and TV differ on purpose:
# on a TV show `Director`/`Editor`/`Director of Photography` are PER-EPISODE credits (dozens of names
# that would flood the list and bury the showrunner), so TV keys on `Creator` (from the payload's
# top-level `created_by`, not the crew list) + the show-level producers/writers instead.
_MOVIE_CREW_JOBS = (
"Director",
"Writer",
"Screenplay",
"Story",
"Producer",
"Executive Producer",
"Editor",
"Director of Photography",
"Original Music Composer",
)
_TV_CREW_JOBS = ("Creator", "Executive Producer", "Writer", "Producer", "Original Music Composer")
_MOVIE_PRIORITY = {job: i for i, job in enumerate(_MOVIE_CREW_JOBS)}
_TV_PRIORITY = {job: i for i, job in enumerate(_TV_CREW_JOBS)}
# append_to_response bundles the sub-resources into ONE request per title (keywords + credits +
# recommendations + images + external ids). TV uses aggregate_credits (per-person aggregated jobs).
_MOVIE_APPEND = "keywords,credits,recommendations,images,external_ids"
_TV_APPEND = "keywords,aggregate_credits,recommendations,images,external_ids"
class TmdbError(Exception):
"""Any transient failure talking to TMDB (network, non-404 HTTP, bad payload). The caller leaves
the row's `tmdb_enriched_at` NULL so the next run retries."""
class TmdbNotConfigured(TmdbError):
"""No TMDB API key is set — the module can't do anything."""
class TmdbClient:
"""Bound to the configured api_key + language. Use as a context manager so the httpx client closes."""
def __init__(self, db: Session, timeout: float = 30.0):
self.key = sysconfig.get_str(db, "tmdb_api_key")
if not self.key:
raise TmdbNotConfigured("TMDB API key is not configured")
self.base = settings.tmdb_api_base.rstrip("/")
self.language = sysconfig.get_str(db, "tmdb_language") or settings.tmdb_language
# `timeout` is caller-tunable so the request-path lazy person enrich can cap how long it may
# block a worker thread (the batch job keeps the generous default).
self._http = httpx.Client(timeout=timeout)
self._last = 0.0
def __enter__(self) -> "TmdbClient":
return self
def __exit__(self, *exc) -> None:
self._http.close()
def close(self) -> None:
self._http.close()
def _throttle(self) -> None:
wait = self._last + _MIN_INTERVAL - time.monotonic()
if wait > 0:
time.sleep(wait)
self._last = time.monotonic()
def _get(self, path: str, params: dict | None = None) -> dict | None:
"""GET a TMDB path. Returns the JSON dict, None on a 404 (a definitive "no such id"), and raises
TmdbError on any transient failure (network / other HTTP status / non-JSON)."""
self._throttle()
p = {"api_key": self.key, "language": self.language, **(params or {})}
try:
r = self._http.get(f"{self.base}{path}", params=p)
except httpx.HTTPError as e:
# NEVER put str(e)/the URL in the message: the request URL carries `?api_key=<secret>` in
# its query string, and this message is logged (enrich_batch). Report the type + path only.
raise TmdbError(f"TMDB request error on {path}: {type(e).__name__}") from None
if r.status_code == 404:
return None
try:
r.raise_for_status()
except httpx.HTTPStatusError as e:
# Same reason: the HTTPStatusError string embeds the full URL incl. the api_key. Sever the
# cause (`from None`) so no traceback can resurface it, and log only status + path.
raise TmdbError(f"TMDB {path} returned {e.response.status_code}") from None
try:
return r.json()
except ValueError as e:
raise TmdbError("TMDB returned a non-JSON response") from e
def fetch(self, tmdb_id: int, media_type: str) -> dict | None:
"""Full enrichment payload for a movie or show (one request via append_to_response)."""
if media_type == "tv":
return self._get(f"/tv/{tmdb_id}", {"append_to_response": _TV_APPEND})
return self._get(f"/movie/{tmdb_id}", {"append_to_response": _MOVIE_APPEND})
def find(self, external_id: str, source: str) -> dict | None:
"""Resolve an external id (imdb_id / tvdb_id) to TMDB items via /find."""
return self._get(f"/find/{external_id}", {"external_source": source})
def fetch_person(self, person_id: int) -> dict | None:
"""One person's bio + combined credits (E5 S6), in a single append_to_response request."""
return self._get(f"/person/{person_id}", {"append_to_response": "combined_credits"})
def guid_to_tmdb_id(guid: str | None, media_type: str, tmdb: TmdbClient) -> int | None:
"""Map a stored canonical guid to a TMDB id. `tmdb:` is direct; `imdb:`/`tvdb:` go through /find
(picking the movie_results or tv_results list per `media_type`). Returns None for an unmatchable
guid — the caller then stamps the watermark so it isn't retried every run."""
if not guid or ":" not in guid:
return None
provider, _, ext = guid.partition(":")
if provider == "tmdb":
try:
return int(ext)
except ValueError:
return None
source = {"imdb": "imdb_id", "tvdb": "tvdb_id"}.get(provider)
if not source:
return None
res = tmdb.find(ext, source)
if not res:
return None
items = res.get("tv_results" if media_type == "tv" else "movie_results") or []
return items[0].get("id") if items else None
def _dedup_crew(crew: list[dict]) -> list[dict]:
seen: set[tuple] = set()
out: list[dict] = []
for c in crew:
key = (c["name"], c["job"])
if key in seen:
continue
seen.add(key)
out.append(c)
return out
def parse_tmdb(payload: dict, media_type: str) -> dict:
"""Pure parser: pull the enrichment fields out of a TMDB movie/tv payload (append_to_response form).
Best-effort — any missing sub-resource is just omitted. Returns the row fields plus a `people` list
([{name, tmdb_id}] over cast+crew) for the plex_people id-linking. TMDB image fields are stored as
bare paths ("/abc.jpg"); the client builds the image.tmdb.org URL."""
is_tv = media_type == "tv"
votes = payload.get("vote_count") or 0
kw_container = payload.get("keywords") or {}
kw_list = kw_container.get("results") if is_tv else kw_container.get("keywords")
keywords = [k.get("name") for k in (kw_list or []) if k.get("name")][:20]
priority = _TV_PRIORITY if is_tv else _MOVIE_PRIORITY
credits = payload.get("aggregate_credits" if is_tv else "credits") or {}
crew: list[dict] = []
# A TV show's creators live at the top level (created_by), not in the crew list — surface them.
if is_tv:
for c in payload.get("created_by") or []:
if c.get("name"):
crew.append(
{"name": c["name"], "job": "Creator", "tmdb_id": c.get("id"), "profile_path": c.get("profile_path")}
)
for c in credits.get("crew") or []:
# Movie crew entries carry `job`; TV aggregate_credits carry a `jobs` array per person.
job = (c.get("jobs") or [{}])[0].get("job") if is_tv else c.get("job")
if job in priority and c.get("name"):
crew.append(
{"name": c["name"], "job": job, "tmdb_id": c.get("id"), "profile_path": c.get("profile_path")}
)
# Dedup by (name, job) — a person can hold two distinct key roles (Coppola: Director + Writer) — then
# order by display priority so the lead roles come first, and cap.
crew = _dedup_crew(crew)
crew.sort(key=lambda c: priority.get(c["job"], 99))
crew = crew[:12]
# name -> tmdb person id, for the plex_people foundation (S6 fetches bios off these ids).
people: list[dict] = []
for c in (credits.get("cast") or [])[:30]:
if c.get("name") and c.get("id"):
people.append({"name": c["name"], "tmdb_id": c["id"]})
for c in crew:
if c.get("tmdb_id"):
people.append({"name": c["name"], "tmdb_id": c["tmdb_id"]})
recs: list[dict] = []
for r in ((payload.get("recommendations") or {}).get("results") or [])[:20]:
recs.append(
{
"tmdb_id": r.get("id"),
"title": r.get("title") or r.get("name"),
"poster_path": r.get("poster_path"),
"media_type": r.get("media_type") or media_type,
}
)
backdrops = [
b.get("file_path")
for b in ((payload.get("images") or {}).get("backdrops") or [])
if b.get("file_path")
][:10]
return {
"tmdb_id": payload.get("id"),
# vote_average is meaningless with no votes (TMDB returns 0.0) → treat as unknown.
"tmdb_rating": payload.get("vote_average") if votes else None,
"tmdb_vote_count": votes or None,
# 0 is TMDB's "unknown" for money fields; TV has neither.
"budget": (payload.get("budget") or None) if not is_tv else None,
"revenue": (payload.get("revenue") or None) if not is_tv else None,
"keywords": keywords or None,
"crew": crew or None,
"studios": [c.get("name") for c in (payload.get("production_companies") or []) if c.get("name")][:8]
or None,
"production_countries": [
c.get("name") for c in (payload.get("production_countries") or []) if c.get("name")
]
or None,
"recommendations": recs or None,
"backdrops": backdrops or None,
"people": people,
}
def apply_tmdb_detail(row, parsed: dict) -> None:
"""Persist the parsed TMDB fields onto a movie/show row. Does NOT touch full_cast / imdb_rating /
rating (Plex-owned). The caller stamps `tmdb_enriched_at` and links people separately."""
row.tmdb_id = parsed["tmdb_id"]
row.tmdb_rating = parsed["tmdb_rating"]
row.tmdb_vote_count = parsed["tmdb_vote_count"]
row.budget = parsed["budget"]
row.revenue = parsed["revenue"]
row.keywords = parsed["keywords"]
row.crew = parsed["crew"]
row.studios = parsed["studios"]
row.production_countries = parsed["production_countries"]
row.recommendations = parsed["recommendations"]
row.backdrops = parsed["backdrops"]
def _link_people(db: Session, links: dict[str, int]) -> None:
"""Upsert plex_people by name, stamping `tmdb_person_id` — keeping any id already set (first wins),
filling it where NULL, inserting a bare row where the person isn't cached yet. Deduped by name so a
single statement can't hit the conflict target twice (the same rule as people.enrich_row_people)."""
values = [{"name": nm, "tmdb_person_id": pid} for nm, pid in links.items() if nm and pid]
if not values:
return
stmt = pg_insert(PlexPerson).values(values)
db.execute(
stmt.on_conflict_do_update(
index_elements=[PlexPerson.name],
set_={"tmdb_person_id": func.coalesce(PlexPerson.tmdb_person_id, stmt.excluded.tmdb_person_id)},
)
)
def enrich_batch(db: Session, tmdb: TmdbClient, limit: int) -> int:
"""Enrich up to `limit` not-yet-TMDB-enriched titles (movies first, shows fill the remainder —
mirrors _enrich_people_batch). A transient TmdbError leaves the watermark NULL for retry; a
definitive no-match (unresolvable guid / TMDB 404) still stamps the watermark so it isn't re-fetched
every run. One commit at the end. Returns the number processed (matched OR definitively unmatched)."""
movies = (
db.execute(
select(PlexItem)
.where(
PlexItem.kind == "movie",
PlexItem.guid.is_not(None),
PlexItem.tmdb_enriched_at.is_(None),
)
.order_by(PlexItem.id)
.limit(limit)
)
.scalars()
.all()
)
shows: list[PlexShow] = []
if len(movies) < limit:
shows = (
db.execute(
select(PlexShow)
.where(PlexShow.guid.is_not(None), PlexShow.tmdb_enriched_at.is_(None))
.order_by(PlexShow.id)
.limit(limit - len(movies))
)
.scalars()
.all()
)
processed = 0
links: dict[str, int] = {}
for row, media_type in [(m, "movie") for m in movies] + [(s, "tv") for s in shows]:
try:
tmdb_id = guid_to_tmdb_id(row.guid, media_type, tmdb)
payload = tmdb.fetch(tmdb_id, media_type) if tmdb_id else None
except TmdbError as e:
log.warning("tmdb enrich %s failed (will retry): %s", row.rating_key, e)
continue # transient — leave tmdb_enriched_at NULL for the next run
if payload:
parsed = parse_tmdb(payload, media_type)
apply_tmdb_detail(row, parsed)
for p in parsed["people"]:
links.setdefault(p["name"], p["tmdb_id"])
# Stamp even on a definitive no-match so an unresolvable title isn't re-fetched every run.
row.tmdb_enriched_at = datetime.now(timezone.utc)
processed += 1
if links:
_link_people(db, links)
db.commit()
return processed
def run_enrich(db: Session) -> dict:
"""Scheduler entry point. No-op (never an error) when the TMDB key isn't configured."""
if not sysconfig.get_str(db, "tmdb_api_key"):
return {"enriched": 0, "skipped": "tmdb_api_key not set"}
with TmdbClient(db) as tmdb:
n = enrich_batch(db, tmdb, settings.tmdb_enrich_batch)
return {"enriched": n}
def resolve_library_guids(
db: Session,
entries: list[dict] | None,
*,
movie_lib_ids: list[int] | None = None,
show_lib_ids: list[int] | None = None,
) -> tuple[dict[str, PlexItem], dict[str, PlexShow]]:
"""Match a list of TMDB credits/recs (each `{tmdb_id, media_type, …}`) to our library rows by the
canonical guid `tmdb:<id>`, returning `(movies_by_guid, shows_by_guid)` of the ORM rows.
⭐ TMDB movie ids and tv ids are INDEPENDENT sequences (id 603 can be BOTH a movie and a show), so
each entry matches ONLY its own media_type's table — a movie credit against a library movie, a tv
credit against a library show; a blind numeric match would resolve to an unrelated same-id title.
Pass `movie_lib_ids`/`show_lib_ids` to scope to the enabled libraries (the person page); leave them
None to match across all libraries (the detail-page recs). An explicit empty list means "that side
has no enabled library" → its bucket is skipped entirely."""
if not entries:
return {}, {}
movie_guids = [
f"tmdb:{e['tmdb_id']}" for e in entries if e.get("tmdb_id") and e.get("media_type") != "tv"
]
tv_guids = [
f"tmdb:{e['tmdb_id']}" for e in entries if e.get("tmdb_id") and e.get("media_type") == "tv"
]
movies: dict[str, PlexItem] = {}
if movie_guids and movie_lib_ids != []:
conds = [PlexItem.kind == "movie", PlexItem.guid.in_(movie_guids)]
if movie_lib_ids is not None:
conds.append(PlexItem.library_id.in_(movie_lib_ids))
for m in db.execute(select(PlexItem).where(*conds)).scalars():
movies.setdefault(m.guid, m)
shows: dict[str, PlexShow] = {}
if tv_guids and show_lib_ids != []:
conds = [PlexShow.guid.in_(tv_guids)]
if show_lib_ids is not None:
conds.append(PlexShow.library_id.in_(show_lib_ids))
for s in db.execute(select(PlexShow).where(*conds)).scalars():
shows.setdefault(s.guid, s)
return movies, shows
def resolve_recs(db: Session, recs: list[dict] | None) -> list[dict]:
"""Tag each recommendation with whether it's in OUR library (matched by guid `tmdb:<id>`, bucketed
by media_type — see resolve_library_guids) and, if so, its rating_key — so the client can deep-link
the ones we have and merely display the rest."""
if not recs:
return []
movies, shows = resolve_library_guids(db, recs)
out: list[dict] = []
for r in recs:
g = f"tmdb:{r.get('tmdb_id')}"
row = shows.get(g) if r.get("media_type") == "tv" else movies.get(g)
out.append(
{**r, "in_library": row is not None, "rating_key": row.rating_key if row else None}
)
return out
def serve_block(db: Session, row) -> dict | None:
"""The TMDB extras for a movie/show detail response, served from the row (no live TMDB call).
None until the row has been TMDB-enriched (the client then shows nothing extra)."""
if row.tmdb_enriched_at is None:
return None
return {
"tmdb_id": row.tmdb_id,
"rating": row.tmdb_rating,
"vote_count": row.tmdb_vote_count,
"budget": row.budget,
"revenue": row.revenue,
"keywords": row.keywords or [],
"crew": row.crew or [],
"studios": row.studios or [],
"production_countries": row.production_countries or [],
"recommendations": resolve_recs(db, row.recommendations),
"backdrops": row.backdrops or [],
}
# --- Person enrichment (E5 S6) -------------------------------------------------------------------
# A person's bio + birth facts + a "Known for" credit pool, fetched once (the first person-page open)
# and served from the plex_people row. `known_for` stores the top combined-credits ids; the person
# endpoint resolves them to library rows by guid at serve time (like recommendations).
_KNOWN_FOR_MAX = 15
def parse_person(payload: dict) -> dict:
"""TMDB /person/{id} (+combined_credits) → the persisted bio fields. `known_for` = the person's
most-popular movie/tv credits (deduped by id), top `_KNOWN_FOR_MAX`, resolved to library later."""
credits = (payload.get("combined_credits") or {}).get("cast") or []
# Dedup on (id, media_type), not the bare id: TMDB movie ids and tv ids are independent sequences
# (id 603 can be both a movie and a show — see resolve_library_guids), so a same-id movie+show pair
# is two distinct credits and both must survive.
seen: set[tuple[int, str]] = set()
known: list[dict] = []
for c in sorted(credits, key=lambda c: c.get("popularity") or 0, reverse=True):
tid, mt = c.get("id"), c.get("media_type")
if not tid or mt not in ("movie", "tv") or (tid, mt) in seen:
continue
seen.add((tid, mt))
known.append({"tmdb_id": tid, "media_type": mt, "title": c.get("title") or c.get("name")})
if len(known) >= _KNOWN_FOR_MAX:
break
return {
"biography": (payload.get("biography") or "").strip() or None,
"birthday": payload.get("birthday") or None,
"deathday": payload.get("deathday") or None,
"place_of_birth": payload.get("place_of_birth") or None,
"known_for_department": payload.get("known_for_department") or None,
"also_known_as": payload.get("also_known_as") or None,
"known_for": known or None,
}
def apply_person(row: PlexPerson, parsed: dict) -> None:
row.biography = parsed["biography"]
row.birthday = parsed["birthday"]
row.deathday = parsed["deathday"]
row.place_of_birth = parsed["place_of_birth"]
row.known_for_department = parsed["known_for_department"]
row.also_known_as = parsed["also_known_as"]
row.known_for = parsed["known_for"]
row.tmdb_enriched_at = datetime.now(timezone.utc)
def enrich_person(db: Session, person: PlexPerson) -> bool:
"""Lazily fetch + persist one person's TMDB bio (first person-page open), committing the write so it
survives regardless of the read path. No-op (False) without a key or a `tmdb_person_id`; a transient
error leaves `tmdb_enriched_at` NULL so the next open retries. A 404 (deleted person) stamps the
watermark with empty fields so we don't refetch it every open."""
if not person.tmdb_person_id or not sysconfig.get_str(db, "tmdb_api_key"):
return False
try:
# Called from a background task (off the request path — see people._enrich_person_bg), so it no
# longer blocks a serve; still cap the outbound TMDB call with a short timeout so a hanging TMDB
# doesn't tie up a threadpool worker for the default 30s.
with TmdbClient(db, timeout=8.0) as tmdb:
payload = tmdb.fetch_person(person.tmdb_person_id)
except TmdbError:
return False
if payload is None:
person.tmdb_enriched_at = datetime.now(timezone.utc)
else:
apply_person(person, parse_person(payload))
db.commit()
return True
+2
View File
@@ -15,6 +15,7 @@ from app.routes.plex import (
browse,
collections,
detail,
discover,
people,
playback,
playlists,
@@ -25,6 +26,7 @@ router.include_router(admin.router)
router.include_router(browse.router)
router.include_router(collections.router)
router.include_router(detail.router)
router.include_router(discover.router)
router.include_router(people.router)
router.include_router(playback.router)
router.include_router(playlists.router)
+32
View File
@@ -38,6 +38,7 @@ from app.plex.query import (
movie_status_case,
show_status_case,
split_csv,
summary_col,
title_col,
vector_col,
vector_match,
@@ -130,6 +131,8 @@ def unified_library(
title_col(PlexItem, lang).label("title"),
PlexItem.year.label("year"),
PlexItem.rating.label("rating"),
# Truncated so the card hover-overview costs a few hundred bytes, not the full synopsis.
func.left(summary_col(PlexItem, lang), 300).label("summary"),
PlexItem.added_at.label("added_at"),
PlexItem.originally_available_at.label("rel"),
PlexItem.duration_s.label("duration_s"),
@@ -170,6 +173,7 @@ def unified_library(
title_col(PlexShow, lang).label("title"),
PlexShow.year.label("year"),
PlexShow.rating.label("rating"),
func.left(summary_col(PlexShow, lang), 300).label("summary"),
PlexShow.added_at.label("added_at"),
PlexShow.originally_available_at.label("rel"),
cast(null(), Integer).label("duration_s"),
@@ -227,6 +231,8 @@ def unified_library(
"type": "movie",
"title": r.title,
"year": r.year,
"rating": r.rating,
"summary": r.summary,
"duration_seconds": r.duration_s,
"thumb": f"/api/plex/image/{r.rk}",
"playable": r.playable,
@@ -241,6 +247,8 @@ def unified_library(
"type": "show",
"title": r.title,
"year": r.year,
"rating": r.rating,
"summary": r.summary,
"thumb": f"/api/plex/image/{r.rk}",
"season_count": r.season_count,
"status": r.status,
@@ -304,6 +312,7 @@ def facets(
empty = {
"genres": [],
"content_ratings": [],
"studios": [],
"year_min": None,
"year_max": None,
"rating_max": None,
@@ -347,6 +356,7 @@ def facets(
genre_counts: dict[str, int] = {}
cr_counts: dict[str, int] = {}
studio_counts: dict[str, int] = {}
years: list[int] = []
ratings: list[float] = []
durs: list[int] = []
@@ -398,6 +408,21 @@ def facets(
)
for cr, c in db.execute(crq).all():
cr_counts[cr] = cr_counts.get(cr, 0) + c
# Studios — exclude its own (each offered studio is a valid OR-addition that only widens).
stq = (
wsq(
select(model.studio, func.count()),
model,
ids,
{**p, "studios": None},
kind_movie,
True,
)
.where(model.studio.isnot(None))
.group_by(model.studio)
)
for s, c in db.execute(stq).all():
studio_counts[s] = studio_counts.get(s, 0) + c
# Year bounds — exclude the year filter.
by = db.execute(
wsq(
@@ -452,6 +477,9 @@ def facets(
# undo a zero-result selection. Count 0 is fine (genre chips don't display counts).
for g in split_csv(f.genres):
genre_counts.setdefault(g, 0)
# Same for the actively-selected studios, so a selected studio stays removable even at zero matches.
for s in split_csv(f.studios):
studio_counts.setdefault(s, 0)
return {
"genres": [
{"value": g, "count": c}
@@ -461,6 +489,10 @@ def facets(
{"value": cr, "count": c}
for cr, c in sorted(cr_counts.items(), key=lambda kv: -kv[1])
],
"studios": [
{"value": s, "count": c}
for s, c in sorted(studio_counts.items(), key=lambda kv: (-kv[1], kv[0]))
],
"year_min": min(years) if years else None,
"year_max": max(years) if years else None,
"rating_max": max(ratings) if ratings else None,
+5
View File
@@ -23,6 +23,7 @@ from app.models import (
from app.plex import detail as plex_detail
from app.plex import people as plex_people
from app.plex import rollup, subtitles
from app.plex import tmdb as plex_tmdb
from app.plex.cards import episode_card, movie_card, show_card
from app.plex.client import PlexClient, PlexError, PlexNotConfigured
from app.plex.lookups import by_rating_key
@@ -172,6 +173,8 @@ def show_detail(
"first": show_roll["first"],
"episode_count": show_roll["episode_count"],
"collection_keys": sh.collection_keys or [],
# TMDB enrichment (E5 S2), served from the row.
"tmdb": plex_tmdb.serve_block(db, sh),
},
"seasons": [_season_block(se, by_season.get(se.id, [])) for se in seasons],
"related": related,
@@ -435,4 +438,6 @@ def item_detail(
"prev_id": prev_id,
"next_id": next_id,
"collections": _collection_strips(db, it, user.id),
# TMDB enrichment (E5 S2), served from the row — movies only (episodes aren't enriched).
"tmdb": plex_tmdb.serve_block(db, it) if it.kind == "movie" else None,
}
+296
View File
@@ -0,0 +1,296 @@
"""Plex Discover: the rail-surface read-model (E5 S4).
A consolidated payload for the dedicated Discover landing — a stack of horizontal rails:
Continue watching · Recently added · Browse-by-genre tiles · Because you watched X — plus a
separate, reshuffling Surprise-me sample. Serve-from-DB only: the recommendation rail reads the
recs the S2 TMDB engine already persisted (`PlexItem.recommendations`), never a live TMDB call, and
every card comes from the same `movie_card`/`show_card` serializers the grid uses (so rails inherit
S1's `art` + S3's rating/summary for free). The rails carry EN metadata (like the other non-grid
card paths); the HU overlay coalesces only in the main grid.
"""
from fastapi import APIRouter, Depends, Query
from sqlalchemy import and_, func, or_, select
from sqlalchemy.orm import Session
from app.auth import current_user
from app.db import get_db
from app.models import PlexItem, PlexShow, PlexState, User
from app.plex import rollup
from app.plex.cards import movie_card, show_card
from app.plex.query import apply_show_state_filter, genres_col
from app.plex.tmdb import resolve_recs
from app.routes.plex._shared import _content_libs, _norm_lang
router = APIRouter(prefix="/api/plex", tags=["plex"])
RAIL_LIMIT = 20
GENRE_TILES = 12
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)
return (
movie_ids if scope in ("movie", "both") else [],
show_ids if scope in ("show", "both") else [],
)
def _movie_cards(db: Session, uid: int, items: list[PlexItem]) -> list[dict]:
sts = rollup.item_state_map(db, uid, [it.id for it in items])
return [movie_card(it, sts.get(it.id)) for it in items]
def _show_cards(db: Session, uid: int, shows: list[PlexShow]) -> list[dict]:
statuses = rollup.state_map(db, uid, [s.id for s in shows])
return [show_card(s, statuses.get(s.id, "new")) for s in shows]
def _recently_added(
db: Session, uid: int, movie_ids: list[int], show_ids: list[int]
) -> list[dict]:
"""Newest movies + shows, interleaved by `added_at` desc into one rail."""
movies = (
db.execute(
select(PlexItem)
.where(PlexItem.kind == "movie", PlexItem.library_id.in_(movie_ids))
.order_by(PlexItem.added_at.desc().nullslast())
.limit(RAIL_LIMIT)
).scalars().all()
if movie_ids
else []
)
shows = (
db.execute(
select(PlexShow)
.where(PlexShow.library_id.in_(show_ids))
.order_by(PlexShow.added_at.desc().nullslast())
.limit(RAIL_LIMIT)
).scalars().all()
if show_ids
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.
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)
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.
top_movies = [o for _, kind, o in top if kind == "movie"]
top_shows = [o for _, kind, o in top if kind == "show"]
mcards = {m.rating_key: c for m, c in zip(top_movies, _movie_cards(db, uid, top_movies))}
scards = {s.rating_key: c for s, c in zip(top_shows, _show_cards(db, uid, top_shows))}
return [
mcards[o.rating_key] if kind == "movie" else scards[o.rating_key]
for _, kind, o in top
]
def _continue_watching(
db: Session, uid: int, movie_ids: list[int], show_ids: list[int]
) -> list[dict]:
"""Resume rail: in-progress movies + partially-watched shows, most-recently-progressed first.
A movie's resume time is its own `progress_updated_at`; a show's is the max across its episodes."""
rows: list[tuple[object, dict]] = [] # (resumed_at, card)
if movie_ids:
mrows = db.execute(
select(PlexItem, PlexState)
.join(
PlexState,
and_(PlexState.item_id == PlexItem.id, PlexState.user_id == uid),
)
.where(
PlexItem.kind == "movie",
PlexItem.library_id.in_(movie_ids),
PlexState.position_seconds > 0,
# A NULL status is still "started" (mirrors apply_movie_state_filter's in_progress) —
# `notin_` alone would drop it, since `NULL NOT IN (...)` is NULL, not TRUE.
or_(
PlexState.status.is_(None),
PlexState.status.notin_(["watched", "hidden"]),
),
)
.order_by(PlexState.progress_updated_at.desc().nullslast())
.limit(RAIL_LIMIT)
).all()
for it, st in mrows:
rows.append((st.progress_updated_at, movie_card(it, st)))
if show_ids:
# Reuse the shared per-show episode rollup (total/watched/inprog) rather than re-inlining it, plus
# its `resumed_at` = max(progress_updated_at); the in-progress predicate comes from the same shared
# `apply_show_state_filter` the grid uses, so the rail can't drift from the grid's definition.
prog = rollup.agg_subquery(db, uid, include_resumed=True).subquery()
srows = db.execute(
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_ids))
.order_by(prog.c.resumed_at.desc().nullslast())
.limit(RAIL_LIMIT)
).all()
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)
return [card for _, card in rows[:RAIL_LIMIT]]
def _genre_tiles(
db: Session, movie_ids: list[int], show_ids: list[int], lang: str
) -> list[dict]:
"""The most-common genres across the scope, as {value, count} tiles → tap to filter the grid.
The genre vocabulary is language-specific (coalesce(genres_hu, genres) in HU), matching the grid."""
counts: dict[str, int] = {}
for model, ids in ((PlexItem, movie_ids), (PlexShow, show_ids)):
if not ids:
continue
gcol = genres_col(model, lang)
base = select(func.jsonb_array_elements_text(gcol).label("g")).where(
model.library_id.in_(ids), func.jsonb_typeof(gcol) == "array"
)
if model is PlexItem:
base = base.where(model.kind == "movie")
expanded = base.subquery()
for g, c in db.execute(
select(expanded.c.g, func.count()).group_by(expanded.c.g)
).all():
counts[g] = counts.get(g, 0) + c
top = sorted(counts.items(), key=lambda kv: (-kv[1], kv[0]))[:GENRE_TILES]
return [{"value": g, "count": c} for g, c in top]
def _because_you_watched(
db: Session, uid: int, movie_ids: list[int], show_ids: list[int]
) -> dict | None:
"""Recommendations anchored on the user's most-recently-watched, TMDB-enriched movie. Only the
recs that resolve to OUR library (by guid) become cards — the rest aren't actionable here. Returns
None (rail hidden) when there's no enriched watch history or none of its recs are in the library."""
if not movie_ids:
return None
anchor = db.execute(
select(PlexItem)
.join(
PlexState,
and_(PlexState.item_id == PlexItem.id, PlexState.user_id == uid),
)
.where(
PlexItem.kind == "movie",
PlexItem.library_id.in_(movie_ids),
PlexState.status == "watched",
PlexItem.recommendations.isnot(None),
)
.order_by(PlexState.watched_at.desc().nullslast())
.limit(1)
).scalars().first()
if anchor is None:
return None
resolved = resolve_recs(db, anchor.recommendations)
# Preserve TMDB's recommendation order, keep only in-library hits, and drop the anchor itself.
rks = [
r["rating_key"]
for r in resolved
if r.get("in_library") and r.get("rating_key") != anchor.rating_key
]
if not rks:
return None
movies = {
m.rating_key: m
for m in db.execute(
select(PlexItem).where(
PlexItem.kind == "movie", PlexItem.rating_key.in_(rks)
)
).scalars().all()
}
shows = {
s.rating_key: s
for s in db.execute(
select(PlexShow).where(PlexShow.rating_key.in_(rks))
).scalars().all()
}
ordered_movies = [movies[rk] for rk in rks if rk in movies]
ordered_shows = [shows[rk] for rk in rks if rk in shows]
# rating_key is globally unique in Plex, so it keys the serialized cards back safely (no id()).
mcards = {m.rating_key: c for m, c in zip(ordered_movies, _movie_cards(db, uid, ordered_movies))}
scards = {s.rating_key: c for s, c in zip(ordered_shows, _show_cards(db, uid, ordered_shows))}
items: list[dict] = []
for rk in rks:
if rk in mcards:
items.append(mcards[rk])
elif rk in scards:
items.append(scards[rk])
if not items:
return None
return {"anchor": {"id": anchor.rating_key, "title": anchor.title}, "items": items}
@router.get("/discover")
def discover(
scope: str = "both",
lang: str = "en",
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
"""The Discover landing payload — every rail in one pass (serve-from-DB). Empty rails come back as
empty lists (the client hides them); `because_you_watched` is null when there's no anchor."""
lang = _norm_lang(lang)
movie_ids, show_ids = _scope_libs(db, scope)
return {
"scope": scope,
"continue_watching": _continue_watching(db, user.id, movie_ids, show_ids),
"recently_added": _recently_added(db, user.id, movie_ids, show_ids),
"genres": _genre_tiles(db, movie_ids, show_ids, lang),
"because_you_watched": _because_you_watched(db, user.id, movie_ids, show_ids),
}
@router.get("/surprise")
def surprise(
scope: str = "both",
limit: int = Query(default=RAIL_LIMIT, ge=1, le=40),
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
"""A fresh random sample for the reshuffling Surprise-me rail (a random draw each call). Movies and
shows are drawn separately then interleaved so a lopsided library doesn't crowd out one type."""
movie_ids, show_ids = _scope_libs(db, scope)
movies = (
db.execute(
select(PlexItem)
.where(PlexItem.kind == "movie", PlexItem.library_id.in_(movie_ids))
.order_by(func.random())
.limit(limit)
).scalars().all()
if movie_ids
else []
)
shows = (
db.execute(
select(PlexShow)
.where(PlexShow.library_id.in_(show_ids))
.order_by(func.random())
.limit(limit)
).scalars().all()
if show_ids
else []
)
mcards = _movie_cards(db, user.id, movies)
scards = _show_cards(db, user.id, shows)
# Interleave movie/show cards, then trim to `limit`.
items: list[dict] = []
for i in range(max(len(mcards), len(scards))):
if i < len(mcards):
items.append(mcards[i])
if i < len(scards):
items.append(scards[i])
return {"scope": scope, "items": items[:limit]}
+119 -16
View File
@@ -1,24 +1,60 @@
"""Plex people: cast/crew filter-chip cards + a single person's filmography page."""
from fastapi import APIRouter, Depends
import logging
import threading
import time
from fastapi import APIRouter, BackgroundTasks, Depends
from sqlalchemy import func, or_, select
from sqlalchemy.orm import Session
from app.auth import current_user
from app.db import get_db
from app.db import SessionLocal, get_db
from app.models import (
PlexItem,
PlexPerson,
PlexShow,
PlexState,
User,
)
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
router = APIRouter(prefix="/api/plex", tags=["plex"])
log = logging.getLogger("siftlode.plex")
def _resolve_known_for(
db: Session,
known_for: list | None,
uid: int,
movie_lib_ids: list[int],
show_lib_ids: list[int],
) -> list[dict]:
"""The person's TMDB "Known for" credits (top by popularity) that are IN our library, as cards, in
popularity order. Matched by the canonical guid `tmdb:<id>`; out-of-library credits are dropped."""
if not known_for:
return []
# Match each credit to a library row by guid, bucketed by media_type (movie ids and tv ids collide
# numerically) and scoped to the enabled libraries — the shared helper owns that collision-safe
# logic (the detail-page recs use the same one).
movies, shows = plex_tmdb.resolve_library_guids(
db, known_for, movie_lib_ids=movie_lib_ids, show_lib_ids=show_lib_ids
)
m_states = rollup.item_state_map(db, uid, [m.id for m in movies.values()])
show_statuses = rollup.state_map(db, uid, [s.id for s in shows.values()])
out: list[dict] = []
for k in known_for:
g = f"tmdb:{k.get('tmdb_id')}"
# A credit resolves against its OWN media_type's bucket only.
if k.get("media_type") == "tv":
if g in shows:
out.append(show_card(shows[g], show_statuses.get(shows[g].id, "new")))
elif g in movies:
out.append(movie_card(movies[g], m_states.get(movies[g].id)))
return out
@router.get("/people/cards")
@@ -78,9 +114,54 @@ def people_cards(
_PERSON_SECTION_MAX = 200
# A person's bio is enriched at most once per this window: it dedups concurrent opens of the same person
# (each would otherwise schedule its own 8s TMDB task) AND rate-limits re-triggering when TMDB is
# down/slow (a transient failure leaves tmdb_enriched_at NULL, so without this every reload would spawn a
# fresh task and pile up on the shared threadpool). In-process + best-effort: a restart just clears it, at
# worst costing a few extra fetches. A successful enrich stamps the watermark, so the reservation only
# ever expires harmlessly.
_ENRICH_COOLDOWN_S = 60.0
_enrich_inflight: dict[int, float] = {}
_enrich_lock = threading.Lock()
def _should_schedule_enrich(person_id: int) -> bool:
"""True (and reserves a cooldown) if this person isn't already being enriched / recently attempted."""
now = time.monotonic()
with _enrich_lock:
deadline = _enrich_inflight.get(person_id)
if deadline is not None and now < deadline:
return False
_enrich_inflight[person_id] = now + _ENRICH_COOLDOWN_S
# Bound the map: drop expired reservations once it grows (a large library has many people).
if len(_enrich_inflight) > 1024:
for pid in [p for p, d in _enrich_inflight.items() if d <= now]:
del _enrich_inflight[pid]
return True
def _enrich_person_bg(person_id: int) -> None:
"""Fetch + persist one person's TMDB bio OFF the request path — a FastAPI background task that runs
AFTER the response is sent, so the outbound TMDB call never sits on the serve latency (the module's
"no live TMDB call at serve time" invariant). The request's session is already closed by then, so
open a fresh one. Best-effort: a TMDB failure just leaves `tmdb_enriched_at` NULL to retry on the
next open, and the bio lands from the row by then."""
db = SessionLocal()
try:
person = db.get(PlexPerson, person_id)
if person is not None:
plex_tmdb.enrich_person(db, person)
except Exception: # noqa: BLE001 — background best-effort; never surfaces to a user
db.rollback()
log.warning("background person bio enrich failed for id=%s", person_id, exc_info=True)
finally:
db.close()
@router.get("/person/{name}")
def person_detail(
name: str,
background_tasks: BackgroundTasks,
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
@@ -88,8 +169,24 @@ def person_detail(
libraries) whose cast OR directors include this name — the inverse of the cast-name → library-filter
link. The name string is the identity key (Plex cast carries no stable per-person id), so distinct
people who share a name share a page. Each section is capped at _PERSON_SECTION_MAX. An unknown name
simply returns empty sections."""
photo = db.scalar(select(PlexPerson.photo_thumb).where(PlexPerson.name == name))
simply returns empty sections.
E5 S6: the first time a page is opened, the person's TMDB bio + "Known for" credits are lazily
fetched (from the S2-linked `tmdb_person_id`) and persisted, then served from the row on every open.
The fetch runs OFF the request path (a background task); the first open serves whatever's in the row
(bio possibly still null) and the bio lands from the row by the next open.
"""
person = db.execute(select(PlexPerson).where(PlexPerson.name == name)).scalars().first()
# Lazy one-time bio enrich, scheduled AFTER the response so the TMDB call is never on the serve path.
# Gated by a per-person cooldown so concurrent/rapid opens (or repeated opens while TMDB is degraded)
# can't pile background tasks onto the shared threadpool.
if (
person is not None
and person.tmdb_person_id
and person.tmdb_enriched_at is None
and _should_schedule_enrich(person.id)
):
background_tasks.add_task(_enrich_person_bg, person.id)
movie_lib_ids, show_lib_ids = _content_libs(db)
movies: list[dict] = []
@@ -107,16 +204,7 @@ def person_detail(
.order_by(PlexItem.year.desc().nullslast(), func.lower(PlexItem.title))
.limit(_PERSON_SECTION_MAX)
).scalars().all()
item_ids = [it.id for it in items]
states = {
s.item_id: s
for s in db.execute(
select(PlexState).where(
PlexState.user_id == user.id,
PlexState.item_id.in_(item_ids or [0]),
)
).scalars()
}
states = rollup.item_state_map(db, user.id, [it.id for it in items])
movies = [movie_card(it, states.get(it.id)) for it in items]
shows: list[dict] = []
@@ -136,4 +224,19 @@ def person_detail(
statuses = rollup.state_map(db, user.id, [s.id for s in show_rows])
shows = [show_card(sh, statuses.get(sh.id, "new")) for sh in show_rows]
return {"name": name, "photo": photo, "movies": movies, "shows": shows}
known_for = _resolve_known_for(
db, person.known_for if person else None, user.id, movie_lib_ids, show_lib_ids
)
return {
"name": name,
"photo": person.photo_thumb if person else None,
"biography": person.biography if person else None,
"birthday": person.birthday if person else None,
"deathday": person.deathday if person else None,
"place_of_birth": person.place_of_birth if person else None,
"known_for_department": person.known_for_department if person else None,
"also_known_as": (person.also_known_as if person else None) or [],
"known_for": known_for,
"movies": movies,
"shows": shows,
}
+23 -4
View File
@@ -12,6 +12,7 @@ Ingested results are flagged (videos.via_search / channels.from_search) so the L
this search-discovered "noise" by default. Shorts and currently-live/upcoming videos are never
ingested this way (Shorts are confirmed by the youtube.com/shorts probe; live/upcoming are
dropped from the search response up front)."""
import logging
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime, timezone
@@ -43,6 +44,8 @@ from app.youtube import search_scrape
from app.youtube.client import YouTubeClient, YouTubeError
from app.youtube.shorts import make_client, probe_is_short
log = logging.getLogger("siftlode.search")
router = APIRouter(prefix="/api/search", tags=["search"])
# search.list costs this many quota units per page.
@@ -136,6 +139,10 @@ def _ingest_candidates(
# 2) Video stubs, flagged via_search. Existing rows keep their flags (ON CONFLICT DO
# NOTHING), so an organically-synced video stays organic.
# published_at is NOT NULL (migration 0068); the scrape source exposes no publish date
# (only a relative "3 years ago"), so fall back to now() to satisfy the constraint — the
# enrich in step 3 overwrites it with the video's real snippet date.
now = datetime.now(timezone.utc)
insert_stubs(
db,
[
@@ -143,7 +150,7 @@ def _ingest_candidates(
"id": it["id"],
"channel_id": it["channel_id"],
"title": it["title"],
"published_at": parse_dt(it["published_at"]),
"published_at": parse_dt(it["published_at"]) or now,
"thumbnail_url": it["thumbnail_url"],
"via_search": True,
}
@@ -158,13 +165,13 @@ def _ingest_candidates(
detail_by_id = {it["id"]: it for it in yt.get_videos(ids)}
except YouTubeError:
detail_by_id = {}
now = datetime.now(timezone.utc)
enriched_at = datetime.now(timezone.utc) # when THIS enrich ran (distinct from the stub placeholder)
for v in videos:
item = detail_by_id.get(v.id)
if item is None:
continue
apply_video_details(v, item)
v.enriched_at = now
v.enriched_at = enriched_at
db.commit()
# 4) Confirm/deny Shorts (no quota) so none enter via search.
@@ -282,7 +289,19 @@ def search_youtube(
and it["channel_id"]
and it["channel_id"] not in blocked
]
collected.extend(_ingest_candidates(db, yt, user, candidates))
# Ingest (DB writes + enrich) runs outside the page-fetch try above; guard it too so a
# write/shape failure degrades to a 422 with a message on this JSON endpoint instead of a
# bare 500 hidden behind the FE's generic "search failed" (same rationale as B-core-1).
try:
collected.extend(_ingest_candidates(db, yt, user, candidates))
except Exception as exc: # noqa: BLE001 — any ingest failure, not just YouTubeError
db.rollback()
log.exception("live search ingest failed for %r", term)
if collected:
break # keep what earlier pages already gathered
raise HTTPException(
status_code=422, detail=f"YouTube search failed: {exc}"
) from exc
next_cursor = page["next_page_token"]
# The API source pages cost 100 units each — never auto-page; one page per request.
if source == "api" or len(collected) >= limit or not next_cursor:
+10
View File
@@ -17,6 +17,7 @@ from app.demo import reset_demo_state
from app.downloads.gc import run_download_gc
from app.models import SchedulerSetting, User
from app.plex.sync import sync as run_plex_sync
from app.plex.tmdb import run_enrich as run_tmdb_enrich
from app.plex.watch_sync import run_plex_watch_reconcile, run_plex_watch_sync
from app.notifications import create_notification
from app.state import is_sync_paused
@@ -68,6 +69,7 @@ JOB_INTERVALS: dict[str, int] = {
"plex_sync": settings.plex_sync_interval_min,
"plex_watch_sync": settings.plex_watch_sync_interval_min,
"plex_watch_reconcile": settings.plex_watch_reconcile_interval_min,
"tmdb_enrich": settings.tmdb_enrich_interval_min,
"audit_gc": settings.audit_gc_minutes,
}
@@ -351,6 +353,13 @@ def _plex_watch_reconcile_job() -> None:
_job("plex_watch_reconcile", run_plex_watch_reconcile)
def _tmdb_enrich_job() -> None:
# Enrich a batch of mirrored Plex titles from TMDB (keywords/crew/budget/revenue/TMDb score/
# recommendations/backdrops), matched by guid. External metadata (no YouTube quota); a no-op when
# the TMDB key is unset or the Plex catalog is empty.
_job("tmdb_enrich", run_tmdb_enrich)
def _audit_gc_job() -> None:
# Prune audit-log rows older than the admin-set retention (audit_retention_days; 0 = keep
# forever). Pure DB, no quota.
@@ -378,6 +387,7 @@ JOB_FUNCS: dict[str, Callable[[], None]] = {
"plex_sync": _plex_sync_job,
"plex_watch_sync": _plex_watch_sync_job,
"plex_watch_reconcile": _plex_watch_reconcile_job,
"tmdb_enrich": _tmdb_enrich_job,
"audit_gc": _audit_gc_job,
}
+3 -2
View File
@@ -6,6 +6,7 @@ from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models import Channel, Subscription, User
from app.utils import normalize_description
from app.youtube.client import YouTubeClient, best_thumbnail
log = logging.getLogger("siftlode.sync")
@@ -55,7 +56,7 @@ def apply_channel_details(db: Session, items: list[dict]) -> None:
channel.title = snippet.get("title") or channel.title
channel.handle = snippet.get("customUrl")
channel.description = snippet.get("description")
channel.description = normalize_description(snippet.get("description"))
channel.thumbnail_url = (
best_thumbnail(snippet.get("thumbnails")) or channel.thumbnail_url
)
@@ -104,7 +105,7 @@ def import_subscriptions(db: Session, user: User) -> dict:
channel = Channel(
id=cid,
title=sub.get("title"),
description=sub.get("description"),
description=normalize_description(sub.get("description")),
thumbnail_url=sub.get("thumbnail_url"),
)
db.add(channel)
+9 -3
View File
@@ -245,9 +245,15 @@ def apply_video_details(video: Video, item: dict) -> None:
video.original_title = raw_title
video.title = normalize_title(raw_title)
video.description = snippet.get("description")
if not video.published_at:
# NOT NULL (0068): a fresh insert with no date yet falls back to now() (enrich normally has one).
video.published_at = parse_dt(snippet.get("publishedAt")) or _now()
# published_at is NOT NULL (0068). Prefer the enrich snippet's real date whenever it has one —
# this also corrects a now() placeholder written at stub time (e.g. a scrape-search stub, which
# carries no publish date). Only fall back to now() when the row still has nothing at all; never
# clobber an existing real date with now() (a re-enrich whose snippet lacks a date).
snippet_published = parse_dt(snippet.get("publishedAt"))
if snippet_published:
video.published_at = snippet_published
elif not video.published_at:
video.published_at = _now()
video.thumbnail_url = best_thumbnail(snippet.get("thumbnails")) or video.thumbnail_url
video.duration_seconds = parse_iso8601_duration(content.get("duration"))
video.view_count = int(stats["viewCount"]) if stats.get("viewCount") else None
+5
View File
@@ -102,6 +102,11 @@ SPECS: tuple[ConfigSpec, ...] = (
ConfigSpec("plex_max_transcodes", "int", "plex", "plex_max_transcodes", min=0, max=16),
# NOTE: plex_watch_sync_interval_min is a config.py default read by the watch-sync scheduler job
# (scheduler.py); it is intentionally NOT a runtime admin knob, so it stays out of SPECS.
# --- TMDB enrichment (E5 S2; optional) — the API key is the only secret ---
ConfigSpec("tmdb_api_key", "str", "tmdb", "tmdb_api_key", secret=True),
ConfigSpec("tmdb_language", "str", "tmdb", "tmdb_language"),
# (tmdb_enrich_interval_min is a scheduler job, tuned from the Scheduler dashboard; tmdb_enrich_batch
# is a config.py default — both stay out of SPECS, like plex_sync_interval_min.)
)
_BY_KEY: dict[str, ConfigSpec] = {s.key: s for s in SPECS}
+15
View File
@@ -36,3 +36,18 @@ def to_tsquery_str(q: str) -> str | None:
return None
tokens[-1] = tokens[-1] + ":*"
return " & ".join(tokens)
def normalize_description(text: str | None) -> str | None:
"""Strip whitespace-only lines from a CHANNEL description before storing. YouTube channel
descriptions pad their contact/link blocks with runs of blank lines; keeping them wastes storage,
bloats the About popover, and adds nothing to the search document. Drops purely-empty lines and
right-strips each kept line; `str.splitlines()` also folds CR / CRLF and Unicode line separators
to "\\n" (a benign line-ending normalization). None stays None; all-blank collapses to "".
Deliberately CHANNEL-only — video descriptions are left with their original formatting: they are
user-facing prose where paragraph breaks carry meaning, unlike a channel's metadata block."""
if text is None:
return None
kept = [line.rstrip() for line in text.splitlines() if line.strip()]
return "\n".join(kept)
+31
View File
@@ -0,0 +1,31 @@
"""Plex card serializers (app.plex.cards) — pure model->dict projections, no DB needed.
E5 S1: movie/show cards now also emit `art` (the wide backdrop, for a Discover rail's landscape tile
and the crossfading hero), but ONLY when the item actually has an `art_key` — otherwise the URL would
resolve to a blank image on every card that tried to use it, so it must be null and let the client fall
back to the poster."""
from app.models import PlexItem, PlexShow
from app.plex.cards import movie_card, show_card
def test_movie_card_emits_art_when_art_key_present():
it = PlexItem(rating_key="m1", kind="movie", title="M", year=2020, art_key="/library/art/1")
card = movie_card(it, None)
assert card["thumb"] == "/api/plex/image/m1"
assert card["art"] == "/api/plex/image/m1?variant=art"
def test_movie_card_art_is_none_without_art_key():
it = PlexItem(rating_key="m2", kind="movie", title="M2")
assert movie_card(it, None)["art"] is None
def test_show_card_emits_art_when_art_key_present():
sh = PlexShow(rating_key="s1", title="S", art_key="/library/art/2")
assert show_card(sh)["art"] == "/api/plex/image/s1?variant=art"
def test_show_card_art_is_none_without_art_key():
sh = PlexShow(rating_key="s2", title="S2")
assert show_card(sh)["art"] is None
+202
View File
@@ -0,0 +1,202 @@
"""E5 S4: the Plex Discover rail-surface. GET /api/plex/discover returns the consolidated rail
payload (continue watching / recently added / genre tiles / because-you-watched) served from the DB,
and GET /api/plex/surprise returns a scope-bounded random sample. DB lane (`db` fixture) — skipped
without Postgres.
"""
from datetime import datetime, timedelta, timezone
from fastapi.testclient import TestClient
import app.state as state
from app.auth import current_user
from app.db import get_db
from app.main import app
from app.models import PlexItem, PlexLibrary, PlexShow, PlexState, User
NOW = datetime(2024, 6, 1, tzinfo=timezone.utc)
def _seed(db):
"""A movie + show library. m1 is a watched, TMDB-enriched anchor whose one recommendation is m2
(in library by guid); m3 is a movie in-progress; sh1 is a show with an in-progress episode."""
lib_m = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
lib_s = PlexLibrary(plex_key="2", title="Shows", kind="show", enabled=True)
db.add_all([lib_m, lib_s])
db.flush()
m1 = PlexItem(
rating_key="m1", library_id=lib_m.id, kind="movie", title="Anchor Movie",
guid="tmdb:111", genres=["Drama"], year=2001,
added_at=NOW - timedelta(days=1),
tmdb_enriched_at=NOW - timedelta(days=1),
recommendations=[
{"tmdb_id": 555, "title": "Rec Movie", "poster_path": None, "media_type": "movie"}
],
)
m2 = PlexItem(
rating_key="m2", library_id=lib_m.id, kind="movie", title="Rec Movie",
guid="tmdb:555", genres=["Comedy"], year=2002,
added_at=NOW - timedelta(days=2),
)
m3 = PlexItem(
rating_key="m3", library_id=lib_m.id, kind="movie", title="Half-Watched Movie",
guid="tmdb:333", genres=["Drama"], year=2003, duration_s=6000,
added_at=NOW - timedelta(days=3),
)
sh1 = PlexShow(rating_key="sh1", library_id=lib_s.id, title="A Show", child_count=1,
added_at=NOW - timedelta(hours=1)) # newest → leads recently-added
db.add_all([m1, m2, m3, sh1])
db.flush()
ep1 = PlexItem(
rating_key="sh1e1", library_id=lib_s.id, kind="episode", title="Ep 1",
show_id=sh1.id, season_number=1, episode_number=1,
)
ep2 = PlexItem(
rating_key="sh1e2", library_id=lib_s.id, kind="episode", title="Ep 2",
show_id=sh1.id, season_number=1, episode_number=2,
)
db.add_all([ep1, ep2])
user = User(email="discover@example.com")
db.add(user)
db.flush()
db.add_all([
# m1 watched most recently → the because-you-watched anchor.
PlexState(user_id=user.id, item_id=m1.id, status="watched", watched_at=NOW),
# m3 in-progress → continue watching.
PlexState(user_id=user.id, item_id=m3.id, status="new", position_seconds=1200,
progress_updated_at=NOW - timedelta(hours=1)),
# sh1's ep1 in-progress → the show is in continue watching.
PlexState(user_id=user.id, item_id=ep1.id, status="new", position_seconds=300,
progress_updated_at=NOW - timedelta(minutes=30)),
])
db.commit()
return user
def _get(db, user, monkeypatch, path):
monkeypatch.setattr(state, "_configured_cache", True)
app.dependency_overrides[get_db] = lambda: db
app.dependency_overrides[current_user] = lambda: user
try:
client = TestClient(app) # no context manager: skip lifespan
return client.get(path)
finally:
app.dependency_overrides.clear()
def _ids(cards):
return [c["id"] for c in cards]
def test_discover_shape_and_recently_added_order(db, monkeypatch):
user = _seed(db)
resp = _get(db, user, monkeypatch, "/api/plex/discover?scope=both")
assert resp.status_code == 200, resp.text
d = resp.json()
assert set(d) >= {"continue_watching", "recently_added", "genres", "because_you_watched"}
# Recently added is newest-first; the show (added 1d ago) leads, then m1, m2, m3.
assert _ids(d["recently_added"])[:4] == ["sh1", "m1", "m2", "m3"]
# Genre tiles carry counts; Drama (m1 + m3) outweighs Comedy (m2).
genres = {g["value"]: g["count"] for g in d["genres"]}
assert genres.get("Drama") == 2 and genres.get("Comedy") == 1
def test_continue_watching_movies_and_shows(db, monkeypatch):
user = _seed(db)
d = _get(db, user, monkeypatch, "/api/plex/discover?scope=both").json()
ids = _ids(d["continue_watching"])
# The in-progress movie and the show with an in-progress episode are both present; the watched
# anchor movie (m1) is not. Order is most-recently-progressed first (the show's ep beats m3).
assert ids == ["sh1", "m3"]
assert "m1" not in ids
def test_continue_watching_null_status_in_hidden_out(db, monkeypatch):
# A started movie whose PlexState.status is NULL is still "in progress": `NULL NOT IN (...)` is NULL
# (not TRUE), so a bare notin_ would wrongly drop it — the or_(is_(None), notin_) branch keeps it.
# A hidden started movie must be excluded.
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
db.add(lib)
db.flush()
m_null = PlexItem(rating_key="mn", library_id=lib.id, kind="movie", title="Null Status", guid="tmdb:1")
m_hidden = PlexItem(rating_key="mh", library_id=lib.id, kind="movie", title="Hidden", guid="tmdb:2")
db.add_all([m_null, m_hidden])
db.flush()
user = User(email="cw-null@example.com")
db.add(user)
db.flush()
db.add_all([
PlexState(user_id=user.id, item_id=m_null.id, status=None, position_seconds=500,
progress_updated_at=NOW),
PlexState(user_id=user.id, item_id=m_hidden.id, status="hidden", position_seconds=500,
progress_updated_at=NOW - timedelta(hours=1)),
])
db.commit()
ids = _ids(_get(db, user, monkeypatch, "/api/plex/discover?scope=movie").json()["continue_watching"])
assert "mn" in ids # NULL-status started movie IS in progress
assert "mh" not in ids # hidden is excluded
def test_continue_watching_excludes_fully_watched_show(db, monkeypatch):
# A show whose every episode is watched is DONE, not in-progress: the `watched < total` guard must
# keep it off the Continue-watching rail (a bare `watched > 0` would wrongly resurface it).
lib_s = PlexLibrary(plex_key="2", title="Shows", kind="show", enabled=True)
db.add(lib_s)
db.flush()
sh = PlexShow(rating_key="shw", library_id=lib_s.id, title="All Watched", child_count=1)
db.add(sh)
db.flush()
ep = PlexItem(rating_key="shwe1", library_id=lib_s.id, kind="episode", title="E1",
show_id=sh.id, season_number=1, episode_number=1)
db.add(ep)
db.flush()
user = User(email="fullywatched@example.com")
db.add(user)
db.flush()
db.add(PlexState(user_id=user.id, item_id=ep.id, status="watched", position_seconds=0,
progress_updated_at=NOW))
db.commit()
ids = _ids(_get(db, user, monkeypatch, "/api/plex/discover?scope=show").json()["continue_watching"])
assert "shw" not in ids # fully watched → excluded from continue-watching
def test_because_you_watched_resolves_in_library_recs(db, monkeypatch):
user = _seed(db)
d = _get(db, user, monkeypatch, "/api/plex/discover?scope=both").json()
bw = d["because_you_watched"]
assert bw is not None
assert bw["anchor"]["id"] == "m1" and bw["anchor"]["title"] == "Anchor Movie"
# m1's single recommendation (tmdb:555) is m2 in our library → it becomes the one rail card.
assert _ids(bw["items"]) == ["m2"]
def test_because_you_watched_null_without_history(db, monkeypatch):
_seed(db)
fresh = User(email="fresh-discover@example.com")
db.add(fresh)
db.commit()
d = _get(db, fresh, monkeypatch, "/api/plex/discover?scope=both").json()
# A user with no watched-and-enriched movie gets no recommendation rail.
assert d["because_you_watched"] is None
assert d["continue_watching"] == []
def test_surprise_scope_and_bound(db, monkeypatch):
user = _seed(db)
resp = _get(db, user, monkeypatch, "/api/plex/surprise?scope=movie&limit=2")
assert resp.status_code == 200, resp.text
items = resp.json()["items"]
assert len(items) <= 2
assert all(c["type"] == "movie" for c in items) # scope=movie excludes shows
def test_discover_scope_movie_excludes_shows(db, monkeypatch):
user = _seed(db)
d = _get(db, user, monkeypatch, "/api/plex/discover?scope=movie").json()
assert all(c["type"] == "movie" for c in d["recently_added"])
# The show drops out of continue watching under the movie scope.
assert _ids(d["continue_watching"]) == ["m3"]
+27 -1
View File
@@ -12,7 +12,33 @@ import app.state as state
from app.auth import current_user
from app.db import get_db
from app.main import app
from app.models import PlexLibrary, PlexShow, User
from app.models import PlexItem, PlexLibrary, PlexShow, User
def test_facets_returns_studio_vocabulary(db, monkeypatch):
"""E5 S7: /facets returns the studio vocabulary (distinct studio + count) for the sidebar picker."""
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
db.add(lib)
db.flush()
db.add_all([
PlexItem(rating_key="m1", library_id=lib.id, kind="movie", title="A", studio="Pixar"),
PlexItem(rating_key="m2", library_id=lib.id, kind="movie", title="B", studio="Pixar"),
PlexItem(rating_key="m3", library_id=lib.id, kind="movie", title="C", studio="A24"),
PlexItem(rating_key="m4", library_id=lib.id, kind="movie", title="D"), # no studio
])
user = User(email="studiofacets@example.com")
db.add(user)
db.commit()
monkeypatch.setattr(state, "_configured_cache", True)
app.dependency_overrides[get_db] = lambda: db
app.dependency_overrides[current_user] = lambda: user
try:
resp = TestClient(app).get("/api/plex/facets?scope=movie")
finally:
app.dependency_overrides.clear()
assert resp.status_code == 200, resp.text
studios = {s["value"]: s["count"] for s in resp.json()["studios"]}
assert studios == {"Pixar": 2, "A24": 1}
def test_facets_survives_json_null_genres(db, monkeypatch):
+61
View File
@@ -0,0 +1,61 @@
"""E5 S5: the TMDB keyword filter. A keyword chip on the detail page sets ?keywords=<kw>, which
narrows the unified library to rows whose persisted `keywords` JSONB array contains it. DB lane
(`db` fixture) — skipped without Postgres.
"""
from fastapi.testclient import TestClient
import app.state as state
from app.auth import current_user
from app.db import get_db
from app.main import app
from app.models import PlexItem, PlexLibrary, User
def _seed(db):
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
db.add(lib)
db.flush()
db.add_all([
PlexItem(rating_key="m1", library_id=lib.id, kind="movie", title="Heist Movie",
keywords=["heist", "bank robbery"]),
PlexItem(rating_key="m2", library_id=lib.id, kind="movie", title="Space Movie",
keywords=["space", "aliens"]),
PlexItem(rating_key="m3", library_id=lib.id, kind="movie", title="No Keywords Movie"),
])
user = User(email="kwfilter@example.com")
db.add(user)
db.commit()
return user
def _get(db, user, monkeypatch, path):
monkeypatch.setattr(state, "_configured_cache", True)
app.dependency_overrides[get_db] = lambda: db
app.dependency_overrides[current_user] = lambda: user
try:
return TestClient(app).get(path)
finally:
app.dependency_overrides.clear()
def _titles(resp):
return {i["title"] for i in resp.json()["items"]}
def test_keyword_filter_narrows_to_matching_rows(db, monkeypatch):
user = _seed(db)
resp = _get(db, user, monkeypatch, "/api/plex/library?scope=movie&keywords=heist")
assert resp.status_code == 200, resp.text
assert _titles(resp) == {"Heist Movie"}
def test_no_keyword_filter_returns_all(db, monkeypatch):
user = _seed(db)
resp = _get(db, user, monkeypatch, "/api/plex/library?scope=movie")
assert _titles(resp) == {"Heist Movie", "Space Movie", "No Keywords Movie"}
def test_unknown_keyword_matches_nothing(db, monkeypatch):
user = _seed(db)
resp = _get(db, user, monkeypatch, "/api/plex/library?scope=movie&keywords=nonesuch")
assert resp.json()["items"] == []
+14 -1
View File
@@ -100,7 +100,20 @@ def test_person_detail_unknown_name_is_empty(db, monkeypatch):
resp = _get(db, user, monkeypatch, "/api/plex/person/Ghost%20Actor")
assert resp.status_code == 200, resp.text
body = resp.json()
assert body == {"name": "Ghost Actor", "photo": None, "movies": [], "shows": []}
# An unknown name has no plex_people row → no bio, no titles (E5 S6 added the bio fields, all null).
assert body == {
"name": "Ghost Actor",
"photo": None,
"biography": None,
"birthday": None,
"deathday": None,
"place_of_birth": None,
"known_for_department": None,
"also_known_as": [],
"known_for": [],
"movies": [],
"shows": [],
}
def test_content_libs_partitions_only_enabled(db):
+292
View File
@@ -0,0 +1,292 @@
"""E5 S6: person page TMDB bio enrichment. `parse_person` shapes the /person payload (bio + a
popularity-ranked, deduped `known_for`); `enrich_person` lazily fetches + persists once (guarded on a
key + a tmdb_person_id); and /person/{name} serves the bio + resolves `known_for` to library rows.
DB lane for the endpoint/enrich tests; parse_person is pure. Skipped without Postgres where DB-backed.
"""
from fastapi.testclient import TestClient
import app.state as state
from app.auth import current_user
from app.db import get_db
from app.main import app
from app.models import PlexItem, PlexLibrary, PlexPerson, User
from app.plex import tmdb as plex_tmdb
PAYLOAD = {
"biography": " A celebrated actor. ",
"birthday": "1970-01-01",
"deathday": None,
"place_of_birth": "Springfield",
"known_for_department": "Acting",
"also_known_as": ["AKA One", "AKA Two"],
"combined_credits": {
"cast": [
{"id": 10, "media_type": "movie", "title": "Mid Movie", "popularity": 50},
{"id": 20, "media_type": "tv", "name": "Top Show", "popularity": 80},
{"id": 10, "media_type": "movie", "title": "dup", "popularity": 5}, # dup id
{"id": 30, "media_type": "movie", "title": "Low Movie", "popularity": 1},
{"id": None, "media_type": "movie", "title": "No id", "popularity": 99}, # dropped
]
},
}
def test_parse_person_shapes_bio_and_ranked_known_for():
p = plex_tmdb.parse_person(PAYLOAD)
assert p["biography"] == "A celebrated actor." # stripped
assert p["birthday"] == "1970-01-01"
assert p["known_for_department"] == "Acting"
assert p["also_known_as"] == ["AKA One", "AKA Two"]
# Popularity desc, deduped by id, None-id dropped: Top Show (80) → Mid Movie (50) → Low Movie (1).
assert [k["tmdb_id"] for k in p["known_for"]] == [20, 10, 30]
assert p["known_for"][0]["media_type"] == "tv"
def test_parse_person_empty_payload():
p = plex_tmdb.parse_person({})
assert p["biography"] is None and p["known_for"] is None
def test_parse_person_keeps_same_id_across_media_types():
# TMDB movie ids and tv ids are independent, so a movie-42 and a tv-42 credit are two distinct works
# and BOTH must survive the dedup (keyed on (id, media_type), not the bare id).
payload = {
"combined_credits": {
"cast": [
{"id": 42, "media_type": "movie", "title": "Movie 42", "popularity": 90},
{"id": 42, "media_type": "tv", "name": "Show 42", "popularity": 80},
{"id": 42, "media_type": "movie", "title": "dup movie 42", "popularity": 5}, # real dup
]
}
}
kf = plex_tmdb.parse_person(payload)["known_for"]
assert [(k["tmdb_id"], k["media_type"]) for k in kf] == [(42, "movie"), (42, "tv")] # both kept, dup dropped
class _FakeTmdb:
def __init__(self, payload):
self.payload = payload
def __enter__(self):
return self
def __exit__(self, *a):
return False
def fetch_person(self, pid):
return self.payload
def test_enrich_person_persists_and_stamps(db, monkeypatch):
person = PlexPerson(name="Actor X", tmdb_person_id=555)
db.add(person)
db.commit()
monkeypatch.setattr(plex_tmdb.sysconfig, "get_str", lambda d, k: "key" if k == "tmdb_api_key" else "")
monkeypatch.setattr(plex_tmdb, "TmdbClient", lambda d, timeout=None: _FakeTmdb(PAYLOAD))
assert plex_tmdb.enrich_person(db, person) is True
assert person.biography == "A celebrated actor."
assert person.tmdb_enriched_at is not None
assert [k["tmdb_id"] for k in person.known_for] == [20, 10, 30]
class _FakeTmdbResult:
"""Context-manager TmdbClient stand-in whose fetch_person raises / returns a fixed payload."""
def __init__(self, *, raises=False, payload=None):
self._raises = raises
self._payload = payload
def __enter__(self):
return self
def __exit__(self, *a):
return False
def fetch_person(self, pid):
if self._raises:
raise plex_tmdb.TmdbError("429 rate limited")
return self._payload
def test_enrich_person_transient_error_leaves_watermark_null(db, monkeypatch):
# A transient TmdbError must NOT stamp the watermark, so the next open retries (never permanently
# blank a person after one blip).
person = PlexPerson(name="Transient P", tmdb_person_id=555)
db.add(person)
db.commit()
monkeypatch.setattr(plex_tmdb.sysconfig, "get_str", lambda d, k: "key" if k == "tmdb_api_key" else "")
monkeypatch.setattr(plex_tmdb, "TmdbClient", lambda d, timeout=None: _FakeTmdbResult(raises=True))
assert plex_tmdb.enrich_person(db, person) is False
assert person.tmdb_enriched_at is None # left NULL → retried next open
def test_enrich_person_404_stamps_watermark_without_bio(db, monkeypatch):
# A 404 (deleted person → None payload) stamps the watermark so it isn't refetched every open.
person = PlexPerson(name="Gone P", tmdb_person_id=555)
db.add(person)
db.commit()
monkeypatch.setattr(plex_tmdb.sysconfig, "get_str", lambda d, k: "key" if k == "tmdb_api_key" else "")
monkeypatch.setattr(plex_tmdb, "TmdbClient", lambda d, timeout=None: _FakeTmdbResult(payload=None))
assert plex_tmdb.enrich_person(db, person) is True
assert person.tmdb_enriched_at is not None # stamped
assert person.biography is None # nothing fetched
def test_enrich_person_noop_without_key(db, monkeypatch):
person = PlexPerson(name="Actor Y", tmdb_person_id=555)
db.add(person)
db.commit()
monkeypatch.setattr(plex_tmdb.sysconfig, "get_str", lambda d, k: "") # no key
assert plex_tmdb.enrich_person(db, person) is False
assert person.tmdb_enriched_at is None
def test_enrich_person_noop_without_tmdb_id(db, monkeypatch):
person = PlexPerson(name="Actor Z", tmdb_person_id=None)
db.add(person)
db.commit()
monkeypatch.setattr(plex_tmdb.sysconfig, "get_str", lambda d, k: "key")
assert plex_tmdb.enrich_person(db, person) is False
def _get(db, user, monkeypatch, path):
monkeypatch.setattr(state, "_configured_cache", True)
app.dependency_overrides[get_db] = lambda: db
app.dependency_overrides[current_user] = lambda: user
try:
return TestClient(app).get(path)
finally:
app.dependency_overrides.clear()
def test_person_detail_schedules_enrich_off_request_path(db, monkeypatch):
# The bio fetch runs OFF the request path (a background task after the response), so the serve never
# makes a live TMDB call. An un-enriched person (tmdb_person_id set + tmdb_enriched_at None) schedules
# the enrich and the page still renders immediately with whatever's in the row (bio still null).
from app.routes.plex import people
lib = PlexLibrary(plex_key="9", title="Movies", kind="movie", enabled=True)
db.add(lib)
db.flush()
db.add(PlexPerson(name="Lazy P", tmdb_person_id=777))
user = User(email="lazybg@example.com")
db.add(user)
db.commit()
scheduled: list[int] = []
monkeypatch.setattr(people, "_enrich_person_bg", lambda pid: scheduled.append(pid))
people._enrich_inflight.clear() # ensure the per-person cooldown guard doesn't suppress this open
resp = _get(db, user, monkeypatch, "/api/plex/person/Lazy P")
assert resp.status_code == 200, resp.text
assert resp.json()["biography"] is None # not enriched yet — the bio lands by the next open
assert len(scheduled) == 1 # the enrich was scheduled off the request path
def test_enrich_person_bg_closes_session_and_isolates_errors(monkeypatch):
# The background-task body must open a fresh session, fetch the person, and ALWAYS close it — rolling
# back (never propagating) if enrich_person raises. Pure-unit via a fake session (no DB needed).
from app.routes.plex import people
calls = {"rollback": 0, "close": 0, "enriched": 0}
class _FakeSession:
def __init__(self, person):
self._person = person
def get(self, model, pid):
return self._person
def rollback(self):
calls["rollback"] += 1
def close(self):
calls["close"] += 1
# Success path: person found → enrich called, session closed, no rollback.
monkeypatch.setattr(people, "SessionLocal", lambda: _FakeSession(object()))
monkeypatch.setattr(
people.plex_tmdb, "enrich_person", lambda db, p: calls.__setitem__("enriched", calls["enriched"] + 1)
)
people._enrich_person_bg(5)
assert calls == {"rollback": 0, "close": 1, "enriched": 1}
# Error path: enrich raises → rollback + close, no exception escapes.
calls.update({"rollback": 0, "close": 0, "enriched": 0})
def _boom(db, p):
raise RuntimeError("enrich blew up")
monkeypatch.setattr(people.plex_tmdb, "enrich_person", _boom)
people._enrich_person_bg(5) # must not raise
assert calls["rollback"] == 1 and calls["close"] == 1
# Missing person (deleted id): None-guard skips enrich, session still closed.
calls.update({"rollback": 0, "close": 0, "enriched": 0})
monkeypatch.setattr(people, "SessionLocal", lambda: _FakeSession(None))
people._enrich_person_bg(9)
assert calls == {"rollback": 0, "close": 1, "enriched": 0}
def test_should_schedule_enrich_dedups_within_cooldown():
# Concurrent/rapid opens of the same person must schedule the enrich only once per cooldown window.
from app.routes.plex import people
people._enrich_inflight.clear()
assert people._should_schedule_enrich(12345) is True # first open reserves
assert people._should_schedule_enrich(12345) is False # within cooldown → deduped
assert people._should_schedule_enrich(99999) is True # a different person is independent
people._enrich_inflight.clear()
def test_person_detail_no_enrich_when_already_enriched(db, monkeypatch):
# An already-enriched person (tmdb_enriched_at set) must NOT schedule another background fetch.
from datetime import datetime, timezone
from app.routes.plex import people
lib = PlexLibrary(plex_key="8", title="Movies", kind="movie", enabled=True)
db.add(lib)
db.flush()
db.add(PlexPerson(name="Done P", tmdb_person_id=888,
tmdb_enriched_at=datetime(2024, 1, 1, tzinfo=timezone.utc), biography="Bio."))
user = User(email="donebg@example.com")
db.add(user)
db.commit()
scheduled: list[int] = []
monkeypatch.setattr(people, "_enrich_person_bg", lambda pid: scheduled.append(pid))
resp = _get(db, user, monkeypatch, "/api/plex/person/Done P")
assert resp.status_code == 200, resp.text
assert resp.json()["biography"] == "Bio."
assert scheduled == [] # already enriched → no background work
def test_person_detail_serves_bio_and_resolves_known_for(db, monkeypatch):
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
db.add(lib)
db.flush()
# A library movie that IS one of the person's known_for credits (matched by guid tmdb:100).
db.add(PlexItem(rating_key="km", library_id=lib.id, kind="movie", title="Known Movie",
guid="tmdb:100", cast_names=["Actor X"]))
# Already-enriched person (tmdb_enriched_at set → no lazy fetch), with a known_for pool.
from datetime import datetime, timezone
db.add(PlexPerson(name="Actor X", tmdb_person_id=555,
tmdb_enriched_at=datetime(2024, 1, 1, tzinfo=timezone.utc),
biography="Bio here.", birthday="1970-01-01", known_for_department="Acting",
also_known_as=["Alt Name"],
known_for=[{"tmdb_id": 100, "media_type": "movie", "title": "Known Movie"},
{"tmdb_id": 999, "media_type": "movie", "title": "Not In Library"}]))
user = User(email="personbio@example.com")
db.add(user)
db.commit()
resp = _get(db, user, monkeypatch, "/api/plex/person/Actor X")
assert resp.status_code == 200, resp.text
j = resp.json()
assert j["biography"] == "Bio here."
assert j["birthday"] == "1970-01-01"
assert j["also_known_as"] == ["Alt Name"]
# Only the in-library known_for credit resolves to a card; the out-of-library one is dropped.
assert [c["title"] for c in j["known_for"]] == ["Known Movie"]
+365
View File
@@ -0,0 +1,365 @@
"""TMDB enrichment engine (E5 S2): the pure parser + persister, guid→tmdb-id resolution, and the
DB-lane batch/serve integration (enrich stamps the watermark + links people; serve_block resolves
recommendations to library rows; the whole thing no-ops without an API key).
The DB tests use the `db` fixture (real Postgres) — skipped when none is reachable."""
from datetime import datetime, timezone
import httpx
import pytest
from app.models import PlexItem, PlexLibrary, PlexPerson, PlexShow
from app.plex import tmdb
# A TMDB /movie/{id}?append_to_response=keywords,credits,recommendations,images,external_ids payload.
MOVIE_PAYLOAD = {
"id": 238,
"vote_average": 8.7,
"vote_count": 20000,
"budget": 6000000,
"revenue": 245066411,
"keywords": {"keywords": [{"id": 1, "name": "mafia"}, {"id": 2, "name": "crime family"}]},
"credits": {
"cast": [
{"id": 3084, "name": "Marlon Brando", "character": "Vito", "profile_path": "/a.jpg", "order": 0},
{"id": 1158, "name": "Al Pacino", "character": "Michael", "profile_path": "/b.jpg", "order": 1},
],
"crew": [
{"id": 1776, "name": "Francis Ford Coppola", "job": "Director", "profile_path": "/c.jpg"},
{"id": 1776, "name": "Francis Ford Coppola", "job": "Writer", "profile_path": "/c.jpg"},
{"id": 9, "name": "Some Gaffer", "job": "Gaffer"}, # not a key job → dropped
],
},
"production_companies": [{"name": "Paramount"}, {"name": "Alfran"}],
"production_countries": [{"name": "United States of America"}],
"recommendations": {
"results": [
{"id": 240, "title": "The Godfather Part II", "poster_path": "/p.jpg", "media_type": "movie"},
{"id": 999, "title": "Not In Library", "poster_path": "/q.jpg", "media_type": "movie"},
]
},
"images": {"backdrops": [{"file_path": "/bd1.jpg"}, {"file_path": "/bd2.jpg"}]},
"external_ids": {"imdb_id": "tt0068646"},
}
# A TMDB /tv payload: keywords under `results`, aggregate_credits with per-person `jobs`, no budget.
TV_PAYLOAD = {
"id": 1396,
"vote_average": 9.0,
"vote_count": 12000,
"created_by": [{"id": 66633, "name": "Vince Gilligan", "profile_path": "/vg.jpg"}],
"keywords": {"results": [{"name": "drugs"}, {"name": "new mexico"}]},
"aggregate_credits": {
"cast": [{"id": 17419, "name": "Bryan Cranston"}],
"crew": [
{"id": 66633, "name": "Vince Gilligan", "jobs": [{"job": "Executive Producer"}]},
{"id": 5, "name": "Someone", "jobs": [{"job": "Sound"}]}, # dropped
],
},
"production_companies": [{"name": "Sony Pictures Television"}],
"production_countries": [{"name": "United States of America"}],
"recommendations": {"results": []},
"images": {"backdrops": []},
}
class FakeTmdb:
"""Stand-in for TmdbClient in the parse/batch tests — no network."""
def __init__(self, payloads=None, find_map=None):
self.payloads = payloads or {} # (media_type, tmdb_id) -> payload
self.find_map = find_map or {} # external_id -> /find result
def fetch(self, tmdb_id, media_type):
return self.payloads.get((media_type, tmdb_id))
def find(self, external_id, source):
return self.find_map.get(external_id)
# ---- pure parse/persist --------------------------------------------------------------------------
def test_parse_movie_pulls_all_fields():
d = tmdb.parse_tmdb(MOVIE_PAYLOAD, "movie")
assert d["tmdb_id"] == 238
assert d["tmdb_rating"] == 8.7
assert d["tmdb_vote_count"] == 20000
assert d["budget"] == 6000000
assert d["revenue"] == 245066411
assert d["keywords"] == ["mafia", "crime family"]
assert d["crew"] == [
{"name": "Francis Ford Coppola", "job": "Director", "tmdb_id": 1776, "profile_path": "/c.jpg"},
{"name": "Francis Ford Coppola", "job": "Writer", "tmdb_id": 1776, "profile_path": "/c.jpg"},
] # the Gaffer is filtered; the two Coppola jobs survive (dedup is by name+job)
assert d["studios"] == ["Paramount", "Alfran"]
assert d["production_countries"] == ["United States of America"]
assert [r["tmdb_id"] for r in d["recommendations"]] == [240, 999]
assert d["backdrops"] == ["/bd1.jpg", "/bd2.jpg"]
# cast + crew names → tmdb person ids for the plex_people foundation
links = {(p["name"], p["tmdb_id"]) for p in d["people"]}
assert ("Marlon Brando", 3084) in links
assert ("Al Pacino", 1158) in links
assert ("Francis Ford Coppola", 1776) in links
def test_parse_tv_uses_aggregate_credits_and_no_money():
d = tmdb.parse_tmdb(TV_PAYLOAD, "tv")
assert d["budget"] is None and d["revenue"] is None # TV has neither
assert d["keywords"] == ["drugs", "new mexico"] # from keywords.results
# The showrunner (created_by → Creator) leads; the aggregate-credits EP follows; the Sound person is
# filtered; TV jobs are read from jobs[0]. Crew is display-priority ordered (Creator before EP).
assert d["crew"] == [
{"name": "Vince Gilligan", "job": "Creator", "tmdb_id": 66633, "profile_path": "/vg.jpg"},
{"name": "Vince Gilligan", "job": "Executive Producer", "tmdb_id": 66633, "profile_path": None},
]
assert d["tmdb_rating"] == 9.0
assert ("Bryan Cranston", 17419) in {(p["name"], p["tmdb_id"]) for p in d["people"]}
def test_parse_zero_votes_and_money_are_unknown():
d = tmdb.parse_tmdb({"id": 1, "vote_average": 0.0, "vote_count": 0, "budget": 0, "revenue": 0}, "movie")
assert d["tmdb_rating"] is None # 0.0 with no votes is meaningless
assert d["tmdb_vote_count"] is None
assert d["budget"] is None and d["revenue"] is None
def test_apply_writes_row_fields_without_touching_plex_owned():
it = PlexItem(rating_key="m1", kind="movie", title="The Godfather", imdb_rating=8.7, full_cast=[{"x": 1}])
tmdb.apply_tmdb_detail(it, tmdb.parse_tmdb(MOVIE_PAYLOAD, "movie"))
assert it.tmdb_id == 238
assert it.budget == 6000000
assert it.keywords == ["mafia", "crime family"]
assert it.imdb_rating == 8.7 and it.full_cast == [{"x": 1}] # Plex-owned, untouched
def test_guid_to_tmdb_id_direct_find_and_bad():
fake = FakeTmdb(find_map={"tt0068646": {"movie_results": [{"id": 238}], "tv_results": []}})
assert tmdb.guid_to_tmdb_id("tmdb:238", "movie", fake) == 238
assert tmdb.guid_to_tmdb_id("imdb:tt0068646", "movie", fake) == 238 # resolved via /find
assert tmdb.guid_to_tmdb_id("imdb:tt0000000", "movie", fake) is None # not in find_map
assert tmdb.guid_to_tmdb_id("tmdb:notanumber", "movie", fake) is None
assert tmdb.guid_to_tmdb_id(None, "movie", fake) is None
assert tmdb.guid_to_tmdb_id("local://abc", "movie", fake) is None
def test_guid_to_tmdb_id_tvdb_source_and_tv_media_type():
# A tvdb guid for a TV item goes through /find and must pick the tv_results list (not movie_results),
# and a tvdb source maps to the tvdb_id external_source. The media_type branch is the whole point.
fake = FakeTmdb(find_map={"81189": {"movie_results": [], "tv_results": [{"id": 1396}]}})
assert tmdb.guid_to_tmdb_id("tvdb:81189", "tv", fake) == 1396
# The SAME /find result, asked for a movie, reads the (empty) movie_results → None.
assert tmdb.guid_to_tmdb_id("tvdb:81189", "movie", fake) is None
# A direct tmdb guid for a tv item is just the int (no /find).
assert tmdb.guid_to_tmdb_id("tmdb:1396", "tv", fake) == 1396
# An unknown provider (only imdb/tvdb are /find sources) is unresolvable.
assert tmdb.guid_to_tmdb_id("plex:abc", "tv", fake) is None
# ---- DB-lane: batch enrich + serve --------------------------------------------------------------
def _lib(db, key="1", kind="movie"):
lib = PlexLibrary(plex_key=key, title="L", kind=kind, enabled=True)
db.add(lib)
db.flush()
return lib
def test_enrich_batch_persists_stamps_and_links_people(db):
lib = _lib(db)
mv = PlexItem(rating_key="m1", library_id=lib.id, kind="movie", title="The Godfather", guid="tmdb:238")
unmatched = PlexItem(rating_key="m2", library_id=lib.id, kind="movie", title="Nope", guid="tmdb:777")
db.add_all([mv, unmatched])
db.commit()
fake = FakeTmdb(payloads={("movie", 238): MOVIE_PAYLOAD}) # 777 has no payload → definitive no-match
n = tmdb.enrich_batch(db, fake, 50)
assert n == 2 # both processed (one matched, one definitively unmatched)
db.refresh(mv)
assert mv.tmdb_id == 238
assert mv.keywords == ["mafia", "crime family"]
assert mv.revenue == 245066411
assert mv.tmdb_enriched_at is not None
db.refresh(unmatched)
assert unmatched.tmdb_enriched_at is not None # stamped so it isn't re-fetched every run
assert unmatched.tmdb_id is None # but nothing stored
brando = db.execute(
PlexPerson.__table__.select().where(PlexPerson.name == "Marlon Brando")
).mappings().first()
assert brando is not None and brando["tmdb_person_id"] == 3084
def test_serve_block_none_until_enriched_and_resolves_recs(db):
lib = _lib(db)
# A recommendation target that IS in our library (guid tmdb:240) → deep-linkable.
sequel = PlexItem(rating_key="m240", library_id=lib.id, kind="movie", title="Part II", guid="tmdb:240")
fresh = PlexItem(rating_key="m1", library_id=lib.id, kind="movie", title="The Godfather", guid="tmdb:238")
db.add_all([sequel, fresh])
db.commit()
assert tmdb.serve_block(db, fresh) is None # not enriched yet
tmdb.apply_tmdb_detail(fresh, tmdb.parse_tmdb(MOVIE_PAYLOAD, "movie"))
fresh.tmdb_enriched_at = datetime.now(timezone.utc)
db.commit()
block = tmdb.serve_block(db, fresh)
assert block is not None
assert block["budget"] == 6000000
assert block["keywords"] == ["mafia", "crime family"]
recs = {r["tmdb_id"]: r for r in block["recommendations"]}
assert recs[240]["in_library"] is True and recs[240]["rating_key"] == "m240"
assert recs[999]["in_library"] is False and recs[999]["rating_key"] is None
def test_get_never_leaks_api_key_in_error_messages(monkeypatch):
# The api_key rides in the request URL query string, so an error message built from the httpx
# exception (which embeds that URL) would leak the secret to logs (enrich_batch logs it). Both the
# HTTP-status and the network error paths must report status/path only — never the key.
monkeypatch.setattr(
tmdb.sysconfig, "get_str", lambda d, k: "SECRETKEY" if k == "tmdb_api_key" else "en-US"
)
client = tmdb.TmdbClient(None)
req = httpx.Request(
"GET", "https://api.themoviedb.org/3/movie/1?api_key=SECRETKEY&language=en-US"
)
class _StatusHttp:
def get(self, url, params=None):
return httpx.Response(401, request=req) # non-404 → raise_for_status → HTTPStatusError
def close(self):
pass
client._http = _StatusHttp()
with pytest.raises(tmdb.TmdbError) as ei:
client._get("/movie/1")
assert "SECRETKEY" not in str(ei.value) and "401" in str(ei.value)
class _NetHttp:
def get(self, url, params=None):
raise httpx.ConnectError("connect failed", request=req)
def close(self):
pass
client._http = _NetHttp()
with pytest.raises(tmdb.TmdbError) as ei2:
client._get("/movie/1")
assert "SECRETKEY" not in str(ei2.value)
client.close()
def test_run_enrich_noops_without_key(db):
# No tmdb_api_key configured (default "") → clean no-op, never an error.
out = tmdb.run_enrich(db)
assert out["enriched"] == 0 and "skipped" in out
class _RaisingTmdb:
"""A client whose fetch raises a transient TmdbError (e.g. a 429), to exercise the retry path."""
def find(self, external_id, source):
return None
def fetch(self, tmdb_id, media_type):
raise tmdb.TmdbError("429 rate limited")
def test_enrich_batch_transient_error_leaves_watermark_null(db):
# A transient TmdbError mid-batch must leave the row's tmdb_enriched_at NULL so the next run retries
# it (unlike a definitive no-match, which stamps the watermark). The row is NOT counted as processed.
lib = _lib(db)
mv = PlexItem(rating_key="m1", library_id=lib.id, kind="movie", title="Godfather", guid="tmdb:238")
db.add(mv)
db.commit()
n = tmdb.enrich_batch(db, _RaisingTmdb(), 50)
assert n == 0 # the transient failure is skipped, not processed
db.refresh(mv)
assert mv.tmdb_enriched_at is None # left NULL → retried next run
assert mv.tmdb_id is None
def test_enrich_batch_skips_shows_without_guid(db):
lib = _lib(db, key="2", kind="show")
sh = PlexShow(rating_key="s1", library_id=lib.id, title="No Guid Show") # guid None → not selected
db.add(sh)
db.commit()
assert tmdb.enrich_batch(db, FakeTmdb(), 50) == 0
db.refresh(sh)
assert sh.tmdb_enriched_at is None
def test_resolve_library_guids_scopes_to_given_libraries(db):
# The scoped path (person page) must keep a credit that lives in a NON-scoped library (disabled or an
# HU-overlay twin) OUT of the result; the unscoped path (detail recs) matches across all libraries;
# and an explicit empty list skips that media side entirely (distinct from None = match all).
lib_on = _lib(db, key="on", kind="movie")
lib_off = _lib(db, key="off", kind="movie")
m_on = PlexItem(rating_key="on1", library_id=lib_on.id, kind="movie", title="In Scope", guid="tmdb:100")
m_off = PlexItem(rating_key="off1", library_id=lib_off.id, kind="movie", title="Out", guid="tmdb:200")
db.add_all([m_on, m_off])
db.commit()
entries = [{"tmdb_id": 100, "media_type": "movie"}, {"tmdb_id": 200, "media_type": "movie"}]
# Scoped to lib_on: the row in lib_off is excluded.
movies, shows = tmdb.resolve_library_guids(
db, entries, movie_lib_ids=[lib_on.id], show_lib_ids=[]
)
assert set(movies) == {"tmdb:100"}
assert shows == {}
# Unscoped (None defaults): matches across all libraries.
movies_all, _ = tmdb.resolve_library_guids(db, entries)
assert set(movies_all) == {"tmdb:100", "tmdb:200"}
# Empty list for a media side skips its bucket even with candidates present (vs None = match all).
_, shows_empty = tmdb.resolve_library_guids(
db, [{"tmdb_id": 100, "media_type": "tv"}], show_lib_ids=[]
)
assert shows_empty == {}
def test_resolve_recs_matches_by_media_type_not_bare_id(db):
# TMDB movie ids and tv ids are independent, so tmdb:500 can be BOTH a show (in our library) and a
# movie recommendation. The match must respect media_type — a bare-id match would deep-link the movie
# rec to the unrelated show (and vice-versa).
mlib = _lib(db, key="10", kind="movie")
slib = _lib(db, key="11", kind="show")
show500 = PlexShow(rating_key="s500", library_id=slib.id, title="Show 500", guid="tmdb:500")
movie900 = PlexItem(
rating_key="m900", library_id=mlib.id, kind="movie", title="Movie 900", guid="tmdb:900"
)
db.add_all([show500, movie900])
db.commit()
# A movie rec for id 500 must NOT match show s500; a tv rec for id 900 must NOT match movie m900.
miss = {
r["tmdb_id"]: r
for r in tmdb.resolve_recs(
db,
[
{"tmdb_id": 500, "title": "Movie 500", "media_type": "movie"},
{"tmdb_id": 900, "title": "Show 900", "media_type": "tv"},
],
)
}
assert miss[500]["in_library"] is False and miss[500]["rating_key"] is None
assert miss[900]["in_library"] is False and miss[900]["rating_key"] is None
# The right media_type DOES resolve to the right row.
hit = {
r["tmdb_id"]: r
for r in tmdb.resolve_recs(
db,
[
{"tmdb_id": 900, "media_type": "movie"},
{"tmdb_id": 500, "media_type": "tv"},
],
)
}
assert hit[900]["in_library"] is True and hit[900]["rating_key"] == "m900"
assert hit[500]["in_library"] is True and hit[500]["rating_key"] == "s500"
+155
View File
@@ -0,0 +1,155 @@
"""Regression: live YouTube search ingest must survive the scrape source carrying no publish date.
`videos.published_at` is NOT NULL (migration 0068, PERF-15). The scrape source exposes only a
relative time ("3 years ago"), so its stub has `published_at=None`; the ingest used to pass that
None straight through, which the NOT NULL constraint rejected with an IntegrityError. That error
is raised OUTSIDE the route's page-fetch try/except, so the whole `/api/search/youtube` request
500'd and the UI showed the generic "YouTube search failed." — every scrape search was broken.
The stub now falls back to now() (a non-null placeholder), and the enrich overwrites it with the
video's real snippet date. These DB-lane tests lock both halves: no crash on a date-less stub, and
a placeholder never becomes the video's permanent date.
"""
from datetime import datetime, timezone
from app import sysconfig
from app.models import User, Video
from app.routes.search import _ingest_candidates
class _FakeYT:
"""Offline stand-in for YouTubeClient. Channel enrich is a no-op (the from_search stub survives);
video enrich returns a real publishedAt plus a long duration so Shorts classification finalises
without the youtube.com/shorts probe (no network in the DB lane)."""
def __init__(self, enrich: dict[str, dict]):
self._enrich = enrich
def get_channels(self, ids):
return []
def get_videos(self, ids):
return [self._enrich[i] for i in ids if i in self._enrich]
def _candidate(vid: str, ch: str) -> dict:
# The scrape shape (see search_scrape._video_item): published_at is ALWAYS None.
return {
"id": vid,
"channel_id": ch,
"channel_title": f"Chan {ch}",
"title": f"Title {vid}",
"published_at": None,
"thumbnail_url": None,
"live_broadcast": "none",
}
def _enrich_item(vid: str, published_iso: str) -> dict:
# PT30M keeps the video well above shorts_probe_max_seconds so no probe fires.
return {
"id": vid,
"snippet": {"title": f"Title {vid}", "publishedAt": published_iso},
"contentDetails": {"duration": "PT30M"},
"statistics": {},
"status": {},
}
def test_scrape_ingest_dateless_stub_ingests_and_takes_real_enrich_date(db):
# Small cap so the 1800s enrich duration keeps the shorts step offline.
sysconfig.set_value(db, "shorts_probe_max_seconds", 60)
user = User(email="searcher@example.com")
db.add(user)
db.commit()
candidates = [_candidate("vid_aaaaaaa", "UC_chan_a"), _candidate("vid_bbbbbbb", "UC_chan_a")]
enrich = {
"vid_aaaaaaa": _enrich_item("vid_aaaaaaa", "2021-02-12T19:00:07Z"),
"vid_bbbbbbb": _enrich_item("vid_bbbbbbb", "2023-10-30T17:00:08Z"),
}
kept = _ingest_candidates(db, _FakeYT(enrich), user, candidates)
# No IntegrityError, and both non-Short/non-live videos are kept in input order.
assert kept == ["vid_aaaaaaa", "vid_bbbbbbb"]
a = db.get(Video, "vid_aaaaaaa")
# The now() placeholder was replaced by the video's real snippet date, not left as "now".
assert a.published_at == datetime(2021, 2, 12, 19, 0, 7, tzinfo=timezone.utc)
assert a.via_search is True
assert a.enriched_at is not None
def test_scrape_ingest_enrich_miss_keeps_nonnull_placeholder(db):
# If enrich yields nothing (proxy blip / omitted row), the stub must STILL satisfy the NOT NULL
# constraint (a non-null placeholder) — it simply isn't kept (enriched_at stays None).
user = User(email="searcher2@example.com")
db.add(user)
db.commit()
kept = _ingest_candidates(db, _FakeYT({}), user, [_candidate("vid_ccccccc", "UC_chan_c")])
assert kept == []
c = db.get(Video, "vid_ccccccc")
assert c.published_at is not None # placeholder wrote a real value → no crash
assert c.enriched_at is None
def test_search_youtube_route_returns_422_when_ingest_fails(db, monkeypatch):
# Route-level guard (B-core-1): an ingest failure with nothing yet collected must surface as a 422
# + message on this JSON endpoint (the FE hides 5xx behind a generic "connection lost" + auto-retry,
# burning a second quota-costed search), and the session is rolled back. Here the page fetch succeeds
# (one candidate) and _ingest_candidates raises → the endpoint returns 422.
from fastapi.testclient import TestClient
import app.state as state
from app.auth import require_human
from app.db import get_db
from app.main import app
from app.routes import search as search_mod
user = User(email="route422@example.com")
db.add(user)
db.commit()
monkeypatch.setattr(state, "_configured_cache", True)
monkeypatch.setattr(
search_mod.sysconfig, "get_str", lambda d, k: "scrape" if k == "search_source" else ""
)
monkeypatch.setattr(search_mod.sysconfig, "get_int", lambda d, k: 100) # daily cap not reached
monkeypatch.setattr(search_mod.quota, "actions_today", lambda d, uid, action: 0)
class _FakeYTCM:
def __init__(self, *a, **k):
pass
def __enter__(self):
return self
def __exit__(self, *a):
return False
monkeypatch.setattr(search_mod, "YouTubeClient", _FakeYTCM)
monkeypatch.setattr(
search_mod.search_scrape,
"search",
lambda term, continuation=None: {
"items": [_candidate("vid_route12", "UC_route_a")],
"next_page_token": None,
},
)
def _boom(db_, yt, user_, candidates):
raise RuntimeError("ingest exploded")
monkeypatch.setattr(search_mod, "_ingest_candidates", _boom)
app.dependency_overrides[get_db] = lambda: db
app.dependency_overrides[require_human] = lambda: user
try:
resp = TestClient(app).get("/api/search/youtube?q=test+term")
finally:
app.dependency_overrides.clear()
assert resp.status_code == 422, resp.text
assert "YouTube search failed" in resp.json()["detail"]
+28 -1
View File
@@ -1,7 +1,7 @@
"""iso(): the single date/datetime→JSON serializer the routes share (R6 S1a)."""
from datetime import date, datetime, timezone
from app.utils import iso
from app.utils import iso, normalize_description
def test_none_maps_to_none():
@@ -28,3 +28,30 @@ def test_matches_datetime_isoformat_contract():
# hand-roll `x.isoformat() if x else None`.
dt = datetime(2020, 1, 2, 3, 4, 5, 678901, tzinfo=timezone.utc)
assert iso(dt) == dt.isoformat()
def test_normalize_description_none_stays_none():
assert normalize_description(None) is None
def test_normalize_description_drops_blank_lines():
# A YouTube contact block padded with runs of blank lines — the waste we strip before storing.
raw = "Contact us\n\n\nEmail: a@b.com\n \nInstagram: @x\n\n\n"
assert normalize_description(raw) == "Contact us\nEmail: a@b.com\nInstagram: @x"
def test_normalize_description_right_strips_and_trims():
assert normalize_description(" hi there \n\n bye ") == " hi there\n bye"
def test_normalize_description_all_blank_collapses_to_empty():
assert normalize_description("\n \n\t\n") == ""
def test_normalize_description_single_line_unchanged():
assert normalize_description("just one line") == "just one line"
def test_normalize_description_folds_crlf_line_endings():
# splitlines() folds CR/CRLF to \n — a documented, benign line-ending normalization.
assert normalize_description("a\r\nb\r\n\r\nc") == "a\nb\nc"
+28 -51
View File
@@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { api, getActiveAccount, setActiveAccount, setUnauthorizedHandler } from "./lib/api";
import { setLanguage, isSupported, type LangCode } from "./i18n";
import { hasFilterParams, stripUrlParams } from "./lib/urlState";
import { clearParams } from "./lib/urlState";
import type { Page } from "./lib/urlState";
import {
loadLayout,
@@ -15,7 +15,8 @@ import {
type PanelLayout,
} from "./lib/panelLayout";
import { notify, removeByMetaKind } from "./lib/notifications";
import { LS, readAccount, setAccountRaw, writeAccount } from "./lib/storage";
import { LS, setAccountRaw } from "./lib/storage";
import { usePref } from "./lib/usePref";
import { useNavigation, useNavigationActions } from "./components/NavigationProvider";
import { useFeedFilters, useFeedFiltersActions } from "./components/FeedFiltersProvider";
import { useChannelLayout } from "./components/ChannelsProvider";
@@ -90,37 +91,20 @@ export default function App() {
}
// Collapse state for the two full-height panels (left nav + filter sidebar). Persisted to the
// user's preferences so it follows the account across devices; a localStorage cache seeds the
// initial render synchronously so nothing flashes open before the server prefs arrive.
// user's preferences (via usePref) so it follows the account across devices; the localStorage mirror
// seeds the initial render synchronously so nothing flashes open before the server prefs arrive.
// Default unpinned (tab + hover-expand overlay); accounts that explicitly pin keep their choice.
const [filterCollapsed, setFilterCollapsedState] = useState<boolean>(() =>
Boolean(readAccount(LS.filterCollapsed, true)),
);
const [filterCollapsed, setFilterCollapsed] = usePref<boolean>("filterCollapsed", true, {
localKey: LS.filterCollapsed,
parse: Boolean,
});
// The Playlists rail has its own collapse flag (it's a list/navigator, not filters).
const [playlistsCollapsed, setPlaylistsCollapsedState] = useState<boolean>(() =>
Boolean(readAccount(LS.playlistsCollapsed, true)),
);
function setFilterCollapsed(next: boolean) {
setFilterCollapsedState(next);
writeAccount(LS.filterCollapsed, next);
api.savePrefs({ filterCollapsed: next }).catch(() => {});
}
function setPlaylistsCollapsed(next: boolean) {
setPlaylistsCollapsedState(next);
writeAccount(LS.playlistsCollapsed, next);
api.savePrefs({ playlistsCollapsed: next }).catch(() => {});
}
// Filters live in localStorage, not the address bar. If we arrived via a "Share view"
// link, its params have already hydrated the initial state — persist them and strip the
// query so the URL stays clean from here on.
useEffect(() => {
const params = new URLSearchParams(window.location.search);
if (hasFilterParams(params) || params.has("page")) {
// A share link's filters are held in state; they're persisted to the account's key by the
// post-login effect (which knows the account id). Here we only clean the address bar.
stripUrlParams();
}
}, []);
const [playlistsCollapsed, setPlaylistsCollapsed] = usePref<boolean>("playlistsCollapsed", true, {
localKey: LS.playlistsCollapsed,
parse: Boolean,
});
// (The feed filters + page/channel now LIVE in the address bar — FeedFiltersProvider and
// NavigationProvider read them on load and keep them mirrored — so there's no query to strip here.)
const queryClient = useQueryClient();
// First-run install gate: a fresh instance reports configured=false and runs in setup mode.
@@ -152,11 +136,11 @@ export default function App() {
if (meQuery.data && getActiveAccount() == null) setActiveAccount(meQuery.data.id);
}, [meQuery.data?.id]);
// Once signed in, drop any leftover query string from the pre-login flow (e.g.
// ?access=requested, ?verify, ?reset). The logged-in app reads nothing from the URL, so the
// address bar stays clean. Gated on auth so the logged-out Welcome page still reads its params.
// Once signed in, drop any leftover ONE-TIME query params from the pre-login flow (?access, ?verify,
// ?reset) — but NOT the nav/filter params, which the logged-in app now reads from and keeps in the
// URL. Gated on auth so the logged-out Welcome page still reads its params first.
useEffect(() => {
if (meQuery.data) stripUrlParams();
if (meQuery.data) clearParams("access", "verify", "reset");
}, [meQuery.data?.id]); // eslint-disable-line react-hooks/exhaustive-deps
// Any request that 401s means the session ended server-side. If we were signed in (cached
@@ -188,7 +172,7 @@ export default function App() {
const key = result === "conflict" || result === "mismatch" ? result : "error";
notify({ level: "error", message: t(`settings.account.googleLink.${key}`) });
}
stripUrlParams();
clearParams("link");
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// A Google OAuth flow started while already signed in (add-another-account, link, or upgrade) was
@@ -197,12 +181,12 @@ export default function App() {
useEffect(() => {
if (new URLSearchParams(window.location.search).get("oauth") !== "cancelled") return;
notify({ level: "info", message: t("settings.account.googleCancelled") });
stripUrlParams();
clearParams("oauth");
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// Deep-link from the "new access request" admin email (?admin=access-requests) → jump straight to
// the admin Users → Access requests view. Snapshot the param so the pre-login URL strip doesn't
// drop it; act once `me` is known, and only for an admin (others just land on their default page).
// the admin Users → Access requests view. Snapshot the param up front (read once at mount); act once
// `me` is known, and only for an admin (others just land on their default page).
const [adminDeepLink] = useState(() => new URLSearchParams(window.location.search).get("admin"));
useEffect(() => {
if (adminDeepLink !== "access-requests" || !meQuery.data) return;
@@ -210,12 +194,13 @@ export default function App() {
focusAccessRequestsTab();
setPage("users");
}
stripUrlParams();
clearParams("admin");
}, [adminDeepLink, meQuery.data?.id]); // eslint-disable-line react-hooks/exhaustive-deps
// On login, adopt the server-stored SHELL preferences (panel layouts, rail collapse flags,
// language). The Settings-page prefs (theme/perf/hints/notifications) are adopted by PrefsProvider
// and the feed view by FeedViewProvider — each via its own passive qk.me() observer.
// On login, adopt the server-stored SHELL preferences (panel layouts + language). The rail-collapse
// flags (filterCollapsed / playlistsCollapsed) are adopted by their own usePref hooks, the
// Settings-page prefs (theme/perf/hints/notifications) by PrefsProvider, and the feed view by
// FeedViewProvider — each via its own passive qk.me() observer.
useEffect(() => {
const prefs = meQuery.data?.preferences;
if (!prefs) return;
@@ -227,14 +212,6 @@ export default function App() {
saveLayoutLocal(p, l);
}
});
if (typeof prefs.filterCollapsed === "boolean") {
setFilterCollapsedState(prefs.filterCollapsed);
writeAccount(LS.filterCollapsed, prefs.filterCollapsed);
}
if (typeof prefs.playlistsCollapsed === "boolean") {
setPlaylistsCollapsedState(prefs.playlistsCollapsed);
writeAccount(LS.playlistsCollapsed, prefs.playlistsCollapsed);
}
const lang = prefs.language;
if (typeof lang === "string" && isSupported(lang)) setLanguage(lang);
// (Per-account filters — incl. the demo account's whole-library default — are loaded by the
+8 -1
View File
@@ -28,6 +28,10 @@ export default function AboutCell({ text, channelId }: { text: string | null; ch
offset: 6,
// Pop instantly like the old cell did; the close grace lets the cursor reach the card.
hoverDelay: { open: 0, close: 160 },
// Cap the panel to the space between the trigger and the viewport edge (publishes --fit-max-h) so a
// long About never runs its top off-screen — at high browser zoom the short viewport made a fixed
// max-height overflow above the top edge, where the scroll container can't reach.
fitHeight: true,
});
const { data: ch } = useQuery({
@@ -51,7 +55,10 @@ export default function AboutCell({ text, channelId }: { text: string | null; ch
</span>
<PopoverSurface
popover={pop}
className="glass w-[480px] max-h-112 overflow-y-auto px-3.5 py-3 rounded-lg animate-[fadeIn_0.12s_ease]"
className="glass w-[480px] overflow-y-auto px-3.5 py-3 rounded-lg animate-[fadeIn_0.12s_ease]"
// min(design cap, available space): keeps the 28rem cap on a tall viewport, shrinks to fit on a
// short one (high zoom). --fit-max-h is set by the fitHeight `size` middleware once positioned.
style={{ maxHeight: "min(28rem, var(--fit-max-h, 28rem))" }}
>
{/* Full About once the detail loads; until then the list's truncated description. */}
{ch ? (
@@ -0,0 +1,60 @@
// @vitest-environment jsdom
//
// Gates the shared PopoverItem ARIA contract: aria-checked for menuitemradio/menuitemcheckbox and
// aria-selected for option are rendered as explicit attributes AFTER the floating-ui item props (so they
// win over getItemProps' single-select guess). Six live menus (LanguageSwitcher, ViewSwitcher,
// AddToPlaylist, ChannelTags, tableHeader, plexDetailUi) depend on this; without a test, a regression in
// the shared path would silently drop the checked indicator for screen-reader users. Floating-ui recipe:
// stub the two observers autoUpdate wires, wrap in a FloatingTree, query the hidden surface.
import { describe, it, expect, beforeAll, afterEach } from "vitest";
import { useState } from "react";
import { render, screen, cleanup } from "@testing-library/react";
import { FloatingTree } from "@floating-ui/react";
import { useAnchoredPopover, PopoverSurface, PopoverItem } from "./AnchoredPopover";
beforeAll(() => {
class Stub {
observe() {}
unobserve() {}
disconnect() {}
takeRecords() {
return [];
}
}
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
});
afterEach(cleanup);
function Menu() {
const [open, setOpen] = useState(true);
const pop = useAnchoredPopover({ open, onOpenChange: setOpen, mode: "menu" });
return (
<PopoverSurface popover={pop} ariaLabel="Choices">
<PopoverItem role="menuitemradio" checked onSelect={() => {}}>
Newest
</PopoverItem>
<PopoverItem role="menuitemradio" checked={false} onSelect={() => {}}>
Oldest
</PopoverItem>
<PopoverItem role="menuitemcheckbox" checked onSelect={() => {}}>
Show shorts
</PopoverItem>
</PopoverSurface>
);
}
describe("PopoverItem ARIA contract", () => {
it("exposes aria-checked on menuitemradio and menuitemcheckbox items", () => {
render(
<FloatingTree>
<Menu />
</FloatingTree>,
);
const radios = screen.getAllByRole("menuitemradio", { hidden: true });
expect(radios.map((r) => r.getAttribute("aria-checked"))).toEqual(["true", "false"]);
const checkbox = screen.getByRole("menuitemcheckbox", { hidden: true });
expect(checkbox.getAttribute("aria-checked")).toBe("true");
});
});
+118 -30
View File
@@ -2,6 +2,7 @@ import {
createContext,
useContext,
useEffect,
useId,
useLayoutEffect,
useRef,
useState,
@@ -61,14 +62,29 @@ import { useFullscreenElement } from "../lib/fullscreen";
// right role (menuitem / menuitemradio+aria-checked / option+aria-selected). A menu that also holds a
// free-text input (the tag creator) must stopPropagation non-Escape keys on that input so ↑/↓ and
// typeahead act on the text, not the list — see ChannelTags.
//
// S? adds a "combobox" mode — the ONE right way to build a typeable combobox (a search/filter input
// with a listbox of options below), so a hand-rolled floating-ui engine (the old MultiSelectChips /
// PlexSearchBox pattern) is no longer needed. It differs from menu/listbox in exactly the focus model:
// - the trigger IS a text input, so open is driven by the caller (focus/typing), NOT useClick;
// - focus STAYS on the input — the surface is NOT focus-trapped and list items are never DOM-focused;
// arrow-nav is VIRTUAL (useListNavigation `virtual:true` → the ↑/↓/Home/End handler rides on the
// input's getReferenceProps and moves an `aria-activedescendant` pointer, not real focus).
// Because `role:"combobox"` is passed to useRole, Floating UI wires the whole ARIA tree for free: the
// reference input gets role=combobox + aria-expanded/controls/autocomplete/activedescendant, the surface
// gets role=listbox, and each <PopoverItem role="option"> gets its option id (so activedescendant has a
// target). The caller mounts the INPUT as `refs.setReference` (makes it a "typeable combobox reference")
// and the field box as `refs.setPositionReference` (so the panel anchors under the whole chips+input
// field). The universal <Combobox> component wraps all of this — call sites use it, not this mode raw.
// Internal to the platform; call sites pass the string literal via `mode`, they don't name the type.
type PopoverMode = "menu" | "listbox" | "hover" | "dialog";
type PopoverMode = "menu" | "listbox" | "hover" | "dialog" | "combobox";
export interface AnchoredPopoverOptions {
open: boolean;
onOpenChange: (open: boolean, event?: Event) => void;
/** "menu"/"listbox"/"dialog" open on click; "hover" opens on hover+focus (tooltips, hover cards). */
/** "menu"/"listbox"/"dialog" open on click; "hover" opens on hover+focus (tooltips, hover cards);
* "combobox" is a typeable input + listbox where focus stays in the field (use via <Combobox>). */
mode?: PopoverMode;
placement?: Placement;
/** Gap between trigger and panel, px. Default 4. */
@@ -99,9 +115,14 @@ type AnchoredPopover = ReturnType<typeof useAnchoredPopover>;
export function useAnchoredPopover(opts: AnchoredPopoverOptions) {
const mode = opts.mode ?? "menu";
const isHover = mode === "hover";
const isCombobox = mode === "combobox";
// Roving-keyboard list surfaces: a menu of actions or a listbox of options. A "dialog" popover holds
// arbitrary focusable content (its own Tab order), and "hover" is a passive tooltip — neither roves.
const isNav = mode === "menu" || mode === "listbox";
// Surfaces that host a navigable item list (registry + list-navigation): the roving menu/listbox AND
// the virtual-nav combobox. The combobox navigates WITHOUT moving DOM focus (see below), so it is kept
// out of `isNav` (which also gates the focus trap + typeahead + roving tabindex) and folded in here.
const roves = isNav || isCombobox;
// null when not inside a <FloatingTree> — the hook still works, just without nested-layer awareness.
const nodeId = useFloatingNodeId();
@@ -131,7 +152,27 @@ export function useAnchoredPopover(opts: AnchoredPopoverOptions) {
size({
padding: 8,
apply({ availableHeight, elements }) {
// Cap the positioned element, AND publish the available space as a custom property so
// the INNER styled/scroll wrapper (PopoverSurface renders `className` on a child, not on
// the floating element) can cap itself too — e.g. `maxHeight: min(28rem, var(--fit-max-h))`
// keeps a caller's design cap while still shrinking to fit at high zoom / short viewports.
// Capping only the outer element would leave the inner overflow-y-auto box at its fixed
// height, spilling its top off-screen (the whole point of fitHeight).
elements.floating.style.maxHeight = `${availableHeight}px`;
elements.floating.style.setProperty("--fit-max-h", `${availableHeight}px`);
},
}),
]
: []),
// A combobox panel matches the field's width (with a sane floor for a very narrow rail); its inner
// scroll box owns the height cap. `rects.reference` is the POSITION reference — the whole chips+input
// field box (the caller mounts it via setPositionReference), not the bare input.
...(isCombobox
? [
size({
padding: 8,
apply({ rects, elements }) {
elements.floating.style.width = `${Math.max(rects.reference.width, 192)}px`;
},
}),
]
@@ -149,7 +190,9 @@ export function useAnchoredPopover(opts: AnchoredPopoverOptions) {
}
}, [opts.open]);
const click = useClick(context, { enabled: !isHover });
// A combobox opens from the field's own focus/typing (wired by <Combobox>), never a click-to-toggle —
// a click on the input must place the caret, not close the just-opened list.
const click = useClick(context, { enabled: !isHover && !isCombobox });
const hover = useHover(context, {
enabled: isHover,
move: false,
@@ -163,9 +206,19 @@ export function useAnchoredPopover(opts: AnchoredPopoverOptions) {
// stopPropagation the Escape away from a Modal/player underneath, or double-close both.) Outside-
// press stays with floating-ui (tree-aware).
const dismiss = useDismiss(context, { escapeKey: false });
const role = useRole(context, { role: opts.role ?? (isHover ? "tooltip" : "menu") });
// `role:"combobox"` makes Floating UI wire the whole typeable-combobox ARIA tree: the reference input
// → role=combobox + aria-expanded/controls/autocomplete + aria-activedescendant (from the virtual nav),
// the surface → role=listbox, the items → role=option + their option ids. (`combobox` maps to the
// `listbox` aria-role internally.) So <Combobox> sets NO aria-* by hand.
const role = useRole(context, {
role: opts.role ?? (isHover ? "tooltip" : isCombobox ? "combobox" : "menu"),
});
const listNav = useListNavigation(context, {
enabled: isNav,
enabled: roves,
// Virtual navigation for a combobox: DOM focus stays on the text input while ↑/↓ move an
// `aria-activedescendant` pointer over the options (the handler rides on getReferenceProps → the
// input). A menu/listbox uses real roving focus instead.
virtual: isCombobox,
listRef: elementsRef,
activeIndex,
// Keyboard (and pointer, when floating-ui owns it) navigation takes the visual highlight back from
@@ -181,12 +234,15 @@ export function useAnchoredPopover(opts: AnchoredPopoverOptions) {
// `isListIndexDisabled`.) No explicit index list needed.
loop: true,
// Focus the first item on open for BOTH click and keyboard (the default 'auto' skips pointer-opens,
// which strands focus on the menu container — arrows then can't enter the roving list at all).
focusItemOnOpen: true,
// A menu that hosts a free-text input must NOT pull focus onto a list item when the pointer merely
// passes over it (the library default) — that blurs the input mid-typing and mis-routes the next
// Enter to the hovered item. Turned off together with typeahead for such menus.
focusItemOnHover: !opts.hostsInput,
// which strands focus on the menu container — arrows then can't enter the roving list at all). A
// combobox opens with NO active item — the caret is in the input, and the first ↓ lands on the first
// option; auto-highlighting item 0 on open would fight the typed query.
focusItemOnOpen: isCombobox ? false : true,
// A menu that hosts a free-text input, and every combobox, must NOT pull focus onto a list item when
// the pointer merely passes over it (the library default) — that blurs the input mid-typing and
// mis-routes the next Enter to the hovered item. The visual hover highlight is driven off hoverIndex
// instead (see PopoverItem), so the pointer still highlights without stealing the caret.
focusItemOnHover: isCombobox ? false : !opts.hostsInput,
});
const typeahead = useTypeahead(context, {
enabled: isNav && (opts.typeahead ?? true) && !opts.hostsInput,
@@ -215,6 +271,8 @@ export function useAnchoredPopover(opts: AnchoredPopoverOptions) {
nodeId,
mode,
isNav,
isCombobox,
roves,
open: opts.open,
selectedIndex: opts.selectedIndex ?? null,
refs,
@@ -238,6 +296,9 @@ interface PopoverListContextValue {
highlightIndex: number | null;
setHoverIndex: (index: number | null) => void;
getItemProps: AnchoredPopover["getItemProps"];
/** Combobox surface: items are navigated VIRTUALLY (aria-activedescendant), so a <PopoverItem> never
* takes DOM focus (roving tabindex off) and keeps the caret in the input on click. */
virtual: boolean;
}
const PopoverListContext = createContext<PopoverListContextValue | null>(null);
@@ -295,6 +356,12 @@ export function PopoverItem({
label?: string;
} & Omit<ButtonHTMLAttributes<HTMLButtonElement>, "onSelect" | "role" | "children" | "className">) {
const ctx = useContext(PopoverListContext);
const virtual = ctx?.virtual ?? false;
// A virtual (combobox) option needs a STABLE DOM id from first render: the input's aria-activedescendant
// is set to the active option's `.id` when the arrow handler reads it, so the id must already be there
// (Floating UI's own on-activation id assignment races the re-render and lands empty through our
// indirection). A menu/listbox item uses roving DOM focus instead and needs no id.
const generatedId = useId();
const textLabel = label ?? (typeof children === "string" ? children : undefined);
const { ref, index } = useListItem({ label: textLabel });
// Roving tabindex / DOM focus follow the KEYBOARD position (activeIndex); the visible highlight
@@ -302,12 +369,12 @@ export function PopoverItem({
const focusActive = ctx?.activeIndex === index;
const visualActive = ctx?.highlightIndex === index;
const ariaProps =
role === "menuitemradio" || role === "menuitemcheckbox"
? { role, "aria-checked": !!checked }
: role === "option"
? { role, "aria-selected": !!selected }
: { role };
// The checked/selected state is rendered as an EXPLICIT attribute after {...handlers} below (so it
// wins): for a combobox `role:"combobox"` makes getItemProps emit its own `aria-selected` off the
// single selectedIndex, which is always false for a MULTI-select — our per-value truth must override.
const ariaChecked =
role === "menuitemradio" || role === "menuitemcheckbox" ? !!checked : undefined;
const ariaSelected = role === "option" ? !!selected : undefined;
// Route the caller's own props (onClick, onFocus, …) THROUGH floating-ui's item-prop getter, which
// composes handlers rather than dropping them — spreading `{...rest}` separately would let the
@@ -325,19 +392,37 @@ export function PopoverItem({
rest.onMouseMove?.(e);
if (ctx && !disabled && index != null && ctx.highlightIndex !== index) ctx.setHoverIndex(index);
};
// In a combobox, clicking an option must NOT move DOM focus off the input (that would close the list
// and drop the caret) — preventDefault the mousedown so focus stays put; the onClick still selects.
const mergedMouseDown = (e: React.MouseEvent<HTMLButtonElement>) => {
rest.onMouseDown?.(e);
if (virtual) e.preventDefault();
};
const handlers = ctx
? ctx.getItemProps({ ...rest, onClick: mergedClick, onMouseMove: mergedMouseMove })
: { ...rest, onClick: mergedClick };
? ctx.getItemProps({
...rest,
onClick: mergedClick,
onMouseMove: mergedMouseMove,
onMouseDown: mergedMouseDown,
})
: { ...rest, onClick: mergedClick, onMouseDown: mergedMouseDown };
return (
<button
ref={ref}
type="button"
{...ariaProps}
role={role}
{...handlers}
// These win over anything in `handlers` — the roving tabindex must not be a caller override,
// and disabled/className/data-active are the platform's to set.
tabIndex={focusActive ? 0 : -1}
// A virtual option carries its own stable id (the aria-activedescendant target); it wins over any
// id Floating UI's getItemProps would inject on activation.
id={virtual ? generatedId : undefined}
// These win over anything in `handlers` — the roving tabindex must not be a caller override, the
// checked/selected truth must beat getItemProps' single-select guess, and disabled/className/
// data-active are the platform's to set. A virtual (combobox) item never holds DOM focus, so its
// tabindex is always -1; navigation is by aria-activedescendant.
aria-checked={ariaChecked}
aria-selected={ariaSelected}
tabIndex={virtual ? -1 : focusActive ? 0 : -1}
data-active={visualActive ? "true" : undefined}
disabled={disabled}
className={`outline-hidden ${
@@ -402,6 +487,8 @@ function PopoverSurfaceInner({
isPositioned,
mode,
isNav,
isCombobox,
roves,
selectedIndex,
activeIndex,
highlightIndex,
@@ -434,9 +521,10 @@ function PopoverSurfaceInner({
if (ariaLabel) delete floatingProps["aria-labelledby"];
const zClass = mode === "hover" ? "z-tooltip" : "z-popover";
// Everything but a passive hover card manages focus by default (trap into the surface, return to
// the trigger on close).
const managed = focusManaged ?? mode !== "hover";
// A menu/listbox/dialog traps focus into the surface (and returns it on close). A hover card is
// passive, and a COMBOBOX must leave focus in the input (its list is navigated virtually) — neither
// is focus-managed.
const managed = focusManaged ?? (mode !== "hover" && !isCombobox);
const surface = (
<div
@@ -470,11 +558,11 @@ function PopoverSurfaceInner({
</div>
);
// Menu/listbox surfaces host the item registry (FloatingList) + active-state context so nested
// <PopoverItem>s can self-register and rove.
const positioned = isNav ? (
// Menu/listbox AND combobox surfaces host the item registry (FloatingList) + active-state context so
// nested <PopoverItem>s can self-register and rove/aria-activedescendant.
const positioned = roves ? (
<PopoverListContext.Provider
value={{ activeIndex, highlightIndex, setHoverIndex, getItemProps }}
value={{ activeIndex, highlightIndex, setHoverIndex, getItemProps, virtual: isCombobox }}
>
<FloatingList elementsRef={elementsRef} labelsRef={labelsRef}>
{surface}
@@ -0,0 +1,93 @@
// @vitest-environment jsdom
//
// Contract of the shared horizontal rail. jsdom lays out nothing (scrollWidth/clientWidth are 0), so
// the overflow-driven scroll buttons only appear once we feed the scroller fake metrics and fire a
// scroll — which is exactly the affordance under test: a button shows ONLY on the clipped edge, and
// clicking it pages the scroller. i18n is key-passthrough, so asserted names are the i18n keys.
import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return { ...actual, useTranslation: () => ({ t: (k: string) => k }) };
});
import { Carousel } from "./Carousel";
beforeAll(() => {
// useHScroll wires a ResizeObserver (watch-the-children); jsdom ships none.
class Stub {
observe() {}
unobserve() {}
disconnect() {}
takeRecords() {
return [];
}
}
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
});
afterEach(cleanup);
function setMetrics(
el: HTMLElement,
m: { scrollWidth: number; clientWidth: number; scrollLeft: number },
) {
Object.defineProperty(el, "scrollWidth", { configurable: true, value: m.scrollWidth });
Object.defineProperty(el, "clientWidth", { configurable: true, value: m.clientWidth });
Object.defineProperty(el, "scrollLeft", {
configurable: true,
writable: true,
value: m.scrollLeft,
});
}
describe("Carousel", () => {
it("renders children and an optional header with a see-all action", () => {
const onSeeAll = vi.fn();
render(
<Carousel title="Recently added" onSeeAll={onSeeAll}>
<div>card-a</div>
<div>card-b</div>
</Carousel>,
);
expect(screen.getByText("card-a")).toBeTruthy();
expect(screen.getByRole("heading", { name: "Recently added" })).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "common.seeAll" }));
expect(onSeeAll).toHaveBeenCalledOnce();
});
it("omits the header when no title is given and names the scroll region", () => {
render(
<Carousel ariaLabel="Cast">
<div>x</div>
</Carousel>,
);
expect(screen.queryByRole("heading")).toBeNull();
expect(screen.getByRole("group", { name: "Cast" })).toBeTruthy();
});
it("shows only the clipped edge's button and pages the scroller on click", () => {
render(
<Carousel ariaLabel="rail">
<div>x</div>
</Carousel>,
);
const scroller = screen.getByRole("group", { name: "rail" });
const scrollBy = vi.fn();
scroller.scrollBy = scrollBy as unknown as typeof scroller.scrollBy;
// At the left boundary with more content to the right → only the right button.
setMetrics(scroller, { scrollWidth: 1000, clientWidth: 300, scrollLeft: 0 });
fireEvent.scroll(scroller);
expect(screen.queryByRole("button", { name: "common.scrollLeft" })).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "common.scrollRight" }));
expect(scrollBy).toHaveBeenCalled();
// Scrolled into the middle → both edges clipped → both buttons.
setMetrics(scroller, { scrollWidth: 1000, clientWidth: 300, scrollLeft: 350 });
fireEvent.scroll(scroller);
expect(screen.getByRole("button", { name: "common.scrollLeft" })).toBeTruthy();
expect(screen.getByRole("button", { name: "common.scrollRight" })).toBeTruthy();
});
});
+135
View File
@@ -0,0 +1,135 @@
import { useCallback, type ReactNode } from "react";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { useTranslation } from "react-i18next";
import clsx from "clsx";
import { useScrollEdges } from "../lib/useScrollEdges";
// Shared horizontal rail — the one place the app's "row of cards you scroll sideways" lives, replacing
// the hand-rolled `flex gap overflow-x-auto` strips (Cast / Related / collections) and the future
// Plex-Discover rows. Gives every consumer the same affordances for free: edge fade, hover/focus scroll
// buttons that appear only on the side that actually has more content, a native scrollbar hidden behind
// `.no-scrollbar`, and reduced-motion-aware paging. Purely presentational — the caller owns the cards.
// Prefer an instant jump when the viewer asked for reduced motion; smooth otherwise. Guarded for jsdom,
// where matchMedia is often absent (the button paging is still exercised — it just uses "auto" there).
function scrollBehavior(): ScrollBehavior {
const reduce =
typeof window !== "undefined" &&
typeof window.matchMedia === "function" &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
return reduce ? "auto" : "smooth";
}
// Horizontal scroll-affordance: whether the scroller is clipped on the left / right, so the rail shows
// a button + fade only where there's more to see. The X-axis face of the shared `useScrollEdges` core
// (the same watch-the-scroller-and-its-children discipline as the vertical `useScrollFade`), plus the
// element itself so the paging buttons can scroll it.
function useHScroll(): {
ref: (node: HTMLElement | null) => void;
el: HTMLElement | null;
left: boolean;
right: boolean;
} {
const { ref, el, start, end } = useScrollEdges("x");
return { ref, el, left: start, right: end };
}
const FADE_PX = 28;
export function Carousel({
title,
onSeeAll,
seeAllLabel,
ariaLabel,
gap = "gap-3",
className,
children,
}: {
/** Optional built-in header (rails). Omit for a bare scroller inside a card that owns its own heading. */
title?: ReactNode;
/** When set, a "see all" affordance appears in the header (needs `title`). */
onSeeAll?: () => void;
seeAllLabel?: string;
/** Names the scroll region for AT; falls back to the string `title` when that's a plain string. */
ariaLabel?: string;
/** Tailwind gap class for the scroller (e.g. "gap-4"). */
gap?: string;
className?: string;
children: ReactNode;
}) {
const { t } = useTranslation();
const { ref, el, left, right } = useHScroll();
const page = useCallback(
(dir: -1 | 1) => {
if (!el) return;
// ~one viewport-worth, but never a hair scroll on a narrow rail (or 0 in a zero-width jsdom box).
const amount = Math.max(Math.round(el.clientWidth * 0.85), 200);
el.scrollBy({ left: dir * amount, behavior: scrollBehavior() });
},
[el],
);
// Fade only the clipped edge — no fade at a boundary, none when it all fits (matches useScrollFade).
const mask = `linear-gradient(to right, transparent, #000 ${left ? FADE_PX : 0}px, #000 calc(100% - ${
right ? FADE_PX : 0
}px), transparent)`;
const label = ariaLabel ?? (typeof title === "string" ? title : undefined);
const arrowBtn =
"absolute top-1/2 -translate-y-1/2 z-10 grid h-9 w-9 place-items-center rounded-full " +
"bg-black/55 text-white backdrop-blur-xs border border-white/15 shadow-lg transition " +
"opacity-0 group-hover/car:opacity-100 focus-visible:opacity-100 hover:bg-black/75";
return (
<div className={clsx("group/car relative", className)}>
{title != null && (
<div className="mb-3 flex items-baseline gap-2">
<h2 className="text-sm font-semibold">{title}</h2>
{onSeeAll && (
<button
onClick={onSeeAll}
className="ml-auto inline-flex items-center gap-0.5 text-xs text-muted transition hover:text-accent"
>
{seeAllLabel ?? t("common.seeAll")}
<ChevronRight className="h-3.5 w-3.5" />
</button>
)}
</div>
)}
<div className="relative">
{left && (
<button
type="button"
onClick={() => page(-1)}
aria-label={t("common.scrollLeft")}
className={clsx(arrowBtn, "left-1")}
>
<ChevronLeft className="h-5 w-5" />
</button>
)}
<div
ref={ref}
className={clsx("no-scrollbar flex overflow-x-auto pb-1", gap)}
style={{ maskImage: mask, WebkitMaskImage: mask }}
role="group"
aria-label={label}
>
{children}
</div>
{right && (
<button
type="button"
onClick={() => page(1)}
aria-label={t("common.scrollRight")}
className={clsx(arrowBtn, "right-1")}
>
<ChevronRight className="h-5 w-5" />
</button>
)}
</div>
</div>
);
}
@@ -0,0 +1,211 @@
// @vitest-environment jsdom
//
// The universal combobox (chips + typed search field over the AnchoredPopover `combobox` mode). Covers
// chip removal, open-on-focus, client filter, toggling, Backspace-removes-last, the big-vocabulary cap,
// single-select, and the combobox ARIA the old hand-rolled control lacked (role=combobox, aria-expanded,
// and a virtual aria-activedescendant that keeps DOM focus on the input). i18n is key-passthrough; the
// floating-ui recipe (ActionMenu's): stub the two observers autoUpdate wires, wrap in a FloatingTree,
// and query the hidden surface with `{ hidden: true }` since jsdom never positions it.
import { describe, it, expect, vi, afterEach, beforeAll } from "vitest";
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
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: (k: string) => k }) };
});
import Combobox from "./Combobox";
afterEach(cleanup);
const OPTIONS = [
{ value: "Drama", count: 120 },
{ value: "Action", count: 90 },
{ value: "Comedy", count: 60 },
];
function wrap(ui: React.ReactElement) {
return render(<FloatingTree>{ui}</FloatingTree>);
}
describe("Combobox", () => {
it("renders selected values as removable chips and removes on ✕", () => {
const onChange = vi.fn();
wrap(<Combobox label="Genre" options={OPTIONS} selected={["Drama"]} onChange={onChange} />);
expect(screen.getByText("Drama")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "common.remove" }));
expect(onChange).toHaveBeenCalledWith([]); // Drama removed
});
it("exposes combobox ARIA: role=combobox, aria-expanded toggling, aria-controls when open", () => {
wrap(<Combobox label="Genre" options={OPTIONS} selected={[]} onChange={vi.fn()} />);
const input = screen.getByRole("combobox");
expect(input.getAttribute("aria-expanded")).toBe("false");
expect(input.getAttribute("aria-controls")).toBeNull();
fireEvent.focus(input);
expect(input.getAttribute("aria-expanded")).toBe("true");
// aria-controls now points at the listbox surface.
const controls = input.getAttribute("aria-controls");
expect(controls).toBeTruthy();
expect(document.getElementById(controls!)?.getAttribute("role")).toBe("listbox");
});
it("keeps the option list unmounted until the field is focused", () => {
wrap(<Combobox label="Genre" options={OPTIONS} selected={[]} onChange={vi.fn()} />);
expect(screen.queryByRole("option", { hidden: true })).toBeNull();
fireEvent.focus(screen.getByRole("combobox"));
expect(screen.getAllByRole("option", { hidden: true }).length).toBe(3);
});
it("filters the options by the typed query", () => {
wrap(<Combobox label="Genre" options={OPTIONS} selected={[]} onChange={vi.fn()} />);
const field = screen.getByRole("combobox");
fireEvent.focus(field);
fireEvent.change(field, { target: { value: "com" } });
const items = screen.getAllByRole("option", { hidden: true });
expect(items.length).toBe(1);
expect(items[0]?.textContent).toContain("Comedy");
});
it("marks a selected option aria-selected and toggles another on", () => {
const onChange = vi.fn();
wrap(<Combobox label="Genre" options={OPTIONS} selected={["Drama"]} onChange={onChange} />);
fireEvent.focus(screen.getByRole("combobox"));
const drama = screen
.getAllByRole("option", { hidden: true })
.find((el) => el.textContent?.includes("Drama"));
expect(drama?.getAttribute("aria-selected")).toBe("true");
fireEvent.click(screen.getByText("Action")); // clicking the option's label toggles it on
expect(onChange).toHaveBeenCalledWith(["Drama", "Action"]);
});
it("moves an aria-activedescendant on ArrowDown while DOM focus stays on the input", () => {
wrap(<Combobox label="Genre" options={OPTIONS} selected={[]} onChange={vi.fn()} />);
const input = screen.getByRole("combobox");
input.focus();
fireEvent.focus(input);
fireEvent.keyDown(input, { key: "ArrowDown" });
// Virtual navigation: the input keeps DOM focus, the active option is referenced by id.
expect(document.activeElement).toBe(input);
expect(input.getAttribute("aria-activedescendant")).toBeTruthy();
});
it("removes the last chip on Backspace in an empty field", () => {
const onChange = vi.fn();
wrap(
<Combobox
label="Genre"
options={OPTIONS}
selected={["Drama", "Action"]}
onChange={onChange}
/>,
);
fireEvent.keyDown(screen.getByRole("combobox"), { key: "Backspace" });
expect(onChange).toHaveBeenCalledWith(["Drama"]); // last chip dropped
});
it("caps the rendered rows for a large vocabulary and shows a refine hint", () => {
// A studio-sized list (hundreds) must not render every row — cap at 50 + a "N more" footer.
const many = Array.from({ length: 60 }, (_, i) => ({ value: `Studio ${i}`, count: 60 - i }));
wrap(<Combobox label="Studio" options={many} selected={[]} onChange={vi.fn()} />);
fireEvent.focus(screen.getByRole("combobox"));
expect(screen.getAllByRole("option", { hidden: true }).length).toBe(50);
expect(screen.getByText("plex.filter.moreRefine")).toBeTruthy(); // 10 hidden behind search
});
it("Enter commits the pointer-highlighted option, not the arrow-active one", () => {
// The visible highlight follows the pointer (hoverIndex); Enter must commit THAT row, not the bare
// keyboard activeIndex — otherwise arrow-then-hover-then-Enter toggles a different option than shown.
const onChange = vi.fn();
wrap(<Combobox label="Genre" options={OPTIONS} selected={[]} onChange={onChange} />);
const input = screen.getByRole("combobox");
input.focus();
fireEvent.focus(input);
fireEvent.keyDown(input, { key: "ArrowDown" }); // activeIndex → 0 (Drama)
const action = screen
.getAllByRole("option", { hidden: true })
.find((el) => el.textContent?.includes("Action"))!;
fireEvent.mouseMove(action); // hoverIndex → Action (the visibly highlighted row)
fireEvent.keyDown(input, { key: "Enter" });
expect(onChange).toHaveBeenCalledWith(["Action"]); // the highlighted (hovered) row, not Drama
});
it("Enter commits a hover-highlighted option even with no prior arrow key", () => {
const onChange = vi.fn();
wrap(<Combobox label="Genre" options={OPTIONS} selected={[]} onChange={onChange} />);
const input = screen.getByRole("combobox");
input.focus();
fireEvent.focus(input);
const comedy = screen
.getAllByRole("option", { hidden: true })
.find((el) => el.textContent?.includes("Comedy"))!;
fireEvent.mouseMove(comedy); // highlight via hover, activeIndex still null
fireEvent.keyDown(input, { key: "Enter" });
expect(onChange).toHaveBeenCalledWith(["Comedy"]); // no longer a silent no-op
});
it("async mode: controlled query + onQueryChange/onOpenChange, no client filter, selectedLabels chip", () => {
const onQueryChange = vi.fn();
const onOpenChange = vi.fn();
const onChange = vi.fn();
const serverOpts = [{ value: "id1", label: "Harry Potter", count: 8 }];
wrap(
<Combobox
label="Collection"
multiple={false}
clientFilter={false}
options={serverOpts}
selected={["stored-id"]}
selectedLabels={{ "stored-id": "Stored Collection" }}
query="har"
onQueryChange={onQueryChange}
onOpenChange={onOpenChange}
onChange={onChange}
/>,
);
// The selected value's option isn't in the (server-narrowed) list, so its chip uses selectedLabels.
expect(screen.getByText("Stored Collection")).toBeTruthy();
const input = screen.getByRole("combobox");
fireEvent.focus(input);
expect(onOpenChange).toHaveBeenCalledWith(true);
fireEvent.change(input, { target: { value: "harr" } });
expect(onQueryChange).toHaveBeenCalledWith("harr"); // controlled query propagates out
// clientFilter=false: the single server option renders verbatim (not re-filtered against "har").
const opts = screen.getAllByRole("option", { hidden: true });
expect(opts.length).toBe(1);
expect(opts[0]?.textContent).toContain("Harry Potter");
fireEvent.click(screen.getByText("Harry Potter"));
expect(onChange).toHaveBeenCalledWith(["id1"]); // single-select replaces
});
it("single-select: picking replaces the value and closes the list", () => {
const onChange = vi.fn();
wrap(
<Combobox
label="Collection"
multiple={false}
options={OPTIONS}
selected={["Drama"]}
onChange={onChange}
/>,
);
fireEvent.focus(screen.getByRole("combobox"));
fireEvent.click(screen.getByText("Action"));
expect(onChange).toHaveBeenCalledWith(["Action"]); // replaced, not appended
expect(screen.getByRole("combobox").getAttribute("aria-expanded")).toBe("false"); // closed
});
});
+272
View File
@@ -0,0 +1,272 @@
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Check, ChevronDown, X } from "lucide-react";
import { useAnchoredPopover, PopoverSurface, PopoverItem } from "./AnchoredPopover";
import { useScrollFade } from "../lib/useScrollFade";
// The universal combobox: a react-select / Overseerr-style "combi input" — selected values are
// removable chips that live INSIDE a text field you type into, and a checklist of options opens below.
// It is the ONE control for "pick value(s) from a (possibly large, possibly async) vocabulary": the E5
// Plex filters (genre / age-rating / studio, multi-select) and the collection picker (single-select,
// server-searched) are both instances, and the header search typeahead can move onto it later.
//
// All the fiddly parts live in the AnchoredPopover `combobox` mode, NOT here: floating-ui positioning,
// the paint-before-position + transform-clobber flash guards, the topmost-only Escape layer, and the
// FULL combobox ARIA tree (input role=combobox + aria-expanded/controls/activedescendant, listbox
// surface, option ids, virtual ↑/↓ arrow-nav that keeps the caret in the field). This component only
// adds the field chrome (chips + input + chevron), the query/selection state, and the big-vocabulary
// render cap. That is what makes it accessible where the old hand-rolled MultiSelectChips was not.
export interface ComboboxOption {
value: string;
/** Display text; defaults to `value`. */
label?: string;
/** A facet count shown at the row's end. */
count?: number;
disabled?: boolean;
}
export interface ComboboxProps {
/** Accessible name for the input (the section heading usually names it visibly too). */
label: string;
options: readonly ComboboxOption[];
/** Selected values. Single-select carries 0 or 1. */
selected: string[];
onChange: (next: string[]) => void;
/** Several values at once (chips accumulate) vs one (picking replaces + closes). Default true. */
multiple?: boolean;
/** Notified when the list opens/closes — a server-searched caller gates its fetch on `open`. */
onOpenChange?: (open: boolean) => void;
placeholder?: string;
/** Controlled query text — pass with `onQueryChange` for a server-searched (async) vocabulary; omit
* to let the combobox own the query state and filter `options` on the client. */
query?: string;
onQueryChange?: (q: string) => void;
/** Filter `options` by the query on the client (default). Set false when the caller already narrows
* the list server-side (then `options` arrives pre-filtered). */
clientFilter?: boolean;
loading?: boolean;
/** Shown when there are no options to list. */
emptyText?: string;
/** Cap on rendered rows for a huge vocabulary; the rest hide behind the search. Default 50. */
maxVisible?: number;
/** Custom row body (icon + label + count …); defaults to a checkbox + label + count. */
renderOption?: (option: ComboboxOption, isSelected: boolean) => ReactNode;
/** Chip/label text for a selected value whose option is not in the current (e.g. server-narrowed)
* list — the collection picker passes the stored title here so a reloaded filter still names itself. */
selectedLabels?: Record<string, string>;
}
export default function Combobox({
label,
options,
selected,
onChange,
multiple = true,
onOpenChange,
placeholder,
query: queryProp,
onQueryChange,
clientFilter = true,
loading = false,
emptyText,
maxVisible = 50,
renderOption,
selectedLabels,
}: ComboboxProps) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
// The query is controlled when the caller drives it (async search), else owned here.
const [queryState, setQueryState] = useState("");
const query = queryProp ?? queryState;
const setQuery = (q: string) => {
if (queryProp === undefined) setQueryState(q);
onQueryChange?.(q);
};
const inputRef = useRef<HTMLInputElement>(null);
const fade = useScrollFade();
// Remember each value's display label as options flow through, so a selected chip can name itself
// even after the option leaves the query-narrowed list (async single-select). Caller-supplied
// `selectedLabels` win (they survive a page reload where nothing has been fetched yet).
const labelCache = useRef<Record<string, string>>({});
useEffect(() => {
for (const o of options) if (o.label != null) labelCache.current[o.value] = o.label;
}, [options]);
const labelFor = (v: string) => selectedLabels?.[v] ?? labelCache.current[v] ?? v;
const setOpenAnd = (next: boolean) => {
setOpen(next);
onOpenChange?.(next);
};
const pop = useAnchoredPopover({ open, onOpenChange: setOpenAnd, mode: "combobox" });
// `highlightIndex` = the row the user actually SEES highlighted (pointer hover, else keyboard); Enter
// commits THAT, not the bare keyboard `activeIndex` — otherwise arrow-then-hover-then-Enter would toggle
// a different option than the highlighted one, and a hover with no prior arrow (activeIndex still null)
// would make Enter a no-op over a highlighted row.
const { activeIndex, highlightIndex } = pop;
// Clear the typed query when the list closes.
useEffect(() => {
if (!open) setQuery("");
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
const filtered = useMemo(() => {
if (!clientFilter) return options;
const q = query.trim().toLowerCase();
if (!q) return options;
return options.filter((o) => (o.label ?? o.value).toLowerCase().includes(q));
}, [options, query, clientFilter]);
// Options arrive count-desc (facet order), so the capped default view is the most-common values; a
// footer flags how many more are reachable through the search.
const visible = filtered.slice(0, maxVisible);
const hiddenCount = filtered.length - visible.length;
const commit = (value: string) => {
if (multiple) {
onChange(
selected.includes(value) ? selected.filter((x) => x !== value) : [...selected, value],
);
} else {
onChange([value]);
setOpenAnd(false);
}
inputRef.current?.focus();
};
return (
<div>
{/* The field: removable chips + a free-text input + a chevron, styled as one input box. The whole
box is the POSITION anchor (the panel matches its width); the input is the combobox reference. */}
<div
ref={pop.refs.setPositionReference}
onMouseDown={(e) => {
// A press anywhere but the input (padding / a chip gap) focuses the field and opens the list —
// focus drives "open", so a caret click never toggles it shut.
if (e.target !== inputRef.current) {
e.preventDefault();
inputRef.current?.focus();
setOpenAnd(true);
}
}}
className="glass-card flex min-h-9 w-full cursor-text flex-wrap items-center gap-1 rounded-lg border border-border px-1.5 py-1 transition focus-within:border-accent focus-within:ring-2 focus-within:ring-accent/30"
>
{selected.map((v) => (
<span key={v} className="chip inline-flex items-center gap-1 px-2 py-0.5 text-xs">
<span className="max-w-36 truncate">{labelFor(v)}</span>
<button
type="button"
onMouseDown={(e) => {
// Keep the box's focus-the-field handler from firing when removing a chip.
e.stopPropagation();
e.preventDefault();
}}
onClick={() => onChange(selected.filter((x) => x !== v))}
aria-label={t("common.remove", { defaultValue: "Remove" })}
className="hover:opacity-70"
>
<X className="h-3 w-3" />
</button>
</span>
))}
<input
ref={pop.refs.setReference}
{...pop.getReferenceProps({
onChange: (e) => {
setQuery((e.target as HTMLInputElement).value);
setOpenAnd(true);
},
onFocus: () => setOpenAnd(true),
onKeyDown: (e) => {
const idx = highlightIndex ?? activeIndex;
if (e.key === "Enter" && open && idx != null && visible[idx]) {
e.preventDefault(); // select the highlighted option, don't submit a surrounding form
if (!visible[idx].disabled) commit(visible[idx].value);
} else if (e.key === "Backspace" && query === "" && selected.length > 0) {
// Backspace on an empty field removes the last chip (react-select behaviour).
onChange(selected.slice(0, -1));
}
},
})}
value={query}
aria-label={label}
placeholder={
selected.length === 0
? (placeholder ?? t("common.search", { defaultValue: "Search" }))
: ""
}
className="min-w-16 flex-1 bg-transparent px-1 py-0.5 text-sm outline-hidden placeholder:text-muted/60"
/>
<ChevronDown className="pointer-events-none mr-0.5 h-3.5 w-3.5 shrink-0 text-muted" />
</div>
<PopoverSurface
popover={pop}
ariaLabel={label}
className="glass-menu w-full rounded-xl p-1.5 shadow-lg animate-[popIn_0.16s_ease]"
>
{/* The scroll box owns the height cap AND the scroll-fade mask (ref+style on the same element). */}
<div ref={fade.ref} style={fade.style} className="max-h-64 overflow-y-auto no-scrollbar">
{loading && filtered.length === 0 ? (
<p className="px-2 py-1.5 text-xs text-muted">
{t("plex.loading", { defaultValue: "Loading…" })}
</p>
) : filtered.length === 0 ? (
<p className="px-2 py-1.5 text-xs text-muted">
{emptyText ?? t("common.noResults", { defaultValue: "No matches" })}
</p>
) : (
<>
{visible.map((o) => {
const on = selected.includes(o.value);
return (
<PopoverItem
key={o.value}
role="option"
selected={on}
disabled={o.disabled}
onSelect={() => commit(o.value)}
label={o.label ?? o.value}
className={`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm transition ${
on ? "text-fg" : "text-muted"
}`}
>
{renderOption ? (
renderOption(o, on)
) : (
<>
<span
className={`flex h-3.5 w-3.5 shrink-0 items-center justify-center rounded border ${
on ? "border-accent bg-accent text-accent-fg" : "border-border"
}`}
>
{on && <Check className="h-2.5 w-2.5" />}
</span>
<span className="flex-1 truncate">{o.label ?? o.value}</span>
{o.count != null && (
<span className="shrink-0 text-2xs tabular-nums text-muted">
{o.count}
</span>
)}
</>
)}
</PopoverItem>
);
})}
{hiddenCount > 0 && (
<p className="px-2 py-1.5 text-2xs text-muted">
{t("plex.filter.moreRefine", {
count: hiddenCount,
defaultValue: "{{count}} more — refine with search",
})}
</p>
)}
</>
)}
</div>
</PopoverSurface>
</div>
);
}
+4 -725
View File
@@ -1,14 +1,11 @@
import { lazy, Suspense, useEffect, useMemo, useState } from "react";
import { lazy, Suspense, useEffect, useState } from "react";
import { qk } from "../lib/queryKeys";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
Ban,
CalendarClock,
ClipboardPaste,
Copy,
Download,
Link2,
Pause,
Play,
Plus,
@@ -19,11 +16,9 @@ import {
Pencil,
Trash2,
} from "lucide-react";
import clsx from "clsx";
import Tabs from "./Tabs";
import { PageToolbar } from "./PageShell";
import { LoadingState, StateMessage } from "./QueryState";
import Modal from "./Modal";
// 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"));
@@ -35,726 +30,10 @@ import { useLiveQuery } from "../lib/useLiveQuery";
import { useMe } from "../lib/useMe";
import { api, type DownloadJob } from "../lib/api";
import { invalidateDownloads } from "../lib/downloads";
import { notify } from "../lib/notifications";
import { copyText } from "../lib/clipboard";
import { formatBytes, formatDuration, formatEta, formatSpeed, relativeTime } from "../lib/format";
import { inputCls } from "./ui/form";
const GiB = 1024 ** 3;
const STATUS_CLS: Record<string, string> = {
queued: "text-muted",
running: "text-accent",
paused: "text-warn",
done: "text-success",
error: "text-danger",
canceled: "text-muted",
};
// Byte-progress phases (show a % bar) vs ffmpeg post-steps (no %, indeterminate pulse).
// "editing" (the phase-2 editor) reports a real % from ffmpeg's out_time, so it gets a bar.
const DOWNLOAD_PHASES = ["video", "audio", "editing"];
const PHASE_KEYS = [
"video",
"audio",
"editing",
"merging",
"audio_extract",
"thumbnail",
"sponsorblock",
"metadata",
"processing",
];
function StatusPill({ job }: { job: DownloadJob }) {
const { t } = useTranslation();
// While downloading, show the concrete phase (video / audio / merging) so the user isn't
// confused by the bar resetting between the separate video and audio streams.
if (job.status === "running" && job.phase && PHASE_KEYS.includes(job.phase)) {
return (
<span className="text-xs font-medium text-accent">{t(`downloads.phase.${job.phase}`)}</span>
);
}
const key = job.expired ? "expired" : job.status;
return (
<span className={clsx("text-xs font-medium", STATUS_CLS[job.status] ?? "text-muted")}>
{t(`downloads.status.${key}`)}
</span>
);
}
function Thumb({ job }: { job: DownloadJob }) {
// Prefer the source thumbnail; fall back to our generated poster for thumbnail-less sources.
const primary = job.asset?.thumbnail_url || job.poster_url;
// A remote i.ytimg.com thumbnail 404s once the source video is deleted/privated, leaving a
// broken image. On load failure fall back to our always-present self-hosted poster.
const [src, setSrc] = useState(primary);
useEffect(() => setSrc(primary), [primary]);
return (
<div className="w-28 aspect-video shrink-0 rounded-lg overflow-hidden bg-surface border border-border">
{src ? (
<img
src={src}
alt=""
loading="lazy"
className="w-full h-full object-cover"
onError={() => {
if (job.poster_url && src !== job.poster_url) setSrc(job.poster_url);
}}
/>
) : null}
</div>
);
}
function jobTitle(job: DownloadJob): string {
return job.display_name || job.asset?.title || job.source_ref;
}
// The channel to show under the title: a user override wins over the auto-extracted asset value.
// `url` is null for sources without a channel link (e.g. reddit) — then it renders as plain text.
function jobChannel(job: DownloadJob): { name: string; url: string | null } {
return {
name: job.display_uploader || job.asset?.uploader || job.source_ref,
url: job.display_uploader_url || job.asset?.uploader_url || null,
};
}
function IconBtn({
onClick,
title,
danger,
children,
}: {
onClick: () => void;
title: string;
danger?: boolean;
children: React.ReactNode;
}) {
return (
<button
onClick={onClick}
title={title}
className={clsx(
"p-1.5 rounded-md hover:bg-surface text-muted transition",
danger ? "hover:text-danger" : "hover:text-fg",
)}
>
{children}
</button>
);
}
// Compact display of a URL: drop the protocol + trailing slash so "youtube.com/watch?v=…" fits.
function displayUrl(url: string): string {
try {
const u = new URL(url);
return (u.host + u.pathname + u.search).replace(/\/$/, "");
} catch {
return url;
}
}
// "Downloaded from" reference: opens the original page in a new tab, or copies the link.
function SourceRef({ url }: { url: string }) {
const { t } = useTranslation();
const copy = async () => {
if (await copyText(url)) {
notify({ level: "success", message: t("downloads.source.copied") });
} else {
notify({ level: "error", message: t("downloads.source.copyFailed") });
}
};
return (
<div className="flex items-center gap-1 mt-1 text-xs text-muted min-w-0">
<Link2 className="w-3 h-3 shrink-0" />
<a
href={url}
target="_blank"
rel="noopener noreferrer"
title={`${t("downloads.source.open")} — ${url}`}
className="truncate hover:text-accent hover:underline"
>
{displayUrl(url)}
</a>
<button
onClick={copy}
aria-label={t("downloads.source.copy")}
title={t("downloads.source.copy")}
className="shrink-0 p-0.5 rounded-sm hover:bg-surface hover:text-fg transition"
>
<Copy className="w-3 h-3" />
</button>
</div>
);
}
// Channel under the title — a clickable link when a channel URL is known, plain text otherwise.
function ChannelLine({ job }: { job: DownloadJob }) {
const { name, url } = jobChannel(job);
if (url) {
return (
<div className="text-xs mt-0.5 truncate">
<a
href={url}
target="_blank"
rel="noopener noreferrer"
title={url}
className="text-muted hover:text-accent hover:underline"
>
{name}
</a>
</div>
);
}
return <div className="text-xs text-muted truncate mt-0.5">{name}</div>;
}
function DownloadRow({
job,
onShowError,
children,
}: {
job: DownloadJob;
onShowError?: (job: DownloadJob) => void;
children?: React.ReactNode;
}) {
const { t } = useTranslation();
const running = job.status === "running";
return (
<div className="flex gap-3 p-2.5 rounded-xl glass-card glass-hover transition">
<Thumb job={job} />
<div className="min-w-0 flex-1">
<div className="font-medium leading-snug line-clamp-1 flex items-center gap-1.5">
{job.job_kind === "edit" && (
<span className="inline-flex items-center gap-1 shrink-0 px-1.5 py-0.5 rounded-sm text-3xs font-semibold uppercase tracking-wide bg-accent/15 text-accent">
<Scissors className="w-3 h-3" /> {t("downloads.clipBadge")}
</span>
)}
<span className="truncate">{jobTitle(job)}</span>
</div>
<ChannelLine job={job} />
<div className="flex items-center gap-2 mt-1 text-xs">
<StatusPill job={job} />
{job.asset?.size_bytes ? (
<span className="text-muted">· {formatBytes(job.asset.size_bytes)}</span>
) : null}
{job.asset?.duration_s ? (
<span className="text-muted">· {formatDuration(job.asset.duration_s)}</span>
) : null}
{job.status === "done" && job.created_at ? (
<span
className="text-muted inline-flex items-center gap-1"
title={new Date(job.created_at).toLocaleString()}
>
· <CalendarClock className="w-3 h-3" /> {relativeTime(job.created_at)}
</span>
) : null}
{job.status === "error" ? (
<button
onClick={() => onShowError?.(job)}
className="text-danger hover:opacity-80 inline-flex items-center gap-1 truncate"
title={t("downloads.errors.fail.title")}
>
· {t(`downloads.errors.fail.cat.${job.error_code || "unknown"}.short`)}
<span className="text-muted underline">{t("downloads.errors.fail.detailsLink")}</span>
</button>
) : null}
</div>
{job.source_url && <SourceRef url={job.source_url} />}
{job.extra_links?.map((url) => (
<SourceRef key={url} url={url} />
))}
{running && (
<div className="mt-1.5">
{job.phase && !DOWNLOAD_PHASES.includes(job.phase) ? (
// ffmpeg post-steps aren't byte-progress — show an indeterminate pulse, no %.
<div className="h-1.5 rounded-full bg-surface overflow-hidden">
<div className="h-full w-full bg-accent/60 animate-pulse" />
</div>
) : (
<>
<div className="h-1.5 rounded-full bg-surface overflow-hidden">
<div
className="h-full bg-accent transition-all"
style={{ width: `${job.progress}%` }}
/>
</div>
<div className="text-2xs text-muted mt-0.5 flex gap-2">
<span>{job.progress}%</span>
{job.speed_bps ? <span>{formatSpeed(job.speed_bps)}</span> : null}
{job.eta_s ? <span>{formatEta(job.eta_s)}</span> : null}
</div>
</>
)}
</div>
)}
</div>
<div className="flex items-start gap-0.5 shrink-0">{children}</div>
</div>
);
}
// --- Rename + Share small modals ------------------------------------------------------------
// Edit a download's display metadata: title, channel (name + link), and extra reference links.
// All are display-only overrides — the on-disk file is never renamed.
function MetaEditModal({ job, onClose }: { job: DownloadJob; onClose: () => void }) {
const { t } = useTranslation();
const qc = useQueryClient();
const [name, setName] = useState(job.display_name || job.asset?.title || "");
const [channel, setChannel] = useState(job.display_uploader || job.asset?.uploader || "");
const [channelUrl, setChannelUrl] = useState(
job.display_uploader_url || job.asset?.uploader_url || "",
);
// Editable extra-link rows; keep one blank row so there's always somewhere to type.
const [links, setLinks] = useState<string[]>(() => [...(job.extra_links ?? []), ""]);
const setLink = (i: number, v: string) => setLinks((ls) => ls.map((l, j) => (j === i ? v : l)));
const addLink = () => setLinks((ls) => [...ls, ""]);
const removeLink = (i: number) => setLinks((ls) => ls.filter((_, j) => j !== i));
const save = useMutation({
mutationFn: () =>
api.updateDownloadMeta(job.id, {
display_name: name.trim(),
display_uploader: channel.trim(),
display_uploader_url: channelUrl.trim(),
extra_links: links.map((l) => l.trim()).filter(Boolean),
}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: qk.downloads() });
onClose();
},
});
return (
<Modal title={t("downloads.edit.title")} onClose={onClose}>
<div className="space-y-3">
<label className="block">
<span className="block text-sm font-medium mb-1">{t("downloads.edit.name")}</span>
<input
value={name}
onChange={(e) => setName(e.target.value)}
className={inputCls}
autoFocus
/>
</label>
<label className="block">
<span className="block text-sm font-medium mb-1">{t("downloads.edit.channel")}</span>
<input
value={channel}
onChange={(e) => setChannel(e.target.value)}
className={inputCls}
/>
</label>
<label className="block">
<span className="block text-sm font-medium mb-1">{t("downloads.edit.channelUrl")}</span>
<input
value={channelUrl}
onChange={(e) => setChannelUrl(e.target.value)}
placeholder="https://…"
className={inputCls}
/>
</label>
<div>
<span className="block text-sm font-medium mb-1">{t("downloads.edit.extraLinks")}</span>
<div className="space-y-2">
{links.map((l, i) => (
<div key={i} className="flex gap-2">
<input
value={l}
onChange={(e) => setLink(i, e.target.value)}
placeholder="https://…"
className={inputCls}
/>
<IconBtn
onClick={() => removeLink(i)}
title={t("downloads.edit.removeLink")}
danger
>
<Trash2 className="w-4 h-4" />
</IconBtn>
</div>
))}
</div>
<button
onClick={addLink}
className="mt-2 inline-flex items-center gap-1.5 text-sm text-muted hover:text-fg transition"
>
<Plus className="w-4 h-4" /> {t("downloads.edit.addLink")}
</button>
</div>
<p className="text-xs text-muted">{t("downloads.edit.hint")}</p>
<div className="flex justify-end">
<button
onClick={() => save.mutate()}
disabled={!name.trim() || save.isPending}
className="px-3 py-1.5 rounded-lg text-sm font-medium btn-primary disabled:opacity-50 transition"
>
{t("downloads.edit.save")}
</button>
</div>
</div>
</Modal>
);
}
// --- Error detail modal ---------------------------------------------------------------------
// Transient failures worth an in-modal Retry button; the rest are permanent (private/geo/DRM/…),
// where a retry can't help — the queue row still keeps its own retry control regardless.
const RETRYABLE_ERRORS = ["postprocess", "network", "unknown"];
// A readable "why did this fail" window for a failed job: a category headline + plain explanation,
// the raw yt-dlp/ffmpeg text behind a disclosure, and a Retry when the failure may be transient.
function ErrorModal({
job,
onRetry,
onClose,
}: {
job: DownloadJob;
onRetry: () => void;
onClose: () => void;
}) {
const { t } = useTranslation();
const code = job.error_code || "unknown";
const [showRaw, setShowRaw] = useState(false);
const retryable = RETRYABLE_ERRORS.includes(code);
return (
<Modal title={t("downloads.errors.fail.title")} onClose={onClose}>
<div className="space-y-3">
<div className="font-medium">{t(`downloads.errors.fail.cat.${code}.short`)}</div>
<p className="text-sm text-muted">{t(`downloads.errors.fail.cat.${code}.body`)}</p>
{job.error && (
<div>
<button
onClick={() => setShowRaw((v) => !v)}
className="text-xs text-muted hover:text-fg transition"
>
{t("downloads.errors.fail.detailsToggle")}
</button>
{showRaw && (
<pre className="mt-1.5 text-xs bg-surface rounded-lg p-2.5 overflow-x-auto whitespace-pre-wrap wrap-break-word max-h-40 text-muted">
{job.error}
</pre>
)}
</div>
)}
<div className="flex justify-end gap-2 pt-1">
{retryable && (
<button
onClick={() => {
onRetry();
onClose();
}}
className="px-3 py-1.5 rounded-lg text-sm font-medium btn-primary transition"
>
{t("downloads.actions.retry")}
</button>
)}
<button
onClick={onClose}
className="px-3 py-1.5 rounded-lg text-sm text-muted hover:text-fg hover:bg-surface transition"
>
{t("common.close")}
</button>
</div>
</div>
</Modal>
);
}
// --- Usage bar ------------------------------------------------------------------------------
function UsageBar() {
const { t } = useTranslation();
// Poll live (like the library list) so the footprint updates the moment the worker finishes an
// edit/download — enqueue only creates a queued job (0 bytes), the size lands asynchronously, so
// a one-shot fetch at enqueue time would show a stale 0 B until a manual reload.
const usage = useLiveQuery(qk.downloadUsage(), api.downloadUsage, { intervalMs: 2000 });
const u = usage.data;
if (!u) return null;
const pct =
u.unlimited || !u.max_bytes ? 0 : Math.min(100, (u.footprint_bytes / u.max_bytes) * 100);
return (
<div className="rounded-xl glass-card p-3 mb-4">
<div className="flex justify-between text-sm mb-1.5">
<span className="font-medium">{t("downloads.usage.title")}</span>
<span className="text-muted">
{formatBytes(u.footprint_bytes)}
{u.unlimited ? "" : ` / ${formatBytes(u.max_bytes)}`} ·{" "}
{u.unlimited
? t("downloads.usage.unlimited")
: t("downloads.usage.items", { used: u.active_jobs, max: u.max_jobs })}
</span>
</div>
{!u.unlimited && (
<div className="h-2 rounded-full bg-surface overflow-hidden">
<div
className={clsx("h-full transition-all", pct > 90 ? "bg-danger" : "bg-accent")}
style={{ width: `${pct}%` }}
/>
</div>
)}
</div>
);
}
// --- Admin system tab -----------------------------------------------------------------------
function QuotaModal({
userId,
email,
onClose,
}: {
userId: number;
email: string;
onClose: () => void;
}) {
const { t } = useTranslation();
const qc = useQueryClient();
const q = useQuery({
queryKey: qk.dlQuota(userId),
queryFn: () => api.adminGetDownloadQuota(userId),
});
const [form, setForm] = useState<{
gb: number;
jobs: number;
conc: number;
unlimited: boolean;
} | null>(null);
const cur = q.data;
const state =
form ??
(cur
? {
gb: Math.round((cur.max_bytes / GiB) * 10) / 10,
jobs: cur.max_jobs,
conc: cur.max_concurrent,
unlimited: cur.unlimited,
}
: null);
const done = () => {
qc.invalidateQueries({ queryKey: qk.dlQuota(userId) });
qc.invalidateQueries({ queryKey: qk.adminStorage() });
onClose();
};
const save = useMutation({
mutationFn: () =>
api.adminSetDownloadQuota(userId, {
max_bytes: Math.round(state!.gb * GiB),
max_jobs: state!.jobs,
max_concurrent: state!.conc,
unlimited: state!.unlimited,
}),
onSuccess: done,
});
const reset = useMutation({
mutationFn: () => api.adminResetDownloadQuota(userId),
onSuccess: done,
});
if (!state) return null;
const upd = (p: Partial<typeof state>) => setForm({ ...state, ...p });
return (
<Modal title={t("downloads.admin.quotaTitle", { email })} onClose={onClose}>
<div className="space-y-3">
<div className="grid grid-cols-3 gap-3">
<label className="text-sm">
{t("downloads.admin.maxBytes")}
<input
type="number"
value={state.gb}
onChange={(e) => upd({ gb: Number(e.target.value) })}
className={inputCls}
disabled={state.unlimited}
/>
</label>
<label className="text-sm">
{t("downloads.admin.maxJobs")}
<input
type="number"
value={state.jobs}
onChange={(e) => upd({ jobs: Number(e.target.value) })}
className={inputCls}
/>
</label>
<label className="text-sm">
{t("downloads.admin.maxConcurrent")}
<input
type="number"
value={state.conc}
onChange={(e) => upd({ conc: Number(e.target.value) })}
className={inputCls}
/>
</label>
</div>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input
type="checkbox"
checked={state.unlimited}
onChange={(e) => upd({ unlimited: e.target.checked })}
/>
{t("downloads.admin.unlimited")}
</label>
<div className="flex justify-between pt-1">
<button
onClick={() => reset.mutate()}
className="px-3 py-1.5 rounded-lg text-sm text-muted hover:text-fg hover:bg-surface transition"
>
{t("downloads.admin.reset")}
</button>
<button
onClick={() => save.mutate()}
disabled={save.isPending}
className="px-3 py-1.5 rounded-lg text-sm font-medium btn-primary disabled:opacity-50 transition"
>
{t("downloads.admin.save")}
</button>
</div>
</div>
</Modal>
);
}
// Pick ANY user to set a download quota for — even one who hasn't downloaded anything yet (the
// footprint list below only shows users who already have files).
function QuotaUserPicker({ onPick }: { onPick: (u: { id: number; email: string }) => void }) {
const { t } = useTranslation();
const [q, setQ] = useState("");
const [open, setOpen] = useState(false);
const users = useQuery({ queryKey: qk.adminUsers(), queryFn: api.adminUsers });
const list = (users.data ?? []).filter((u) => !u.is_demo);
const filtered = useMemo(() => {
const s = q.trim().toLowerCase();
const base = s
? list.filter(
(u) =>
u.email.toLowerCase().includes(s) || (u.display_name ?? "").toLowerCase().includes(s),
)
: list;
return base.slice(0, 8);
}, [q, list]);
return (
<div className="relative w-full sm:w-72">
<input
value={q}
onChange={(e) => {
setQ(e.target.value);
setOpen(true);
}}
onFocus={() => setOpen(true)}
onBlur={() => setTimeout(() => setOpen(false), 150)}
placeholder={t("downloads.admin.quotaPickPlaceholder")}
className={inputCls}
/>
{open && filtered.length > 0 && (
<div className="absolute z-base mt-1 w-full rounded-lg glass-menu overflow-hidden">
{filtered.map((u) => (
<button
key={u.id}
onClick={() => {
onPick({ id: u.id, email: u.email });
setQ("");
setOpen(false);
}}
className="w-full text-left px-3 py-2 text-sm hover:bg-surface transition flex flex-col"
>
<span className="truncate">{u.display_name || u.email}</span>
{u.display_name && <span className="text-xs text-muted truncate">{u.email}</span>}
</button>
))}
</div>
)}
</div>
);
}
function AdminSystem() {
const { t } = useTranslation();
const storage = useLiveQuery(qk.adminStorage(), api.adminDownloadStorage, { intervalMs: 5000 });
const jobs = useLiveQuery(qk.adminDownloads(), api.adminDownloads, { intervalMs: 5000 });
const [quotaFor, setQuotaFor] = useState<{ id: number; email: string } | null>(null);
const s = storage.data;
return (
<div>
{s && (
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-5">
{[
[t("downloads.admin.readyFiles"), String(s.ready_files)],
[t("downloads.admin.totalSize"), formatBytes(s.total_bytes)],
[
t("downloads.admin.cap"),
s.total_cap_bytes ? formatBytes(s.total_cap_bytes) : t("downloads.admin.noCap"),
],
].map(([label, val]) => (
<div key={label} className="rounded-xl glass-card p-3">
<div className="text-xs text-muted">{label}</div>
<div className="text-lg font-semibold mt-0.5">{val}</div>
</div>
))}
</div>
)}
{s && (
<div className="mb-6">
<div className="flex items-center justify-between gap-3 mb-2 flex-wrap">
<div className="text-sm font-medium">{t("downloads.admin.perUser")}</div>
{/* Set a quota for any user, regardless of whether they've downloaded anything. */}
<QuotaUserPicker onPick={setQuotaFor} />
</div>
<div className="space-y-1">
{s.per_user.map((u) => (
<div
key={u.user_id}
className="flex items-center gap-2 text-sm px-3 py-1.5 rounded-lg bg-card/40"
>
<span className="flex-1 truncate">{u.email}</span>
<span className="text-muted">{formatBytes(u.footprint_bytes)}</span>
<button
onClick={() => setQuotaFor({ id: u.user_id, email: u.email ?? "" })}
className="p-1 rounded-sm hover:bg-surface text-muted hover:text-fg"
title={t("downloads.admin.editQuota")}
>
<Settings2 className="w-4 h-4" />
</button>
</div>
))}
{s.per_user.length === 0 && (
<div className="text-xs text-muted px-3 py-2">{t("downloads.admin.noFootprint")}</div>
)}
</div>
</div>
)}
<div className="text-sm font-medium mb-2">{t("downloads.tabs.system")}</div>
<div className="space-y-1.5">
{(jobs.data ?? []).map((j) => (
<div
key={j.id}
className="flex items-center gap-3 text-sm px-3 py-1.5 rounded-lg bg-card/40"
>
<span className="flex-1 truncate">{jobTitle(j)}</span>
<span className="text-muted truncate w-40">{j.user_email}</span>
<StatusPill job={j} />
<span className="text-muted w-16 text-right">
{j.asset?.size_bytes ? formatBytes(j.asset.size_bytes) : ""}
</span>
</div>
))}
{jobs.data && jobs.data.length === 0 && (
<div className="text-sm text-muted py-6 text-center">{t("downloads.empty.admin")}</div>
)}
</div>
{quotaFor && (
<QuotaModal userId={quotaFor.id} email={quotaFor.email} onClose={() => setQuotaFor(null)} />
)}
</div>
);
}
// --- Main -----------------------------------------------------------------------------------
import { DownloadRow, IconBtn, UsageBar } from "./downloadRow";
import { MetaEditModal, ErrorModal } from "./downloadModals";
import { AdminSystem } from "./downloadAdmin";
export default function DownloadCenter() {
const me = useMe();
@@ -10,8 +10,10 @@ import {
import { qk } from "../lib/queryKeys";
import { useQuery } from "@tanstack/react-query";
import { api, getActiveAccount, type FeedFilters } from "../lib/api";
import { hasFilterParams, paramsToFilters } from "../lib/urlState";
import { filterParamUpdates, hasFilterParams, paramsToFilters } from "../lib/urlState";
import { useUrlMirror } from "../lib/useUrlMirror";
import { accountKey, LS, readJSON, readMerged, writeJSON } from "../lib/storage";
import { useNavigation } from "./NavigationProvider";
// Owns the GLOBAL feed-filter state (the feed's scope/tags/content/sort/show), lifted out of the App
// god component so consumers read it via a hook instead of prop-drilling `filters`/`setFilters`
@@ -90,8 +92,8 @@ export function useFeedFiltersActions(): FeedFiltersActions {
export function FeedFiltersProvider({ children }: { children: ReactNode }) {
const [filters, setFiltersState] = useState<FeedFilters>(loadInitialFilters);
// Whether this load arrived via a "Share view" link (captured once, before the URL is stripped)
// — such filters win over the account's stored view and are persisted to the account instead.
// Whether this load arrived with feed-filter params in the URL (a bookmark/shared link). Captured
// once at mount — such filters win over the account's stored view and are persisted to the account.
const [initialHadUrlFilters] = useState(() =>
hasFilterParams(new URLSearchParams(window.location.search)),
);
@@ -108,6 +110,14 @@ export function FeedFiltersProvider({ children }: { children: ReactNode }) {
if (key) writeJSON(key, next);
}, []);
// Mirror the feed filters into the address bar so every feed view is bookmarkable. Gated on
// page===feed: on other pages the filter keys are cleared from the URL (localStorage still holds
// them; they re-mirror on return to the feed). The channel detail page keeps its own isolated
// filters, so those never reach here. useUrlMirror preserves the nav params (?page/?chan/?v/?pl) the
// other providers own.
const { page } = useNavigation();
useUrlMirror(filterParamUpdates(page === "feed" ? filters : null));
// Load THIS account's own filters when the account first resolves or changes (login / switch /
// add). Filters are per-account localStorage state, so another account's last view (incl. its
// default saved view) must never carry over. A share link's filters (captured at mount) win and
+11 -41
View File
@@ -1,26 +1,15 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react";
import { qk } from "../lib/queryKeys";
import { useQuery } from "@tanstack/react-query";
import { api } from "../lib/api";
import { createContext, useContext, useMemo, type ReactNode } from "react";
import { parseFeedView, type FeedView } from "../lib/feedView";
import { LS, readAccount, writeAccount } from "../lib/storage";
import { LS } from "../lib/storage";
import { usePref } from "../lib/usePref";
// Owns the feed's view mode (the card/row/tile density switcher), lifted out of App so both the feed
// page and the channel page read it via hooks instead of prop-drilling `view`/`setView` through App.
// Split State + Actions contexts, mirroring the other providers; composed in main.tsx around <App/>.
//
// The view mode is an account PREFERENCE, not a filter: it persists to the server on pick (savePrefs)
// and is adopted from server prefs on login — so unlike the ephemeral list filters, this provider
// carries the same server-sync the App god component used to. A localStorage mirror seeds the initial
// render synchronously; the passive qk.me() observer adopts the server value once the account resolves.
// The view mode is an account PREFERENCE, not a filter: it persists to the server on pick and is
// adopted from server prefs on login. usePref carries that server-sync (plus the localStorage mirror
// that seeds the first render synchronously) so this provider is just the two contexts over it.
interface FeedViewActions {
setView: (v: FeedView) => void;
@@ -37,30 +26,11 @@ export function useFeedViewActions(): FeedViewActions {
}
export function FeedViewProvider({ children }: { children: ReactNode }) {
const [view, setViewState] = useState<FeedView>(() =>
parseFeedView(readAccount(LS.feedView, null)),
);
// Passive observer of the account App fetches (enabled:false → reads cache, re-renders on update).
const { data: me } = useQuery({ queryKey: qk.me(), queryFn: api.me, enabled: false });
// A user pick persists both to the localStorage mirror (instant reload) and to the server.
const setView = useCallback((next: FeedView) => {
setViewState(next);
writeAccount(LS.feedView, next);
api.savePrefs({ view: next }).catch(() => {});
}, []);
// Adopt the server-stored view on login / account switch. Writes the mirror but does NOT savePrefs
// (that would echo the just-adopted value straight back). parseFeedView also maps the pre-0.45
// "grid" / "list" values forward.
useEffect(() => {
const prefs = me?.preferences;
if (!prefs) return;
const adopted = parseFeedView(prefs.view);
setViewState(adopted);
writeAccount(LS.feedView, adopted);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [me?.id]);
// parseFeedView also maps the pre-0.45 "grid"/"list" values forward (idempotent for current values).
const [view, setView] = usePref<FeedView>("view", "cards", {
localKey: LS.feedView,
parse: parseFeedView,
});
const actions = useMemo<FeedViewActions>(() => ({ setView }), [setView]);
+62
View File
@@ -0,0 +1,62 @@
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";
// 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
// the customize (reorder/hide) `editing` state and the fixed title-bar wiring (edit toggle, reset to
// the layout default, edit hint); each rail supplies only what actually differs — its identity
// (title/icon), the active-filter count + Clear-all action, its `items`, and any content above the
// groups (`top`, e.g. the feed's scope toggle) or below (`children`, e.g. the feed's tag manager).
// `kind` is the PanelId (also the layout key), so `top`/`children` default to nothing for the rails
// that need neither.
export default function FilterRail({
kind,
title,
icon,
count,
actions,
collapsed,
onToggleCollapse,
layout,
setLayout,
items,
top,
children,
}: {
kind: PanelId;
title: string;
icon: ReactNode;
count?: number;
actions?: ReactNode;
collapsed: boolean;
onToggleCollapse: () => void;
layout: PanelLayout;
setLayout: (l: PanelLayout) => void;
items: PanelItem[];
top?: ReactNode;
children?: ReactNode;
}) {
const { t } = useTranslation();
const [editing, setEditing] = useState(false);
return (
<SidePanel
title={title}
icon={icon}
count={count}
collapsed={collapsed}
onToggleCollapse={onToggleCollapse}
editing={editing}
onToggleEditing={() => setEditing((e) => !e)}
onReset={() => setLayout(defaultLayout(kind))}
editHint={t("sidebar.editHint")}
actions={actions}
>
{top}
<PanelGroups items={items} layout={layout} setLayout={setLayout} editing={editing} />
{children}
</SidePanel>
);
}
+1 -1
View File
@@ -189,7 +189,7 @@ function ConversationList({
onClick={() => openChat(c.partner)}
title={t("messages.popOut")}
aria-label={t("messages.popOut")}
className="shrink-0 mr-1.5 p-1.5 rounded-lg text-muted opacity-0 group-hover:opacity-100 hover:text-accent hover:bg-bg transition"
className="shrink-0 mr-1.5 p-1.5 rounded-lg text-muted reveal hover:text-accent hover:bg-bg transition"
>
<ExternalLink className="w-4 h-4" />
</button>
+191
View File
@@ -0,0 +1,191 @@
// @vitest-environment jsdom
//
// Modal: focus management (FE-9). This was DEFERRED from the E4 deep-review because it's
// jsdom-untestable as written — `focusablesIn` filters on `el.offsetParent !== null`, and jsdom
// returns null for EVERY element (it never lays out), so the trap would see zero focusables and do
// nothing. We stub `HTMLElement.prototype.offsetParent` to report "visible" (non-null) for any
// connected node, which is exactly what a real browser reports for the shown dialog controls; the
// separate no-stub describe below documents the raw-jsdom reality (empty → the guard no-ops).
//
// The overlay/history machinery (useBackToClose) is exercised in history.dom.test.ts, so it's mocked
// out here to isolate the focus-trap behaviour from its module-global history side-effects.
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { render, screen, act, cleanup } from "@testing-library/react";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
});
// Isolate the focus trap from the Back-to-close history stack (tested in history.dom.test.ts).
vi.mock("../lib/history", () => ({ useBackToClose: () => {} }));
import Modal from "./Modal";
// Fire a keydown on window (where Modal installs its handler), inside act.
const press = (key: string, shiftKey = false) =>
act(() => {
window.dispatchEvent(
new KeyboardEvent("keydown", { key, shiftKey, bubbles: true, cancelable: true }),
);
});
const panelEl = () => document.querySelector<HTMLElement>('[tabindex="-1"]')!;
const closeBtn = () => screen.getByRole("button", { name: "common.close" });
// A modal with three extra focusable controls; DOM order inside the panel is [close, A, input, B].
function Body() {
return (
<>
<button>alpha</button>
<input aria-label="field" />
<button>beta</button>
</>
);
}
describe("Modal focus trap (offsetParent stubbed 'visible')", () => {
let origDesc: PropertyDescriptor | undefined;
beforeEach(() => {
origDesc = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "offsetParent");
Object.defineProperty(HTMLElement.prototype, "offsetParent", {
configurable: true,
get(this: HTMLElement) {
return this.isConnected ? document.body : null;
},
});
});
afterEach(() => {
cleanup();
if (origDesc) Object.defineProperty(HTMLElement.prototype, "offsetParent", origDesc);
else delete (HTMLElement.prototype as unknown as Record<string, unknown>).offsetParent;
});
it("moves initial focus to the first control on open", () => {
render(
<Modal title="t" onClose={() => {}}>
<Body />
</Modal>,
);
expect(document.activeElement).toBe(closeBtn());
});
it("Tab from the last control wraps to the first", () => {
render(
<Modal title="t" onClose={() => {}}>
<Body />
</Modal>,
);
screen.getByText("beta").focus(); // last
press("Tab");
expect(document.activeElement).toBe(closeBtn());
});
it("Shift+Tab from the first control wraps to the last", () => {
render(
<Modal title="t" onClose={() => {}}>
<Body />
</Modal>,
);
closeBtn().focus(); // first
press("Tab", true);
expect(document.activeElement).toBe(screen.getByText("beta"));
});
it("Tab pulls focus back inside when it has escaped to the page behind", () => {
const outside = document.createElement("button");
document.body.appendChild(outside);
render(
<Modal title="t" onClose={() => {}}>
<Body />
</Modal>,
);
outside.focus(); // focus escaped the dialog
expect(document.activeElement).toBe(outside);
press("Tab");
expect(document.activeElement).toBe(closeBtn());
document.body.removeChild(outside);
});
it("Shift+Tab from the panel container itself goes to the last control (no leak to the background)", () => {
render(
<Modal title="t" onClose={() => {}}>
<Body />
</Modal>,
);
panelEl().focus(); // active === panel (tabIndex=-1, reached by clicking dead space)
press("Tab", true);
expect(document.activeElement).toBe(screen.getByText("beta"));
});
it("does not trap a Tab in the middle — only the edges wrap", () => {
render(
<Modal title="t" onClose={() => {}}>
<Body />
</Modal>,
);
const mid = screen.getByLabelText("field");
mid.focus();
press("Tab"); // interior: native Tab handles it, the trap must not force-move
expect(document.activeElement).toBe(mid);
});
it("Escape calls onClose", () => {
const onClose = vi.fn();
render(
<Modal title="t" onClose={onClose}>
<Body />
</Modal>,
);
press("Escape");
expect(onClose).toHaveBeenCalledTimes(1);
});
it("Escape closes only the topmost modal", () => {
const outerClose = vi.fn();
const innerClose = vi.fn();
render(
<Modal title="outer" onClose={outerClose}>
<button>outer-body</button>
</Modal>,
);
render(
<Modal title="inner" onClose={innerClose}>
<button>inner-body</button>
</Modal>,
);
press("Escape");
expect(innerClose).toHaveBeenCalledTimes(1);
expect(outerClose).not.toHaveBeenCalled();
});
it("restores focus to the trigger on unmount", () => {
const trigger = document.createElement("button");
document.body.appendChild(trigger);
trigger.focus(); // captured as restoreTo during Modal's first render
const { unmount } = render(
<Modal title="t" onClose={() => {}}>
<Body />
</Modal>,
);
expect(document.activeElement).not.toBe(trigger); // focus moved into the dialog
unmount();
expect(document.activeElement).toBe(trigger);
document.body.removeChild(trigger);
});
});
describe("Modal focus trap (raw jsdom — every offsetParent null)", () => {
afterEach(cleanup);
it("with no visible focusables: initial focus falls to the panel and Tab is a safe no-op", () => {
render(
<Modal title="t" onClose={() => {}}>
<Body />
</Modal>,
);
// focusablesIn() returns [] in raw jsdom, so the initializer focuses the panel itself.
expect(document.activeElement).toBe(panelEl());
expect(() => press("Tab")).not.toThrow();
expect(document.activeElement).toBe(panelEl());
});
});
+64 -5
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef, type ReactNode } from "react";
import { useEffect, useId, useRef, useState, type ReactNode } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { X } from "lucide-react";
@@ -6,6 +6,14 @@ import { useBackToClose } from "../lib/history";
import { pushLayer, removeLayer, isTopmost } from "../lib/layerStack";
import { useFullscreenElement } from "../lib/fullscreen";
// Tabbable elements inside the dialog, in DOM order. `:not([tabindex="-1"])` drops programmatically
// focusable-only nodes; the visibility filter (offsetParent) drops display:none ones (e.g. a
// collapsed disclosure) so the trap never lands focus on something the user can't see.
const FOCUSABLE =
'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
const focusablesIn = (root: HTMLElement): HTMLElement[] =>
[...root.querySelectorAll<HTMLElement>(FOCUSABLE)].filter((el) => el.offsetParent !== null);
// Small centered modal shell: backdrop + ESC + scroll-lock close. Each open modal is a layer in the
// shared `layerStack` so ESC only closes the topmost one — e.g. an error dialog over the tag editor
// dismisses just the error and returns to the editor underneath, and a player below defers to it
@@ -27,6 +35,13 @@ export default function Modal({
// Browser/mouse Back closes the topmost modal instead of navigating away.
useBackToClose(onClose);
const fsRoot = useFullscreenElement();
const panelRef = useRef<HTMLDivElement>(null);
const titleId = useId();
// Capture the trigger to restore focus to, during THIS render — i.e. before commit runs the
// dialog's autoFocus (or our own initial-focus) and moves activeElement into the dialog. Reading
// it later (in the effect) would capture a now-inside element that unmounts on close, stranding
// focus on <body>. useState initializer runs exactly once, on first render.
const [restoreTo] = useState(() => document.activeElement as HTMLElement | null);
// Backdrop-click close must only fire when the press STARTED on the backdrop too. Otherwise a
// drag that begins inside the dialog (e.g. selecting text in an input) and is released out on
@@ -39,20 +54,57 @@ export default function Modal({
useEffect(() => {
const id = pushLayer();
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape" && isTopmost(id)) {
if (!isTopmost(id)) return;
if (e.key === "Escape") {
e.stopPropagation();
onCloseRef.current();
return;
}
// Focus trap: keep Tab / Shift+Tab cycling inside the dialog instead of reaching the inert
// page behind the backdrop. Only the topmost modal traps (a nested one defers to its child).
if (e.key === "Tab") {
const panel = panelRef.current;
if (!panel) return;
const items = focusablesIn(panel);
const first = items[0];
const last = items[items.length - 1];
if (!first || !last) {
e.preventDefault();
return;
}
const active = document.activeElement;
if (!panel.contains(active) || active === panel) {
// Focus escaped the dialog, OR sits on the panel container itself (tabIndex=-1, reached by
// clicking dialog dead space) — Node.contains(self) is true, so `active === panel` must be
// checked explicitly or Shift+Tab from the panel would leak to the background. Pull it back.
e.preventDefault();
(e.shiftKey ? last : first).focus();
} else if (e.shiftKey && active === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && active === last) {
e.preventDefault();
first.focus();
}
}
};
window.addEventListener("keydown", onKey);
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
// Initial focus: unless an autoFocus child already placed it inside (e.g. a rename input), move
// focus to the first control so the dialog opens focused, not stranded on the background.
const panel = panelRef.current;
if (panel && !panel.contains(document.activeElement)) {
(focusablesIn(panel)[0] ?? panel).focus();
}
return () => {
window.removeEventListener("keydown", onKey);
removeLayer(id);
document.body.style.overflow = prev;
// Restore focus to the trigger, if it's still in the document and focusable.
if (restoreTo && restoreTo.isConnected) restoreTo.focus();
};
}, []);
}, [restoreTo]);
return createPortal(
<div
@@ -65,10 +117,17 @@ export default function Modal({
}}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
>
<div className={`glass relative w-full ${maxWidth} max-h-full overflow-y-auto rounded-2xl`}>
<div
ref={panelRef}
tabIndex={-1}
className={`glass relative w-full ${maxWidth} max-h-full overflow-y-auto rounded-2xl outline-none`}
>
<div className="flex items-start justify-between gap-3 p-5 pb-3">
<h2 className="text-lg font-semibold leading-snug">{title}</h2>
<h2 id={titleId} className="text-lg font-semibold leading-snug">
{title}
</h2>
<button
onClick={onClose}
title={t("common.close")}
+49 -36
View File
@@ -9,11 +9,12 @@ import {
type ReactNode,
} from "react";
import { useTranslation } from "react-i18next";
import { isPage, readPage, type Page } from "../lib/urlState";
import { urlWithParams, type Page } from "../lib/urlState";
import { pageTitleKey } from "../lib/pageMeta";
import { getAccountRaw, LS, setAccountRaw } from "../lib/storage";
import { setAccountRaw } from "../lib/storage";
import { useAccountRehydrate } from "../lib/useAccountRehydrate";
import { setNavigator } from "../lib/nav";
import { channelViewFrom, loadInitialPage, PAGE_KEY, type ChannelView } from "./navigationInit";
// Owns the app's navigation + browser-history state, lifted out of the App god component so pages
// consume it via a hook instead of prop-drilling `page`/`setPage`/`onOpenChannel` through the tree.
@@ -26,13 +27,6 @@ import { setNavigator } from "../lib/nav";
// re-render on a page/channel switch — preserving the memo that keeps feed appends from re-rendering
// every existing card.
const PAGE_KEY = LS.page;
export interface ChannelView {
id: string;
name?: string;
}
export interface NavigationState {
page: Page;
/** The open channel page (null = none). Overlays `page`; rides in history.state._chan. */
@@ -49,13 +43,6 @@ export interface NavigationActions {
exitYtSearch: () => void;
}
function loadInitialPage(): Page {
const params = new URLSearchParams(window.location.search);
if (params.has("page")) return readPage();
const stored = getAccountRaw(PAGE_KEY);
return isPage(stored) ? stored : "feed";
}
const StateContext = createContext<NavigationState>({
page: "feed",
channelView: null,
@@ -88,11 +75,12 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
return st?._yt && st?._ytScrape ? (st._yt as string) : null;
});
// The open channel page rides in history.state (_chan = id, _chanName = a name to show before the
// detail loads), so Back closes it and a reload restores it (unlike ytSearch, which is dropped).
const [channelView, setChannelView] = useState<ChannelView | null>(() => {
const c = window.history.state?._chan as string | undefined;
return c ? { id: c, name: (window.history.state?._chanName as string) ?? undefined } : null;
});
// detail loads) AND is mirrored to the address bar as ?chan=<id>, so Back closes it, a reload restores
// it, and a bookmarked ?chan link opens it (unlike ytSearch, which is dropped). Reload of an in-app
// entry reads history.state._chan; a fresh bookmark has only the ?chan param.
const [channelView, setChannelView] = useState<ChannelView | null>(() =>
channelViewFrom(window.history.state, window.location.search),
);
// Refs so the STABLE action callbacks can read the latest state without depending on it — a dep
// would recreate them on every navigation and re-render every actions consumer (e.g. VideoCard).
@@ -116,8 +104,13 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
setAccountRaw(PAGE_KEY, stored); // no-op re-write, keeps storage authoritative
// Correct the CURRENT entry (replace, not push) and PRESERVE any markers already on it — a channel
// (_chan) or YT search (_yt) opened in the pre-pin window survives on top of the restored page,
// rather than being orphaned from history so Back can't close it.
window.history.replaceState({ ...(window.history.state || {}), sfPage: stored }, "");
// rather than being orphaned from history so Back can't close it. Mirror the corrected page to the
// address bar too (urlWithParams keeps any ?chan/filter params already there).
window.history.replaceState(
{ ...(window.history.state || {}), sfPage: stored },
"",
urlWithParams({ page: stored === "feed" ? null : stored }),
);
});
const setPage = useCallback(
@@ -130,9 +123,15 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
setPageState(next);
setAccountRaw(PAGE_KEY, next);
// Push an in-app history entry so browser/mouse Back steps through pages instead of leaving the
// app. The URL stays clean — the page rides in history.state. A fresh { sfPage } (no carried-over
// _yt/_chan markers) so each page starts at its root.
window.history.pushState({ sfPage: next }, "");
// app. A fresh { sfPage } (no carried-over _yt/_chan markers) so each page starts at its root, and
// the address bar reflects the page (feed is implicit) while clearing the view-specific params
// (chan/v/pl) — a page switch resets to that module's root. Feed filters (owned by
// FeedFiltersProvider) are left intact.
window.history.pushState(
{ sfPage: next },
"",
urlWithParams({ page: next === "feed" ? null : next, chan: null, v: null, pl: null }),
);
},
[markDirty],
);
@@ -143,13 +142,20 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
const openChannel = useCallback((id: string, name?: string) => {
setChannelView({ id, name });
const st = window.history.state || {};
if (st._chan) window.history.replaceState({ ...st, _chan: id, _chanName: name ?? null }, "");
else window.history.pushState({ ...st, _chan: id, _chanName: name ?? null }, "");
const url = urlWithParams({ chan: id });
if (st._chan)
window.history.replaceState({ ...st, _chan: id, _chanName: name ?? null }, "", url);
else window.history.pushState({ ...st, _chan: id, _chanName: name ?? null }, "", url);
}, []);
const closeChannel = useCallback(() => {
if (window.history.state?._chan) window.history.back();
else setChannelView(null);
else {
// No owned history entry (e.g. opened straight from a ?chan bookmark): just clear the state and
// drop the ?chan param from the address bar.
setChannelView(null);
window.history.replaceState(window.history.state, "", urlWithParams({ chan: null }));
}
}, []);
const enterYtSearch = useCallback((q: string) => {
@@ -189,19 +195,23 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
document.title = label ? `${label} · ${brand}` : brand;
}, [page, channelView, i18n.language, t]);
// In-app Back/Forward: stamp the initial entry with the current page, then sync state from
// history.state on popstate. (stripUrlParams preserves history.state, so this stamp survives a
// later query-string strip in App.)
// In-app Back/Forward: stamp the initial entry with the current page (mirrored to the address bar),
// then sync state from history.state on popstate — the browser restores each entry's URL for us, so
// the popstate handler only needs to read history.state.
useEffect(() => {
// A reload drops a stale _yt EXCEPT when scrape-sourced (zero quota): then keep _yt + _ytScrape
// so it restores and re-fetches free (ytSearch above already initialised from it). _chan/_chanName
// are always kept so a channel page survives F5.
const st = window.history.state || {};
// Mirror the resolved initial page to the address bar (feed implicit) so a page reached from the
// stored landing pref — not just from the URL — is itself bookmarkable. urlWithParams preserves any
// ?chan / ?v / ?pl / filter params the loaders already read.
const url = urlWithParams({ page: page === "feed" ? null : page });
if (st._yt && st._ytScrape) {
window.history.replaceState({ ...st, sfPage: page }, "");
window.history.replaceState({ ...st, sfPage: page }, "", url);
} else {
const { _yt: _staleYt, _ytScrape: _staleScrape, ...rest } = st;
window.history.replaceState({ ...rest, sfPage: page }, "");
window.history.replaceState({ ...rest, sfPage: page }, "", url);
}
function onPop(e: PopStateEvent) {
const p = (e.state?.sfPage as Page) ?? "feed";
@@ -210,8 +220,11 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
// The YouTube-search sub-view and the channel page ride in history.state, so Back/Forward
// restores or clears them to match the entry we landed on.
setYtSearch((e.state?._yt as string) ?? null);
const chan = e.state?._chan as string | undefined;
setChannelView(chan ? { id: chan, name: (e.state?._chanName as string) ?? undefined } : null);
// The channel page rides in history.state._chan, with a ?chan URL fallback for a fresh bookmark
// entry (URL-only, no _chan) — reading e.state alone would wrongly close the channel when a
// popstate lands here (e.g. after an overlay's programmatic history.back()). channelViewFrom
// shares this resolution with the seed initializer above.
setChannelView(channelViewFrom(e.state, window.location.search));
}
window.addEventListener("popstate", onPop);
return () => window.removeEventListener("popstate", onPop);
+66 -525
View File
@@ -1,9 +1,17 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { createPortal } from "react-dom";
import { useNavigationActions } from "./NavigationProvider";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys";
import { usePref } from "../lib/usePref";
import {
AUTO_MODES,
LOOP_MODES,
PlayerTransportControls,
type AutoMode,
type LoopMode,
} from "./playerModalControls";
import {
AlertTriangle,
ArrowLeft,
@@ -15,12 +23,7 @@ import {
Maximize,
Minimize,
Minimize2,
Repeat,
Repeat1,
Settings,
Shuffle,
SkipBack,
SkipForward,
Volume2,
VolumeX,
X,
@@ -31,7 +34,7 @@ import { PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
import { pushLayer, removeLayer, anyLayerAbove } from "../lib/layerStack";
import DownloadButton from "./DownloadButton";
import { api, type Video, type VideoStatus } from "../lib/api";
import type { YTNamespace, YTPlayer, YTPlayerEvent } from "../lib/youtube";
import type { YTPlayer } from "../lib/youtube";
import {
channelYouTubeUrl,
formatDate,
@@ -40,15 +43,14 @@ import {
relativeTime,
} from "../lib/format";
import { renderDescription } from "../lib/descriptionLinks";
import { fullscreenElement } from "../lib/fullscreen";
import { useBackToClose } from "../lib/history";
import {
exitFullscreen,
fullscreenElement,
onFullscreenChange,
requestFullscreen,
} from "../lib/fullscreen";
import { useBackToClose, useBackToExit } from "../lib/history";
import { DEFAULT_VOLUME, normalizeVolume, stepVolume } from "../lib/playerVolume";
import { LS, readAccountMerged, writeAccount } from "../lib/storage";
useNativeControlsYield,
useYtBigView,
useYtProgress,
useYtVolume,
} from "../lib/playerModalHooks";
import { useScrollFade } from "../lib/useScrollFade";
// Experiment (branch experiment/inline-player): play the video in-app via the
@@ -57,119 +59,6 @@ import { useScrollFade } from "../lib/useScrollFade";
// button, the backdrop, or ESC (ESC only while focus is on our page, not inside
// the cross-origin player iframe — a browser security boundary we can't cross).
// How close to the end (seconds) counts as "finished" → auto-mark watched.
const FINISH_MARGIN = 10;
// Persistent playback settings (stored in users.preferences). Auto-advance = what plays when a
// video ends; loop = whether it repeats the current video ("one"), wraps the list at its ends
// ("all"), or neither ("off"). Both apply to any queued player (feed or playlist).
type AutoMode = "off" | "next" | "prev" | "random";
type LoopMode = "off" | "one" | "all";
const AUTO_MODES: AutoMode[] = ["off", "next", "prev", "random"];
const LOOP_MODES: LoopMode[] = ["off", "one", "all"];
/** The queue transport cluster — prev / counter / next, then the auto-advance and loop mode toggles.
* Rendered in two places with identical logic: the modal's queue bar (`variant:"modal"`, normal glass
* theme, labels always shown) and the big-view docked control bar (`variant:"docked"`, white-on-dark,
* labels hidden until a breakpoint). Only the theme + label-visibility differ; the behaviour is one,
* so a mode/icon change lands once. The wrapper (fragment vs. bordered row) stays at the call site. */
function PlayerTransportControls({
variant,
index,
queueLength,
autoMode,
loopMode,
onPrev,
onNext,
onCycleAuto,
onCycleLoop,
}: {
variant: "docked" | "modal";
index: number;
queueLength: number;
autoMode: AutoMode;
loopMode: LoopMode;
onPrev: () => void;
onNext: () => void;
onCycleAuto: () => void;
onCycleLoop: () => void;
}) {
const { t } = useTranslation();
const docked = variant === "docked";
const navBtn = docked
? "inline-flex items-center gap-1 text-white/80 enabled:hover:text-white disabled:opacity-30 transition"
: "inline-flex items-center gap-1.5 text-muted enabled:hover:text-fg disabled:opacity-40 transition";
const navLabel = docked ? "hidden sm:inline" : "";
const counter = docked ? "tabular-nums text-white/70" : "text-muted tabular-nums";
const divider = docked ? "h-4 w-px bg-white/20" : "w-px h-4 bg-border";
const toggle = (on: boolean) =>
docked
? `inline-flex items-center gap-1 rounded-lg px-2 py-1 transition ${
on ? "bg-white/20 text-white" : "text-white/70 hover:bg-white/10 hover:text-white"
}`
: `inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1 transition ${
on ? "bg-accent/15 text-accent" : "text-muted hover:bg-surface hover:text-fg"
}`;
const toggleLabel = docked ? "hidden md:inline" : "";
return (
<>
<button
onClick={onPrev}
disabled={index === 0}
title={`${t("player.previous")} · Shift+←`}
className={navBtn}
>
<SkipBack className="h-4 w-4" />
<span className={navLabel}>{t("player.previous")}</span>
</button>
<span className={counter}>
{index + 1} / {queueLength}
</span>
<button
onClick={onNext}
disabled={index === queueLength - 1}
title={`${t("player.next")} · Shift+→`}
className={navBtn}
>
<span className={navLabel}>{t("player.next")}</span>
<SkipForward className="h-4 w-4" />
</button>
<span className={divider} />
{/* Persistent playback settings — cycle on click, saved to your account. */}
<button
onClick={onCycleAuto}
title={t("player.autoAdvance.hint")}
className={toggle(autoMode !== "off")}
>
{autoMode === "prev" ? (
<SkipBack className="h-3.5 w-3.5" />
) : autoMode === "random" ? (
<Shuffle className="h-3.5 w-3.5" />
) : (
<SkipForward className="h-3.5 w-3.5" />
)}
<span className={toggleLabel}>
{t("player.autoAdvance.label")}: {t(`player.autoAdvance.${autoMode}`)}
</span>
</button>
<button
onClick={onCycleLoop}
title={t("player.loop.hint")}
className={toggle(loopMode !== "off")}
>
{loopMode === "one" ? (
<Repeat1 className="h-3.5 w-3.5" />
) : (
<Repeat className="h-3.5 w-3.5" />
)}
<span className={toggleLabel}>
{t("player.loop.label")}: {t(`player.loop.${loopMode}`)}
</span>
</button>
</>
);
}
/** Can `el` (or an ancestor inside the modal) still scroll vertically in `dir` (-1 up, 1 down)?
* Used to leave ArrowUp/ArrowDown alone while there is content to scroll — the volume shortcut
* must not eat the only keyboard way to read a long description. Stops at `<body>`: the page
@@ -185,26 +74,6 @@ function canScrollBy(el: HTMLElement | null, dir: -1 | 1): boolean {
return false;
}
// --- IFrame Player API loader (singleton) ---
let apiPromise: Promise<YTNamespace> | null = null;
function loadYouTubeApi(): Promise<YTNamespace> {
if (apiPromise) return apiPromise;
apiPromise = new Promise<YTNamespace>((resolve) => {
const w = window;
if (w.YT && w.YT.Player) return resolve(w.YT);
const prev = w.onYouTubeIframeAPIReady;
w.onYouTubeIframeAPIReady = () => {
if (typeof prev === "function") prev();
// The API-ready callback only fires once the script has installed window.YT.
resolve(w.YT!);
};
const tag = document.createElement("script");
tag.src = "https://www.youtube.com/iframe_api";
document.head.appendChild(tag);
});
return apiPromise;
}
export default function PlayerModal({
video,
startAt,
@@ -264,11 +133,6 @@ export default function PlayerModal({
startAt != null && active.id === video.id ? startAt : active.position_seconds || 0;
const mountRef = useRef<HTMLDivElement | null>(null);
const playerRef = useRef<YTPlayer | null>(null);
const autoMarkedRef = useRef(false);
// Bound consecutive unplayable items so an auto-advancing queue (esp. loop=all) can't spin over a
// run of dead videos — after the cap we stop on the error overlay instead of skipping forever.
const MAX_CONSECUTIVE_ERRORS = 3;
const errorStreakRef = useRef(0);
// Keyboard/wheel controls. We keep focus on the modal card (not the cross-origin player
// iframe) so F / Space / Esc keep working until the user clicks into YouTube's native
// controls; the interaction overlay below also stops the iframe stealing focus on a video
@@ -278,52 +142,6 @@ export default function PlayerModal({
// The whole modal (backdrop + card) — the wheel-to-volume target, so scrolling anywhere over the
// modal adjusts volume (not just the small player area).
const dialogRef = useRef<HTMLDivElement | null>(null);
const volTimerRef = useRef<number | undefined>(undefined);
// Volume level to flash in the on-player overlay (null = hidden). Auto-fades after a moment.
const [volumeUi, setVolumeUi] = useState<number | null>(null);
// When the user interacts with YouTube's own controls (gear/seek/CC), focus moves into the
// cross-origin player iframe. We detect that and drop the interaction overlay's pointer-events
// so YouTube's settings menu — which expands DOWN over the video, past our overlay — is fully
// navigable (previously the overlay ate clicks below the top items). Moving the pointer off the
// video, or the window regaining focus, re-arms our click/scroll/keyboard shortcuts.
const [nativeControls, setNativeControls] = useState(false);
// True while OUR stage element is the fullscreen element. In fullscreen the interaction overlay
// yields entirely (see the overlay's pointer-events below): YouTube's control bar only appears
// while the iframe itself sees mouse movement, so an armed overlay kept the bar — and with it
// the native fullscreen button — permanently hidden after pressing F.
const [isFullscreen, setIsFullscreen] = useState(false);
// Our own "fills the viewport" mode (the F key). Not the Fullscreen API — see toggleFullscreen.
const [maximized, setMaximized] = useState(false);
const maximizedRef = useRef(false);
maximizedRef.current = maximized;
// Mirrored into a ref because the keydown handler is bound once (deps: [onClose]) and would
// otherwise read `false` forever — the house pattern here (see navRef/volumeRef).
const isFullscreenRef = useRef(false);
// Nested Back: peel fullscreen → maximise → close, one browser/mouse Back at a time (each is a
// history layer only while active). Mirrors the Esc ladder in the keydown handler below.
useBackToExit(isFullscreen, exitFullscreen);
useBackToExit(maximized, () => setMaximized(false));
// Volume survives the modal, the next video and a reload: a YT.Player is created per video and
// always starts at full volume. Per-account, like the Plex player's prefs. Level 0 IS the muted
// state (that's how YouTube's own control behaves), so there's no separate `muted` flag to
// contradict it.
//
// Ref + explicit read/write rather than `useAccountPersistedObject`: nothing RENDERS the level
// (the on-player flash has its own `volumeUi` state), the once-bound wheel/key listeners need a
// ref anyway, and the hook's write lives inside a `setState` updater — which is both a second
// write on every settle and not something React must run while the component is unmounting.
// Sanitised HERE, at the boundary where the untrusted value enters (`normalizeVolume`, see
// lib/playerVolume): a stored `null` / `"80"` / missing key becomes the default once, instead of
// travelling into the arithmetic. Read via a lazy `useState` initializer so it happens once, not
// on every render (the house pattern — see the frozen queue above).
const [storedVolume] = useState(() =>
normalizeVolume(readAccountMerged(LS.ytPlayerPrefs, { volume: DEFAULT_VOLUME }).volume),
);
const volumeRef = useRef(storedVolume);
const volSaveTimerRef = useRef<number | undefined>(undefined);
// When the last volume gesture happened — see nudgeVolume (a spin must not re-read the player).
const lastNudgeAtRef = useRef(0);
const focusModal = () => cardRef.current?.focus();
const togglePlay = () => {
const p = playerRef.current;
@@ -332,84 +150,28 @@ export default function PlayerModal({
p.pauseVideo?.(); // 1 === playing
else p.playVideo?.();
};
/** F = maximise, deliberately WITHOUT the Fullscreen API.
*
* The measured reason (Chrome/Brave/Firefox alike, so it is spec behaviour, not a browser bug):
* a cross-origin embed reads its fullscreen state from ITS OWN document. While WE hold the
* browser's fullscreen lock — on our wrapper or even on the iframe element — the YouTube player
* never learns it is fullscreen, so its own fullscreen button stays in the "enter" state and a
* click on it changes nothing visible. That is the reported bug: every other YouTube control
* (gear, CC, volume) works, only its fullscreen button looks dead.
*
* So we don't take the lock at all: `maximized` is a CSS layer that fills the viewport. The page
* stays non-fullscreen, which means the embed's own fullscreen button works exactly as it does
* anywhere else — and OUR shortcuts and overlays (wheel volume, the level flash, the queue bar)
* keep working, because our DOM is what's on screen. The one thing we give up is hiding the
* browser's own chrome; YouTube's button provides that, correctly, when the user wants it.
*/
const toggleFullscreen = () => setMaximized((m) => !m);
// True browser fullscreen that WE own (the stage element). Unlike YouTube's own fullscreen it keeps
// OUR overlay + control bar on screen, so wheel/arrow volume and our controls work inside it too.
// We never surface YouTube's own fullscreen button, so the "a cross-origin embed's own button looks
// dead while we hold the lock" problem that made F avoid the Fullscreen API does not arise.
const toggleTrueFullscreen = () => {
if (fullscreenElement()) exitFullscreen();
else if (stageRef.current) requestFullscreen(stageRef.current);
};
// Leave the big view back to the modal card: drop fullscreen first if held, then un-maximise.
const exitBigView = () => {
if (fullscreenElement()) exitFullscreen();
setMaximized(false);
};
const flashVolume = (level: number) => {
setVolumeUi(level);
window.clearTimeout(volTimerRef.current);
volTimerRef.current = window.setTimeout(() => setVolumeUi(null), 1200);
};
/** Write the current level out now, cancelling any pending debounce. */
const persistVolume = () => {
window.clearTimeout(volSaveTimerRef.current);
volSaveTimerRef.current = undefined;
writeAccount(LS.ytPlayerPrefs, { volume: volumeRef.current });
};
/** Push a level (0–100) to the player and remember it; 0 also mutes (a player left at zero
* volume but unmuted shows YouTube's speaker icon as ON, which reads as broken). */
const applyVolume = (level: number, flash = true) => {
// Callers hand over an already-normalised level (stepVolume / the stored value), but the
// player can still answer NaN, so re-normalise here against the LAST GOOD level — never the
// default: jumping a quiet session to full blast is the surprise this feature exists to avoid.
const next = normalizeVolume(level, volumeRef.current);
const p = playerRef.current;
if (p && typeof p.setVolume === "function") {
if (next > 0) p.unMute?.();
p.setVolume(next);
if (next === 0) p.mute?.();
}
volumeRef.current = next;
// Debounced: one write when the spin settles, not one per notch. The unmount cleanup flushes
// a pending write, so the last change is never lost.
window.clearTimeout(volSaveTimerRef.current);
volSaveTimerRef.current = window.setTimeout(persistVolume, 400);
if (flash) flashVolume(next);
};
const nudgeVolume = (delta: number) => {
// No early return when the player isn't ready yet: the level lives in `volumeRef` and onReady
// applies it, so a wheel spin in the first second after opening (the natural reaction to a
// video that starts loud) now lands instead of being silently dropped.
const p = playerRef.current;
// Whose level do we step from? Our own ref, EXCEPT when the gesture starts fresh — then read
// the player, because the user may have moved YouTube's own slider in the meantime.
// `getVolume()` is answered across the iframe boundary and lags our `setVolume` by a beat, so
// re-reading it mid-spin resurrects a stale level and silently drops notches (measured: five
// wheel notches moved 100 → 90 instead of 75).
const now = Date.now();
const continuing = now - lastNudgeAtRef.current < 1000;
lastNudgeAtRef.current = now;
const live =
p && typeof p.getVolume === "function" ? (p.isMuted?.() ? 0 : p.getVolume()) : null;
const cur = continuing || live === null ? volumeRef.current : live;
applyVolume(stepVolume(cur, { delta, fallback: volumeRef.current }));
};
// Per-account volume (wheel over the whole modal + ↑/↓): the level lives in the hook's ref and is
// re-applied to each freshly-created player via reapplyStored (onReady).
const { volumeUi, reapplyStored, nudgeVolume } = useYtVolume({
playerRef,
dialogRef,
focusModal,
});
const { nativeControls, setNativeControls } = useNativeControlsYield({
playerRef,
stageRef,
focusModal,
});
const {
isFullscreen,
maximized,
maximizedRef,
isFullscreenRef,
toggleFullscreen,
toggleTrueFullscreen,
exitBigView,
setMaximized,
} = useYtBigView({ stageRef, playerRef, focusModal });
// The player can navigate to other videos (YouTube links in a description). The
// currently-playing id may differ from the active item.
@@ -481,36 +243,18 @@ export default function PlayerModal({
p.seekTo(Math.max(0, (p.getCurrentTime() || 0) + delta), true);
};
// Auto-advance + loop are persistent per-user settings. Read the current values from the cached
// `me`, mirror them in local state for instant UI, and write both to the server + cache on change
// so they apply to every player and survive reloads. A ref feeds the (once-bound) end handler.
const savedPrefs = useMemo(
() =>
(qc.getQueryData<{ preferences?: Record<string, unknown> }>(qk.me())?.preferences ?? {}) as {
playerAutoAdvance?: AutoMode;
playerLoop?: LoopMode;
},
[qc],
);
const [autoMode, setAutoMode] = useState<AutoMode>(savedPrefs.playerAutoAdvance ?? "off");
const [loopMode, setLoopMode] = useState<LoopMode>(savedPrefs.playerLoop ?? "off");
// Auto-advance + loop are persistent per-user settings (usePref: server + `me` cache, adopted on
// login) so they apply to every player and survive reloads. The player mounts after `me` is cached,
// so it seeds from the cache — no localStorage mirror. A ref feeds the (once-bound) end handler.
const [autoMode, setAutoMode] = usePref<AutoMode>("playerAutoAdvance", "off");
const [loopMode, setLoopMode] = usePref<LoopMode>("playerLoop", "off");
const modeRef = useRef({ autoMode, loopMode });
modeRef.current = { autoMode, loopMode };
const persistPref = (patch: Record<string, string>) => {
api.savePrefs(patch).catch(() => {});
qc.setQueryData<{ preferences?: Record<string, unknown> } | undefined>(qk.me(), (m) =>
m ? { ...m, preferences: { ...(m.preferences || {}), ...patch } } : m,
);
};
const cycleAuto = () => {
const next = AUTO_MODES[(AUTO_MODES.indexOf(autoMode) + 1) % AUTO_MODES.length]!;
setAutoMode(next);
persistPref({ playerAutoAdvance: next });
setAutoMode(AUTO_MODES[(AUTO_MODES.indexOf(autoMode) + 1) % AUTO_MODES.length]!);
};
const cycleLoop = () => {
const next = LOOP_MODES[(LOOP_MODES.indexOf(loopMode) + 1) % LOOP_MODES.length]!;
setLoopMode(next);
persistPref({ playerLoop: next });
setLoopMode(LOOP_MODES[(LOOP_MODES.indexOf(loopMode) + 1) % LOOP_MODES.length]!);
};
const replayCurrent = () => {
const p = playerRef.current;
@@ -656,232 +400,29 @@ export default function PlayerModal({
return () => {
window.removeEventListener("keydown", onKey);
document.body.style.overflow = prevOverflow;
window.clearTimeout(volTimerRef.current);
// Flush a debounced volume write — closing the player right after a wheel spin (the normal
// way to leave) must not drop the level the user just set.
if (volSaveTimerRef.current != null) persistVolume();
// (The debounced volume write is flushed by useYtVolume's own unmount cleanup.)
};
}, [onClose]);
// Mouse-wheel volume over the WHOLE modal (backdrop + card), not just the small player area.
// Non-passive so preventDefault stops the modal/page scrolling. Note: the cross-origin iframe
// swallows wheel events over its own native-control strips, but the central interaction overlay
// is our element (wheel there bubbles here), and everything outside the video works directly.
useEffect(() => {
const el = dialogRef.current;
if (!el) return;
const onWheel = (e: WheelEvent) => {
// Ctrl/⌘+wheel is the browser's zoom gesture — never swallow it. Volume is the PLAIN wheel
// (and ↑/↓); taking the modifier too left zoom unreachable while the player was open.
if (e.ctrlKey || e.metaKey) return;
e.preventDefault();
focusModal(); // re-grab focus if it had slipped into the native controls
nudgeVolume(e.deltaY < 0 ? 5 : -5);
};
el.addEventListener("wheel", onWheel, { passive: false });
return () => el.removeEventListener("wheel", onWheel);
}, []);
// Track the browser's fullscreen state. WE never request it any more (F maximises via CSS), so
// this is YouTube's own fullscreen — entered from its control bar, left by its button or Esc.
useEffect(() => {
const onChange = () => {
const fsEl = fullscreenElement();
// Refs only — this listener is bound once, so it must not close over render values.
const p = playerRef.current;
const frame = p && typeof p.getIframe === "function" ? p.getIframe() : null;
const ours = !!fsEl && (fsEl === stageRef.current || fsEl === frame);
isFullscreenRef.current = ours; // read by the once-bound keydown handler
setIsFullscreen(ours); // drives the overlay's pointer-events
// LEAVING fullscreen is where the user got stranded: to reach YouTube's fullscreen button
// they had to click INTO the player, which moves keyboard focus into the cross-origin iframe
// — and it stays there afterwards. Our window-level shortcuts then never fire (F went to
// YouTube instead, giving a second, control-less fullscreen), so the maximised view had no
// way out. Taking focus back here restores F/Space/arrows the moment fullscreen ends.
if (!fsEl) focusModal();
};
return onFullscreenChange(onChange); // both spellings — see lib/fullscreen
}, []);
// Yield the interaction overlay to YouTube's native UI. Clicking a native control (gear / seek
// bar / CC) moves focus into the player iframe — the only cross-origin signal we get. While the
// iframe holds focus we drop the overlay's pointer-events so the (arbitrarily tall) settings
// menu is clickable end to end. Re-arm — restoring click-to-play, wheel volume and our keyboard
// shortcuts — when the pointer leaves the video or the window regains focus (a click back on our
// card blurs the iframe → the parent window fires 'focus').
useEffect(() => {
const iframeFocused = () => {
const p = playerRef.current;
const f = p && typeof p.getIframe === "function" ? p.getIframe() : null;
return !!f && document.activeElement === f;
};
// activeElement settles on the tick after blur, so defer the check.
const onBlur = () => window.setTimeout(() => iframeFocused() && setNativeControls(true), 0);
const rearm = () => {
setNativeControls(false);
focusModal();
};
const stage = stageRef.current;
window.addEventListener("blur", onBlur);
window.addEventListener("focus", rearm);
stage?.addEventListener("mouseleave", rearm);
return () => {
window.removeEventListener("blur-sm", onBlur);
window.removeEventListener("focus", rearm);
stage?.removeEventListener("mouseleave", rearm);
};
}, []);
// Create the player, resume from the saved position, persist progress, and
// auto-mark watched once playback reaches the end.
useEffect(() => {
let cancelled = false;
const id = active.id;
autoMarkedRef.current = false;
setPlayerError(null);
// Auto-watch only applies to the active item — not to other videos the player
// navigated to via description links.
const maybeAutoWatch = (current: number, duration: number): boolean => {
if (autoMarkedRef.current || duration <= 0 || currentIdRef.current !== id) return false;
// Cap the finish margin at half the clip, so a video shorter than FINISH_MARGIN isn't
// auto-marked watched almost immediately (its threshold would otherwise go negative).
const margin = Math.min(FINISH_MARGIN, duration / 2);
if (current > duration - margin) {
autoMarkedRef.current = true;
setWatched(true);
return true;
}
return false;
};
const persist = (): Promise<unknown> | void => {
const p = playerRef.current;
if (!p || typeof p.getCurrentTime !== "function") return;
try {
const cur = p.getCurrentTime();
const dur = typeof p.getDuration === "function" ? p.getDuration() : 0;
// If we just auto-marked watched, skip the near-end progress checkpoint: it would
// only clear the resume position, and firing both at once needlessly races the
// watched write against the progress row it deletes.
if (maybeAutoWatch(cur, dur)) return;
// Only checkpoint the feed video we opened with — a navigated-to video may not
// be in this user's library, so the server would 404 on it.
if (currentIdRef.current === id) {
return api.saveProgress(id, cur, dur).catch(() => {});
}
} catch {
/* player may be tearing down */
}
};
loadYouTubeApi().then((YT) => {
if (cancelled || !mountRef.current) return;
playerRef.current = new YT.Player(mountRef.current, {
width: "100%",
height: "100%",
videoId: id,
playerVars: {
autoplay: 1,
start: resumeAt || undefined,
rel: 0, // limit "related" to the same channel (full removal is no longer possible)
enablejsapi: 1,
origin: window.location.origin,
playsinline: 1,
// Best-effort HD hint. The IFrame API's setPlaybackQuality/suggestedQuality are hard no-ops
// now (YouTube removed them), and the undocumented `vq` URL param is only occasionally
// honoured — kept because it's harmless. NOTE: YouTube hard-caps an embed's max quality by
// the player's on-screen size; a windowed embed is stuck ~360p and only true fullscreen
// lifts the cap (after which the higher pick persists for the session). We can't beat that
// programmatically — a CSS transform-scale trick was tried and does NOT fool the cap.
vq: "hd1080",
},
events: {
// Keep keyboard focus on the modal, not the iframe, so shortcuts work right away, and
// restore the remembered volume — a fresh player always starts at 100.
onReady: () => {
focusModal();
applyVolume(volumeRef.current, false);
},
onStateChange: (e: YTPlayerEvent) => {
// 1 === playing → sync the navigated-to video's title/author for display.
if (e?.data === 1) {
errorStreakRef.current = 0; // a successful play breaks any unplayable run
const p = playerRef.current;
const d = p && typeof p.getVideoData === "function" ? p.getVideoData() : null;
if (d) setLiveData({ title: d.title, author: d.author });
}
// 0 === ended → mark watched, then advance to the next queue item if any.
if (e?.data === 0 && currentIdRef.current === id) {
if (!autoMarkedRef.current) {
autoMarkedRef.current = true;
setWatched(true);
}
advanceOnEnd();
}
},
// The embed couldn't play this video (embedding disabled, removed, private…).
// Surface our own message + an "Open on YouTube" escape hatch.
onError: (e: YTPlayerEvent) => {
setPlayerError(typeof e?.data === "number" ? e.data : -1);
// The IFrame API fires onError (not 'ended') for an unplayable item, so advanceOnEnd()
// never runs and an auto-advancing queue would freeze here. Skip forward instead — but
// bounded, so a run of dead items (or loop=all over a broken queue) can't spin. We do
// NOT mark the broken video watched (only the ended-handler does that).
if (currentIdRef.current !== id) return; // a navigated description-link error: leave the queue alone
const { autoMode: a } = modeRef.current;
const { queue: q } = navRef.current;
if (a === "off" || !q || q.length <= 1) return;
errorStreakRef.current += 1;
if (errorStreakRef.current >= MAX_CONSECUTIVE_ERRORS) return; // give up; leave the overlay
advanceOnEnd();
},
},
});
});
// Periodic checkpoint so progress (and auto-watch) survive a crash/refresh.
const timer = window.setInterval(persist, 5000);
// Save on page unload too (F5/close/navigate): effect cleanup doesn't run on a full reload, so
// without this the resume point lags up to 5s (a seek right before F5 would be lost). A keepalive
// beacon so the POST survives the unload. Only for the library item we opened with — a navigated
// description-link video may not be in this user's library (the server would 404).
const onHide = () => {
const p = playerRef.current;
if (!p || typeof p.getCurrentTime !== "function" || currentIdRef.current !== id) return;
try {
const cur = p.getCurrentTime();
const dur = typeof p.getDuration === "function" ? p.getDuration() : 0;
if (cur > 0 && dur > 0) api.saveProgressBeacon(id, cur, dur);
} catch {
/* player tearing down */
}
};
window.addEventListener("pagehide", onHide);
return () => {
cancelled = true;
window.clearInterval(timer);
window.removeEventListener("pagehide", onHide);
// Final flush, then mark the feed + playlists stale so resume bars reflect this session on the
// next natural refetch — WITHOUT refetching every loaded feed page on each player close (PERF-2:
// opening/closing the player through a watch session otherwise re-runs the whole infinite query
// each time). Progress is already persisted server-side; only the on-screen resume bar waits.
Promise.resolve(persist()).finally(() => {
qc.invalidateQueries({ queryKey: qk.feed(), refetchType: "none" });
qc.invalidateQueries({ queryKey: qk.playlist(), refetchType: "none" });
});
const p = playerRef.current;
if (p && typeof p.destroy === "function") {
try {
p.destroy();
} catch {
/* ignore */
}
}
playerRef.current = null;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [active.id]);
// Create the player, resume from the saved position, checkpoint progress, and auto-mark watched at
// the end — recreated per active item. The once-bound event closures read fresh queue/mode/volume via
// the refs passed in. See lib/playerModalHooks.
useYtProgress({
activeId: active.id,
resumeAt,
mountRef,
playerRef,
currentIdRef,
navRef,
modeRef,
setWatched,
setPlayerError,
setLiveData,
reapplyStored,
focusModal,
advanceOnEnd,
qc,
});
// Portaled to <body>, like every other full-screen overlay here. A fixed, full-viewport modal
// must not render inside the page scroller: once that scroller is a stacking context (its
+10 -6
View File
@@ -1,6 +1,9 @@
import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from "react";
import { getAccountRaw, LS, setAccountRaw } from "../lib/storage";
import { LS, setAccountRaw } from "../lib/storage";
import { useAccountRehydrate } from "../lib/useAccountRehydrate";
import { useUrlMirror } from "../lib/useUrlMirror";
import { useNavigation } from "./NavigationProvider";
import { readSelectedId } from "./playlistsInit";
// Owns the Playlists module's shared local state — the rail's name filter and the selected playlist
// — lifted out of the App god component so the header search box, the PlaylistsRail, and the
@@ -36,11 +39,6 @@ export function usePlaylistsActions(): PlaylistsActions {
return useContext(ActionsContext);
}
function readSelectedId(): number | null {
const s = getAccountRaw(LS.playlist);
return s ? Number(s) : null;
}
export function PlaylistsProvider({ children }: { children: ReactNode }) {
const [search, setSearchState] = useState("");
const [selectedId, setSelectedIdState] = useState<number | null>(readSelectedId);
@@ -59,6 +57,12 @@ export function PlaylistsProvider({ children }: { children: ReactNode }) {
[markDirty],
);
// Mirror the selected playlist to ?pl on the Playlists page so it's bookmarkable; clear it elsewhere
// (the localStorage copy still restores it on return). useUrlMirror keeps ?page and the other nav
// params intact.
const { page } = useNavigation();
useUrlMirror({ pl: page === "playlists" && selectedId != null ? String(selectedId) : null });
const state = useMemo<PlaylistsState>(() => ({ search, selectedId }), [search, selectedId]);
const actions = useMemo<PlaylistsActions>(
() => ({ setSearch, setSelectedId }),
+9 -12
View File
@@ -8,9 +8,9 @@ import { formatDuration } from "../lib/format";
import { notify } from "../lib/notifications";
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
import { LS, readAccountMerged, writeAccount } from "../lib/storage";
import { defaultLayout, type PanelLayout } from "../lib/panelLayout";
import SidePanel from "./SidePanel";
import PanelGroups, { type PanelItem } from "./PanelGroups";
import { type PanelLayout } from "../lib/panelLayout";
import FilterRail from "./FilterRail";
import { type PanelItem } from "./PanelGroups";
import SortControl from "./SortControl";
import { type SortOption } from "../lib/columnSort";
import { usePlaylists, usePlaylistsActions } from "./PlaylistsProvider";
@@ -58,7 +58,6 @@ export default function PlaylistsRail({
}, [plSort]);
const [newName, setNewName] = useState("");
const [syncing, setSyncing] = useState(false);
const [editing, setEditing] = useState(false);
const selectedRef = useRef<HTMLButtonElement | null>(null);
const scrolledRef = useRef(false);
@@ -260,17 +259,15 @@ export default function PlaylistsRail({
];
return (
<SidePanel
<FilterRail
kind="playlists"
title={t("playlists.title")}
icon={<ListVideo className="w-4 h-4" />}
collapsed={collapsed}
onToggleCollapse={onToggleCollapse}
editing={editing}
onToggleEditing={() => setEditing((e) => !e)}
onReset={() => setLayout(defaultLayout("playlists"))}
editHint={t("sidebar.editHint")}
>
<PanelGroups items={items} layout={layout} setLayout={setLayout} editing={editing} />
</SidePanel>
layout={layout}
setLayout={setLayout}
items={items}
/>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,181 @@
// @vitest-environment jsdom
//
// E5 S4: the Plex Discover landing. Covers the rail surface served from the mocked /discover +
// /surprise calls — that each non-empty rail renders, the genre tile fires onGenre, the recently-added
// "see all" fires onSeeAllRecent, the ↻ reshuffle refetches the surprise rail alone, and an all-empty
// payload shows the empty message. i18n is key-passthrough; the api is mocked so queries resolve
// synchronously. The rails scroll inside a Carousel (useHScroll → ResizeObserver), stubbed for jsdom.
import { describe, it, expect, afterEach, beforeAll, vi } from "vitest";
import { render, screen, fireEvent, cleanup, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
beforeAll(() => {
class Stub {
observe() {}
unobserve() {}
disconnect() {}
takeRecords() {
return [];
}
}
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
});
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
// Interpolate {{title}} so the because-you-watched heading is asserted with its anchor name.
return {
...actual,
useTranslation: () => ({
t: (key: string, opts?: Record<string, unknown>) =>
opts?.title ? `${key}:${opts.title}` : key,
}),
};
});
const plexDiscover = vi.fn();
const plexSurprise = vi.fn();
const plexSetState = vi.fn((..._a: unknown[]) => Promise.resolve());
vi.mock("../lib/api", () => ({
api: {
plexDiscover: (...a: unknown[]) => plexDiscover(...a),
plexSurprise: (...a: unknown[]) => plexSurprise(...a),
plexSetState: (...a: unknown[]) => plexSetState(...a),
},
}));
import PlexDiscover from "./PlexDiscover";
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
const card = (id: string, title: string, type: "movie" | "show" = "movie") => ({
id,
type,
title,
year: 2000,
duration_seconds: 3600,
thumb: `/api/plex/image/${id}`,
playable: "direct",
status: "new",
position_seconds: 0,
});
const fullPayload = {
scope: "both",
continue_watching: [card("cw1", "Resume Me")],
recently_added: [card("ra1", "Just Added")],
genres: [{ value: "Drama", count: 12 }],
because_you_watched: { anchor: { id: "a1", title: "Anchor" }, items: [card("bw1", "Rec One")] },
};
function wrap(ui: React.ReactElement) {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(<QueryClientProvider client={qc}>{ui}</QueryClientProvider>);
}
const noop = () => {};
describe("PlexDiscover", () => {
it("renders each non-empty rail, the genre tiles, and the surprise sample", async () => {
plexDiscover.mockResolvedValue(fullPayload);
plexSurprise.mockResolvedValue({ scope: "both", items: [card("sp1", "Random Pick")] });
wrap(
<PlexDiscover
scope="both"
onOpenCard={noop}
onInfoCard={noop}
onGenre={noop}
onSeeAllRecent={noop}
/>,
);
await screen.findByText("plex.discover.continueWatching");
expect(screen.getByText("plex.discover.recentlyAdded")).toBeTruthy();
expect(screen.getByText("plex.discover.surprise")).toBeTruthy();
expect(screen.getByText("plex.discover.byGenre")).toBeTruthy();
// The because-you-watched heading interpolates the anchor title.
expect(screen.getByText("plex.discover.becauseWatched:Anchor")).toBeTruthy();
// A card from each rail is present (title rendered in the poster label).
expect(screen.getAllByText("Resume Me").length).toBeGreaterThan(0);
expect(screen.getAllByText("Just Added").length).toBeGreaterThan(0);
expect(screen.getAllByText("Random Pick").length).toBeGreaterThan(0);
expect(screen.getAllByText("Rec One").length).toBeGreaterThan(0);
expect(plexDiscover).toHaveBeenCalledWith("both", "en");
});
it("fires onGenre with the tile's value", async () => {
plexDiscover.mockResolvedValue(fullPayload);
plexSurprise.mockResolvedValue({ scope: "both", items: [] });
const onGenre = vi.fn();
wrap(
<PlexDiscover
scope="both"
onOpenCard={noop}
onInfoCard={noop}
onGenre={onGenre}
onSeeAllRecent={noop}
/>,
);
fireEvent.click(await screen.findByText("Drama"));
expect(onGenre).toHaveBeenCalledWith("Drama");
});
it("fires onSeeAllRecent from the recently-added rail's see-all", async () => {
plexDiscover.mockResolvedValue(fullPayload);
plexSurprise.mockResolvedValue({ scope: "both", items: [] });
const onSeeAllRecent = vi.fn();
wrap(
<PlexDiscover
scope="both"
onOpenCard={noop}
onInfoCard={noop}
onGenre={noop}
onSeeAllRecent={onSeeAllRecent}
/>,
);
fireEvent.click(await screen.findByRole("button", { name: "common.seeAll" }));
expect(onSeeAllRecent).toHaveBeenCalledOnce();
});
it("reshuffles the surprise rail alone (refetch) without refetching the surface", async () => {
plexDiscover.mockResolvedValue(fullPayload);
plexSurprise.mockResolvedValue({ scope: "both", items: [card("sp1", "Random Pick")] });
wrap(
<PlexDiscover
scope="both"
onOpenCard={noop}
onInfoCard={noop}
onGenre={noop}
onSeeAllRecent={noop}
/>,
);
await screen.findByText("Random Pick");
expect(plexSurprise).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getByRole("button", { name: "plex.discover.reshuffle" }));
await waitFor(() => expect(plexSurprise).toHaveBeenCalledTimes(2));
expect(plexDiscover).toHaveBeenCalledTimes(1); // the whole surface was NOT refetched
});
it("shows the empty message when every rail is empty", async () => {
plexDiscover.mockResolvedValue({
scope: "both",
continue_watching: [],
recently_added: [],
genres: [],
because_you_watched: null,
});
plexSurprise.mockResolvedValue({ scope: "both", items: [] });
wrap(
<PlexDiscover
scope="both"
onOpenCard={noop}
onInfoCard={noop}
onGenre={noop}
onSeeAllRecent={noop}
/>,
);
await screen.findByText("plex.discover.empty");
});
});
+235
View File
@@ -0,0 +1,235 @@
import { type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { RotateCcw } from "lucide-react";
import { qk } from "../lib/queryKeys";
import { api, type PlexCard, type PlexDiscover as PlexDiscoverData } from "../lib/api";
import { plexLang } from "../i18n";
import { toggleWatchState } from "../lib/plexWatch";
import { Carousel } from "./Carousel";
import { PlexPosterCard } from "./plexCards";
import { LoadingState, StateMessage } from "./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
// watched X — plus a separately-fetched, reshuffling Surprise-me rail. The flat browse grid stays its
// own view (PlexBrowse's grid branch); a header search or filter flips the user there. Card click /
// info / watched-toggle handlers come from the shell (PlexBrowse) so a rail card behaves exactly like
// a grid card; the watched-toggle optimistically flips THIS surface's cache too.
// A stable, pleasant hue per genre string (no art to collage yet — colored tiles stand in). Hashing
// keeps a genre the same color across renders/sessions without storing anything.
function genreHue(name: string): number {
let h = 0;
for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) % 360;
return h;
}
function CardRail({
title,
ariaLabel,
seeAllLabel,
onSeeAll,
cards,
onOpenCard,
onInfoCard,
onToggleWatched,
action,
}: {
title: ReactNode;
ariaLabel: string;
seeAllLabel?: string;
onSeeAll?: () => void;
cards: PlexCard[];
onOpenCard: (c: PlexCard) => void;
onInfoCard: (c: PlexCard) => void;
onToggleWatched: (c: PlexCard) => void;
action?: ReactNode; // optional header control (e.g. the Surprise-me reshuffle button)
}) {
const heading =
action != null ? (
<span className="inline-flex items-center gap-2">
{title}
{action}
</span>
) : (
title
);
return (
<section className="mb-5">
<Carousel title={heading} ariaLabel={ariaLabel} onSeeAll={onSeeAll} seeAllLabel={seeAllLabel}>
{cards.map((c) => (
<div key={c.id} className="w-[150px] shrink-0">
<PlexPosterCard
card={c}
onOpen={() => onOpenCard(c)}
onInfo={() => onInfoCard(c)}
onToggleWatched={onToggleWatched}
/>
</div>
))}
</Carousel>
</section>
);
}
export default function PlexDiscover({
scope,
onOpenCard,
onInfoCard,
onGenre,
onSeeAllRecent,
}: {
scope: string;
onOpenCard: (c: PlexCard) => void;
onInfoCard: (c: PlexCard) => void;
onGenre: (genre: string) => void;
onSeeAllRecent: () => void;
}) {
const { t } = useTranslation();
const qc = useQueryClient();
const lang = plexLang();
const q = useQuery({
queryKey: qk.plexDiscover(scope, lang),
queryFn: () => api.plexDiscover(scope, lang),
});
// Surprise-me is its own query so the ↻ button can redraw it without refetching the whole surface.
const surpriseQ = useQuery({
queryKey: qk.plexSurprise(scope),
queryFn: () => api.plexSurprise(scope),
staleTime: 0,
});
// Watched toggle over BOTH this surface's cache (so a rail card flips instantly) and the surprise
// list, then persist + resync via the shared helper — invalidating the grid too so it agrees.
const toggleWatched = (card: PlexCard) =>
toggleWatchState(card, t, {
optimistic: (next) => {
const flip = (cards: PlexCard[]) =>
cards.map((c) => (c.id === card.id ? { ...c, status: next } : c));
qc.setQueryData<PlexDiscoverData>(qk.plexDiscover(scope, lang), (old) =>
old
? {
...old,
continue_watching: flip(old.continue_watching),
recently_added: flip(old.recently_added),
because_you_watched: old.because_you_watched
? { ...old.because_you_watched, items: flip(old.because_you_watched.items) }
: null,
}
: old,
);
qc.setQueryData<{ scope: string; items: PlexCard[] }>(qk.plexSurprise(scope), (old) =>
old ? { ...old, items: flip(old.items) } : old,
);
},
resync: () => {
qc.invalidateQueries({ queryKey: qk.plexDiscover() });
qc.invalidateQueries({ queryKey: qk.plexLibrary() });
},
});
if (q.isError && !q.data) {
return (
<StateMessage tone="error" onRetry={() => q.refetch()}>
{t("common.loadError")}
</StateMessage>
);
}
if (q.isLoading || !q.data) {
return <LoadingState label={t("plex.loading")} />;
}
const d = q.data;
const surprise = surpriseQ.data?.items ?? [];
const bw = d.because_you_watched;
const nothing =
d.continue_watching.length === 0 &&
d.recently_added.length === 0 &&
d.genres.length === 0 &&
surprise.length === 0 &&
(!bw || bw.items.length === 0);
if (nothing) {
return <p className="text-muted text-sm">{t("plex.discover.empty")}</p>;
}
const railProps = { onOpenCard, onInfoCard, onToggleWatched: toggleWatched };
return (
<div>
{d.continue_watching.length > 0 && (
<CardRail
title={t("plex.discover.continueWatching")}
ariaLabel={t("plex.discover.continueWatching")}
cards={d.continue_watching}
{...railProps}
/>
)}
{d.recently_added.length > 0 && (
<CardRail
title={t("plex.discover.recentlyAdded")}
ariaLabel={t("plex.discover.recentlyAdded")}
onSeeAll={onSeeAllRecent}
cards={d.recently_added}
{...railProps}
/>
)}
{surprise.length > 0 && (
<CardRail
title={t("plex.discover.surprise")}
ariaLabel={t("plex.discover.surprise")}
cards={surprise}
action={
<button
type="button"
onClick={() => surpriseQ.refetch()}
aria-label={t("plex.discover.reshuffle")}
title={t("plex.discover.reshuffle")}
className="rounded-full p-1 text-muted transition hover:text-accent"
>
<RotateCcw className={`h-3.5 w-3.5 ${surpriseQ.isFetching ? "animate-spin" : ""}`} />
</button>
}
{...railProps}
/>
)}
{d.genres.length > 0 && (
<section className="mb-5">
<h2 className="mb-3 text-sm font-semibold">{t("plex.discover.byGenre")}</h2>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6">
{d.genres.map((g) => {
const hue = genreHue(g.value);
return (
<button
key={g.value}
type="button"
onClick={() => onGenre(g.value)}
className="glass-hover relative flex h-20 items-end overflow-hidden rounded-xl p-3 text-left transition"
style={{
background: `linear-gradient(135deg, hsl(${hue} 55% 32%), hsl(${(hue + 40) % 360} 60% 22%))`,
}}
>
<span className="text-sm font-semibold text-white drop-shadow">{g.value}</span>
<span className="absolute right-2 top-2 text-2xs text-white/70">{g.count}</span>
</button>
);
})}
</div>
</section>
)}
{bw && bw.items.length > 0 && (
<CardRail
title={t("plex.discover.becauseWatched", { title: bw.anchor.title })}
ariaLabel={t("plex.discover.becauseWatched", { title: bw.anchor.title })}
cards={bw.items}
{...railProps}
/>
)}
</div>
);
}
@@ -0,0 +1,138 @@
// @vitest-environment jsdom
//
// E5 S5: the enriched Plex detail page. Covers the TMDB-fed pieces added on the page variant — the
// multi-source ratings row (IMDb / TMDb / audience), the facts panel (budget / revenue / studios /
// countries), the keyword chips (→ onFilter), and the full crew (→ onOpenPerson). i18n is
// key-passthrough; PlexInfo is prop-driven (no queries) so it just needs a QueryClient for its cache
// reads. The overlay variant is asserted to stay lean (no facts/keywords/crew).
import { describe, it, expect, afterEach, vi } from "vitest";
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
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?.name ? `${key}:${opts.name}` : key,
}),
};
});
import PlexInfo from "./PlexInfo";
import type { PlexItemDetail } from "../lib/api";
afterEach(cleanup);
const detail: PlexItemDetail = {
id: "m1",
kind: "movie",
title: "Test Movie",
summary: "A summary.",
year: 2010,
duration_seconds: 7200,
playable: "direct",
thumb: "/api/plex/image/m1",
art: "/api/plex/image/m1?variant=art",
cast: [],
imdb_rating: 7.8,
imdb_id: "tt1",
imdb_url: "https://www.imdb.com/title/tt1",
rating: 8.4, // Plex audience
content_rating: "R",
genres: ["Crime"],
directors: ["Plex Director"],
studio: "Fallback Studio",
tagline: "A tagline.",
markers: [],
audio_streams: [],
subtitle_streams: [],
status: "new",
position_seconds: 0,
tmdb: {
tmdb_id: 42,
rating: 7.2,
vote_count: 1234,
budget: 160000000,
revenue: 830000000,
keywords: ["heist", "bank robbery"],
crew: [
{ name: "Jane Director", job: "Director" },
{ name: "Sam Writer", job: "Writer" },
],
studios: ["Studio A", "Studio B"],
production_countries: ["United States"],
recommendations: [],
backdrops: [],
},
};
function wrap(ui: React.ReactElement) {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(<QueryClientProvider client={qc}>{ui}</QueryClientProvider>);
}
describe("PlexInfo enriched detail (page)", () => {
it("renders the multi-source ratings row (IMDb / TMDb / audience)", () => {
wrap(<PlexInfo detail={detail} variant="page" />);
expect(screen.getByText("IMDb")).toBeTruthy();
expect(screen.getByText("7.8")).toBeTruthy();
expect(screen.getByText(/TMDb/)).toBeTruthy(); // "TMDb · 1.2K" (vote count compacted)
expect(screen.getByText("7.2")).toBeTruthy();
expect(screen.getByText("plex.info.audience")).toBeTruthy();
expect(screen.getByText("8.4")).toBeTruthy();
});
it("renders the facts panel with budget / revenue / studios / countries", () => {
wrap(<PlexInfo detail={detail} variant="page" />);
expect(screen.getByText("plex.info.facts.title")).toBeTruthy();
expect(screen.getByText("plex.info.facts.budget")).toBeTruthy();
expect(screen.getByText("$160M")).toBeTruthy();
expect(screen.getByText("$830M")).toBeTruthy();
expect(screen.getByText("Studio A")).toBeTruthy(); // tmdb.studios preferred over the fallback
expect(screen.getByText("United States")).toBeTruthy();
});
it("keyword chips filter the library on click", () => {
const onFilter = vi.fn();
wrap(<PlexInfo detail={detail} variant="page" onFilter={onFilter} />);
fireEvent.click(screen.getByText("heist"));
expect(onFilter).toHaveBeenCalledWith({ keywords: ["heist"] });
});
it("crew members open the person page on click", () => {
const onOpenPerson = vi.fn();
wrap(<PlexInfo detail={detail} variant="page" onOpenPerson={onOpenPerson} />);
expect(screen.getByText("plex.info.crew")).toBeTruthy();
fireEvent.click(screen.getByText("Jane Director"));
expect(onOpenPerson).toHaveBeenCalledWith("Jane Director");
});
it("RatingsRow: with no IMDb score, the audience score becomes the filterable one", () => {
const onFilter = vi.fn();
const noImdb = { ...detail, imdb_rating: null, imdb_id: null, imdb_url: null };
wrap(<PlexInfo detail={noImdb} variant="page" onFilter={onFilter} />);
expect(screen.queryByText("IMDb")).toBeNull();
// audience (8.4) is now the primary/filterable score → clicking it filters ratingMin.
fireEvent.click(screen.getByText("8.4"));
expect(onFilter).toHaveBeenCalledWith({ ratingMin: 8 });
});
it("RatingsRow: suppresses the duplicate audience chip when it equals the IMDb score", () => {
const dupe = { ...detail, rating: 7.8 }; // audience == imdb_rating (7.8)
wrap(<PlexInfo detail={dupe} variant="page" />);
expect(screen.getByText("IMDb")).toBeTruthy();
expect(screen.queryByText("plex.info.audience")).toBeNull(); // duplicate audience chip suppressed
expect(screen.getAllByText("7.8")).toHaveLength(1); // the score shows once (IMDb only)
});
it("the overlay variant stays lean (no facts / keywords / crew panels)", () => {
wrap(<PlexInfo detail={detail} variant="overlay" />);
expect(screen.queryByText("plex.info.facts.title")).toBeNull();
expect(screen.queryByText("plex.info.keywords")).toBeNull();
expect(screen.queryByText("plex.info.crew")).toBeNull();
// but the ratings row still renders (it's compact enough for the overlay)
expect(screen.getByText("IMDb")).toBeTruthy();
});
});
+330 -56
View File
@@ -1,19 +1,22 @@
import { useState } from "react";
import { useState, type ReactNode } from "react";
import { qk } from "../lib/queryKeys";
import { useTranslation } from "react-i18next";
import { useQueryClient } from "@tanstack/react-query";
import { Check, ExternalLink, FolderPlus, ListPlus, Play, RotateCcw, Star, X } from "lucide-react";
import { api, type PlexFilters, type PlexItemDetail } from "../lib/api";
import { formatRuntime } from "../lib/format";
import { pickRatingBadge } from "../lib/plexRating";
import { notifyActionError } from "../lib/actionErrors";
import {
DetailCustomizeMenu,
Filterable,
PrefToggle,
useArtBackdrop,
tmdbBackdropUrl,
useCrossfadeBackdrop,
useDetailPrefs,
} from "../lib/plexDetailUi";
import { usePref } from "../lib/usePref";
import i18n from "../i18n";
import { Carousel } from "./Carousel";
import PlexCollectionEditor from "./PlexCollectionEditor";
import PlexPlaylistAdd from "./PlexPlaylistAdd";
@@ -22,6 +25,233 @@ import PlexPlaylistAdd from "./PlexPlaylistAdd";
// a card's "i" button (variant="page", with a big Play/Resume + watch controls), and a lean overlay
// over the running player (variant="overlay", opened with "i"). Two GUI elements are user-toggleable
// personal preferences, persisted in users.preferences: the faint art backdrop and the cast row.
// The TMDB-enriched pieces (multi-source ratings row, facts, keyword chips, full crew — E5 S5) render
// on the page variant only; the overlay stays lean.
// Localize the grouping/compact notation to the active language (the app convention, lib/api/core.ts);
// `i18n.language` is read at call time, and the component re-renders on a language switch. Currency stays
// USD — TMDB budget/revenue figures are USD regardless of the viewer's language.
const compact = (n: number) =>
new Intl.NumberFormat(i18n.language, { notation: "compact" }).format(n);
const money = (n: number) =>
new Intl.NumberFormat(i18n.language, {
notation: "compact",
style: "currency",
currency: "USD",
maximumFractionDigits: 1,
}).format(n);
// Multi-source ratings cluster (E5 S5): IMDb (links out) · TMDb (with vote count) · Plex audience —
// whichever are present. The "primary" score (IMDb, else audience) mirrors the backend rating column
// (coalesce(imdb, audience)), so only it carries the ratingMin filter; the others are informational.
function RatingsRow({
imdbRating,
imdbUrl,
audience,
tmdbRating,
tmdbVotes,
onFilterRating,
}: {
imdbRating?: number | null;
imdbUrl?: string | null;
audience?: number | null;
tmdbRating?: number | null;
tmdbVotes?: number | null;
onFilterRating?: (score: number) => void;
}) {
const { t } = useTranslation();
const primary = imdbRating ?? audience ?? null; // the filterable score
const chipCls =
"inline-flex items-center gap-1 rounded-md bg-amber-400/15 px-1.5 py-0.5 font-semibold text-amber-500";
const Score = ({ score, filterable }: { score: number; filterable: boolean }) =>
filterable && onFilterRating ? (
<button
onClick={() => onFilterRating(Math.floor(score))}
className="inline-flex items-center gap-1 hover:opacity-80"
title={t("plex.info.filterRating", { n: Math.floor(score) })}
>
<Star className="h-3.5 w-3.5 fill-current" />
{score.toFixed(1)}
</button>
) : (
<span className="inline-flex items-center gap-1">
<Star className="h-3.5 w-3.5 fill-current" />
{score.toFixed(1)}
</span>
);
const has = (x?: number | null): x is number => x != null;
if (!has(imdbRating) && !has(audience) && !has(tmdbRating)) return null;
return (
<div className="flex flex-wrap items-center gap-2 text-sm">
{has(imdbRating) && (
<span className={chipCls}>
<Score score={imdbRating} filterable={imdbRating === primary} />
<span className="text-2xs font-normal opacity-70">IMDb</span>
{imdbUrl && (
<a
href={imdbUrl}
target="_blank"
rel="noopener noreferrer"
title={t("plex.info.openImdb")}
>
<ExternalLink className="h-3 w-3 opacity-70 hover:opacity-100" />
</a>
)}
</span>
)}
{has(tmdbRating) && (
<span className={chipCls}>
<Score score={tmdbRating} filterable={false} />
<span className="text-2xs font-normal opacity-70">
TMDb{has(tmdbVotes) ? ` · ${compact(tmdbVotes)}` : ""}
</span>
</span>
)}
{has(audience) && imdbRating !== audience && (
<span className={chipCls}>
<Score score={audience} filterable={!has(imdbRating)} />
<span className="text-2xs font-normal opacity-70">{t("plex.info.audience")}</span>
</span>
)}
</div>
);
}
// A structured facts panel (E5 S5): release / runtime / cert / budget / revenue / studios / countries,
// pulling from the item's own fields + the persisted TMDB block. Rendered only when it has something.
function FactsPanel({
detail,
onFilter,
}: {
detail: PlexItemDetail;
onFilter?: (patch: Partial<PlexFilters>) => void;
}) {
const { t } = useTranslation();
const tmdb = detail.tmdb;
const studios = tmdb?.studios?.length ? tmdb.studios : detail.studio ? [detail.studio] : [];
const rows: { key: string; label: string; node: ReactNode }[] = [];
if (detail.year != null)
rows.push({ key: "year", label: t("plex.info.facts.release"), node: detail.year });
if (detail.duration_seconds)
rows.push({
key: "runtime",
label: t("plex.info.facts.runtime"),
node: formatRuntime(detail.duration_seconds),
});
if (detail.content_rating)
rows.push({ key: "cert", label: t("plex.info.facts.cert"), node: detail.content_rating });
if (tmdb?.budget)
rows.push({ key: "budget", label: t("plex.info.facts.budget"), node: money(tmdb.budget) });
if (tmdb?.revenue)
rows.push({ key: "revenue", label: t("plex.info.facts.revenue"), node: money(tmdb.revenue) });
if (studios.length)
rows.push({
key: "studios",
label: t("plex.info.facts.studios"),
node: (
<span className="flex flex-wrap gap-x-1.5 gap-y-0.5">
{studios.map((s, i) => (
<span key={s}>
<Filterable
onClick={onFilter ? () => onFilter({ studios: [s] }) : undefined}
className="hover:text-accent"
>
{s}
</Filterable>
{i < studios.length - 1 ? "," : ""}
</span>
))}
</span>
),
});
if (tmdb?.production_countries?.length)
rows.push({
key: "countries",
label: t("plex.info.facts.countries"),
node: tmdb.production_countries.join(", "),
});
if (rows.length === 0) return null;
return (
<div className="glass-card rounded-2xl p-4 sm:p-5">
<h2 className="mb-3 text-sm font-semibold">{t("plex.info.facts.title")}</h2>
<dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1.5 text-sm">
{rows.map((r) => (
<div key={r.key} className="contents">
<dt className="text-muted">{r.label}</dt>
<dd className="min-w-0 font-medium">{r.node}</dd>
</div>
))}
</dl>
</div>
);
}
// TMDB keyword chips (E5 S5) — tap one to filter the library by that keyword (via applyFilter).
function KeywordsPanel({
keywords,
onFilter,
}: {
keywords: string[];
onFilter?: (patch: Partial<PlexFilters>) => void;
}) {
const { t } = useTranslation();
if (keywords.length === 0) return null;
return (
<div className="glass-card rounded-2xl p-4 sm:p-5">
<h2 className="mb-3 text-sm font-semibold">{t("plex.info.keywords")}</h2>
<div className="flex flex-wrap gap-1.5">
{keywords.map((k) => (
<Filterable
key={k}
className="rounded-full bg-surface px-2.5 py-1 text-xs text-muted hover:text-accent"
title={onFilter ? t("plex.info.filterKeyword", { name: k }) : undefined}
onClick={onFilter ? () => onFilter({ keywords: [k] }) : undefined}
>
{k}
</Filterable>
))}
</div>
</div>
);
}
// Full crew (E5 S5) — the TMDB key-crew (Director / Writer / Producer / …), each clickable to open
// their person page. Complements the hero's Director line (which comes from Plex directly).
function CrewPanel({
crew,
onOpenPerson,
}: {
crew: { name: string; job: string }[];
onOpenPerson?: (name: string) => void;
}) {
const { t } = useTranslation();
if (crew.length === 0) return null;
return (
<div className="glass-card rounded-2xl p-4 sm:p-5">
<h2 className="mb-3 text-sm font-semibold">{t("plex.info.crew")}</h2>
{/* Two columns inside the (now narrow) side box — viewport media-queries can't see the box width,
so a fixed small column count keeps names from over-spreading. */}
<div className="grid grid-cols-2 gap-x-3 gap-y-1.5">
{crew.map((c, i) => (
<div key={`${c.name}:${c.job}:${i}`} className="min-w-0">
{onOpenPerson ? (
<button
onClick={() => onOpenPerson(c.name)}
title={t("plex.info.viewPerson", { name: c.name })}
className="truncate text-left text-sm font-medium hover:text-accent"
>
{c.name}
</button>
) : (
<div className="truncate text-sm font-medium">{c.name}</div>
)}
<div className="truncate text-2xs text-muted">{c.job}</div>
</div>
))}
</div>
</div>
);
}
type Props = {
detail: PlexItemDetail;
@@ -57,7 +287,12 @@ export default function PlexInfo({
// Personal display prefs (default ON), shared with the series detail pages via useDetailPrefs:
// artBg/showCast + their toggles and the raw savePref persister. The collection-strip source hiding
// is PlexInfo-specific state, persisted through the same savePref.
const { artBg, showCast, toggleArtBg, toggleCast, savePref } = useDetailPrefs();
const { artBg, showCast, crossfade, toggleArtBg, toggleCast, toggleCrossfade, savePref } =
useDetailPrefs();
// The three TMDB-enriched side panels are each independently hideable (default ON), like the cast row.
const [showFacts, setShowFacts] = usePref<boolean>("plexInfoFacts", true);
const [showKeywords, setShowKeywords] = usePref<boolean>("plexInfoKeywords", true);
const [showCrew, setShowCrew] = usePref<boolean>("plexInfoCrew", true);
const prefs = (qc.getQueryData<{ preferences?: Record<string, unknown> }>(qk.me())?.preferences ??
{}) as { plexHiddenStripSources?: string[] };
// Which collection-strip source buckets the user has HIDDEN (default: none → all shown). Toggled
@@ -76,9 +311,15 @@ export default function PlexInfo({
const [editorOpen, setEditorOpen] = useState(false);
const [playlistOpen, setPlaylistOpen] = useState(false);
// Faint art backdrop, HTPC-style (page variant only) — the shared hook paints the item's art as a
// fixed background on <main> and clears it on unmount / when disabled / in the overlay variant.
useArtBackdrop(detail.art, variant !== "overlay" && artBg);
// Faint art backdrop, HTPC-style (page variant only) — cross-dissolves through the item's own art,
// its TMDB backdrops (E5 S2), and its collection siblings' art (E5 S8). Cleared in the overlay
// variant / when disabled / on unmount.
const backdropPool = [
detail.art,
...(detail.tmdb?.backdrops ?? []).map(tmdbBackdropUrl),
...(detail.collections?.flatMap((c) => c.items.map((i) => i.art)) ?? []),
];
useCrossfadeBackdrop(backdropPool, variant !== "overlay" && artBg, crossfade);
const inProgress = (detail.position_seconds ?? 0) > 0 && detail.status !== "watched";
const setState = async (status: "new" | "watched") => {
@@ -105,9 +346,24 @@ export default function PlexInfo({
const sourceLabel = (src: string) =>
t(`plex.info.stripSource.${src}`, { defaultValue: src.toUpperCase() });
// Whether each enriched side panel has anything to show — drives both its render and its toggle row
// (no toggle for a panel that would be empty anyway).
const tmdb = detail.tmdb;
const hasFacts =
detail.year != null ||
!!detail.duration_seconds ||
!!detail.content_rating ||
!!tmdb?.budget ||
!!tmdb?.revenue ||
!!tmdb?.studios?.length ||
!!detail.studio ||
!!tmdb?.production_countries?.length;
const hasKeywords = (tmdb?.keywords?.length ?? 0) > 0;
const hasCrew = (tmdb?.crew?.length ?? 0) > 0;
return (
<div className={overlay ? "relative w-full max-w-3xl" : "relative glass-media"}>
<div className={overlay ? "p-5" : "flex flex-col gap-4 p-3 sm:p-4"}>
<div className={overlay ? "p-5" : "flex flex-col gap-3 p-3 sm:p-4"}>
{/* Hero block: floats as a frosted glass panel over the fixed art backdrop (page variant). */}
<div className={overlay ? "" : "glass rounded-2xl p-4 sm:p-6"}>
{/* Header: close (overlay) / customize */}
@@ -126,17 +382,44 @@ export default function PlexInfo({
overlay={overlay}
artBg={artBg}
onToggleArtBg={toggleArtBg}
crossfade={crossfade}
onToggleCrossfade={toggleCrossfade}
showCast={showCast}
onToggleCast={toggleCast}
hasCast={detail.cast.length > 0}
extra={presentSources.map((src) => (
<PrefToggle
key={src}
label={sourceLabel(src)}
on={!hiddenSources.includes(src)}
onClick={() => toggleSource(src)}
/>
))}
extra={
<>
{hasFacts && (
<PrefToggle
label={t("plex.info.prefFacts")}
on={showFacts}
onClick={() => setShowFacts(!showFacts)}
/>
)}
{hasKeywords && (
<PrefToggle
label={t("plex.info.prefKeywords")}
on={showKeywords}
onClick={() => setShowKeywords(!showKeywords)}
/>
)}
{hasCrew && (
<PrefToggle
label={t("plex.info.prefCrew")}
on={showCrew}
onClick={() => setShowCrew(!showCrew)}
/>
)}
{presentSources.map((src) => (
<PrefToggle
key={src}
label={sourceLabel(src)}
on={!hiddenSources.includes(src)}
onClick={() => toggleSource(src)}
/>
))}
</>
}
/>
{overlay && onClose && (
<button
@@ -196,42 +479,18 @@ export default function PlexInfo({
{detail.studio}
</Filterable>
)}
{(() => {
// IMDb score when we have one (with the IMDb link); otherwise the Plex audience
// score (no IMDb link/branding, so a non-IMDb score is never mislabelled as IMDb).
const badge = pickRatingBadge(detail.imdb_rating, detail.rating);
if (!badge) return null;
const { score, isImdb } = badge;
return (
// decorative rating gold — intentionally NOT a status token
<span className="inline-flex items-center gap-1 rounded-md bg-amber-400/15 px-1.5 py-0.5 font-semibold text-amber-500">
<button
onClick={
onFilter ? () => onFilter({ ratingMin: Math.floor(score) }) : undefined
}
className={`inline-flex items-center gap-1 ${onFilter ? "cursor-pointer hover:opacity-80" : "cursor-default"}`}
title={
onFilter
? t("plex.info.filterRating", { n: Math.floor(score) })
: undefined
}
>
<Star className="w-3.5 h-3.5 fill-current" />
{score.toFixed(1)}
</button>
{isImdb && detail.imdb_url && (
<a
href={detail.imdb_url}
target="_blank"
rel="noopener noreferrer"
title={t("plex.info.openImdb")}
>
<ExternalLink className="w-3 h-3 opacity-70 hover:opacity-100" />
</a>
)}
</span>
);
})()}
</div>
{/* Multi-source ratings row (E5 S5): IMDb / TMDb / audience, whichever are present. */}
<div className="mt-2">
<RatingsRow
imdbRating={detail.imdb_rating}
imdbUrl={detail.imdb_url}
audience={detail.rating}
tmdbRating={detail.tmdb?.rating}
tmdbVotes={detail.tmdb?.vote_count}
onFilterRating={onFilter ? (n) => onFilter({ ratingMin: n }) : undefined}
/>
</div>
{detail.tagline && (
@@ -342,13 +601,26 @@ export default function PlexInfo({
</div>
{/* End hero panel. */}
{/* Enriched detail (E5 S5) — page variant only; the overlay stays lean. Details / Keywords /
Crew sit side by side (each its natural height, items-start) so no single box hogs the full
width with dead space; each is independently hideable from the customize menu. */}
{!overlay && (showFacts || showKeywords || showCrew) && (
<div className="grid items-start gap-3 sm:grid-cols-2 xl:grid-cols-3">
{showFacts && <FactsPanel detail={detail} onFilter={onFilter} />}
{showKeywords && (
<KeywordsPanel keywords={detail.tmdb?.keywords ?? []} onFilter={onFilter} />
)}
{showCrew && <CrewPanel crew={detail.tmdb?.crew ?? []} onOpenPerson={onOpenPerson} />}
</div>
)}
{/* Cast row (toggleable), circular photos. */}
{cast.length > 0 && (
<div className={overlay ? "mt-5" : "glass-card rounded-2xl p-4 sm:p-5"}>
<h2 className={`mb-2 text-sm font-semibold ${overlay ? "text-white/80" : ""}`}>
{t("plex.info.cast")}
</h2>
<div className="flex gap-3 overflow-x-auto pb-2">
<Carousel ariaLabel={t("plex.info.cast")} gap="gap-3">
{cast.map((c, i) => {
const clickable = !!(onOpenPerson || onFilter);
const inner = (
@@ -403,10 +675,12 @@ export default function PlexInfo({
</div>
);
})}
</div>
</Carousel>
</div>
)}
{/* (Full crew now lives in the enriched Details/Keywords/Crew grid above the cast row.) */}
{/* Collection strips — the other titles in this movie's collection(s), directly playable.
Each source bucket (Collections / IMDb / TMDb / smart / …) is independently hideable. */}
{detail.collections
@@ -426,7 +700,7 @@ export default function PlexInfo({
</button>
)}
</div>
<div className="flex gap-3 overflow-x-auto pb-2">
<Carousel ariaLabel={col.title} gap="gap-3">
{col.items.map((m) => (
<button
key={m.id}
@@ -471,7 +745,7 @@ export default function PlexInfo({
{m.year ? <div className="text-2xs text-muted">{m.year}</div> : null}
</button>
))}
</div>
</Carousel>
</div>
))}
</div>
@@ -5,10 +5,23 @@
// filter gesture only when no onOpenPerson is wired) and PlexPersonView's render (headshot/name hero,
// the Movies/Shows sections, and the "filter library by this person" action). i18n is key-passthrough
// and the api is mocked so the query resolves synchronously in the test.
import { describe, it, expect, afterEach, vi } from "vitest";
import { describe, it, expect, afterEach, beforeAll, vi } from "vitest";
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
beforeAll(() => {
// CastStrip now scrolls inside a Carousel, whose useHScroll wires a ResizeObserver; jsdom ships none.
class Stub {
observe() {}
unobserve() {}
disconnect() {}
takeRecords() {
return [];
}
}
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
});
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
@@ -25,7 +38,8 @@ vi.mock("../lib/api", () => ({
plexFilterCount: () => 0,
}));
import { CastStrip, PlexPersonView } from "./PlexBrowse";
import { CastStrip } from "./plexCards";
import { PlexPersonView } from "./plexViews";
afterEach(cleanup);
@@ -71,6 +85,12 @@ describe("PlexPersonView", () => {
plexPerson.mockResolvedValue({
name: "Jane Doe",
photo: null,
biography: "A celebrated actor.",
birthday: "1970-01-01",
place_of_birth: "Springfield",
known_for_department: "Acting",
also_known_as: ["Janie D"],
known_for: [card("kf1", "Famous Film")],
movies: [card("m1", "Movie One")],
shows: [card("s1", "Show One", "show")],
});
@@ -90,13 +110,30 @@ describe("PlexPersonView", () => {
expect(screen.getByText("plex.person.movies")).toBeTruthy(); // section headings (key passthrough)
expect(screen.getByText("plex.person.shows")).toBeTruthy();
// E5 S6 bio hero: department, biography, birth facts, aka.
expect(screen.getByText("Acting")).toBeTruthy();
expect(screen.getByText("A celebrated actor.")).toBeTruthy();
expect(screen.getByText(/1970-01-01/)).toBeTruthy();
expect(screen.getByText(/plex\.person\.aka/)).toBeTruthy();
expect(screen.getByText(/Janie D/)).toBeTruthy();
// "Known for" rail renders its card.
expect(screen.getByText("plex.person.knownFor")).toBeTruthy();
expect(screen.getAllByText("Famous Film").length).toBeGreaterThan(0);
fireEvent.click(screen.getByText("plex.person.filterBy"));
expect(onFilter).toHaveBeenCalledWith({ actors: ["Jane Doe"] });
expect(plexPerson).toHaveBeenCalledWith("Jane Doe");
});
it("shows the empty message when the person has no titles", async () => {
plexPerson.mockResolvedValue({ name: "Nobody", photo: null, movies: [], shows: [] });
plexPerson.mockResolvedValue({
name: "Nobody",
photo: null,
also_known_as: [],
known_for: [],
movies: [],
shows: [],
});
wrap(
<PlexPersonView
name="Nobody"
+111 -112
View File
@@ -3,15 +3,16 @@ import { qk } from "../lib/queryKeys";
import { useTranslation } from "react-i18next";
import { plexLang } from "../i18n";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Layers, ListMusic, Plus, SlidersHorizontal, X } from "lucide-react";
import { ListMusic, Plus, SlidersHorizontal, X } from "lucide-react";
import { api, plexFilterCount, type PlexFilters } from "../lib/api";
import { defaultLayout, type PanelLayout } from "../lib/panelLayout";
import { type PanelLayout } from "../lib/panelLayout";
import { type MediaSourceKind } from "../lib/mediaSource";
import { useDebounced } from "../lib/useDebounced";
import { useScrollFade } from "../lib/useScrollFade";
import SidePanel from "./SidePanel";
import PanelGroups, { type PanelItem } from "./PanelGroups";
import FilterRail from "./FilterRail";
import { type PanelItem } from "./PanelGroups";
import SegmentedToggle from "./SegmentedToggle";
import FilterChipGroup, { filterChipClass } from "./FilterChipGroup";
import FilterChipGroup from "./FilterChipGroup";
import Combobox, { type ComboboxOption } from "./Combobox";
import { usePlexClearFilters } from "./usePlexActiveFilters";
import { usePlex, usePlexActions } from "./PlexProvider";
@@ -67,10 +68,11 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl
// hand-off to PlexBrowse via the shared `playlistOpen` slot.
const { scope, show, filters } = usePlex();
const { setShow, setFilters, setPlaylistOpen: onOpenPlaylist } = usePlexActions();
const collFade = useScrollFade();
const playlistsQ = useQuery({ queryKey: qk.plexPlaylists(), queryFn: () => api.plexPlaylists() });
const [newPlaylist, setNewPlaylist] = useState("");
const [editing, setEditing] = useState(false);
// This rail's media source (the Plex library) — names the abstraction the feed and Plex rails
// share; drives the panel layout key. `FilterRail` owns the customize `editing` state now.
const source: MediaSourceKind = "plex";
const qc = useQueryClient();
async function createPlaylist() {
const title = newPlaylist.trim();
@@ -90,15 +92,6 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl
});
const facets = facetsQ.data;
const [collSearch, setCollSearch] = useState("");
const collSearchDeb = useDebounced(collSearch.trim(), 300);
const collectionsQ = useQuery({
queryKey: qk.plexCollections("union", collSearchDeb),
queryFn: () => api.plexCollections(undefined, collSearchDeb || undefined),
enabled: !filters.collection,
});
const collections = collectionsQ.data?.collections ?? [];
const fCount = plexFilterCount(filters);
// Scope and sort moved to the header / above-the-grid toolbar (S3); the panel badge now counts only
// the filters that still live in this panel (expanded filters + watch-state).
@@ -106,15 +99,14 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl
const anyActive = activeCount > 0;
const patch = (p: Partial<PlexFilters>) => setFilters({ ...filters, ...p });
const toggleIn = (arr: string[], v: string) =>
arr.includes(v) ? arr.filter((x) => x !== v) : [...arr, v];
const clearAll = usePlexClearFilters();
const durBucketKey = DURATION_BUCKETS.find(
(b) => (filters.durationMin ?? null) === b.min && (filters.durationMax ?? null) === b.max,
)?.key;
const peopleActive =
filters.directors.length + filters.actors.length + filters.studios.length > 0;
// Directors + actors have no picker (they're added from the detail/cast surfaces) — this section
// just lists them as removable chips. Studios now have their own picker section (below).
const peopleActive = filters.directors.length + filters.actors.length > 0;
// Group bodies (no title — the PanelGroup card supplies it). Only available groups are pushed,
// so facet-gated / scope-gated sections drop out cleanly; PanelGroups orders them by the saved
@@ -203,13 +195,6 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl
onRemove={() => patch({ actors: filters.actors.filter((x) => x !== a) })}
/>
))}
{filters.studios.map((s) => (
<RemovableChip
key={`s:${s}`}
label={s}
onRemove={() => patch({ studios: filters.studios.filter((x) => x !== s) })}
/>
))}
</div>
),
},
@@ -218,44 +203,12 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl
{
id: "collection",
title: t("plex.filter.collection"),
body: filters.collection ? (
<div className="flex flex-wrap gap-1.5">
<RemovableChip
label={filters.collectionTitle || filters.collection}
onRemove={() => patch({ collection: null, collectionTitle: null })}
/>
</div>
) : (
<div>
<input
value={collSearch}
onChange={(e) => setCollSearch(e.target.value)}
placeholder={t("plex.filter.collectionSearch")}
className="mb-1.5 w-full rounded-lg border border-border bg-surface px-2 py-1 text-sm focus:outline-hidden focus:ring-2 focus:ring-accent/40"
/>
<div
ref={collFade.ref}
style={collFade.style}
className="flex max-h-56 flex-col gap-0.5 overflow-y-auto no-scrollbar"
>
{collections.map((c) => (
<button
key={c.id}
onClick={() => patch({ collection: c.id, collectionTitle: c.title })}
className="glass-hover flex items-center gap-2 rounded-lg px-2 py-1 text-left text-sm"
>
<Layers className="w-3.5 h-3.5 shrink-0 text-muted" />
<span className="flex-1 truncate">{c.title}</span>
<span className="text-xs text-muted">{c.child_count}</span>
</button>
))}
{collections.length === 0 && (
<span className="px-2 py-1 text-xs text-muted">
{t("plex.filter.collectionEmpty")}
</span>
)}
</div>
</div>
body: (
<CollectionPicker
value={filters.collection}
title={filters.collectionTitle}
onPick={(id, title) => patch({ collection: id, collectionTitle: title })}
/>
),
},
{
@@ -281,27 +234,19 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl
body: (
<>
{filters.genres.length > 1 && (
<MatchToggle
mode={filters.genreMode ?? "any"}
onChange={(m) => patch({ genreMode: m })}
/>
<div className="mb-1.5">
<MatchToggle
mode={filters.genreMode ?? "any"}
onChange={(m) => patch({ genreMode: m })}
/>
</div>
)}
<div
className="flex flex-wrap gap-1.5"
role="group"
aria-label={t("plex.filter.genre")}
>
{facets.genres.map((g) => (
<button
key={g.value}
onClick={() => patch({ genres: toggleIn(filters.genres, g.value) })}
aria-pressed={filters.genres.includes(g.value)}
className={filterChipClass}
>
{g.value}
</button>
))}
</div>
<Combobox
label={t("plex.filter.genre")}
options={facets.genres}
selected={filters.genres}
onChange={(next) => patch({ genres: next })}
/>
</>
),
},
@@ -377,24 +322,28 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl
id: "contentrating",
title: t("plex.filter.contentRating"),
body: (
<div
className="flex flex-wrap gap-1.5"
role="group"
aria-label={t("plex.filter.contentRating")}
>
{facets.content_ratings.map((c) => (
<button
key={c.value}
onClick={() =>
patch({ contentRatings: toggleIn(filters.contentRatings, c.value) })
}
aria-pressed={filters.contentRatings.includes(c.value)}
className={filterChipClass}
>
{c.value}
</button>
))}
</div>
<Combobox
label={t("plex.filter.contentRating")}
options={facets.content_ratings}
selected={filters.contentRatings}
onChange={(next) => patch({ contentRatings: next })}
/>
),
},
]
: []),
...(facets && facets.studios.length > 0
? [
{
id: "studio",
title: t("plex.filter.studio"),
body: (
<Combobox
label={t("plex.filter.studio")}
options={facets.studios}
selected={filters.studios}
onChange={(next) => patch({ studios: next })}
/>
),
},
]
@@ -402,16 +351,16 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl
];
return (
<SidePanel
<FilterRail
kind={source}
title={t("sidebar.filters")}
icon={<SlidersHorizontal className="w-4 h-4" />}
count={activeCount}
collapsed={collapsed}
onToggleCollapse={onToggleCollapse}
editing={editing}
onToggleEditing={() => setEditing((e) => !e)}
onReset={() => setLayout(defaultLayout("plex"))}
editHint={t("sidebar.editHint")}
layout={layout}
setLayout={setLayout}
items={items}
actions={
anyActive ? (
<button
@@ -423,9 +372,59 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl
</button>
) : undefined
}
>
<PanelGroups items={items} layout={layout} setLayout={setLayout} editing={editing} />
</SidePanel>
/>
);
}
// The collection filter (E5 S7 → combobox rework): a single-select combi-input matching the
// genre/studio pickers, its vocabulary searched SERVER-side (the union can run to hundreds). The
// generic <Combobox> owns the field/keyboard/ARIA; this wrapper only owns the query→fetch (gated on
// `open`) and maps collections to options. Picking sets the filter (id + title for the chip); clearing
// the chip drops it.
function CollectionPicker({
value,
title,
onPick,
}: {
value: string | null | undefined;
title: string | null | undefined;
onPick: (id: string | null, title: string | null) => void;
}) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const [search, setSearch] = useState("");
const searchDeb = useDebounced(search.trim(), 300);
const q = useQuery({
queryKey: qk.plexCollections("union", searchDeb),
queryFn: () => api.plexCollections(undefined, searchDeb || undefined),
enabled: open, // only fetch while the picker is open
});
const options: ComboboxOption[] = (q.data?.collections ?? []).map((c) => ({
value: c.id,
label: c.title,
count: c.child_count ?? undefined,
}));
return (
<Combobox
label={t("plex.filter.collection")}
multiple={false}
clientFilter={false} // the server already narrows to `search`
options={options}
selected={value ? [value] : []}
selectedLabels={value && title ? { [value]: title } : undefined}
query={search}
onQueryChange={setSearch}
onOpenChange={setOpen}
loading={q.isFetching}
placeholder={t("plex.filter.collectionSearch")}
emptyText={t("plex.filter.collectionEmpty")}
onChange={(next) => {
const id = next[0] ?? null;
const picked = options.find((o) => o.value === id);
onPick(id, id ? (picked?.label ?? title ?? id) : null);
}}
/>
);
}
+5 -27
View File
@@ -2,7 +2,7 @@ import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys";
import { BookmarkPlus, Check, GripVertical, Pencil, Share2, Star, Trash2, X } from "lucide-react";
import { BookmarkPlus, Check, GripVertical, Pencil, Star, Trash2, X } from "lucide-react";
import {
closestCenter,
DndContext,
@@ -19,10 +19,8 @@ import {
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { api, getActiveAccount, type FeedFilters, type SavedView } from "../lib/api";
import { filtersToParams, shareUrl } from "../lib/urlState";
import { notify } from "../lib/notifications";
import { filtersToParams } from "../lib/urlState";
import { notifyActionError } from "../lib/actionErrors";
import { copyText } from "../lib/clipboard";
import { accountKey, LS, writeJSON } from "../lib/storage";
import { useConfirm } from "./ConfirmProvider";
@@ -131,14 +129,6 @@ export default function SavedViewsWidget({
await runViewMutation(() => api.deleteView(v.id), t("views.actionFailed"));
}
async function share(v: SavedView) {
if (await copyText(shareUrl(asFilters(v.filters)))) {
notify({ level: "success", message: t("views.shareCopied") });
} else {
notify({ level: "warning", message: t("sidebar.shareFailed") });
}
}
async function onDragEnd(e: DragEndEvent) {
const { active, over } = e;
if (!over || active.id === over.id) return;
@@ -177,7 +167,6 @@ export default function SavedViewsWidget({
onRename={(name) => rename(v.id, name)}
onCancelRename={() => setRenameId(null)}
onToggleDefault={() => toggleDefault(v)}
onShare={() => share(v)}
onDelete={() => remove(v)}
/>
))}
@@ -239,7 +228,6 @@ function ViewRow({
onRename,
onCancelRename,
onToggleDefault,
onShare,
onDelete,
}: {
view: SavedView;
@@ -250,7 +238,6 @@ function ViewRow({
onRename: (name: string) => void;
onCancelRename: () => void;
onToggleDefault: () => void;
onShare: () => void;
onDelete: () => void;
}) {
const { t } = useTranslation();
@@ -332,31 +319,22 @@ function ViewRow({
onClick={onToggleDefault}
title={view.is_default ? t("views.isDefault") : t("views.makeDefault")}
className={`p-1 rounded transition ${
view.is_default
? "text-accent"
: "text-muted opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 hover:text-accent"
view.is_default ? "text-accent" : "text-muted reveal hover:text-accent"
}`}
>
<Star className={`w-3.5 h-3.5 ${view.is_default ? "fill-current" : ""}`} />
</button>
<button
onClick={onShare}
title={t("views.share")}
className="p-1 rounded-sm text-muted opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 hover:text-fg transition"
>
<Share2 className="w-3.5 h-3.5" />
</button>
<button
onClick={onStartRename}
title={t("views.rename")}
className="p-1 rounded-sm text-muted opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 hover:text-fg transition"
className="p-1 rounded-sm text-muted reveal hover:text-fg transition"
>
<Pencil className="w-3.5 h-3.5" />
</button>
<button
onClick={onDelete}
title={t("views.delete")}
className="p-1 rounded-sm text-muted opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 hover:text-danger transition"
className="p-1 rounded-sm text-muted reveal hover:text-danger transition"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
+1 -1
View File
@@ -198,7 +198,7 @@ function JobRow({
className="group text-muted font-normal hover:text-accent inline-flex items-center gap-1 transition"
>
· {t("scheduler.everyMin", { count: job.interval_minutes })}
<Pencil className="w-3 h-3 opacity-0 group-hover:opacity-100 transition" />
<Pencil className="w-3 h-3 reveal transition" />
</button>
</Tooltip>
)}
+38 -55
View File
@@ -2,19 +2,17 @@ import { type ReactNode, useState } from "react";
import { useTranslation } from "react-i18next";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys";
import { Library, Pencil, Share2, SlidersHorizontal, User } from "lucide-react";
import { Library, Pencil, SlidersHorizontal, User } from "lucide-react";
import { api, type FeedFilters, type Tag } from "../lib/api";
import { defaultLayout, type PanelLayout } from "../lib/panelLayout";
import { shareUrl } from "../lib/urlState";
import { copyText } from "../lib/clipboard";
import { notify } from "../lib/notifications";
import { useDebounced } from "../lib/useDebounced";
import { clearedFilters, useActiveFilters } from "../lib/useActiveFilters";
import { applyDateChip, deriveDateKey } from "../lib/dateFilter";
import TagManager from "./TagManager";
import SavedViewsWidget from "./SavedViewsWidget";
import SidePanel from "./SidePanel";
import PanelGroups, { type PanelItem } from "./PanelGroups";
import FilterRail from "./FilterRail";
import { type PanelItem } from "./PanelGroups";
import { type MediaSourceKind } from "../lib/mediaSource";
import ChannelPicker from "./ChannelPicker";
import SegmentedToggle from "./SegmentedToggle";
import FilterChipGroup, { filterChipClass } from "./FilterChipGroup";
@@ -111,7 +109,9 @@ export default function Sidebar({
const userTags = tags.filter((t) => !t.system);
const [customDates, setCustomDates] = useState(false);
const [tagManagerOpen, setTagManagerOpen] = useState(false);
const [editing, setEditing] = useState(false);
// This rail's media source (the feed). Names the abstraction the feed and Plex rails share; drives
// the panel layout key. `FilterRail` owns the customize `editing` state now.
const source: MediaSourceKind = "feed";
function toggleTag(id: number) {
const has = filters.tags.includes(id);
@@ -126,14 +126,6 @@ export default function Sidebar({
const activeCount = useActiveFilters(filters, setFilters).length;
const active = activeCount > 0;
async function shareView() {
if (await copyText(shareUrl(filters))) {
notify({ level: "success", message: t("sidebar.shareCopied") });
} else {
notify({ level: "warning", message: t("sidebar.shareFailed") });
}
}
function clearAll() {
setCustomDates(false);
setFilters(clearedFilters(filters));
@@ -308,7 +300,7 @@ export default function Sidebar({
// Group ids come from the shared default order (panelLayout) so there's one source of truth;
// the actual render order is the user's saved layout (PanelGroups orders by it).
const items: PanelItem[] = defaultLayout("feed")
const items: PanelItem[] = defaultLayout(source)
.order.filter((id) => available[id])
.map((id) => ({ id, title: t("sidebar.widget." + id), body: widgetBody(id) }));
@@ -321,58 +313,49 @@ export default function Sidebar({
>
{t("sidebar.clearAll")}
</button>
<button
onClick={shareView}
title={t("sidebar.shareView")}
aria-label={t("sidebar.shareView")}
className="p-1.5 rounded-lg text-muted hover:text-fg hover:bg-card transition"
>
<Share2 className="w-4 h-4" />
</button>
</>
);
return (
<SidePanel
<FilterRail
kind={source}
title={t("sidebar.filters")}
icon={<SlidersHorizontal className="w-4 h-4" />}
count={activeCount}
collapsed={collapsed}
onToggleCollapse={onToggleCollapse}
editing={editing}
onToggleEditing={() => setEditing((e) => !e)}
onReset={() => setLayout(defaultLayout("feed"))}
editHint={t("sidebar.editHint")}
layout={layout}
setLayout={setLayout}
items={items}
actions={actions}
top={
/* Scope: your own subscriptions (Mine) vs the shared Library. */
<SegmentedToggle
ariaLabel={t("header.scope.label")}
fullWidth
size="md"
options={[
{
value: "my",
label: t("header.scope.my"),
icon: <User className="w-3.5 h-3.5" />,
title: t("header.scope.myTip"),
},
{
value: "all",
label: t("header.scope.all"),
icon: <Library className="w-3.5 h-3.5" />,
title: t("header.scope.allTip"),
},
]}
value={filters.scope}
onChange={(scope) => setFilters({ ...filters, scope })}
/>
}
>
{/* Scope: your own subscriptions (Mine) vs the shared Library. */}
<SegmentedToggle
ariaLabel={t("header.scope.label")}
fullWidth
size="md"
options={[
{
value: "my",
label: t("header.scope.my"),
icon: <User className="w-3.5 h-3.5" />,
title: t("header.scope.myTip"),
},
{
value: "all",
label: t("header.scope.all"),
icon: <Library className="w-3.5 h-3.5" />,
title: t("header.scope.allTip"),
},
]}
value={filters.scope}
onChange={(scope) => setFilters({ ...filters, scope })}
/>
<PanelGroups items={items} layout={layout} setLayout={setLayout} editing={editing} />
{tagManagerOpen && (
<TagManager onClose={() => setTagManagerOpen(false)} onFocusChannel={onFocusChannel} />
)}
</SidePanel>
</FilterRail>
);
}
+1 -6
View File
@@ -60,12 +60,7 @@ function Actions({
// flex-wrap is the safety net, not the plan: the sizing above is what makes them fit. If a
// future button or a narrower column breaks that arithmetic, the row wraps to a second line
// rather than silently rendering a half-clipped control outside the card.
<div
className={clsx(
"flex flex-wrap opacity-0 group-hover:opacity-100 transition",
dense ? "gap-0.5" : "gap-1",
)}
>
<div className={clsx("flex flex-wrap reveal transition", dense ? "gap-0.5" : "gap-1")}>
<button
onClick={act("watched")}
title={video.status === "watched" ? t("card.watchedUnmark") : t("card.markWatched")}
+9 -7
View File
@@ -8,6 +8,7 @@ import {
type ReactNode,
} from "react";
import { useVirtualizer } from "@tanstack/react-virtual";
import { shouldPrefetchNext } from "./readAhead";
// The virtualization engine, lifted out of VirtualFeed so any long client- or server-side list can
// reuse it (the feed's video grid, the channel manager's card/row views). It owns ONLY the layout
@@ -23,9 +24,6 @@ import { useVirtualizer } from "@tanstack/react-virtual";
// (grid-cols-[repeat(auto-fill,minmax(260px,1fr))] with a gap-4 / 1rem gutter).
const GRID_GAP = 16;
const LIST_GAP = 4;
// Start fetching the next page this many rows before the end scrolls into view
// (keeps the old IntersectionObserver's generous prefetch feel).
const PREFETCH_ROWS = 4;
/** Columns that fit `width`, or 1 for the single-column layout (`minCol == null`). */
function columnsFor(minCol: number | null, width: number): number {
@@ -140,13 +138,17 @@ export default function VirtualGrid<T>({
const virtualRows = virtualizer.getVirtualItems();
// Infinite-scroll trigger: once the last rendered row is within PREFETCH_ROWS of the end, pull
// the next page. A no-op for finite lists (no fetchNextPage / hasNextPage false).
// Infinite-scroll read-ahead: pull the next page once the last rendered row is within the
// read-ahead window of the loaded end (see readAhead.ts — the window scales with the rendered row
// count, clamped so a tall window doesn't prefetch unboundedly). A no-op for finite lists (no
// fetchNextPage / hasNextPage false). Because this effect re-runs when a page settles
// (isFetchingNextPage flips false) it CHAINS — refilling the buffer as the user consumes it, so a
// fast scroll reaches the loaded end far less often.
useEffect(() => {
if (!fetchNextPage) return;
if (!fetchNextPage || !hasNextPage || isFetchingNextPage) return;
const last = virtualRows[virtualRows.length - 1];
if (!last) return;
if (last.index >= rows.length - 1 - PREFETCH_ROWS && hasNextPage && !isFetchingNextPage) {
if (shouldPrefetchNext(last.index, rows.length, virtualRows.length)) {
fetchNextPage();
}
}, [virtualRows, rows.length, hasNextPage, isFetchingNextPage, fetchNextPage]);
+267
View File
@@ -0,0 +1,267 @@
import { useMemo, useState } from "react";
import { qk } from "../lib/queryKeys";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Settings2 } from "lucide-react";
import Modal from "./Modal";
import { useLiveQuery } from "../lib/useLiveQuery";
import { api } from "../lib/api";
import { formatBytes } from "../lib/format";
import { inputCls } from "./ui/form";
import { StatusPill, jobTitle } from "./downloadRow";
// The admin "System" tab — storage overview, per-user footprint, quota editor, and the global job
// list. Split out of DownloadCenter (self-contained, admin-only surface).
const GiB = 1024 ** 3;
function QuotaModal({
userId,
email,
onClose,
}: {
userId: number;
email: string;
onClose: () => void;
}) {
const { t } = useTranslation();
const qc = useQueryClient();
const q = useQuery({
queryKey: qk.dlQuota(userId),
queryFn: () => api.adminGetDownloadQuota(userId),
});
const [form, setForm] = useState<{
gb: number;
jobs: number;
conc: number;
unlimited: boolean;
} | null>(null);
const cur = q.data;
const state =
form ??
(cur
? {
gb: Math.round((cur.max_bytes / GiB) * 10) / 10,
jobs: cur.max_jobs,
conc: cur.max_concurrent,
unlimited: cur.unlimited,
}
: null);
const done = () => {
qc.invalidateQueries({ queryKey: qk.dlQuota(userId) });
qc.invalidateQueries({ queryKey: qk.adminStorage() });
onClose();
};
const save = useMutation({
mutationFn: () =>
api.adminSetDownloadQuota(userId, {
max_bytes: Math.round(state!.gb * GiB),
max_jobs: state!.jobs,
max_concurrent: state!.conc,
unlimited: state!.unlimited,
}),
onSuccess: done,
});
const reset = useMutation({
mutationFn: () => api.adminResetDownloadQuota(userId),
onSuccess: done,
});
if (!state) return null;
const upd = (p: Partial<typeof state>) => setForm({ ...state, ...p });
return (
<Modal title={t("downloads.admin.quotaTitle", { email })} onClose={onClose}>
<div className="space-y-3">
<div className="grid grid-cols-3 gap-3">
<label className="text-sm">
{t("downloads.admin.maxBytes")}
<input
type="number"
value={state.gb}
onChange={(e) => upd({ gb: Number(e.target.value) })}
className={inputCls}
disabled={state.unlimited}
/>
</label>
<label className="text-sm">
{t("downloads.admin.maxJobs")}
<input
type="number"
value={state.jobs}
onChange={(e) => upd({ jobs: Number(e.target.value) })}
className={inputCls}
/>
</label>
<label className="text-sm">
{t("downloads.admin.maxConcurrent")}
<input
type="number"
value={state.conc}
onChange={(e) => upd({ conc: Number(e.target.value) })}
className={inputCls}
/>
</label>
</div>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input
type="checkbox"
checked={state.unlimited}
onChange={(e) => upd({ unlimited: e.target.checked })}
/>
{t("downloads.admin.unlimited")}
</label>
<div className="flex justify-between pt-1">
<button
onClick={() => reset.mutate()}
className="px-3 py-1.5 rounded-lg text-sm text-muted hover:text-fg hover:bg-surface transition"
>
{t("downloads.admin.reset")}
</button>
<button
onClick={() => save.mutate()}
disabled={save.isPending}
className="px-3 py-1.5 rounded-lg text-sm font-medium btn-primary disabled:opacity-50 transition"
>
{t("downloads.admin.save")}
</button>
</div>
</div>
</Modal>
);
}
// Pick ANY user to set a download quota for — even one who hasn't downloaded anything yet (the
// footprint list below only shows users who already have files).
function QuotaUserPicker({ onPick }: { onPick: (u: { id: number; email: string }) => void }) {
const { t } = useTranslation();
const [q, setQ] = useState("");
const [open, setOpen] = useState(false);
const users = useQuery({ queryKey: qk.adminUsers(), queryFn: api.adminUsers });
const list = (users.data ?? []).filter((u) => !u.is_demo);
const filtered = useMemo(() => {
const s = q.trim().toLowerCase();
const base = s
? list.filter(
(u) =>
u.email.toLowerCase().includes(s) || (u.display_name ?? "").toLowerCase().includes(s),
)
: list;
return base.slice(0, 8);
}, [q, list]);
return (
<div className="relative w-full sm:w-72">
<input
value={q}
onChange={(e) => {
setQ(e.target.value);
setOpen(true);
}}
onFocus={() => setOpen(true)}
onBlur={() => setTimeout(() => setOpen(false), 150)}
placeholder={t("downloads.admin.quotaPickPlaceholder")}
className={inputCls}
/>
{open && filtered.length > 0 && (
<div className="absolute z-base mt-1 w-full rounded-lg glass-menu overflow-hidden">
{filtered.map((u) => (
<button
key={u.id}
onClick={() => {
onPick({ id: u.id, email: u.email });
setQ("");
setOpen(false);
}}
className="w-full text-left px-3 py-2 text-sm hover:bg-surface transition flex flex-col"
>
<span className="truncate">{u.display_name || u.email}</span>
{u.display_name && <span className="text-xs text-muted truncate">{u.email}</span>}
</button>
))}
</div>
)}
</div>
);
}
export function AdminSystem() {
const { t } = useTranslation();
const storage = useLiveQuery(qk.adminStorage(), api.adminDownloadStorage, { intervalMs: 5000 });
const jobs = useLiveQuery(qk.adminDownloads(), api.adminDownloads, { intervalMs: 5000 });
const [quotaFor, setQuotaFor] = useState<{ id: number; email: string } | null>(null);
const s = storage.data;
return (
<div>
{s && (
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-5">
{[
[t("downloads.admin.readyFiles"), String(s.ready_files)],
[t("downloads.admin.totalSize"), formatBytes(s.total_bytes)],
[
t("downloads.admin.cap"),
s.total_cap_bytes ? formatBytes(s.total_cap_bytes) : t("downloads.admin.noCap"),
],
].map(([label, val]) => (
<div key={label} className="rounded-xl glass-card p-3">
<div className="text-xs text-muted">{label}</div>
<div className="text-lg font-semibold mt-0.5">{val}</div>
</div>
))}
</div>
)}
{s && (
<div className="mb-6">
<div className="flex items-center justify-between gap-3 mb-2 flex-wrap">
<div className="text-sm font-medium">{t("downloads.admin.perUser")}</div>
{/* Set a quota for any user, regardless of whether they've downloaded anything. */}
<QuotaUserPicker onPick={setQuotaFor} />
</div>
<div className="space-y-1">
{s.per_user.map((u) => (
<div
key={u.user_id}
className="flex items-center gap-2 text-sm px-3 py-1.5 rounded-lg bg-card/40"
>
<span className="flex-1 truncate">{u.email}</span>
<span className="text-muted">{formatBytes(u.footprint_bytes)}</span>
<button
onClick={() => setQuotaFor({ id: u.user_id, email: u.email ?? "" })}
className="p-1 rounded-sm hover:bg-surface text-muted hover:text-fg"
title={t("downloads.admin.editQuota")}
>
<Settings2 className="w-4 h-4" />
</button>
</div>
))}
{s.per_user.length === 0 && (
<div className="text-xs text-muted px-3 py-2">{t("downloads.admin.noFootprint")}</div>
)}
</div>
</div>
)}
<div className="text-sm font-medium mb-2">{t("downloads.tabs.system")}</div>
<div className="space-y-1.5">
{(jobs.data ?? []).map((j) => (
<div
key={j.id}
className="flex items-center gap-3 text-sm px-3 py-1.5 rounded-lg bg-card/40"
>
<span className="flex-1 truncate">{jobTitle(j)}</span>
<span className="text-muted truncate w-40">{j.user_email}</span>
<StatusPill job={j} />
<span className="text-muted w-16 text-right">
{j.asset?.size_bytes ? formatBytes(j.asset.size_bytes) : ""}
</span>
</div>
))}
{jobs.data && jobs.data.length === 0 && (
<div className="text-sm text-muted py-6 text-center">{t("downloads.empty.admin")}</div>
)}
</div>
{quotaFor && (
<QuotaModal userId={quotaFor.id} email={quotaFor.email} onClose={() => setQuotaFor(null)} />
)}
</div>
);
}
+178
View File
@@ -0,0 +1,178 @@
import { useState } from "react";
import { qk } from "../lib/queryKeys";
import { useTranslation } from "react-i18next";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Plus, Trash2 } from "lucide-react";
import Modal from "./Modal";
import { api, type DownloadJob } from "../lib/api";
import { inputCls } from "./ui/form";
import { IconBtn } from "./downloadRow";
// Per-item user modals split out of DownloadCenter (self-contained, prop-driven): rename/metadata
// editing and the failure-detail window.
// Edit a download's display metadata: title, channel (name + link), and extra reference links.
// All are display-only overrides — the on-disk file is never renamed.
export function MetaEditModal({ job, onClose }: { job: DownloadJob; onClose: () => void }) {
const { t } = useTranslation();
const qc = useQueryClient();
const [name, setName] = useState(job.display_name || job.asset?.title || "");
const [channel, setChannel] = useState(job.display_uploader || job.asset?.uploader || "");
const [channelUrl, setChannelUrl] = useState(
job.display_uploader_url || job.asset?.uploader_url || "",
);
// Editable extra-link rows; keep one blank row so there's always somewhere to type.
const [links, setLinks] = useState<string[]>(() => [...(job.extra_links ?? []), ""]);
const setLink = (i: number, v: string) => setLinks((ls) => ls.map((l, j) => (j === i ? v : l)));
const addLink = () => setLinks((ls) => [...ls, ""]);
const removeLink = (i: number) => setLinks((ls) => ls.filter((_, j) => j !== i));
const save = useMutation({
mutationFn: () =>
api.updateDownloadMeta(job.id, {
display_name: name.trim(),
display_uploader: channel.trim(),
display_uploader_url: channelUrl.trim(),
extra_links: links.map((l) => l.trim()).filter(Boolean),
}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: qk.downloads() });
onClose();
},
});
return (
<Modal title={t("downloads.edit.title")} onClose={onClose}>
<div className="space-y-3">
<label className="block">
<span className="block text-sm font-medium mb-1">{t("downloads.edit.name")}</span>
<input
value={name}
onChange={(e) => setName(e.target.value)}
className={inputCls}
autoFocus
/>
</label>
<label className="block">
<span className="block text-sm font-medium mb-1">{t("downloads.edit.channel")}</span>
<input
value={channel}
onChange={(e) => setChannel(e.target.value)}
className={inputCls}
/>
</label>
<label className="block">
<span className="block text-sm font-medium mb-1">{t("downloads.edit.channelUrl")}</span>
<input
value={channelUrl}
onChange={(e) => setChannelUrl(e.target.value)}
placeholder="https://…"
className={inputCls}
/>
</label>
<div>
<span className="block text-sm font-medium mb-1">{t("downloads.edit.extraLinks")}</span>
<div className="space-y-2">
{links.map((l, i) => (
<div key={i} className="flex gap-2">
<input
value={l}
onChange={(e) => setLink(i, e.target.value)}
placeholder="https://…"
className={inputCls}
/>
<IconBtn
onClick={() => removeLink(i)}
title={t("downloads.edit.removeLink")}
danger
>
<Trash2 className="w-4 h-4" />
</IconBtn>
</div>
))}
</div>
<button
onClick={addLink}
className="mt-2 inline-flex items-center gap-1.5 text-sm text-muted hover:text-fg transition"
>
<Plus className="w-4 h-4" /> {t("downloads.edit.addLink")}
</button>
</div>
<p className="text-xs text-muted">{t("downloads.edit.hint")}</p>
<div className="flex justify-end">
<button
onClick={() => save.mutate()}
disabled={!name.trim() || save.isPending}
className="px-3 py-1.5 rounded-lg text-sm font-medium btn-primary disabled:opacity-50 transition"
>
{t("downloads.edit.save")}
</button>
</div>
</div>
</Modal>
);
}
// Transient failures worth an in-modal Retry button; the rest are permanent (private/geo/DRM/…),
// where a retry can't help — the queue row still keeps its own retry control regardless.
const RETRYABLE_ERRORS = ["postprocess", "network", "unknown"];
// A readable "why did this fail" window for a failed job: a category headline + plain explanation,
// the raw yt-dlp/ffmpeg text behind a disclosure, and a Retry when the failure may be transient.
export function ErrorModal({
job,
onRetry,
onClose,
}: {
job: DownloadJob;
onRetry: () => void;
onClose: () => void;
}) {
const { t } = useTranslation();
const code = job.error_code || "unknown";
const [showRaw, setShowRaw] = useState(false);
const retryable = RETRYABLE_ERRORS.includes(code);
return (
<Modal title={t("downloads.errors.fail.title")} onClose={onClose}>
<div className="space-y-3">
<div className="font-medium">{t(`downloads.errors.fail.cat.${code}.short`)}</div>
<p className="text-sm text-muted">{t(`downloads.errors.fail.cat.${code}.body`)}</p>
{job.error && (
<div>
<button
onClick={() => setShowRaw((v) => !v)}
className="text-xs text-muted hover:text-fg transition"
>
{t("downloads.errors.fail.detailsToggle")}
</button>
{showRaw && (
<pre className="mt-1.5 text-xs bg-surface rounded-lg p-2.5 overflow-x-auto whitespace-pre-wrap wrap-break-word max-h-40 text-muted">
{job.error}
</pre>
)}
</div>
)}
<div className="flex justify-end gap-2 pt-1">
{retryable && (
<button
onClick={() => {
onRetry();
onClose();
}}
className="px-3 py-1.5 rounded-lg text-sm font-medium btn-primary transition"
>
{t("downloads.actions.retry")}
</button>
)}
<button
onClick={onClose}
className="px-3 py-1.5 rounded-lg text-sm text-muted hover:text-fg hover:bg-surface transition"
>
{t("common.close")}
</button>
</div>
</div>
</Modal>
);
}
+309
View File
@@ -0,0 +1,309 @@
import { useEffect, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { CalendarClock, Copy, Link2, Scissors } from "lucide-react";
import clsx from "clsx";
import { qk } from "../lib/queryKeys";
import { useLiveQuery } from "../lib/useLiveQuery";
import { api, type DownloadJob } from "../lib/api";
import { notify } from "../lib/notifications";
import { copyText } from "../lib/clipboard";
import { formatBytes, formatDuration, formatEta, formatSpeed, relativeTime } from "../lib/format";
// Download-row presentation + the small shared primitives (status pill, icon button, usage bar),
// split out of DownloadCenter (all purely prop-driven, no shared state). Rendered by the queue,
// library, shared, and admin tabs.
const STATUS_CLS: Record<string, string> = {
queued: "text-muted",
running: "text-accent",
paused: "text-warn",
done: "text-success",
error: "text-danger",
canceled: "text-muted",
};
// Byte-progress phases (show a % bar) vs ffmpeg post-steps (no %, indeterminate pulse).
// "editing" (the phase-2 editor) reports a real % from ffmpeg's out_time, so it gets a bar.
const DOWNLOAD_PHASES = ["video", "audio", "editing"];
const PHASE_KEYS = [
"video",
"audio",
"editing",
"merging",
"audio_extract",
"thumbnail",
"sponsorblock",
"metadata",
"processing",
];
export function StatusPill({ job }: { job: DownloadJob }) {
const { t } = useTranslation();
// While downloading, show the concrete phase (video / audio / merging) so the user isn't
// confused by the bar resetting between the separate video and audio streams.
if (job.status === "running" && job.phase && PHASE_KEYS.includes(job.phase)) {
return (
<span className="text-xs font-medium text-accent">{t(`downloads.phase.${job.phase}`)}</span>
);
}
const key = job.expired ? "expired" : job.status;
return (
<span className={clsx("text-xs font-medium", STATUS_CLS[job.status] ?? "text-muted")}>
{t(`downloads.status.${key}`)}
</span>
);
}
function Thumb({ job }: { job: DownloadJob }) {
// Prefer OUR self-hosted poster: it's always present on disk, never expires, and is same-origin, so
// a source-CDN signature expiry (Facebook's fbcdn URLs expire within days) or an ad-blocker/Shields
// rule on the source domain can't break it. The source's remote thumbnail_url is only a fallback for
// the rare poster-less asset (a thumbnail-less source whose ffmpeg frame-cut also failed).
const remote = job.asset?.thumbnail_url ?? null;
// `job.poster_url` only SIGNALS a poster exists; the actual <img> src must carry ?account= (see
// api.posterImageUrl). A plain <img> can't send the X-Siftlode-Account header, so without it the
// poster resolves to the session-default account and 404s for a per-tab account's download — which,
// now that the poster is the primary image, reads as a blank Library thumbnail.
const poster = job.poster_url ? api.posterImageUrl(job.id) : null;
const primary = poster || remote;
// On load failure (a genuinely missing poster file) fall back to the remote source thumbnail.
const [src, setSrc] = useState(primary);
useEffect(() => setSrc(primary), [primary]);
return (
<div className="w-28 aspect-video shrink-0 rounded-lg overflow-hidden bg-surface border border-border">
{src ? (
<img
src={src}
alt=""
loading="lazy"
className="w-full h-full object-cover"
onError={() => {
if (remote && src !== remote) setSrc(remote);
}}
/>
) : null}
</div>
);
}
export function jobTitle(job: DownloadJob): string {
return job.display_name || job.asset?.title || job.source_ref;
}
// The channel to show under the title: a user override wins over the auto-extracted asset value.
// `url` is null for sources without a channel link (e.g. reddit) — then it renders as plain text.
function jobChannel(job: DownloadJob): { name: string; url: string | null } {
return {
name: job.display_uploader || job.asset?.uploader || job.source_ref,
url: job.display_uploader_url || job.asset?.uploader_url || null,
};
}
export function IconBtn({
onClick,
title,
danger,
children,
}: {
onClick: () => void;
title: string;
danger?: boolean;
children: ReactNode;
}) {
return (
<button
onClick={onClick}
title={title}
className={clsx(
"p-1.5 rounded-md hover:bg-surface text-muted transition",
danger ? "hover:text-danger" : "hover:text-fg",
)}
>
{children}
</button>
);
}
// Compact display of a URL: drop the protocol + trailing slash so "youtube.com/watch?v=…" fits.
function displayUrl(url: string): string {
try {
const u = new URL(url);
return (u.host + u.pathname + u.search).replace(/\/$/, "");
} catch {
return url;
}
}
// "Downloaded from" reference: opens the original page in a new tab, or copies the link.
function SourceRef({ url }: { url: string }) {
const { t } = useTranslation();
const copy = async () => {
if (await copyText(url)) {
notify({ level: "success", message: t("downloads.source.copied") });
} else {
notify({ level: "error", message: t("downloads.source.copyFailed") });
}
};
return (
<div className="flex items-center gap-1 mt-1 text-xs text-muted min-w-0">
<Link2 className="w-3 h-3 shrink-0" />
<a
href={url}
target="_blank"
rel="noopener noreferrer"
title={`${t("downloads.source.open")} — ${url}`}
className="truncate hover:text-accent hover:underline"
>
{displayUrl(url)}
</a>
<button
onClick={copy}
aria-label={t("downloads.source.copy")}
title={t("downloads.source.copy")}
className="shrink-0 p-0.5 rounded-sm hover:bg-surface hover:text-fg transition"
>
<Copy className="w-3 h-3" />
</button>
</div>
);
}
// Channel under the title — a clickable link when a channel URL is known, plain text otherwise.
function ChannelLine({ job }: { job: DownloadJob }) {
const { name, url } = jobChannel(job);
if (url) {
return (
<div className="text-xs mt-0.5 truncate">
<a
href={url}
target="_blank"
rel="noopener noreferrer"
title={url}
className="text-muted hover:text-accent hover:underline"
>
{name}
</a>
</div>
);
}
return <div className="text-xs text-muted truncate mt-0.5">{name}</div>;
}
export function DownloadRow({
job,
onShowError,
children,
}: {
job: DownloadJob;
onShowError?: (job: DownloadJob) => void;
children?: ReactNode;
}) {
const { t } = useTranslation();
const running = job.status === "running";
return (
<div className="flex gap-3 p-2.5 rounded-xl glass-card glass-hover transition">
<Thumb job={job} />
<div className="min-w-0 flex-1">
<div className="font-medium leading-snug line-clamp-1 flex items-center gap-1.5">
{job.job_kind === "edit" && (
<span className="inline-flex items-center gap-1 shrink-0 px-1.5 py-0.5 rounded-sm text-3xs font-semibold uppercase tracking-wide bg-accent/15 text-accent">
<Scissors className="w-3 h-3" /> {t("downloads.clipBadge")}
</span>
)}
<span className="truncate">{jobTitle(job)}</span>
</div>
<ChannelLine job={job} />
<div className="flex items-center gap-2 mt-1 text-xs">
<StatusPill job={job} />
{job.asset?.size_bytes ? (
<span className="text-muted">· {formatBytes(job.asset.size_bytes)}</span>
) : null}
{job.asset?.duration_s ? (
<span className="text-muted">· {formatDuration(job.asset.duration_s)}</span>
) : null}
{job.status === "done" && job.created_at ? (
<span
className="text-muted inline-flex items-center gap-1"
title={new Date(job.created_at).toLocaleString()}
>
· <CalendarClock className="w-3 h-3" /> {relativeTime(job.created_at)}
</span>
) : null}
{job.status === "error" ? (
<button
onClick={() => onShowError?.(job)}
className="text-danger hover:opacity-80 inline-flex items-center gap-1 truncate"
title={t("downloads.errors.fail.title")}
>
· {t(`downloads.errors.fail.cat.${job.error_code || "unknown"}.short`)}
<span className="text-muted underline">{t("downloads.errors.fail.detailsLink")}</span>
</button>
) : null}
</div>
{job.source_url && <SourceRef url={job.source_url} />}
{job.extra_links?.map((url) => (
<SourceRef key={url} url={url} />
))}
{running && (
<div className="mt-1.5">
{job.phase && !DOWNLOAD_PHASES.includes(job.phase) ? (
// ffmpeg post-steps aren't byte-progress — show an indeterminate pulse, no %.
<div className="h-1.5 rounded-full bg-surface overflow-hidden">
<div className="h-full w-full bg-accent/60 animate-pulse" />
</div>
) : (
<>
<div className="h-1.5 rounded-full bg-surface overflow-hidden">
<div
className="h-full bg-accent transition-all"
style={{ width: `${job.progress}%` }}
/>
</div>
<div className="text-2xs text-muted mt-0.5 flex gap-2">
<span>{job.progress}%</span>
{job.speed_bps ? <span>{formatSpeed(job.speed_bps)}</span> : null}
{job.eta_s ? <span>{formatEta(job.eta_s)}</span> : null}
</div>
</>
)}
</div>
)}
</div>
<div className="flex items-start gap-0.5 shrink-0">{children}</div>
</div>
);
}
export function UsageBar() {
const { t } = useTranslation();
// Poll live (like the library list) so the footprint updates the moment the worker finishes an
// edit/download — enqueue only creates a queued job (0 bytes), the size lands asynchronously, so
// a one-shot fetch at enqueue time would show a stale 0 B until a manual reload.
const usage = useLiveQuery(qk.downloadUsage(), api.downloadUsage, { intervalMs: 2000 });
const u = usage.data;
if (!u) return null;
const pct =
u.unlimited || !u.max_bytes ? 0 : Math.min(100, (u.footprint_bytes / u.max_bytes) * 100);
return (
<div className="rounded-xl glass-card p-3 mb-4">
<div className="flex justify-between text-sm mb-1.5">
<span className="font-medium">{t("downloads.usage.title")}</span>
<span className="text-muted">
{formatBytes(u.footprint_bytes)}
{u.unlimited ? "" : ` / ${formatBytes(u.max_bytes)}`} ·{" "}
{u.unlimited
? t("downloads.usage.unlimited")
: t("downloads.usage.items", { used: u.active_jobs, max: u.max_jobs })}
</span>
</div>
{!u.unlimited && (
<div className="h-2 rounded-full bg-surface overflow-hidden">
<div
className={clsx("h-full transition-all", pct > 90 ? "bg-danger" : "bg-accent")}
style={{ width: `${pct}%` }}
/>
</div>
)}
</div>
);
}
@@ -0,0 +1,95 @@
// @vitest-environment jsdom
//
// NavigationProvider's URL-derived seeds, backfilling the E4 deep-review's deferred coverage:
// • loadInitialPage — the bookmark deep-link precedence (?page > ?v > ?pl > filters/?chan > the
// account's stored landing page > feed).
// • channelViewFrom — the shared _chan/?chan resolver behind BOTH the channelView seed and the
// onPop handler (the S1 fix): a popstate landing on a URL-only bookmark entry must restore the
// channel from ?chan, not close it.
// Only getAccountRaw is stubbed (the account's stored landing page); everything else is real.
import { describe, it, expect, beforeEach, vi } from "vitest";
const { stored } = vi.hoisted(() => ({ stored: {} as Record<string, string | null> }));
vi.mock("../lib/storage", async (importOriginal) => {
const actual = await importOriginal<typeof import("../lib/storage")>();
return { ...actual, getAccountRaw: (k: string) => stored[k] ?? null };
});
import { loadInitialPage, channelViewFrom } from "./navigationInit";
import { LS } from "../lib/storage";
const setUrl = (search: string) => window.history.replaceState(null, "", search || "/");
beforeEach(() => {
setUrl("/");
for (const k of Object.keys(stored)) delete stored[k];
});
describe("loadInitialPage precedence", () => {
it("?page wins and is validated", () => {
setUrl("?page=stats");
expect(loadInitialPage()).toBe("stats");
});
it("an invalid ?page falls back to feed", () => {
setUrl("?page=bogus");
expect(loadInitialPage()).toBe("feed");
});
it("?v implies the plex module", () => {
setUrl("?v=show:1");
expect(loadInitialPage()).toBe("plex");
});
it("?pl implies playlists", () => {
setUrl("?pl=3");
expect(loadInitialPage()).toBe("playlists");
});
it("a filter param implies the feed", () => {
setUrl("?tags=1,2");
expect(loadInitialPage()).toBe("feed");
});
it("?chan (the channel overlay) implies the feed", () => {
setUrl("?chan=UC1");
expect(loadInitialPage()).toBe("feed");
});
it("?page beats a lower-precedence param", () => {
setUrl("?page=stats&v=show:1");
expect(loadInitialPage()).toBe("stats");
});
it("?v beats ?pl and filters", () => {
setUrl("?v=show:1&pl=3&tags=1");
expect(loadInitialPage()).toBe("plex");
});
it("a bare URL uses the account's stored landing page", () => {
stored[LS.page] = "downloads";
expect(loadInitialPage()).toBe("downloads");
});
it("a bare URL with no / invalid stored page defaults to feed", () => {
expect(loadInitialPage()).toBe("feed");
stored[LS.page] = "nope";
expect(loadInitialPage()).toBe("feed");
});
});
describe("channelViewFrom", () => {
it("reads _chan (+ _chanName) from an in-app entry", () => {
expect(channelViewFrom({ _chan: "UC1", _chanName: "Chan" }, "")).toEqual({
id: "UC1",
name: "Chan",
});
});
it("_chan without a name yields an undefined name", () => {
expect(channelViewFrom({ _chan: "UC1" }, "")).toEqual({ id: "UC1", name: undefined });
});
it("falls back to the ?chan URL param when the entry has no _chan (fresh bookmark) — the S1 fix", () => {
expect(channelViewFrom(null, "?chan=UC9")).toEqual({ id: "UC9", name: undefined });
});
it("prefers _chan over the URL param", () => {
expect(channelViewFrom({ _chan: "UCstate" }, "?chan=UCurl")).toEqual({
id: "UCstate",
name: undefined,
});
});
it("returns null when neither is present (channel closed)", () => {
expect(channelViewFrom({}, "")).toBeNull();
expect(channelViewFrom(null, "/")).toBeNull();
});
});
+39
View File
@@ -0,0 +1,39 @@
// URL-derived navigation seeds, split out of NavigationProvider so they stay unit-testable AND the
// provider file keeps react-refresh happy (component-only exports). `loadInitialPage` picks the landing
// module from a bookmark's params; `channelViewFrom` is the shared _chan/?chan resolver behind BOTH the
// channelView seed and the onPop handler.
import { hasFilterParams, isPage, readPage, type Page } from "../lib/urlState";
import { getAccountRaw, LS } from "../lib/storage";
export const PAGE_KEY = LS.page;
export interface ChannelView {
id: string;
name?: string;
}
export function loadInitialPage(): Page {
const params = new URLSearchParams(window.location.search);
if (params.has("page")) return readPage();
// A view-specific param implies its page, so a bookmarked deep link lands on the right module even
// without an explicit ?page. Feed filters (or a channel overlay) imply the feed.
if (params.has("v")) return "plex";
if (params.has("pl")) return "playlists";
if (hasFilterParams(params) || params.has("chan")) return "feed";
// A bare URL → the account's stored landing page.
const stored = getAccountRaw(PAGE_KEY);
return isPage(stored) ? stored : "feed";
}
/** Resolve the open channel page from a landed history entry: the in-app `_chan` id (with its optional
* `_chanName`), else the ?chan URL param — a fresh ?chan bookmark's entry is URL-only. The seed AND
* the popstate handler share this ONE resolver so a popstate that lands on a bookmark entry (no
* `_chan`) restores the channel from the URL instead of wrongly closing it. */
export function channelViewFrom(
state: { _chan?: unknown; _chanName?: unknown } | null | undefined,
search: string,
): ChannelView | null {
const id =
(state?._chan as string | undefined) ?? new URLSearchParams(search).get("chan") ?? undefined;
return id ? { id, name: (state?._chanName as string) ?? undefined } : null;
}
@@ -0,0 +1,115 @@
import { useTranslation } from "react-i18next";
import { Repeat, Repeat1, Shuffle, SkipBack, SkipForward } from "lucide-react";
// Presentational controls for the YouTube modal player (PlayerModal), split out of the god component —
// the sibling of plexPlayerControls.tsx. No state or effects here; all behaviour comes in via props.
// Persistent playback settings (stored in users.preferences). Auto-advance = what plays when a video
// ends; loop = whether it repeats the current video ("one"), wraps the list at its ends ("all"), or
// neither ("off"). Both apply to any queued player (feed or playlist).
export type AutoMode = "off" | "next" | "prev" | "random";
export type LoopMode = "off" | "one" | "all";
export const AUTO_MODES: AutoMode[] = ["off", "next", "prev", "random"];
export const LOOP_MODES: LoopMode[] = ["off", "one", "all"];
/** The queue transport cluster — prev / counter / next, then the auto-advance and loop mode toggles.
* Rendered in two places with identical logic: the modal's queue bar (`variant:"modal"`, normal glass
* theme, labels always shown) and the big-view docked control bar (`variant:"docked"`, white-on-dark,
* labels hidden until a breakpoint). Only the theme + label-visibility differ; the behaviour is one,
* so a mode/icon change lands once. The wrapper (fragment vs. bordered row) stays at the call site. */
export function PlayerTransportControls({
variant,
index,
queueLength,
autoMode,
loopMode,
onPrev,
onNext,
onCycleAuto,
onCycleLoop,
}: {
variant: "docked" | "modal";
index: number;
queueLength: number;
autoMode: AutoMode;
loopMode: LoopMode;
onPrev: () => void;
onNext: () => void;
onCycleAuto: () => void;
onCycleLoop: () => void;
}) {
const { t } = useTranslation();
const docked = variant === "docked";
const navBtn = docked
? "inline-flex items-center gap-1 text-white/80 enabled:hover:text-white disabled:opacity-30 transition"
: "inline-flex items-center gap-1.5 text-muted enabled:hover:text-fg disabled:opacity-40 transition";
const navLabel = docked ? "hidden sm:inline" : "";
const counter = docked ? "tabular-nums text-white/70" : "text-muted tabular-nums";
const divider = docked ? "h-4 w-px bg-white/20" : "w-px h-4 bg-border";
const toggle = (on: boolean) =>
docked
? `inline-flex items-center gap-1 rounded-lg px-2 py-1 transition ${
on ? "bg-white/20 text-white" : "text-white/70 hover:bg-white/10 hover:text-white"
}`
: `inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1 transition ${
on ? "bg-accent/15 text-accent" : "text-muted hover:bg-surface hover:text-fg"
}`;
const toggleLabel = docked ? "hidden md:inline" : "";
return (
<>
<button
onClick={onPrev}
disabled={index === 0}
title={`${t("player.previous")} · Shift+←`}
className={navBtn}
>
<SkipBack className="h-4 w-4" />
<span className={navLabel}>{t("player.previous")}</span>
</button>
<span className={counter}>
{index + 1} / {queueLength}
</span>
<button
onClick={onNext}
disabled={index === queueLength - 1}
title={`${t("player.next")} · Shift+→`}
className={navBtn}
>
<span className={navLabel}>{t("player.next")}</span>
<SkipForward className="h-4 w-4" />
</button>
<span className={divider} />
{/* Persistent playback settings — cycle on click, saved to your account. */}
<button
onClick={onCycleAuto}
title={t("player.autoAdvance.hint")}
className={toggle(autoMode !== "off")}
>
{autoMode === "prev" ? (
<SkipBack className="h-3.5 w-3.5" />
) : autoMode === "random" ? (
<Shuffle className="h-3.5 w-3.5" />
) : (
<SkipForward className="h-3.5 w-3.5" />
)}
<span className={toggleLabel}>
{t("player.autoAdvance.label")}: {t(`player.autoAdvance.${autoMode}`)}
</span>
</button>
<button
onClick={onCycleLoop}
title={t("player.loop.hint")}
className={toggle(loopMode !== "off")}
>
{loopMode === "one" ? (
<Repeat1 className="h-3.5 w-3.5" />
) : (
<Repeat className="h-3.5 w-3.5" />
)}
<span className={toggleLabel}>
{t("player.loop.label")}: {t(`player.loop.${loopMode}`)}
</span>
</button>
</>
);
}
@@ -0,0 +1,62 @@
// @vitest-environment jsdom
//
// readSelectedId: the Playlists selected-id seed. Backfills the E4 deep-review's deferred ?pl guard
// coverage — a ?pl bookmark wins over the stored selection but only when it's a positive integer;
// anything else (empty / zero / negative / fractional / non-numeric) falls back to the stored id.
import { describe, it, expect, beforeEach, vi } from "vitest";
const { stored } = vi.hoisted(() => ({ stored: { v: null as string | null } }));
vi.mock("../lib/storage", async (importOriginal) => {
const actual = await importOriginal<typeof import("../lib/storage")>();
return { ...actual, getAccountRaw: () => stored.v };
});
import { readSelectedId } from "./playlistsInit";
const setUrl = (search: string) => window.history.replaceState(null, "", search || "/");
beforeEach(() => {
setUrl("/");
stored.v = null;
});
describe("readSelectedId ?pl guard", () => {
it("accepts a positive-integer ?pl", () => {
setUrl("?pl=5");
expect(readSelectedId()).toBe(5);
});
it("a ?pl bookmark wins over the stored selection", () => {
setUrl("?pl=5");
stored.v = "7";
expect(readSelectedId()).toBe(5);
});
it.each(["", "0", "-2", "1.5", "abc"])("rejects ?pl=%s and falls back to stored", (raw) => {
setUrl(`?pl=${raw}`);
stored.v = "7";
expect(readSelectedId()).toBe(7);
});
it("rejects a bad ?pl and returns null when there is no stored selection", () => {
setUrl("?pl=0");
expect(readSelectedId()).toBeNull();
});
it("uses the stored selection when there is no ?pl", () => {
stored.v = "9";
expect(readSelectedId()).toBe(9);
});
it.each(["", "0", "-2", "1.5", "abc"])(
"rejects a non-positive-integer STORED value %s (guarded like the URL)",
(raw) => {
stored.v = raw;
expect(readSelectedId()).toBeNull();
},
);
it("returns null when neither ?pl nor a stored selection is present", () => {
expect(readSelectedId()).toBeNull();
});
});
+14
View File
@@ -0,0 +1,14 @@
import { getAccountRaw, LS } from "../lib/storage";
import { parsePositiveIntId } from "../lib/parseId";
// The Playlists module's selected-id seed, split out of PlaylistsProvider so its ?pl guard is
// unit-testable and the provider stays react-refresh-clean (component-only exports).
export function readSelectedId(): number | null {
// A ?pl bookmark wins over the stored selection so a pasted link opens that playlist. BOTH must be a
// positive-integer id (a bare "?pl=" gives Number("")===0, "?pl=1.5" a fractional id) — either would
// select a non-existent playlist, so the stored fallback is guarded exactly like the URL.
return (
parsePositiveIntId(new URLSearchParams(window.location.search).get("pl")) ??
parsePositiveIntId(getAccountRaw(LS.playlist))
);
}
@@ -0,0 +1,102 @@
// @vitest-environment jsdom
//
// Plex card a11y: locks the accessible names on the icon-only card controls (the E4 a11y sweep). These
// buttons are icon-only (WatchedIcon / Info / ListPlus, no text), so their name comes solely from the
// aria-label — a dropped label silently reintroduces an unnamed button for screen-reader users. i18n is
// key-passthrough, so the asserted names are the i18n keys.
import { describe, it, expect, afterEach, vi } from "vitest";
import { render, screen, cleanup } from "@testing-library/react";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
});
import { PlexPosterCard, SeasonCard, EpisodeCard } from "./plexCards";
import type { PlexCard, PlexSeasonDetail } from "../lib/api";
const movie = (status?: string): PlexCard => ({
id: "1",
type: "movie",
title: "M",
thumb: "",
status,
});
const episode = (status?: string): PlexCard => ({
id: "e1",
type: "episode",
title: "E1",
thumb: "",
status,
});
const season = (status = "new"): PlexSeasonDetail => ({
id: "s1",
season_number: 1,
title: "S1",
thumb: "",
episode_count: 3,
status,
episodes: [],
});
afterEach(cleanup);
describe("PlexPosterCard a11y", () => {
it("names the watched-toggle (mark-watched when unwatched) and the info button", () => {
render(
<PlexPosterCard card={movie()} onOpen={vi.fn()} onInfo={vi.fn()} onToggleWatched={vi.fn()} />,
);
expect(screen.getByRole("button", { name: "plex.markWatched" })).toBeTruthy();
expect(screen.getByRole("button", { name: "plex.info.title" })).toBeTruthy();
});
it("names the watched-toggle as mark-unwatched when the movie is watched", () => {
render(
<PlexPosterCard
card={movie("watched")}
onOpen={vi.fn()}
onInfo={vi.fn()}
onToggleWatched={vi.fn()}
/>,
);
expect(screen.getByRole("button", { name: "plex.markUnwatched" })).toBeTruthy();
});
it("reveals the synopsis + rating on the hover scrim when the card carries them", () => {
render(
<PlexPosterCard
card={{ ...movie(), year: 2023, rating: 7.5, summary: "A quiet synopsis." }}
onOpen={vi.fn()}
onInfo={vi.fn()}
onToggleWatched={vi.fn()}
/>,
);
expect(screen.getByText("A quiet synopsis.")).toBeTruthy();
expect(screen.getByText("7.5")).toBeTruthy(); // rating.toFixed(1)
});
it("omits the scrim when the card has no synopsis or rating", () => {
render(
<PlexPosterCard card={movie()} onOpen={vi.fn()} onInfo={vi.fn()} onToggleWatched={vi.fn()} />,
);
expect(screen.queryByText(/synopsis/i)).toBeNull();
});
});
describe("SeasonCard a11y", () => {
it("names the watched-toggle and the add-season button", () => {
render(<SeasonCard se={season()} onOpen={vi.fn()} onToggleWatched={vi.fn()} onAdd={vi.fn()} />);
expect(screen.getByRole("button", { name: "plex.series.markSeasonWatched" })).toBeTruthy();
expect(screen.getByRole("button", { name: "plex.playlist.addSeason" })).toBeTruthy();
});
});
describe("EpisodeCard a11y", () => {
it("names the watched-toggle and the add-episode button", () => {
render(
<EpisodeCard ep={episode()} onPlay={vi.fn()} onToggleWatched={vi.fn()} onAdd={vi.fn()} />,
);
expect(screen.getByRole("button", { name: "plex.markWatched" })).toBeTruthy();
expect(screen.getByRole("button", { name: "plex.playlist.addEpisode" })).toBeTruthy();
});
});
+540
View File
@@ -0,0 +1,540 @@
import { useTranslation } from "react-i18next";
import { CheckCircle2, Circle, Film, Info, ListPlus, Play, Star, Tv2 } from "lucide-react";
import { type CSSProperties } from "react";
import { formatRuntime } from "../lib/format";
import { Carousel } from "./Carousel";
import type { PlexCard, PlexCastMember, PlexFilters, PlexSeasonDetail } from "../lib/api";
// Presentational Plex cards + strips, split out of PlexBrowse (all purely prop-driven, no shared
// state). Rendered by the browse grid, the person page, and the series show/season views.
// Playback cost, escalating good→heavy: direct (green) → remux, a light repackage
// (amber) → transcode, a full re-encode (red). Reuses the status trio as a gradient;
// all three stay visually distinct.
const PLAYABLE_TINT: Record<string, string> = {
direct: "bg-success",
remux: "bg-warn",
transcode: "bg-danger",
};
// The reveal class for a status pill (watched / in-progress) that shares a corner with a hover-reveal
// control (the watched-toggle). When that control is present it takes over on touch, so the pill yields
// via `.reveal-idle` (hidden on touch, faded on hover); when there's no such control the pill is the
// sole indicator and just fades on hover. (The single-episode card, where the pill is itself the
// full-size watched marker with no fade, keeps its own inline rule.)
const swapBadgeReveal = (hasControl: boolean): string =>
hasControl ? "reveal-idle" : "group-hover:opacity-0";
// Watched-toggle icon. Distinguishes state by SHAPE (a filled check vs an empty circle), not color
// alone — on touch the toggle is the only surviving watched indicator (the text pill is hidden), so a
// green-vs-white CheckCircle2 would be a use-of-color (WCAG 1.4.1) gap. Watched = CheckCircle2 (green),
// not-watched = Circle (empty, white).
function WatchedIcon({ watched }: { watched: boolean }) {
return watched ? (
<CheckCircle2 className="w-4 h-4 text-success" />
) : (
<Circle className="w-4 h-4 text-white" />
);
}
export function PlexPosterCard({
card,
onOpen,
onInfo,
onToggleWatched,
}: {
card: PlexCard;
onOpen: () => void;
onInfo: () => void;
onToggleWatched: (c: PlexCard) => void;
}) {
const { t } = useTranslation();
const inProgress = (card.position_seconds ?? 0) > 0 && card.status !== "watched";
const isPlayable = card.type !== "show"; // movies/episodes play; shows open the drill-down
const watchedLabel = card.status === "watched" ? t("plex.markUnwatched") : t("plex.markWatched");
const runtime = formatRuntime(card.duration_seconds);
const pct =
inProgress && card.duration_seconds
? Math.min(100, Math.round(((card.position_seconds ?? 0) / card.duration_seconds) * 100))
: 0;
return (
<div
className="group text-left"
// content-visibility keeps off-screen posters cheap to render (smoother long-list scroll).
style={{ contentVisibility: "auto", containIntrinsicSize: "auto 300px" } as CSSProperties}
>
<div
onClick={onOpen}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onOpen();
}
}}
className="glass-card glass-hover relative aspect-2/3 rounded-xl overflow-hidden transition-all duration-200 cursor-pointer"
>
<img
src={card.thumb}
alt=""
loading="lazy"
decoding="async"
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200"
/>
{/* Playable-cost tint (always shown — a quick "will it direct-play" cue). */}
{card.playable && (
<span
className={`absolute top-1.5 left-1.5 w-2 h-2 rounded-full ring-2 ring-black/40 ${PLAYABLE_TINT[card.playable] ?? "bg-gray-400"}`}
title={t(`plex.playable.${card.playable}`)}
/>
)}
{/* Rest-state watched badge (top-right). Yields to the hover quick-actions on a playable card
(reveal-idle: gone on touch where the toggle is always shown); a watched show keeps it. */}
{card.status === "watched" && (
<span
className={`pointer-events-none absolute top-1.5 right-1.5 text-3xs bg-black/70 text-white px-1.5 py-0.5 rounded-sm ${swapBadgeReveal(
isPlayable,
)}`}
>
{t("plex.watched")}
</span>
)}
{/* Aggregate in-progress badge for a partially-watched show. */}
{card.type === "show" && card.status === "in_progress" && (
<span className="pointer-events-none absolute top-1.5 right-1.5 text-3xs bg-black/75 text-accent px-1.5 py-0.5 rounded-sm group-hover:opacity-0">
{t("plex.inProgress")}
</span>
)}
{/* Rest-state bottom badges — hidden on hover, where the scrim takes over the bottom. */}
{card.type === "show" && card.season_count != null && (
<span className="pointer-events-none absolute bottom-1.5 left-1.5 text-3xs bg-black/70 text-white px-1.5 py-0.5 rounded-sm transition-opacity group-hover:opacity-0">
{t("plex.seasons", { count: card.season_count })}
</span>
)}
<span
className="pointer-events-none absolute bottom-1.5 right-1.5 rounded-sm bg-black/65 p-0.5 text-white/90 transition-opacity group-hover:opacity-0"
title={t(
card.type === "show" ? "plex.filter.scopeOpt.show" : "plex.filter.scopeOpt.movie",
)}
>
{card.type === "show" ? <Tv2 className="w-3 h-3" /> : <Film className="w-3 h-3" />}
</span>
{/* Hover reveal: a soft dim, a less-flat play ring, and a bottom scrim that slides up with the
synopsis + rating (the info you'd otherwise click through for). */}
<div className="absolute inset-0 opacity-0 transition-opacity duration-200 group-hover:opacity-100">
<div className="absolute inset-0 bg-black/25" />
<div className="absolute inset-x-0 top-[30%] flex justify-center">
<div
className="grid h-12 w-12 scale-90 place-items-center rounded-full text-white shadow-xl transition-transform duration-200 group-hover:scale-100"
style={{
background:
"radial-gradient(circle at 38% 32%, color-mix(in srgb, var(--accent) 72%, white), var(--accent))",
boxShadow:
"0 10px 22px -6px color-mix(in srgb, var(--accent) 60%, transparent), 0 0 0 4px color-mix(in srgb, var(--accent) 22%, transparent)",
}}
>
<Play className="w-5 h-5 translate-x-px fill-current" />
</div>
</div>
{(card.summary || card.rating != null) && (
<div className="absolute inset-x-0 bottom-0 translate-y-1.5 bg-gradient-to-t from-black/95 via-black/85 to-transparent p-2.5 transition-transform duration-200 group-hover:translate-y-0">
<div className="line-clamp-1 text-xs font-semibold leading-snug text-white">
{card.title}
</div>
<div className="mt-0.5 flex items-center gap-1.5 text-2xs text-white/75">
{(card.year || runtime) && (
<span>{[card.year, runtime].filter(Boolean).join(" · ")}</span>
)}
{card.rating != null && (
<span className="inline-flex items-center gap-0.5 text-amber-300">
<Star className="w-3 h-3 fill-current" />
{card.rating.toFixed(1)}
</span>
)}
</div>
{card.summary && (
<p className="mt-1 line-clamp-3 text-2xs leading-snug text-white/85">
{card.summary}
</p>
)}
</div>
)}
</div>
{/* Quick actions (playable cards): Info + watched toggle — revealed on hover, always on touch. */}
{isPlayable && (
<div className="reveal absolute right-1.5 top-1.5 z-10 flex gap-1">
<button
onClick={(e) => {
e.stopPropagation();
onInfo();
}}
title={t("plex.info.title")}
aria-label={t("plex.info.title")}
className="rounded-full bg-black/60 p-1 transition hover:bg-black/80"
>
<Info className="h-4 w-4 text-white" />
</button>
<button
onClick={(e) => {
e.stopPropagation();
onToggleWatched(card);
}}
title={watchedLabel}
aria-label={watchedLabel}
className="rounded-full bg-black/60 p-1 transition hover:bg-black/80"
>
<WatchedIcon watched={card.status === "watched"} />
</button>
</div>
)}
{pct > 0 && (
<div className="absolute inset-x-0 bottom-0 z-10 h-1 bg-black/40">
<div className="h-full bg-accent" style={{ width: `${pct}%` }} />
</div>
)}
</div>
<div
onClick={onOpen}
className="mt-1.5 rounded-lg px-2 py-1.5 cursor-pointer"
style={{ background: "color-mix(in srgb, var(--card) 60%, transparent)" }}
>
<div className="text-sm font-medium line-clamp-2 leading-tight min-h-[2lh] hover:text-accent">
{card.title}
</div>
<div className="text-xs text-muted">
{[card.year, formatRuntime(card.duration_seconds)].filter(Boolean).join(" · ")}
</div>
</div>
</div>
);
}
export function SeasonCard({
se,
onOpen,
onToggleWatched,
onAdd,
}: {
se: PlexSeasonDetail;
onOpen: () => void;
onToggleWatched?: () => void;
onAdd?: () => void;
}) {
const { t } = useTranslation();
const watched = se.status === "watched";
const watchedLabel = watched
? t("plex.series.markSeasonUnwatched")
: t("plex.series.markSeasonWatched");
return (
<div className="group text-left">
<div
onClick={onOpen}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onOpen();
}
}}
className="glass-card glass-hover relative aspect-2/3 rounded-xl overflow-hidden transition-all duration-200 cursor-pointer"
>
<img
src={se.thumb}
alt=""
loading="lazy"
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200"
/>
<div className="absolute inset-0 bg-black/45 opacity-0 group-hover:opacity-100 transition grid place-items-center">
<div className="flex flex-col items-center gap-1 text-white">
<Play className="w-9 h-9" fill="currentColor" />
<span className="text-xs font-medium">{t("plex.openShow")}</span>
</div>
</div>
{/* Quick watched toggle (whole season), on hover. */}
{onToggleWatched && (
<button
onClick={(e) => {
e.stopPropagation();
onToggleWatched();
}}
title={watchedLabel}
aria-label={watchedLabel}
className="absolute top-1.5 right-1.5 p-1 rounded-full bg-black/60 reveal hover:bg-black/80 transition"
>
<WatchedIcon watched={watched} />
</button>
)}
{/* Add whole season to a playlist, on hover. */}
{onAdd && (
<button
onClick={(e) => {
e.stopPropagation();
onAdd();
}}
title={t("plex.playlist.addSeason")}
aria-label={t("plex.playlist.addSeason")}
className="absolute bottom-1.5 right-1.5 p-1 rounded-full bg-black/60 reveal hover:bg-black/80 transition"
>
<ListPlus className="w-4 h-4 text-white" />
</button>
)}
{watched && (
<span
className={`pointer-events-none absolute top-1.5 right-1.5 text-3xs bg-black/70 text-white px-1.5 py-0.5 rounded-sm ${swapBadgeReveal(
!!onToggleWatched,
)}`}
>
{t("plex.watched")}
</span>
)}
{/* In-progress lives at the top-LEFT (the watched-toggle owns the top-right) and stays visible
on touch: unlike movie/episode cards, a season has no progress bar, and the always-shown
toggle reads as unwatched for a partial season — so this badge is the sole in-progress cue
and must not be a reveal-idle swap-badge (which display:none-s on touch). */}
{se.status === "in_progress" && (
<span className="pointer-events-none absolute top-1.5 left-1.5 text-3xs bg-black/75 text-accent px-1.5 py-0.5 rounded-sm">
{t("plex.inProgress")}
</span>
)}
<span className="absolute bottom-1.5 left-1.5 text-3xs bg-black/70 text-white px-1.5 py-0.5 rounded-sm">
{t("plex.series.episodeCount", { count: se.episode_count })}
</span>
</div>
<div
onClick={onOpen}
className="mt-1.5 rounded-lg px-2 py-1.5 text-sm font-medium line-clamp-2 leading-tight cursor-pointer hover:text-accent"
style={{ background: "color-mix(in srgb, var(--card) 60%, transparent)" }}
>
{se.title}
</div>
</div>
);
}
export function CastStrip({
cast,
onFilter,
onOpenPerson,
}: {
cast: PlexCastMember[];
onFilter?: (patch: Partial<PlexFilters>) => void;
onOpenPerson?: (name: string) => void;
}) {
const { t } = useTranslation();
const clickable = !!(onOpenPerson || onFilter);
return (
<section
className="glass-card rounded-2xl p-4 sm:p-5 mb-6"
style={{ background: "color-mix(in srgb, var(--card) 55%, transparent)" }}
>
<h2 className="text-sm font-semibold mb-3">{t("plex.info.cast")}</h2>
<Carousel gap="gap-4" ariaLabel={t("plex.info.cast")}>
{cast.map((c, i) => {
const inner = (
<>
<div
className={`mx-auto h-20 w-20 overflow-hidden rounded-full border bg-surface ${clickable ? "border-border group-hover/cast:border-accent" : "border-border"}`}
>
{c.thumb ? (
<img src={c.thumb} alt="" loading="lazy" className="h-full w-full object-cover" />
) : (
<div className="grid h-full w-full place-items-center opacity-40">
{c.name.charAt(0)}
</div>
)}
</div>
{/* Name + role are each capped to ONE line (truncate → ellipsis) so every tile has the
same text height and the row of circles sits on one baseline regardless of name length. */}
<div
className={`mt-1.5 truncate text-xs font-medium leading-tight ${clickable ? "group-hover/cast:text-accent" : ""}`}
title={c.name}
>
{c.name}
</div>
{c.role && (
<div className="truncate text-2xs text-muted" title={c.role}>
{c.role}
</div>
)}
</>
);
return clickable ? (
<button
key={i}
onClick={() =>
onOpenPerson ? onOpenPerson(c.name) : onFilter?.({ actors: [c.name] })
}
title={
onOpenPerson
? t("plex.info.viewPerson", { name: c.name })
: t("plex.info.filterActor", { name: c.name })
}
className="group/cast w-20 shrink-0 text-center"
>
{inner}
</button>
) : (
<div key={i} className="w-20 shrink-0 text-center">
{inner}
</div>
);
})}
</Carousel>
</section>
);
}
export function RelatedStrip({
related,
onOpen,
}: {
related: PlexCard[];
onOpen: (id: string) => void;
}) {
const { t } = useTranslation();
return (
<section
className="glass-card rounded-2xl p-4 sm:p-5 mb-6"
style={{ background: "color-mix(in srgb, var(--card) 55%, transparent)" }}
>
<h2 className="text-sm font-semibold mb-3">{t("plex.series.related")}</h2>
<Carousel gap="gap-3" ariaLabel={t("plex.series.related")}>
{related.map((r) => (
<button
key={r.id}
onClick={() => onOpen(r.id)}
className="group w-[130px] shrink-0 text-left"
>
<div className="glass-card glass-hover relative aspect-2/3 rounded-xl overflow-hidden transition-all duration-200">
<img
src={r.thumb}
alt=""
loading="lazy"
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200"
/>
{r.status === "watched" && (
<span className="absolute top-1.5 right-1.5 text-3xs bg-black/70 text-white px-1.5 py-0.5 rounded-sm">
{t("plex.watched")}
</span>
)}
</div>
<div className="mt-1.5 text-sm font-medium line-clamp-2 leading-tight min-h-[2lh]">
{r.title}
</div>
</button>
))}
</Carousel>
</section>
);
}
export function EpisodeCard({
ep,
onPlay,
onAdd,
onToggleWatched,
withShowTitle,
}: {
ep: PlexCard;
onPlay: () => void;
onAdd?: () => void;
onToggleWatched?: () => void;
withShowTitle?: boolean;
}) {
const { t } = useTranslation();
const inProgress = (ep.position_seconds ?? 0) > 0 && ep.status !== "watched";
const watchedLabel = ep.status === "watched" ? t("plex.markUnwatched") : t("plex.markWatched");
const pct =
inProgress && ep.duration_seconds
? Math.min(100, Math.round(((ep.position_seconds ?? 0) / ep.duration_seconds) * 100))
: 0;
return (
<div className="group">
<div
onClick={onPlay}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onPlay();
}
}}
className="relative block w-full aspect-video rounded-xl overflow-hidden bg-card border border-border text-left cursor-pointer"
>
<img
src={ep.thumb}
alt=""
loading="lazy"
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200"
/>
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition grid place-items-center">
<Play className="w-9 h-9 text-white" fill="currentColor" />
</div>
{/* Quick watched toggle (single episode), on hover. */}
{onToggleWatched && (
<button
onClick={(e) => {
e.stopPropagation();
onToggleWatched();
}}
title={watchedLabel}
aria-label={watchedLabel}
className="absolute top-1.5 right-1.5 p-1 rounded-full bg-black/60 reveal hover:bg-black/80 transition"
>
<WatchedIcon watched={ep.status === "watched"} />
</button>
)}
{ep.status === "watched" && (
<span
className={`pointer-events-none absolute top-1.5 right-1.5 p-1 rounded-full bg-black/60 ${
onToggleWatched ? "reveal-idle" : ""
}`}
>
<CheckCircle2 className="w-4 h-4 text-success" />
</span>
)}
{pct > 0 && (
<div className="absolute bottom-0 inset-x-0 h-1 bg-black/40">
<div className="h-full bg-accent" style={{ width: `${pct}%` }} />
</div>
)}
</div>
<div
className="mt-1.5 flex items-start gap-1.5 rounded-lg px-2 py-1.5"
style={{ background: "color-mix(in srgb, var(--card) 60%, transparent)" }}
>
<div className="min-w-0 flex-1">
{withShowTitle && ep.show_title && (
<div className="text-2xs text-muted truncate">
{ep.show_title}
{ep.season_number != null && ep.episode_number != null
? ` · S${ep.season_number}·E${ep.episode_number}`
: ""}
</div>
)}
<div className="text-sm font-medium leading-tight">
{!withShowTitle && <span className="text-muted mr-1">{ep.episode_number}.</span>}
{ep.title}
</div>
<div className="text-2xs text-muted mt-0.5">{formatRuntime(ep.duration_seconds)}</div>
</div>
{onAdd && (
<button
onClick={onAdd}
title={t("plex.playlist.addEpisode")}
aria-label={t("plex.playlist.addEpisode")}
className="shrink-0 rounded-sm p-1 text-muted transition hover:text-accent"
>
<ListPlus className="w-4 h-4" />
</button>
)}
</div>
</div>
);
}
@@ -0,0 +1,71 @@
// PLEX_SUB_URL encode/decode round-trip + guards. Extracted from PlexBrowse for this test (E4
// deep-review deferred gap). Pure — no DOM, no history.
import { describe, it, expect } from "vitest";
import { PLEX_SUB_URL as U, type Sub } from "./plexSubUrl";
describe("PLEX_SUB_URL encode", () => {
it("mirrors each bookmarkable sub-view to ?v=<kind>:<args>", () => {
expect(U.encode({ kind: "grid" })).toBe("browse"); // the flat "Browse all" grid (E5 S4)
expect(U.encode({ kind: "show", id: "s1" })).toBe("show:s1");
expect(U.encode({ kind: "season", showId: "s1", seasonId: "se2" })).toBe("season:s1:se2");
expect(U.encode({ kind: "info", id: "m9" })).toBe("info:m9");
expect(U.encode({ kind: "person", name: "Jane Doe" })).toBe("person:Jane Doe");
expect(U.encode({ kind: "playlist", id: 12 })).toBe("playlist:12");
});
it("does NOT mirror the root Discover landing or the ephemeral player (encode → null)", () => {
expect(U.encode({ kind: "discover" })).toBeNull();
expect(U.encode({ kind: "player", id: "m9" })).toBeNull();
});
});
describe("PLEX_SUB_URL round-trip", () => {
const cases: Sub[] = [
{ kind: "grid" },
{ kind: "show", id: "s1" },
{ kind: "season", showId: "s1", seasonId: "se2" },
{ kind: "info", id: "m9" },
{ kind: "person", name: "Jane Doe" },
{ kind: "playlist", id: 12 },
];
it.each(cases)("encode→decode is identity for %o", (view) => {
const raw = U.encode(view);
expect(raw).not.toBeNull();
expect(U.decode(raw as string)).toEqual(view);
});
it("a person name containing ':' round-trips (tail after the first colon)", () => {
const view: Sub = { kind: "person", name: "Dr: Strange: Origins" };
expect(U.decode(U.encode(view) as string)).toEqual(view);
});
});
describe("PLEX_SUB_URL decode guards", () => {
it("rejects a playlist id that is not a positive integer", () => {
expect(U.decode("playlist:")).toBeNull(); // Number("")===0, not >0
expect(U.decode("playlist:0")).toBeNull();
expect(U.decode("playlist:-1")).toBeNull();
expect(U.decode("playlist:1.5")).toBeNull();
expect(U.decode("playlist:abc")).toBeNull();
expect(U.decode("playlist:12")).toEqual({ kind: "playlist", id: 12 });
});
it("requires both ids for a season", () => {
expect(U.decode("season:s1")).toBeNull();
expect(U.decode("season:")).toBeNull();
expect(U.decode("season:s1:se2")).toEqual({ kind: "season", showId: "s1", seasonId: "se2" });
});
it("requires a non-empty arg for show / info / person", () => {
expect(U.decode("show:")).toBeNull();
expect(U.decode("info:")).toBeNull();
expect(U.decode("person:")).toBeNull();
});
it("rejects an unknown kind or garbage", () => {
expect(U.decode("grid")).toBeNull();
expect(U.decode("player:m9")).toBeNull(); // player is not URL-restorable
expect(U.decode("bogus")).toBeNull();
expect(U.decode("")).toBeNull();
});
});
+69
View File
@@ -0,0 +1,69 @@
import type { SubviewUrl } from "../lib/history";
import { parsePositiveIntId } from "../lib/parseId";
// The Plex module's sub-view union and its address-bar mirror, extracted from PlexBrowse so the
// encode/decode round-trip (and its guards) can be unit-tested without pulling the whole component
// (and its lazy hls.js player) into the test graph.
export type Sub =
| { kind: "discover" }
| { kind: "grid" }
| { kind: "show"; id: string }
| { kind: "season"; showId: string; seasonId: string }
| { kind: "player"; id: string; queue?: string[] }
| { kind: "info"; id: string }
| { kind: "person"; name: string }
| { kind: "playlist"; id: number };
// Mirror the Plex sub-view to ?v=<kind>:<args> so a show / season / movie-info / person / playlist page
// — and the flat browse grid — are bookmarkable. Discover is the ROOT landing (E5 S4), so it (and the
// ephemeral player) is not mirrored (encode → null); the grid gets `?v=browse` so "Browse all" is a
// distinct, shareable URL. The person name is the whole tail after the first ":", so a name containing
// ":" round-trips.
export const PLEX_SUB_URL: SubviewUrl<Sub> = {
key: "v",
encode: (v) => {
switch (v.kind) {
case "grid":
return "browse";
case "show":
return `show:${v.id}`;
case "season":
return `season:${v.showId}:${v.seasonId}`;
case "info":
return `info:${v.id}`;
case "person":
return `person:${v.name}`;
case "playlist":
return `playlist:${v.id}`;
default:
return null; // discover (root) + the ephemeral player are not URL-mirrored
}
},
decode: (raw) => {
const i = raw.indexOf(":");
const kind = i === -1 ? raw : raw.slice(0, i);
const arg = i === -1 ? "" : raw.slice(i + 1);
switch (kind) {
case "browse":
return { kind: "grid" };
case "show":
return arg ? { kind: "show", id: arg } : null;
case "info":
return arg ? { kind: "info", id: arg } : null;
case "person":
return arg ? { kind: "person", name: arg } : null;
case "playlist": {
// Positive-integer id only — a fractional/zero/negative ?v=playlist:<arg> would open a
// sub-view for a non-existent id (shares parsePositiveIntId with readSelectedId's ?pl guard).
const id = parsePositiveIntId(arg);
return id != null ? { kind: "playlist", id } : null;
}
case "season": {
const [showId, seasonId] = arg.split(":");
return showId && seasonId ? { kind: "season", showId, seasonId } : null;
}
default:
return null;
}
},
};
+699
View File
@@ -0,0 +1,699 @@
import { lazy, Suspense, useState } from "react";
import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { ArrowLeft, Layers, ListPlus, Play, RotateCcw, Star, User } from "lucide-react";
import { qk } from "../lib/queryKeys";
import { api, type PlexCard, type PlexFilters, type PlexPersonDetail } from "../lib/api";
import { plexLang } from "../i18n";
import { pickRatingBadge } from "../lib/plexRating";
import { notifyActionError } from "../lib/actionErrors";
import { toggleWatchState } from "../lib/plexWatch";
import {
ActionBtn,
BackBtn,
DetailCustomizeMenu,
DetailHero,
epLabel,
Filterable,
tmdbBackdropUrl,
useCrossfadeBackdrop,
useDetailPrefs,
WatchedToggle,
} from "../lib/plexDetailUi";
import { CastStrip, EpisodeCard, PlexPosterCard, RelatedStrip, SeasonCard } from "./plexCards";
import { Carousel } from "./Carousel";
import PlexPlaylistAdd, { type PlexAddTarget } from "./PlexPlaylistAdd";
import PlexCollectionEditor from "./PlexCollectionEditor";
import { LoadingState, StateMessage } from "./QueryState";
// The Plex sub-router's view branches, split out of PlexBrowse. Each is invoked by PlexBrowse with
// shell callbacks as props (onBack/onPlay/onFilter/…) — no view reaches back into the shell's state,
// which is what made this a pure code-motion split. Cards/strips come from plexCards, the shared
// detail-page UI from lib/plexDetailUi.
// Lazy: the rich movie info panel loads only when an info page is opened.
const PlexInfo = lazy(() => import("./PlexInfo"));
export function PlexInfoView({
id,
onBack,
onPlay,
onPlayItem,
onFilter,
onOpenPerson,
}: {
id: string;
onBack: () => void;
onPlay: () => void;
onPlayItem: (id: string) => void;
onFilter: (patch: Partial<PlexFilters>) => void;
onOpenPerson: (name: string) => void;
}) {
const { t } = useTranslation();
const lang = plexLang();
const q = useQuery({ queryKey: qk.plexItem(id, lang), queryFn: () => api.plexItem(id, lang) });
return (
<div className="w-[90%] max-w-[1600px] mx-auto">
<div className="p-4 pb-0">
<button
onClick={onBack}
className="glass-card glass-hover inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs"
>
<ArrowLeft className="w-4 h-4" />
{t("plex.backToLibrary")}
</button>
</div>
{q.isError && !q.data ? (
<StateMessage tone="error" onRetry={() => q.refetch()}>
{t("common.loadError")}
</StateMessage>
) : q.isLoading || !q.data ? (
<LoadingState label={t("plex.loading")} />
) : (
<Suspense fallback={<p className="p-8 text-muted text-sm">{t("plex.loading")}</p>}>
<PlexInfo
detail={q.data}
variant="page"
library={q.data.library ?? undefined}
onPlay={onPlay}
onPlayItem={onPlayItem}
onFilter={onFilter}
onOpenPerson={onOpenPerson}
// No onStateChange: PlexInfo.setState already invalidates qk.plexItem(id) (this very
// query), so q refetches automatically — a manual refetch here just double-fetched.
/>
</Suspense>
)}
</div>
);
}
// --- Actor detail: a cast/crew member's filmography (movies + shows in one place) ---------------
// The inverse of the cast-name → library-filter link: opened from a cast row (info/show page), it
// shows the person's headshot + name, a "filter the library by them" action, and their titles as two
// poster-card sections (Movies / Shows). Mirrors PlexInfoView's fetch-wrapper + glass-hero shape.
export function PlexPersonView({
name,
onBack,
onOpenCard,
onInfoCard,
onFilter,
}: {
name: string;
onBack: () => void;
onOpenCard: (card: PlexCard) => void;
onInfoCard: (card: PlexCard) => void;
onFilter: (patch: Partial<PlexFilters>) => void;
}) {
const { t } = useTranslation();
const qc = useQueryClient();
const q = useQuery({ queryKey: qk.plexPerson(name), queryFn: () => api.plexPerson(name) });
// E5 S8: cross-dissolve the backdrop through the person's films' art (the touch the roadmap calls
// out). Reuses the shared art-backdrop / crossfade prefs the detail pages own — no per-page control.
const { artBg, crossfade } = useDetailPrefs();
const pd = q.data;
useCrossfadeBackdrop(
[...(pd?.known_for ?? []), ...(pd?.movies ?? []), ...(pd?.shows ?? [])].map((c) => c.art),
artBg,
crossfade,
);
// Quick watch-toggle from a filmography card: optimistically flip in THIS person's cached payload
// (both the movies and shows arrays — an id matches only one), then persist + resync via the shared
// helper; also invalidate the library so its grid agrees.
const toggle = (card: PlexCard) =>
toggleWatchState(card, t, {
optimistic: (next) =>
qc.setQueryData<PlexPersonDetail>(qk.plexPerson(name), (old) =>
old
? {
...old,
movies: old.movies.map((m) => (m.id === card.id ? { ...m, status: next } : m)),
shows: old.shows.map((s) => (s.id === card.id ? { ...s, status: next } : s)),
known_for: old.known_for.map((k) =>
k.id === card.id ? { ...k, status: next } : k,
),
}
: old,
),
resync: () => {
qc.invalidateQueries({ queryKey: qk.plexPerson(name) });
qc.invalidateQueries({ queryKey: qk.plexLibrary() });
},
});
const section = (titleKey: string, cards: PlexCard[]) =>
cards.length > 0 ? (
<section className="mb-5">
<h2 className="text-sm font-semibold mb-3">{t(titleKey, { count: cards.length })}</h2>
<div className="grid grid-cols-[repeat(auto-fill,minmax(150px,1fr))] gap-3">
{cards.map((c) => (
<PlexPosterCard
key={c.id}
card={c}
onOpen={() => onOpenCard(c)}
onInfo={() => onInfoCard(c)}
onToggleWatched={toggle}
/>
))}
</div>
</section>
) : null;
const d = q.data;
const empty = !!d && d.movies.length === 0 && d.shows.length === 0;
return (
<div className="w-[90%] max-w-[1600px] mx-auto">
<div className="p-4 pb-0">
<button
onClick={onBack}
className="glass-card glass-hover inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs"
>
<ArrowLeft className="w-4 h-4" />
{t("plex.backToLibrary")}
</button>
</div>
{q.isError && !d ? (
<StateMessage tone="error" onRetry={() => q.refetch()}>
{t("common.loadError")}
</StateMessage>
) : q.isLoading || !d ? (
<LoadingState label={t("plex.loading")} />
) : (
<div className="p-4">
{/* Hero: headshot + name + TMDB bio/birth facts (E5 S6) + a "filter the library" action. */}
<section className="glass-card rounded-2xl p-4 sm:p-5 mb-6">
<div className="flex items-start gap-4">
<div className="h-24 w-24 shrink-0 overflow-hidden rounded-full border border-border bg-surface">
{d.photo ? (
<img src={d.photo} alt="" className="h-full w-full object-cover" />
) : (
<div className="grid h-full w-full place-items-center opacity-40">
<User className="h-8 w-8" />
</div>
)}
</div>
<div className="min-w-0 flex-1">
<h1 className="text-xl font-semibold">{d.name}</h1>
{d.known_for_department && (
<p className="text-xs font-medium text-accent">{d.known_for_department}</p>
)}
<p className="mt-1 mb-2 text-xs text-muted">
{t("plex.person.credits", { movies: d.movies.length, shows: d.shows.length })}
</p>
<div className="mb-2 space-y-0.5 text-xs text-muted">
{(d.birthday || d.place_of_birth) && (
<p>
<span className="text-fg/70">{t("plex.person.born")}:</span>{" "}
{[d.birthday, d.place_of_birth].filter(Boolean).join(" · ")}
</p>
)}
{d.deathday && (
<p>
<span className="text-fg/70">{t("plex.person.died")}:</span> {d.deathday}
</p>
)}
{d.also_known_as.length > 0 && (
<p className="truncate">
<span className="text-fg/70">{t("plex.person.aka")}:</span>{" "}
{d.also_known_as.slice(0, 4).join(", ")}
</p>
)}
</div>
<button
onClick={() => onFilter({ actors: [d.name] })}
className="rounded-full border border-border bg-card px-3 py-1 text-xs font-medium transition hover:border-accent hover:text-accent"
>
{t("plex.person.filterBy", { name: d.name })}
</button>
</div>
</div>
{d.biography && (
<p className="mt-4 whitespace-pre-line text-sm leading-relaxed text-muted">
{d.biography}
</p>
)}
</section>
{/* "Known for" — the person's most-popular titles that are in our library (E5 S6). */}
{d.known_for.length > 0 && (
<section className="mb-5">
<Carousel title={t("plex.person.knownFor")} ariaLabel={t("plex.person.knownFor")}>
{d.known_for.map((c) => (
<div key={c.id} className="w-[150px] shrink-0">
<PlexPosterCard
card={c}
onOpen={() => onOpenCard(c)}
onInfo={() => onInfoCard(c)}
onToggleWatched={toggle}
/>
</div>
))}
</Carousel>
</section>
)}
{empty ? (
<p className="text-sm text-muted">{t("plex.person.empty")}</p>
) : (
<>
{section("plex.person.movies", d.movies)}
{section("plex.person.shows", d.shows)}
</>
)}
</div>
)}
</div>
);
}
// --- Series drill-down: show detail page → season subpage → player -------------------------------
// Both the show page and the season page read the SAME cached qk.plexShow(showId) payload (the
// season page just picks its season out of it), so opening a season is instant and PlexPlaylistAdd's
// whole-show/season gather keeps working. Actions: Resume (on-deck episode), Play (from the start),
// Mark whole show/season watched/unwatched, Add to playlist (show/season/episode), and — admin only —
// Add the whole show to a collection.
export function PlexShowView({
showId,
onBack,
onPlay,
onOpenSeason,
onOpenShow,
onFilter,
onOpenPerson,
}: {
showId: string;
onBack: () => void;
onPlay: (epRk: string, queue: string[]) => void;
onOpenSeason: (seasonId: string) => void;
onOpenShow: (id: string) => void;
onFilter?: (patch: Partial<PlexFilters>) => void;
onOpenPerson?: (name: string) => void;
}) {
const { t } = useTranslation();
const qc = useQueryClient();
const isAdmin = qc.getQueryData<{ role?: string }>(qk.me())?.role === "admin";
const lang = plexLang();
const q = useQuery({
queryKey: qk.plexShow(showId, lang),
queryFn: () => api.plexShow(showId, lang),
});
const d = q.data;
const [addTarget, setAddTarget] = useState<PlexAddTarget | null>(null);
const [collOpen, setCollOpen] = useState(false);
const [busy, setBusy] = useState(false);
const show = d?.show;
const showLib = show?.library ?? undefined;
const allKeys = (d?.seasons ?? []).flatMap((se) => se.episodes.map((e) => e.id));
const watched = show?.status === "watched";
const {
artBg,
showCast,
showRelated,
crossfade,
toggleArtBg,
toggleCast,
toggleRelated,
toggleCrossfade,
} = useDetailPrefs();
useCrossfadeBackdrop(
[
show?.art,
...(show?.tmdb?.backdrops ?? []).map(tmdbBackdropUrl),
...(d?.related ?? []).map((r) => r.art),
],
artBg,
crossfade,
);
async function markAll(w: boolean) {
setBusy(true);
try {
await api.plexShowState(showId, w);
} catch (e) {
notifyActionError(e, t("plex.watchedFailed"));
} finally {
setBusy(false);
}
qc.invalidateQueries({ queryKey: qk.plexShow(showId) });
qc.invalidateQueries({ queryKey: qk.plexLibrary() });
}
async function markSeason(seasonRk: string, w: boolean) {
try {
await api.plexSeasonState(seasonRk, w);
} catch (e) {
notifyActionError(e, t("plex.watchedFailed"));
}
qc.invalidateQueries({ queryKey: qk.plexShow(showId) });
qc.invalidateQueries({ queryKey: qk.plexLibrary() });
}
return (
<div className="p-4 max-w-[1200px] mx-auto glass-media">
<BackBtn onBack={onBack} label={t("plex.backToLibrary")} />
{q.isError && !q.data ? (
<StateMessage tone="error" onRetry={() => q.refetch()}>
{t("common.loadError")}
</StateMessage>
) : q.isLoading || !d || !show ? (
<LoadingState label={t("plex.loading")} />
) : (
<>
{/* Hero */}
<DetailHero
thumb={show.thumb}
posterTitle={show.status === "new" ? t("plex.play") : t("plex.resume")}
posterDisabled={!show.resume}
showPlayOverlay={!!show.resume}
onPosterClick={() => show.resume && onPlay(show.resume.id, allKeys)}
customizeMenu={
<DetailCustomizeMenu
artBg={artBg}
onToggleArtBg={toggleArtBg}
crossfade={crossfade}
onToggleCrossfade={toggleCrossfade}
showCast={showCast}
onToggleCast={toggleCast}
hasCast={show.cast.length > 0}
showRelated={showRelated}
onToggleRelated={toggleRelated}
hasRelated={d.related.length > 0}
/>
}
>
<h1 className="text-2xl font-semibold leading-tight">{show.title}</h1>
<div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-sm text-muted">
{show.year != null && (
<Filterable
onClick={
onFilter
? () => onFilter({ yearMin: show.year, yearMax: show.year })
: undefined
}
>
{show.year}
</Filterable>
)}
{show.content_rating && (
<Filterable
onClick={
onFilter
? () => onFilter({ contentRatings: [show.content_rating!] })
: undefined
}
>
· {show.content_rating}
</Filterable>
)}
{show.season_count != null && (
<span>· {t("plex.seasons", { count: show.season_count })}</span>
)}
{(() => {
// IMDb score when present, else the Plex audience score — mirrors PlexInfo so a show
// with no imdb:// entry (imdb_rating NULL after the S6 IMDb-only change) still shows a
// rating instead of dropping the badge entirely.
const badge = pickRatingBadge(show.imdb_rating, show.rating);
if (!badge) return null;
const { score } = badge;
return (
<button
onClick={
onFilter ? () => onFilter({ ratingMin: Math.floor(score) }) : undefined
}
className={`inline-flex items-center gap-1 ${onFilter ? "cursor-pointer hover:text-accent" : "cursor-default"}`}
>
{/* decorative rating gold — intentionally NOT a status token */}
· <Star className="w-3.5 h-3.5 text-amber-400" fill="currentColor" />
{score.toFixed(1)}
</button>
);
})()}
</div>
{show.genres.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1.5">
{show.genres.map((g) => (
<Filterable
key={g}
className="text-2xs rounded-full bg-surface px-2 py-0.5 text-muted"
onClick={onFilter ? () => onFilter({ genres: [g] }) : undefined}
>
{g}
</Filterable>
))}
</div>
)}
{show.summary && (
<p className="text-sm text-muted mt-3 leading-relaxed line-clamp-5">{show.summary}</p>
)}
<div className="mt-4 flex flex-wrap gap-2">
{show.resume && (
<ActionBtn
primary
onClick={() => onPlay(show.resume!.id, allKeys)}
icon={Play}
label={show.status === "new" ? t("plex.play") : t("plex.resume")}
sub={epLabel(show.resume)}
/>
)}
{show.first && show.status !== "new" && (
<ActionBtn
onClick={() => onPlay(show.first!.id, allKeys)}
icon={RotateCcw}
label={t("plex.series.playFromStart")}
/>
)}
<WatchedToggle
watched={watched}
busy={busy}
onToggle={() => markAll(!watched)}
watchedLabel={t("plex.series.markShowUnwatched")}
unwatchedLabel={t("plex.series.markShowWatched")}
/>
{allKeys.length > 0 && (
<ActionBtn
onClick={() =>
setAddTarget({ kind: "group", ratingKeys: allKeys, title: show.title })
}
icon={ListPlus}
label={t("plex.playlist.addShow")}
/>
)}
{isAdmin && showLib && (
<ActionBtn
onClick={() => setCollOpen(true)}
icon={Layers}
label={t("plex.series.addShowCollection")}
/>
)}
</div>
</DetailHero>
{/* Seasons — in a glassy panel like the movie info-page strips. */}
<section
className="glass-card rounded-2xl p-4 sm:p-5 mb-6"
style={{ background: "color-mix(in srgb, var(--card) 55%, transparent)" }}
>
<h2 className="text-sm font-semibold mb-3">{t("plex.series.seasons")}</h2>
<div className="grid grid-cols-[repeat(auto-fill,minmax(140px,1fr))] gap-3">
{d.seasons.map((se) => (
<SeasonCard
key={se.id}
se={se}
onOpen={() => onOpenSeason(se.id)}
onToggleWatched={() => markSeason(se.id, se.status !== "watched")}
onAdd={
se.episodes.length > 0
? () =>
setAddTarget({
kind: "group",
ratingKeys: se.episodes.map((e) => e.id),
title: `${show.title} — ${se.title}`,
})
: undefined
}
/>
))}
</div>
</section>
{show.cast.length > 0 && showCast && (
<CastStrip cast={show.cast} onFilter={onFilter} onOpenPerson={onOpenPerson} />
)}
{d.related.length > 0 && showRelated && (
<RelatedStrip related={d.related} onOpen={onOpenShow} />
)}
</>
)}
{addTarget && <PlexPlaylistAdd target={addTarget} onClose={() => setAddTarget(null)} />}
{collOpen && show && showLib && (
<PlexCollectionEditor
item={{ id: show.id, title: show.title }}
library={showLib}
memberOf={show.collection_keys}
onClose={() => setCollOpen(false)}
onChanged={() => qc.invalidateQueries({ queryKey: qk.plexShow(showId) })}
/>
)}
</div>
);
}
export function PlexSeasonView({
showId,
seasonId,
onBack,
onPlay,
}: {
showId: string;
seasonId: string;
onBack: () => void;
onPlay: (epRk: string, queue: string[]) => void;
}) {
const { t } = useTranslation();
const qc = useQueryClient();
const lang = plexLang();
const q = useQuery({
queryKey: qk.plexShow(showId, lang),
queryFn: () => api.plexShow(showId, lang),
});
const d = q.data;
const se = d?.seasons.find((s) => s.id === seasonId);
const [addTarget, setAddTarget] = useState<PlexAddTarget | null>(null);
const [busy, setBusy] = useState(false);
const seasonKeys = se?.episodes.map((e) => e.id) ?? [];
// Play-through queue spans the WHOLE show (all seasons, in order) so a season's last episode rolls
// straight into the next season's first — the same cross-season order the show page already uses.
// seasonKeys stays only for the season-scoped "add to playlist" action.
const allKeys = (d?.seasons ?? []).flatMap((s) => s.episodes.map((e) => e.id));
const watched = se?.status === "watched";
const { artBg, showCast, crossfade, toggleArtBg, toggleCast, toggleCrossfade } = useDetailPrefs();
useCrossfadeBackdrop(
[d?.show.art, ...(d?.show.tmdb?.backdrops ?? []).map(tmdbBackdropUrl)],
artBg,
crossfade,
);
async function markAll(w: boolean) {
setBusy(true);
try {
await api.plexSeasonState(seasonId, w);
} catch (e) {
notifyActionError(e, t("plex.watchedFailed"));
} finally {
setBusy(false);
}
qc.invalidateQueries({ queryKey: qk.plexShow(showId) });
qc.invalidateQueries({ queryKey: qk.plexLibrary() });
}
async function markEpisode(ep: PlexCard) {
try {
await api.plexSetState(ep.id, ep.status === "watched" ? "new" : "watched");
} catch (e) {
notifyActionError(e, t("plex.watchedFailed"));
}
qc.invalidateQueries({ queryKey: qk.plexShow(showId) });
qc.invalidateQueries({ queryKey: qk.plexLibrary() });
}
return (
<div className="p-4 max-w-[1200px] mx-auto glass-media">
<BackBtn onBack={onBack} label={t("plex.series.backToShow")} />
{q.isError && !q.data ? (
<StateMessage tone="error" onRetry={() => q.refetch()}>
{t("common.loadError")}
</StateMessage>
) : q.isLoading || !d || !se ? (
<LoadingState label={t("plex.loading")} />
) : (
<>
<DetailHero
thumb={se.thumb}
posterTitle={se.status === "new" ? t("plex.play") : t("plex.resume")}
posterDisabled={!se.resume}
showPlayOverlay={!!se.resume}
onPosterClick={() => se.resume && onPlay(se.resume.id, allKeys)}
posterSizeClass="w-28 sm:w-40"
customizeMenu={
<DetailCustomizeMenu
artBg={artBg}
onToggleArtBg={toggleArtBg}
crossfade={crossfade}
onToggleCrossfade={toggleCrossfade}
showCast={showCast}
onToggleCast={toggleCast}
hasCast={false}
/>
}
>
<button
onClick={onBack}
className="text-sm text-muted hover:text-accent transition text-left"
>
{d.show.title}
</button>
<h1 className="text-2xl font-semibold leading-tight">{se.title}</h1>
<p className="text-sm text-muted mt-1">
{t("plex.series.episodeCount", { count: se.episode_count })}
</p>
<div className="mt-4 flex flex-wrap gap-2">
{se.resume && (
<ActionBtn
primary
onClick={() => onPlay(se.resume!.id, allKeys)}
icon={Play}
label={se.status === "new" ? t("plex.play") : t("plex.resume")}
sub={epLabel(se.resume)}
/>
)}
{se.first && se.status !== "new" && (
<ActionBtn
onClick={() => onPlay(se.first!.id, allKeys)}
icon={RotateCcw}
label={t("plex.series.playFromStart")}
/>
)}
<WatchedToggle
watched={watched}
busy={busy}
onToggle={() => markAll(!watched)}
watchedLabel={t("plex.series.markSeasonUnwatched")}
unwatchedLabel={t("plex.series.markSeasonWatched")}
/>
{seasonKeys.length > 0 && (
<ActionBtn
onClick={() =>
setAddTarget({
kind: "group",
ratingKeys: seasonKeys,
title: `${d.show.title} — ${se.title}`,
})
}
icon={ListPlus}
label={t("plex.playlist.addSeason")}
/>
)}
</div>
</DetailHero>
<div className="grid grid-cols-[repeat(auto-fill,minmax(230px,1fr))] gap-x-3 gap-y-4">
{se.episodes.map((ep) => (
<EpisodeCard
key={ep.id}
ep={ep}
onPlay={() => onPlay(ep.id, allKeys)}
onAdd={() => setAddTarget({ kind: "single", ratingKey: ep.id, title: ep.title })}
onToggleWatched={() => markEpisode(ep)}
/>
))}
</div>
</>
)}
{addTarget && <PlexPlaylistAdd target={addTarget} onClose={() => setAddTarget(null)} />}
</div>
);
}
+44
View File
@@ -0,0 +1,44 @@
import { describe, expect, it } from "vitest";
import {
MAX_LOOKAHEAD_ROWS,
PREFETCH_ROWS,
PREFETCH_VIEWPORTS,
readAheadLookahead,
shouldPrefetchNext,
} from "./readAhead";
describe("readAheadLookahead", () => {
it("floors at PREFETCH_ROWS when nothing (or little) is rendered", () => {
expect(readAheadLookahead(0)).toBe(PREFETCH_ROWS);
expect(readAheadLookahead(1)).toBe(PREFETCH_ROWS); // 1*2=2 < floor 4
});
it("scales with the rendered row count for a normal viewport", () => {
expect(readAheadLookahead(10)).toBe(10 * PREFETCH_VIEWPORTS); // 20
expect(readAheadLookahead(15)).toBe(30);
});
it("caps at MAX_LOOKAHEAD_ROWS so a tall window doesn't prefetch unboundedly", () => {
// 30 rendered rows would be 60 uncapped — the cap is what bounds per-mount feed prefetch.
expect(readAheadLookahead(30)).toBe(MAX_LOOKAHEAD_ROWS);
expect(readAheadLookahead(1000)).toBe(MAX_LOOKAHEAD_ROWS);
});
});
describe("shouldPrefetchNext", () => {
it("fetches once the last rendered row enters the read-ahead window", () => {
// 100 rows loaded, viewport renders 10 → lookahead 20. Fetch when lastIndex >= 100-1-20 = 79.
expect(shouldPrefetchNext(78, 100, 10)).toBe(false);
expect(shouldPrefetchNext(79, 100, 10)).toBe(true);
expect(shouldPrefetchNext(99, 100, 10)).toBe(true);
});
it("fetches eagerly while fewer rows are loaded than the lookahead (early pages)", () => {
// Only 15 rows loaded, lookahead 20 → the whole list is inside the window.
expect(shouldPrefetchNext(8, 15, 10)).toBe(true);
});
it("does not fetch near the top of a long loaded list", () => {
expect(shouldPrefetchNext(12, 500, 10)).toBe(false);
});
});
+30
View File
@@ -0,0 +1,30 @@
// Infinite-scroll read-ahead policy for VirtualGrid, extracted as a pure function so the decision is
// unit-testable without a layout engine.
//
// The next page is pulled once the last rendered row comes within `lookahead` rows of the loaded end,
// where lookahead scales with the number of rows the viewport currently renders (so it adapts to
// window height / column count / row height) but is clamped to a floor and a ceiling:
// - floor PREFETCH_ROWS: the finite-list / first-paint case where nothing is rendered yet.
// - ceiling MAX_LOOKAHEAD_ROWS: a tall window renders many rows, and an uncapped lookahead would
// eagerly prefetch several pages on every mount / filter change — wasted work on the app's
// heaviest query (the feed). The cap bounds that while still buffering a couple of screens ahead.
export const PREFETCH_ROWS = 4;
export const PREFETCH_VIEWPORTS = 2;
export const MAX_LOOKAHEAD_ROWS = 40;
/** How many rows ahead of the last rendered row to keep buffered, for a viewport rendering
* `renderedRows` rows. Clamped to [PREFETCH_ROWS, MAX_LOOKAHEAD_ROWS]. */
export function readAheadLookahead(renderedRows: number): number {
return Math.min(MAX_LOOKAHEAD_ROWS, Math.max(PREFETCH_ROWS, renderedRows * PREFETCH_VIEWPORTS));
}
/** Whether to fetch the next page: true once the last rendered row (`lastIndex`) is within the
* read-ahead window of the loaded end (`rowCount` total loaded rows). Because the caller re-runs
* this as each page settles, it chains — refilling the buffer as the user consumes it. */
export function shouldPrefetchNext(
lastIndex: number,
rowCount: number,
renderedRows: number,
): boolean {
return lastIndex >= rowCount - 1 - readAheadLookahead(renderedRows);
}
@@ -95,6 +95,13 @@ export function usePlexActiveFilters(): ActiveFilter[] {
remove: () => patch({ studios: filters.studios.filter((x) => x !== s) }),
});
}
for (const k of filters.keywords) {
out.push({
key: `keyword:${k}`,
label: k,
remove: () => patch({ keywords: filters.keywords.filter((x) => x !== k) }),
});
}
if (filters.collection) {
out.push({
key: "collection",
+7 -1
View File
@@ -19,5 +19,11 @@
"demoSharedNotice": "You're in the shared demo account. Everything you do here — playlists, hidden videos, settings — is communal and may be changed by other demo visitors at the same time.",
"backToTop": "Back to top",
"retry": "Retry",
"loadError": "Couldn't load this — check your connection and try again."
"loadError": "Couldn't load this — check your connection and try again.",
"seeAll": "See all",
"scrollLeft": "Scroll left",
"scrollRight": "Scroll right",
"search": "Search",
"remove": "Remove",
"noResults": "No matches"
}
+41 -3
View File
@@ -70,7 +70,9 @@
"collection": "Collection",
"collectionSearch": "Search collections…",
"collectionEmpty": "No collections",
"matchLabel": "Match"
"matchLabel": "Match",
"studio": "Studio",
"moreRefine": "{{count}} more — refine with search"
},
"count": "{{count}} titles",
"searchCount": "{{count}} matches",
@@ -186,6 +188,9 @@
"prefArtBg": "Faint backdrop",
"prefCast": "Cast & crew",
"prefRelated": "Related shows",
"prefFacts": "Details",
"prefKeywords": "Keywords",
"prefCrew": "Crew",
"stripSource": {
"collection": "Collections",
"imdb": "IMDb",
@@ -204,7 +209,22 @@
"filterRating": "Show titles rated {{n}}+",
"filterActor": "Show titles with {{name}}",
"viewPerson": "View {{name}}'s filmography",
"browseCollection": "Browse collection →"
"browseCollection": "Browse collection →",
"audience": "Audience",
"keywords": "Keywords",
"crew": "Crew",
"filterKeyword": "Filter by \"{{name}}\"",
"facts": {
"title": "Details",
"release": "Released",
"runtime": "Runtime",
"cert": "Rating",
"budget": "Budget",
"revenue": "Revenue",
"studios": "Studios",
"countries": "Countries"
},
"prefCrossfade": "Crossfade backdrop"
},
"playable": {
"direct": "Plays directly in the browser",
@@ -252,6 +272,24 @@
"filterBy": "Filter library by {{name}}",
"movies": "Movies ({{count}})",
"shows": "Shows ({{count}})",
"empty": "No titles found for this person."
"empty": "No titles found for this person.",
"born": "Born",
"died": "Died",
"aka": "Also known as",
"knownFor": "Known for"
},
"view": {
"label": "View",
"browse": "Browse"
},
"discover": {
"tab": "Discover",
"continueWatching": "Continue watching",
"recentlyAdded": "Recently added",
"surprise": "Surprise me",
"reshuffle": "Reshuffle",
"byGenre": "Browse by genre",
"becauseWatched": "Because you watched {{title}}",
"empty": "Nothing to discover yet — run a Plex sync from the admin Config page."
}
}
+3 -1
View File
@@ -72,7 +72,8 @@
"plex_sync": "Mirrors the enabled Plex libraries into the app's catalogue — the metadata behind Plex browsing, search, filters and the info pages (genres, cast, ratings, artwork). If it stops, newly added or changed Plex titles don't appear until the next sync.",
"plex_watch_sync": "Pulls recent Plex watch changes (finished episodes, resume positions) into Siftlode and re-pushes any local watch changes that didn't reach Plex. The cheap two-way keep-in-sync between full reconciles. If it stops, watched/resume made in the Plex app takes longer to show here.",
"plex_watch_reconcile": "A full watch-state reconcile that rescans every library — catches what the incremental pass can't, notably an episode un-marked as watched on the Plex side. Heavier, so it runs about once a day. If it stops, un-watches done in Plex aren't mirrored back.",
"audit_gc": "Prunes admin audit-log entries older than the configured retention window (Configuration → Audit log). If it stops, the audit log just keeps growing — harmless but unbounded."
"audit_gc": "Prunes admin audit-log entries older than the configured retention window (Configuration → Audit log). If it stops, the audit log just keeps growing — harmless but unbounded.",
"tmdb_enrich": "Enriches the mirrored Plex catalogue from TMDB (keywords, crew, budget/revenue, TMDb score, recommendations, artwork), matched by each title's canonical id and served from the app's own database. Optional — a no-op until a TMDB API key is set. If it stops, new Plex titles just miss their TMDB extras until it runs again."
},
"jobs": {
"rss_poll": "RSS poll (new uploads)",
@@ -89,6 +90,7 @@
"plex_sync": "Plex library sync",
"plex_watch_sync": "Plex watch sync (incremental)",
"plex_watch_reconcile": "Plex watch reconcile (full)",
"tmdb_enrich": "TMDB enrichment (Plex)",
"audit_gc": "Audit-log cleanup"
},
"queue": {
@@ -28,9 +28,6 @@
"reshuffle": "Reshuffle",
"noMatchingTags": "No matching tags here",
"manageTags": "Manage",
"shareView": "Copy a link to this view",
"shareCopied": "View link copied to clipboard",
"shareFailed": "Couldn't copy the link",
"widget": {
"savedviews": "Saved views",
"show": "Show",
-2
View File
@@ -7,8 +7,6 @@
"apply": "Apply this view",
"makeDefault": "Set as default (loads on start)",
"isDefault": "Default view — loads on start",
"share": "Copy a link to this view",
"shareCopied": "View link copied",
"rename": "Rename",
"delete": "Delete",
"deleteConfirm": "Delete the saved view “{{name}}”?",
+7 -1
View File
@@ -19,5 +19,11 @@
"demoSharedNotice": "Egy közös demo fiókban vagy. Minden, amit itt csinálsz — lejátszási listák, elrejtett videók, beállítások — közös, és más demo látogatók egyszerre módosíthatják.",
"backToTop": "Vissza a tetejére",
"retry": "Újra",
"loadError": "Nem sikerült betölteni — ellenőrizd a kapcsolatot, és próbáld újra."
"loadError": "Nem sikerült betölteni — ellenőrizd a kapcsolatot, és próbáld újra.",
"seeAll": "Összes",
"scrollLeft": "Görgetés balra",
"scrollRight": "Görgetés jobbra",
"search": "Keresés",
"remove": "Eltávolítás",
"noResults": "Nincs találat"
}
+41 -3
View File
@@ -70,7 +70,9 @@
"collection": "Kollekció",
"collectionSearch": "Kollekciók keresése…",
"collectionEmpty": "Nincs kollekció",
"matchLabel": "Egyezés"
"matchLabel": "Egyezés",
"studio": "Stúdió",
"moreRefine": "még {{count}} — finomíts kereséssel"
},
"count": "{{count}} cím",
"searchCount": "{{count}} találat",
@@ -186,6 +188,9 @@
"prefArtBg": "Halvány háttér",
"prefCast": "Szereplők",
"prefRelated": "Kapcsolódó sorozatok",
"prefFacts": "Részletek",
"prefKeywords": "Kulcsszavak",
"prefCrew": "Stáb",
"stripSource": {
"collection": "Kollekciók",
"imdb": "IMDb",
@@ -204,7 +209,22 @@
"filterRating": "{{n}}+ pontos címek",
"filterActor": "Címek {{name}} szereplésével",
"viewPerson": "{{name}} filmográfiája",
"browseCollection": "Kollekció böngészése →"
"browseCollection": "Kollekció böngészése →",
"audience": "Közönség",
"keywords": "Kulcsszavak",
"crew": "Stáb",
"filterKeyword": "Szűrés erre: „{{name}}”",
"facts": {
"title": "Részletek",
"release": "Megjelenés",
"runtime": "Hossz",
"cert": "Korhatár",
"budget": "Költségvetés",
"revenue": "Bevétel",
"studios": "Stúdiók",
"countries": "Országok"
},
"prefCrossfade": "Áttűnő háttér"
},
"playable": {
"direct": "Közvetlenül játszható a böngészőben",
@@ -252,6 +272,24 @@
"filterBy": "Szűrés erre: {{name}}",
"movies": "Filmek ({{count}})",
"shows": "Sorozatok ({{count}})",
"empty": "Nincs találat ehhez a személyhez."
"empty": "Nincs találat ehhez a személyhez.",
"born": "Született",
"died": "Elhunyt",
"aka": "Más néven",
"knownFor": "Ismert szerepei"
},
"view": {
"label": "Nézet",
"browse": "Böngészés"
},
"discover": {
"tab": "Felfedezés",
"continueWatching": "Folytatás",
"recentlyAdded": "Nemrég hozzáadva",
"surprise": "Lepj meg",
"reshuffle": "Újrakeverés",
"byGenre": "Böngészés műfaj szerint",
"becauseWatched": "Mert megnézted: {{title}}",
"empty": "Még nincs mit felfedezni — futtass egy Plex-szinkront az admin Konfiguráció oldalról."
}
}
+3 -1
View File
@@ -72,7 +72,8 @@
"plex_sync": "Tükrözi az engedélyezett Plex-könyvtárakat az app katalógusába — ez adja a Plex böngészés, keresés, szűrők és infó-oldalak metaadatait (műfaj, szereplők, pontszám, borítók). Ha leáll, az újonnan hozzáadott vagy módosított Plex-címek csak a következő sync után jelennek meg.",
"plex_watch_sync": "Áthozza a Plex-oldali friss megtekintés-változásokat (befejezett epizódok, folytatás-pozíciók) a Siftlode-ba, és felnyomja a Plexet el nem érő helyi változásokat. Ez az olcsó kétirányú szinkron a teljes reconcile-ok között. Ha leáll, a Plex-appban jelölt megtekintés/folytatás lassabban jelenik meg itt.",
"plex_watch_reconcile": "Teljes megtekintés-állapot reconcile, ami minden könyvtárat újraszken­nel — elkapja, amit az inkrementális menet nem, főleg ha egy epizódról a Plex oldalán levették a megtekintve jelet. Nehezebb, ezért kb. naponta fut. Ha leáll, a Plexben végzett un-watch nem tükröződik vissza.",
"audit_gc": "Törli a beállított megőrzési időnél (Beállítások → Naplózás) régebbi admin-naplóbejegyzéseket. Ha leáll, a napló csak nő — ártalmatlan, de korlátlanul."
"audit_gc": "Törli a beállított megőrzési időnél (Beállítások → Naplózás) régebbi admin-naplóbejegyzéseket. Ha leáll, a napló csak nő — ártalmatlan, de korlátlanul.",
"tmdb_enrich": "A tükrözött Plex-katalógust dúsítja a TMDB-ből (kulcsszavak, stáb, költségvetés/bevétel, TMDb-pontszám, ajánlások, artwork), a címek kanonikus azonosítója alapján párosítva, és az app saját adatbázisából szolgálja ki. Opcionális — TMDB API-kulcs nélkül nem csinál semmit. Ha leáll, az új Plex-címek csak a következő futásig maradnak TMDB-extrák nélkül."
},
"jobs": {
"rss_poll": "RSS-lekérdezés (új feltöltések)",
@@ -89,6 +90,7 @@
"plex_sync": "Plex-könyvtár szinkron",
"plex_watch_sync": "Plex megtekintés-szinkron (inkrementális)",
"plex_watch_reconcile": "Plex megtekintés-reconcile (teljes)",
"tmdb_enrich": "TMDB-dúsítás (Plex)",
"audit_gc": "Napló-karbantartás"
},
"queue": {
@@ -28,9 +28,6 @@
"reshuffle": "Újrakeverés",
"noMatchingTags": "Nincs ide illő címke",
"manageTags": "Kezelés",
"shareView": "Hivatkozás másolása erre a nézetre",
"shareCopied": "Nézet-hivatkozás a vágólapra másolva",
"shareFailed": "Nem sikerült a hivatkozás másolása",
"widget": {
"savedviews": "Mentett nézetek",
"show": "Megjelenítés",
-2
View File
@@ -7,8 +7,6 @@
"apply": "Nézet alkalmazása",
"makeDefault": "Beállítás alapértelmezettként (indításkor betölt)",
"isDefault": "Alapértelmezett nézet — indításkor betölt",
"share": "Hivatkozás másolása a nézethez",
"shareCopied": "Nézet hivatkozása a vágólapra másolva",
"rename": "Átnevezés",
"delete": "Törlés",
"deleteConfirm": "Törlöd a(z) „{{name}}” mentett nézetet?",
+36
View File
@@ -536,6 +536,42 @@ body {
0 14px 30px -14px rgba(0, 0, 0, 0.5);
}
/* UX-7 — touch-visible reveal for hover-only card controls. A control tagged `.reveal` used to be
`opacity-0 group-hover:opacity-100`: fine on a mouse (the enclosing `.group` reveals it on hover),
but on a touch device — which never hovers — it is invisible AND still tappable. So the reveal is
gated on `@media (hover: hover)`: only a hover-capable pointer hides it until the group is hovered
or focused (keyboard too, via `:focus-within`); a touch device keeps the default opacity:1 and the
control is always shown. `.reveal-idle` is the counterpart badge a control replaces (e.g. the
"watched" pill under the watched-toggle): visible at rest, faded when the group is hovered, and
dropped entirely on touch — where the toggle is always present, so the two would otherwise collide
at the same corner. Requires a `.group` ancestor (Tailwind's group marker). */
.reveal-idle {
transition: opacity 150ms ease;
}
@media (hover: hover) {
.reveal {
opacity: 0;
transition: opacity 150ms ease;
}
.group:hover .reveal,
.group:focus-within .reveal,
.reveal:focus-visible {
opacity: 1;
}
/* Fade the idle badge whenever its companion control is revealed — on hover AND on keyboard
focus-within (the `.reveal` trigger set above lists both), else the two overlap at the same
corner for a keyboard user. */
.group:hover .reveal-idle,
.group:focus-within .reveal-idle {
opacity: 0;
}
}
@media (hover: none) {
.reveal-idle {
display: none;
}
}
/* Dark mode: the ambient accent pools are faint over a near-black bg, so the docked frosted
chrome (nav/header) barely reads — there's no content behind it to refract, unlike dialogs
which sit over the feed. Strengthen the pools and let a touch more bleed through the glass.
+9
View File
@@ -225,6 +225,15 @@ export const downloadsApi = {
? `/api/downloads/${id}/storyboard.jpg?account=${a}`
: `/api/downloads/${id}/storyboard.jpg`;
},
// The locally-generated poster (an <img> src → direct nav, so same ?account= wallet gate). Without
// it the poster resolves to the session-default account and 404s for a per-tab account's download —
// which reads as a blank Library thumbnail once the poster is the card's primary image.
posterImageUrl: (id: number): string => {
const a = getActiveAccount();
return a != null
? `/api/downloads/${id}/poster.jpg?account=${a}`
: `/api/downloads/${id}/poster.jpg`;
},
// admin
adminDownloads: (): Promise<DownloadJob[]> => req("/api/admin/downloads"),
+64 -1
View File
@@ -42,6 +42,8 @@ export interface PlexCard {
year?: number | null;
duration_seconds?: number | null;
thumb: string;
art?: string | null; // wide backdrop (movies/shows) for rails + crossfading hero; null when no art_key
rating?: number | null; // Plex audience score (~IMDb), shown on the card hover-overview
playable?: string; // direct | remux | transcode
status?: string; // new | watched | hidden
position_seconds?: number;
@@ -50,7 +52,29 @@ export interface PlexCard {
episode_number?: number | null; // episode
show_title?: string | null; // episode (in a mixed playlist)
show_id?: string | null; // episode: the show's rating_key (for grouping a playlist by show/season)
summary?: string | null; // episode
summary?: string | null; // movie/show hover-overview synopsis (trimmed) + episode summary
}
// TMDB enrichment block (E5 S2), served from OUR DB (never a live TMDB call) on a movie/show detail.
// null until the row has been enriched. Powers the S5 enriched detail page: multi-source ratings row,
// facts sidebar, keyword chips (→ filter), and the full crew.
export interface PlexTmdbCrew {
name: string;
job: string;
tmdb_id?: number | null;
profile_path?: string | null; // raw TMDB path — we don't proxy crew headshots, so name+job only
}
export interface PlexTmdb {
tmdb_id?: number | null;
rating?: number | null; // TMDb user score (0–10)
vote_count?: number | null;
budget?: number | null; // USD, movies only
revenue?: number | null; // USD, movies only
keywords: string[];
crew: PlexTmdbCrew[];
studios: string[];
production_countries: string[];
recommendations: { tmdb_id?: number | null; title?: string | null; in_library: boolean }[];
backdrops: string[];
}
// Unified cross-library browse (movies + shows mixed). `episodes` is populated only on a search that
// also matches episodes (the grouped "Episodes" section).
@@ -96,6 +120,7 @@ export interface PlexShowDetail {
first?: PlexCard | null;
episode_count: number;
collection_keys: string[];
tmdb?: PlexTmdb | null;
};
seasons: PlexSeasonDetail[];
related: PlexCard[];
@@ -147,6 +172,7 @@ export interface PlexItemDetail {
// `source` buckets the strip so each type can be shown/hidden independently on the info page:
// "collection" (genuine franchise), "imdb"/"tmdb"/"tvdb"/"trakt" (external auto-lists), "smart".
collections?: { id: string; title: string; source: string; items: PlexCard[] }[];
tmdb?: PlexTmdb | null; // movies only (episodes aren't enriched)
}
export interface PlexCollection {
id: string;
@@ -185,6 +211,15 @@ export interface PlexPersonCard {
export interface PlexPersonDetail {
name: string;
photo?: string | null; // cached headshot, or null (placeholder)
// TMDB bio (E5 S6), lazily fetched on first open, then served from the row. Null when the person has
// no linked tmdb_person_id (never appeared in a TMDB-enriched title) or TMDB has no data.
biography?: string | null;
birthday?: string | null; // "YYYY-MM-DD"
deathday?: string | null;
place_of_birth?: string | null;
known_for_department?: string | null;
also_known_as: string[];
known_for: PlexCard[]; // their most-popular credits that are IN our library (deep-linkable)
movies: PlexCard[]; // enabled-library movies whose cast OR directors include this person
shows: PlexCard[]; // enabled-library shows, same
}
@@ -203,6 +238,7 @@ export interface PlexFilters {
actors: string[]; // OR (any) by default, or AND (all) via actorMode
actorMode?: "any" | "all"; // how multiple actors combine (default any = OR)
studios: string[]; // OR — from any selected studio
keywords: string[]; // AND — TMDB keyword chips from the detail page (E5 S5)
collection?: string | null; // a single Plex collection (rating_key)
collectionTitle?: string | null; // its title, for the active-filter chip
}
@@ -212,6 +248,7 @@ export const EMPTY_PLEX_FILTERS: PlexFilters = {
directors: [],
actors: [],
studios: [],
keywords: [],
};
export function plexFilterCount(f: PlexFilters): number {
return (
@@ -224,6 +261,7 @@ export function plexFilterCount(f: PlexFilters): number {
f.directors.length +
f.actors.length +
f.studios.length +
f.keywords.length +
(f.collection ? 1 : 0)
);
}
@@ -244,11 +282,13 @@ function appendPlexFilters(u: URLSearchParams, f: PlexFilters): void {
if (f.actors?.length) u.set("actors", f.actors.join(","));
if (f.actorMode === "all") u.set("actor_mode", "all");
if (f.studios?.length) u.set("studios", f.studios.join(","));
if (f.keywords?.length) u.set("keywords", f.keywords.join(","));
if (f.collection) u.set("collection", f.collection);
}
export interface PlexFacets {
genres: { value: string; count: number }[];
content_ratings: { value: string; count: number }[];
studios: { value: string; count: number }[]; // studio vocabulary for the sidebar picker (E5 S7)
year_min: number | null;
year_max: number | null;
rating_max: number | null;
@@ -261,6 +301,17 @@ export interface PlexPlaySession {
start_s: number;
}
// Discover surface (E5 S4): the consolidated rail payload. Every rail is a plain PlexCard list the
// dedicated Discover landing renders as a horizontal Carousel; `because_you_watched` is null when the
// user has no TMDB-enriched watch history (rail hidden). Served from DB — no live Plex/TMDB call.
export interface PlexDiscover {
scope: string;
continue_watching: PlexCard[];
recently_added: PlexCard[];
genres: { value: string; count: number }[]; // top genres → tap a tile to filter the grid
because_you_watched: { anchor: { id: string; title: string }; items: PlexCard[] } | null;
}
// Two-way Plex watch-state sync link status (owner account; Phase A).
export interface PlexWatchLink {
linked: boolean;
@@ -409,6 +460,18 @@ export const plexApi = {
method: "PUT",
body: JSON.stringify({ item_rating_keys: itemRks }),
}),
// Discover landing rails in one request (serve-from-DB). `lang` picks the genre-tile vocabulary.
plexDiscover: (scope: string, lang?: string): Promise<PlexDiscover> => {
const u = new URLSearchParams({ scope });
if (lang === "hu") u.set("lang", "hu");
return req(`/api/plex/discover?${u.toString()}`);
},
// A fresh random sample for the Surprise-me rail — a new draw every call (the ↻ refetch).
plexSurprise: (scope: string, limit?: number): Promise<{ scope: string; items: PlexCard[] }> => {
const u = new URLSearchParams({ scope });
if (limit) u.set("limit", String(limit));
return req(`/api/plex/surprise?${u.toString()}`);
},
plexShow: (id: string, lang?: string): Promise<PlexShowDetail> =>
req(`/api/plex/show/${encodeURIComponent(id)}${lang === "hu" ? "?lang=hu" : ""}`),
// Mark a whole show / season watched or unwatched (all its episodes) for the current user.
@@ -0,0 +1,104 @@
// @vitest-environment jsdom
//
// useHistorySubview: a module's active sub-view rides in history.state._sub, with optional address-bar
// mirroring so a deep sub-view (Plex show/season/info/person/playlist) is bookmarkable. This backfills
// the E4 deep-review's deferred coverage for the S2 fix — the seed AND the popstate handler must share
// the SAME URL fallback, so a popstate that lands on a bookmark entry (URL-only, no _sub — e.g. after
// an overlay's programmatic history.back()) restores the sub-view from the URL instead of collapsing
// it to root. The overlay/back-ladder half of history.ts is covered in history.dom.test.ts.
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { renderHook, act } from "@testing-library/react";
import { useHistorySubview, type SubviewUrl } from "./history";
// A minimal string-view mirror: "root" is the un-mirrored root (encode → null), anything else mirrors
// verbatim to ?v=<view>. decode rejects the empty string (→ null, i.e. not a valid sub-view).
const url: SubviewUrl<string> = {
key: "v",
encode: (v) => (v === "root" ? null : v),
decode: (raw) => raw || null,
};
const setUrl = (search: string, state: unknown = null) =>
window.history.replaceState(state, "", search || "/");
const popTo = (search: string, state: unknown = null) =>
act(() => {
window.history.replaceState(state, "", search || "/");
window.dispatchEvent(new PopStateEvent("popstate", { state }));
});
beforeEach(() => setUrl("/"));
afterEach(() => vi.clearAllMocks());
describe("useHistorySubview seed", () => {
it("decodes the ?<key> URL param when the entry has no _sub (fresh bookmark)", () => {
setUrl("?v=a");
const { result } = renderHook(() => useHistorySubview("root", url));
expect(result.current.view).toBe("a");
});
it("prefers history.state._sub over the URL param (in-app entry)", () => {
setUrl("?v=a", { _sub: "b" });
const { result } = renderHook(() => useHistorySubview("root", url));
expect(result.current.view).toBe("b");
});
it("falls back to root when neither state nor a decodable URL param is present", () => {
setUrl("/");
const { result } = renderHook(() => useHistorySubview("root", url));
expect(result.current.view).toBe("root");
});
it("ignores the URL entirely when no SubviewUrl is supplied (state-only mirror)", () => {
setUrl("?v=a", { _sub: "fromState" });
const { result } = renderHook(() => useHistorySubview("root"));
expect(result.current.view).toBe("fromState");
// A URL param with no url config must NOT leak in.
setUrl("?v=a", null);
const { result: r2 } = renderHook(() => useHistorySubview("root"));
expect(r2.current.view).toBe("root");
});
});
describe("useHistorySubview open", () => {
it("pushes state._sub and mirrors the URL via encode", () => {
const { result } = renderHook(() => useHistorySubview("root", url));
act(() => result.current.open("a"));
expect(result.current.view).toBe("a");
expect(window.history.state?._sub).toBe("a");
expect(window.location.search).toBe("?v=a");
});
it("clears the mirrored key when encode returns null (root/ephemeral view)", () => {
setUrl("?v=a");
const { result } = renderHook(() => useHistorySubview("root", url));
act(() => result.current.open("root"));
expect(result.current.view).toBe("root");
expect(window.history.state?._sub).toBe("root");
expect(window.location.search).toBe(""); // ?v dropped
});
});
describe("useHistorySubview popstate", () => {
it("restores the sub-view from state._sub on Back/Forward", () => {
const { result } = renderHook(() => useHistorySubview("root", url));
popTo("?v=a", { _sub: "a" });
expect(result.current.view).toBe("a");
});
it("restores from the ?<key> URL when the landed entry has no _sub — the S2 bookmark fix", () => {
// Start in-app at root, then a popstate lands on a URL-only bookmark entry (no _sub). Reading
// history.state alone would wrongly collapse to root; the shared URL fallback must decode ?v=b.
const { result } = renderHook(() => useHistorySubview("root", url));
expect(result.current.view).toBe("root");
popTo("?v=b", null);
expect(result.current.view).toBe("b");
});
it("collapses to root when a popstate lands on an entry with neither _sub nor a decodable URL", () => {
setUrl("?v=a", { _sub: "a" });
const { result } = renderHook(() => useHistorySubview("root", url));
expect(result.current.view).toBe("a");
popTo("/", null);
expect(result.current.view).toBe("root");
});
});
+38 -5
View File
@@ -10,25 +10,58 @@
//
// setPage() pushes a clean { sfPage } entry (dropping _sub/_ov), so each page starts at its root.
import { useEffect, useRef, useState } from "react";
import { urlWithParams } from "./urlState";
// --- Sub-views (value carried in history.state._sub) ---------------------------------------
export function useHistorySubview<T>(root: T): {
/** Optional address-bar mirroring for a sub-view, so a deep sub-view (e.g. a Plex show page) is
* bookmarkable. history.state._sub stays the source of truth for Back/Forward; this only adds the URL. */
export interface SubviewUrl<T> {
/** The query key this sub-view mirrors to (e.g. "v" for the Plex sub-view). */
key: string;
/** Serialize a sub-view to the param value, or null for views not mirrored (root / ephemeral ones). */
encode: (view: T) => string | null;
/** Parse a param value back to a sub-view, or null if it isn't a valid one. */
decode: (raw: string) => T | null;
}
export function useHistorySubview<T>(
root: T,
url?: SubviewUrl<T>,
): {
view: T;
open: (next: T) => void;
back: () => void;
} {
const [view, setView] = useState<T>(() => (window.history.state?._sub as T) ?? root);
// Resolve the current entry's sub-view: history.state._sub for an in-app entry, else decode the
// ?<key> URL param for a fresh bookmark (whose entry carries the param but no _sub), else root.
// ⚠️ The seed AND the popstate handler must use THIS same fallback: a popstate can land on a
// bookmark entry (URL-only, no _sub) — e.g. after an overlay's programmatic history.back() — and
// reading history.state alone would wrongly collapse the bookmarked sub-view to root.
const readView = (): T => {
const fromState = window.history.state?._sub as T | undefined;
if (fromState != null) return fromState;
if (url) {
const raw = new URLSearchParams(window.location.search).get(url.key);
const decoded = raw != null ? url.decode(raw) : null;
if (decoded != null) return decoded;
}
return root;
};
const readViewRef = useRef(readView);
readViewRef.current = readView;
const [view, setView] = useState<T>(readView);
useEffect(() => {
const onPop = () => setView((window.history.state?._sub as T) ?? root);
// Back/Forward restores the sub-view from the landed entry (state OR its restored ?<key> URL).
const onPop = () => setView(readViewRef.current());
window.addEventListener("popstate", onPop);
return () => window.removeEventListener("popstate", onPop);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return {
view,
open: (next: T) => {
setView(next);
window.history.pushState({ ...window.history.state, _sub: next }, "");
const href = url ? urlWithParams({ [url.key]: url.encode(next) }) : "";
window.history.pushState({ ...window.history.state, _sub: next }, "", href);
},
back: () => window.history.back(),
};
+10
View File
@@ -0,0 +1,10 @@
import type { PanelId } from "./panelLayout";
// The two browsable media sources the app filters over: the YouTube feed and the Plex library.
// (A `PanelId` minus "playlists" — a rail, but not a filterable source.) The pair is already unified
// in practice — one shared SortControl, the source-agnostic `ActiveFilter[]` chip row, PanelId-keyed
// sidebar layouts, and the shared `FilterRail` shell. This names that abstraction as one narrow
// contract, so a future third source (or a feature built across sources) targets `MediaSourceKind`
// instead of re-deriving it from the YT/Plex twins. Deliberately thin — just the kind; widen it only
// when a concrete cross-source feature needs more than the name.
export type MediaSourceKind = Extract<PanelId, "feed" | "plex">;
+23
View File
@@ -0,0 +1,23 @@
import { describe, it, expect } from "vitest";
import { parsePositiveIntId } from "./parseId";
describe("parsePositiveIntId", () => {
it("accepts a positive integer", () => {
expect(parsePositiveIntId("1")).toBe(1);
expect(parsePositiveIntId("42")).toBe(42);
});
it("rejects empty / nullish", () => {
expect(parsePositiveIntId("")).toBeNull();
expect(parsePositiveIntId(null)).toBeNull();
expect(parsePositiveIntId(undefined)).toBeNull();
});
it("rejects zero, negative, fractional, and non-numeric", () => {
expect(parsePositiveIntId("0")).toBeNull();
expect(parsePositiveIntId("-1")).toBeNull();
expect(parsePositiveIntId("1.5")).toBeNull();
expect(parsePositiveIntId("abc")).toBeNull();
expect(parsePositiveIntId(" ")).toBeNull(); // Number(" ")===0
});
});
+9
View File
@@ -0,0 +1,9 @@
/** Parse a raw value to a positive-integer id, or null if it isn't one. A bare "" gives
* Number("")===0 (finite but not positive), "1.5" is fractional, "-1"/"abc" invalid — all rejected.
* The single guard behind the ?pl / ?v=playlist id params AND the stored playlist selection, so a bad
* value (a malformed link, a legacy/corrupted storage entry) never selects a non-existent playlist. */
export function parsePositiveIntId(raw: string | null | undefined): number | null {
if (raw == null || raw === "") return null;
const n = Number(raw);
return Number.isInteger(n) && n > 0 ? n : null;
}
+463
View File
@@ -0,0 +1,463 @@
import {
useEffect,
useRef,
useState,
type Dispatch,
type MutableRefObject,
type RefObject,
type SetStateAction,
} from "react";
import type { QueryClient } from "@tanstack/react-query";
import { DEFAULT_VOLUME, normalizeVolume, stepVolume } from "./playerVolume";
import { LS, readAccountMerged, writeAccount } from "./storage";
import {
exitFullscreen,
fullscreenElement,
onFullscreenChange,
requestFullscreen,
} from "./fullscreen";
import { useBackToExit } from "./history";
import { api, type Video } from "./api";
import { qk } from "./queryKeys";
import type { YTNamespace, YTPlayer, YTPlayerEvent } from "./youtube";
import type { AutoMode, LoopMode } from "../components/playerModalControls";
// How close to the end (seconds) counts as "finished" → auto-mark watched.
const FINISH_MARGIN = 10;
// Bound consecutive unplayable items so an auto-advancing queue (esp. loop=all) can't spin over a run
// of dead videos — after the cap we stop on the error overlay instead of skipping forever.
const MAX_CONSECUTIVE_ERRORS = 3;
// --- IFrame Player API loader (singleton) ---
let apiPromise: Promise<YTNamespace> | null = null;
function loadYouTubeApi(): Promise<YTNamespace> {
if (apiPromise) return apiPromise;
apiPromise = new Promise<YTNamespace>((resolve) => {
const w = window;
if (w.YT && w.YT.Player) return resolve(w.YT);
const prev = w.onYouTubeIframeAPIReady;
w.onYouTubeIframeAPIReady = () => {
if (typeof prev === "function") prev();
// The API-ready callback only fires once the script has installed window.YT.
resolve(w.YT!);
};
const tag = document.createElement("script");
tag.src = "https://www.youtube.com/iframe_api";
document.head.appendChild(tag);
});
return apiPromise;
}
// Extracted hooks for the YouTube modal player (PlayerModal), the sibling of plexPlayerHooks.ts. The
// component owns the shared media refs (playerRef, stageRef, dialogRef) and passes them in via an opts
// object; each hook owns its own private state/timers and returns handlers + reactive values. The
// once-bound keyboard handler and layer-stack registration stay in the component shell (they orchestrate
// every hook through refs), exactly as PlexPlayer keeps its keydown handler while consuming these hooks.
/** Per-account volume for the YouTube player, mirroring Plex's usePlayerVolume. A YT.Player is created
* per video and always starts at 100, so the level lives in a ref and onReady re-applies it. Level 0 IS
* the muted state (that's how YouTube's own control behaves), so there's no separate `muted` flag.
* Mouse-wheel over the WHOLE modal (backdrop + card) adjusts volume, not just the small player area. */
export function useYtVolume({
playerRef,
dialogRef,
focusModal,
}: {
playerRef: MutableRefObject<YTPlayer | null>;
dialogRef: RefObject<HTMLDivElement | null>;
focusModal: () => void;
}): {
/** Level (0–100) to flash in the on-player overlay, or null when hidden. */
volumeUi: number | null;
/** Re-apply the remembered level to a freshly-created player (onReady), without flashing. */
reapplyStored: () => void;
/** Step the level by `delta` from the freshest good source (own ref, or the player on a fresh gesture). */
nudgeVolume: (delta: number) => void;
} {
const volTimerRef = useRef<number | undefined>(undefined);
// Sanitised HERE, at the boundary where the untrusted stored value enters (normalizeVolume): a `null`
// / `"80"` / missing key becomes the default ONCE, via a lazy useState so it happens once per mount.
const [volumeUi, setVolumeUi] = useState<number | null>(null);
const [storedVolume] = useState(() =>
normalizeVolume(readAccountMerged(LS.ytPlayerPrefs, { volume: DEFAULT_VOLUME }).volume),
);
const volumeRef = useRef(storedVolume);
const volSaveTimerRef = useRef<number | undefined>(undefined);
// When the last volume gesture happened — see nudgeVolume (a spin must not re-read the player).
const lastNudgeAtRef = useRef(0);
const flashVolume = (level: number) => {
setVolumeUi(level);
window.clearTimeout(volTimerRef.current);
volTimerRef.current = window.setTimeout(() => setVolumeUi(null), 1200);
};
const persistVolume = () => {
window.clearTimeout(volSaveTimerRef.current);
volSaveTimerRef.current = undefined;
writeAccount(LS.ytPlayerPrefs, { volume: volumeRef.current });
};
const applyVolume = (level: number, flash = true) => {
// Re-normalise against the LAST GOOD level (never the default) — the player can answer NaN, and
// jumping a quiet session to full blast is the surprise this feature exists to avoid.
const next = normalizeVolume(level, volumeRef.current);
const p = playerRef.current;
if (p && typeof p.setVolume === "function") {
if (next > 0) p.unMute?.();
p.setVolume(next);
if (next === 0) p.mute?.();
}
volumeRef.current = next;
// Debounced: one write when the spin settles; the unmount cleanup flushes a pending write.
window.clearTimeout(volSaveTimerRef.current);
volSaveTimerRef.current = window.setTimeout(persistVolume, 400);
if (flash) flashVolume(next);
};
const nudgeVolume = (delta: number) => {
// No early return when the player isn't ready: the level lives in volumeRef and onReady applies it,
// so a wheel spin in the first second after opening still lands.
const p = playerRef.current;
// Step from our own ref, EXCEPT when the gesture starts fresh — then read the player (the user may
// have moved YouTube's own slider). getVolume() lags setVolume across the iframe boundary, so
// re-reading it mid-spin resurrects a stale level and silently drops notches.
const now = Date.now();
const continuing = now - lastNudgeAtRef.current < 1000;
lastNudgeAtRef.current = now;
const live =
p && typeof p.getVolume === "function" ? (p.isMuted?.() ? 0 : p.getVolume()) : null;
const cur = continuing || live === null ? volumeRef.current : live;
applyVolume(stepVolume(cur, { delta, fallback: volumeRef.current }));
};
// Mouse-wheel volume over the WHOLE modal. Non-passive so preventDefault stops the modal/page
// scrolling. The cross-origin iframe swallows wheel over its native strips, but the central
// interaction overlay is our element (wheel there bubbles here), and everything outside works.
useEffect(() => {
const el = dialogRef.current;
if (!el) return;
const onWheel = (e: WheelEvent) => {
// Ctrl/⌘+wheel is the browser's zoom gesture — never swallow it.
if (e.ctrlKey || e.metaKey) return;
e.preventDefault();
focusModal(); // re-grab focus if it had slipped into the native controls
nudgeVolume(e.deltaY < 0 ? 5 : -5);
};
el.addEventListener("wheel", onWheel, { passive: false });
return () => el.removeEventListener("wheel", onWheel);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// On unmount, clear the flash timer and flush any pending debounced write — closing the player right
// after a wheel spin (the normal way to leave) must not drop the level the user just set.
useEffect(
() => () => {
window.clearTimeout(volTimerRef.current);
if (volSaveTimerRef.current != null) persistVolume();
},
[],
);
return {
volumeUi,
reapplyStored: () => applyVolume(volumeRef.current, false),
nudgeVolume,
};
}
/** Yield the interaction overlay to YouTube's native UI. Clicking a native control (gear / seek bar /
* CC) moves focus into the cross-origin player iframe — the only signal we get. While the iframe holds
* focus we drop the overlay's pointer-events so the (arbitrarily tall) settings menu is clickable end to
* end. Re-arm — restoring click-to-play, wheel volume and our keyboard shortcuts — when the pointer
* leaves the video or the window regains focus. */
export function useNativeControlsYield({
playerRef,
stageRef,
focusModal,
}: {
playerRef: MutableRefObject<YTPlayer | null>;
stageRef: RefObject<HTMLDivElement | null>;
focusModal: () => void;
}): {
nativeControls: boolean;
setNativeControls: (on: boolean) => void;
} {
const [nativeControls, setNativeControls] = useState(false);
useEffect(() => {
const iframeFocused = () => {
const p = playerRef.current;
const f = p && typeof p.getIframe === "function" ? p.getIframe() : null;
return !!f && document.activeElement === f;
};
// activeElement settles on the tick after blur, so defer the check.
const onBlur = () => window.setTimeout(() => iframeFocused() && setNativeControls(true), 0);
const rearm = () => {
setNativeControls(false);
focusModal();
};
const stage = stageRef.current;
window.addEventListener("blur", onBlur);
window.addEventListener("focus", rearm);
stage?.addEventListener("mouseleave", rearm);
return () => {
window.removeEventListener("blur", onBlur);
window.removeEventListener("focus", rearm);
stage?.removeEventListener("mouseleave", rearm);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return { nativeControls, setNativeControls };
}
/** The YouTube player's big views: `maximized` (the F key — a CSS layer that fills the viewport,
* deliberately NOT the Fullscreen API, so the cross-origin embed's OWN fullscreen button keeps working)
* and true browser fullscreen that WE own on the stage element (keeps our overlay + control bar on
* screen). Both back out one step at a time via the history layers (fullscreen → maximise → close). This
* is deliberately YT-specific and does NOT reuse Plex's useFullscreen. The refs are the bridge the
* once-bound keyboard handler reads. */
export function useYtBigView({
stageRef,
playerRef,
focusModal,
}: {
stageRef: RefObject<HTMLDivElement | null>;
playerRef: MutableRefObject<YTPlayer | null>;
focusModal: () => void;
}): {
isFullscreen: boolean;
maximized: boolean;
maximizedRef: MutableRefObject<boolean>;
isFullscreenRef: MutableRefObject<boolean>;
toggleFullscreen: () => void;
toggleTrueFullscreen: () => void;
exitBigView: () => void;
setMaximized: Dispatch<SetStateAction<boolean>>;
} {
// True while OUR stage is the fullscreen element (drives the overlay's pointer-events).
const [isFullscreen, setIsFullscreen] = useState(false);
// Our own "fills the viewport" mode (the F key). Not the Fullscreen API — see toggleFullscreen.
const [maximized, setMaximized] = useState(false);
const maximizedRef = useRef(false);
maximizedRef.current = maximized;
// Mirrored into a ref because the keydown handler is bound once and would otherwise read false forever.
const isFullscreenRef = useRef(false);
// Nested Back: peel fullscreen → maximise → close, one Back at a time (each a history layer only while
// active). Mirrors the Esc ladder in the component's keydown handler.
useBackToExit(isFullscreen, exitFullscreen);
useBackToExit(maximized, () => setMaximized(false));
const toggleFullscreen = () => setMaximized((m) => !m);
// True browser fullscreen WE own (the stage element) — keeps our overlay + controls on screen. We
// never surface YouTube's own fullscreen button, so the "cross-origin button looks dead while we hold
// the lock" problem that made F avoid the Fullscreen API doesn't arise here.
const toggleTrueFullscreen = () => {
if (fullscreenElement()) exitFullscreen();
else if (stageRef.current) requestFullscreen(stageRef.current);
};
// Leave the big view back to the modal card: drop fullscreen first if held, then un-maximise.
const exitBigView = () => {
if (fullscreenElement()) exitFullscreen();
setMaximized(false);
};
// Track the browser's fullscreen state. WE never request it via F (that maximises via CSS), so this is
// YouTube's own fullscreen — entered from its control bar, left by its button or Esc. On LEAVING,
// focus is stuck in the iframe (the user had to click into the player to reach YT's button), so our
// window-level shortcuts never fire — take focus back so F/Space/arrows work the moment fullscreen ends.
useEffect(() => {
const onChange = () => {
const fsEl = fullscreenElement();
// Refs only — this listener is bound once, so it must not close over render values.
const p = playerRef.current;
const frame = p && typeof p.getIframe === "function" ? p.getIframe() : null;
const ours = !!fsEl && (fsEl === stageRef.current || fsEl === frame);
isFullscreenRef.current = ours; // read by the once-bound keydown handler
setIsFullscreen(ours); // drives the overlay's pointer-events
if (!fsEl) focusModal();
};
return onFullscreenChange(onChange); // both spellings — see lib/fullscreen
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return {
isFullscreen,
maximized,
maximizedRef,
isFullscreenRef,
toggleFullscreen,
toggleTrueFullscreen,
exitBigView,
setMaximized,
};
}
/** Create the YouTube IFrame player for the active item, resume from the saved position, checkpoint
* progress every 5 s (+ a pagehide beacon), and auto-mark watched once playback reaches the end. Keyed
* on activeId (a queue advance recreates the player to reuse this single-video logic). The once-bound
* event closures read fresh queue/mode/volume via the refs passed in. Mirrors Plex's usePlexProgress —
* side-effect only, returns nothing. */
export function useYtProgress({
activeId,
resumeAt,
mountRef,
playerRef,
currentIdRef,
navRef,
modeRef,
setWatched,
setPlayerError,
setLiveData,
reapplyStored,
focusModal,
advanceOnEnd,
qc,
}: {
activeId: string;
resumeAt: number;
mountRef: RefObject<HTMLDivElement | null>;
playerRef: MutableRefObject<YTPlayer | null>;
currentIdRef: MutableRefObject<string>;
navRef: MutableRefObject<{ queue: Video[] | undefined; index: number }>;
modeRef: MutableRefObject<{ autoMode: AutoMode; loopMode: LoopMode }>;
setWatched: (on: boolean) => void;
setPlayerError: (code: number | null) => void;
setLiveData: (d: { title?: string; author?: string } | null) => void;
reapplyStored: () => void;
focusModal: () => void;
advanceOnEnd: () => void;
qc: QueryClient;
}): void {
const autoMarkedRef = useRef(false);
const errorStreakRef = useRef(0);
useEffect(() => {
let cancelled = false;
const id = activeId;
autoMarkedRef.current = false;
setPlayerError(null);
// Auto-watch only applies to the active item — not to other videos the player navigated to via
// description links. Cap the finish margin at half the clip so a very short video isn't auto-marked
// almost immediately.
const maybeAutoWatch = (current: number, duration: number): boolean => {
if (autoMarkedRef.current || duration <= 0 || currentIdRef.current !== id) return false;
const margin = Math.min(FINISH_MARGIN, duration / 2);
if (current > duration - margin) {
autoMarkedRef.current = true;
setWatched(true);
return true;
}
return false;
};
const persist = (): Promise<unknown> | void => {
const p = playerRef.current;
if (!p || typeof p.getCurrentTime !== "function") return;
try {
const cur = p.getCurrentTime();
const dur = typeof p.getDuration === "function" ? p.getDuration() : 0;
// If we just auto-marked watched, skip the near-end checkpoint (it would only clear the resume
// position, racing the watched write against the progress row it deletes).
if (maybeAutoWatch(cur, dur)) return;
// Only checkpoint the library item we opened with — a navigated-to video may not be in this
// user's library, so the server would 404 on it.
if (currentIdRef.current === id) {
return api.saveProgress(id, cur, dur).catch(() => {});
}
} catch {
/* player may be tearing down */
}
};
loadYouTubeApi().then((YT) => {
if (cancelled || !mountRef.current) return;
playerRef.current = new YT.Player(mountRef.current, {
width: "100%",
height: "100%",
videoId: id,
playerVars: {
autoplay: 1,
start: resumeAt || undefined,
rel: 0, // limit "related" to the same channel (full removal is no longer possible)
enablejsapi: 1,
origin: window.location.origin,
playsinline: 1,
// Best-effort HD hint (mostly a no-op now; YouTube hard-caps embed quality by on-screen size).
vq: "hd1080",
},
events: {
onReady: () => {
focusModal();
reapplyStored();
},
onStateChange: (e: YTPlayerEvent) => {
// 1 === playing → sync the navigated-to video's title/author for display.
if (e?.data === 1) {
errorStreakRef.current = 0; // a successful play breaks any unplayable run
const p = playerRef.current;
const d = p && typeof p.getVideoData === "function" ? p.getVideoData() : null;
if (d) setLiveData({ title: d.title, author: d.author });
}
// 0 === ended → mark watched, then advance to the next queue item if any.
if (e?.data === 0 && currentIdRef.current === id) {
if (!autoMarkedRef.current) {
autoMarkedRef.current = true;
setWatched(true);
}
advanceOnEnd();
}
},
onError: (e: YTPlayerEvent) => {
setPlayerError(typeof e?.data === "number" ? e.data : -1);
// onError (not 'ended') fires for an unplayable item, so advanceOnEnd() never runs and an
// auto-advancing queue would freeze — skip forward instead, but bounded so a run of dead
// items can't spin. We do NOT mark the broken video watched.
if (currentIdRef.current !== id) return; // a navigated description-link error: leave the queue alone
const { autoMode: a } = modeRef.current;
const { queue: q } = navRef.current;
if (a === "off" || !q || q.length <= 1) return;
errorStreakRef.current += 1;
if (errorStreakRef.current >= MAX_CONSECUTIVE_ERRORS) return; // give up; leave the overlay
advanceOnEnd();
},
},
});
});
// Periodic checkpoint so progress (and auto-watch) survive a crash/refresh.
const timer = window.setInterval(persist, 5000);
// Save on page unload too (F5/close/navigate): effect cleanup doesn't run on a full reload. A
// keepalive beacon so the POST survives the unload. Only for the library item we opened with.
const onHide = () => {
const p = playerRef.current;
if (!p || typeof p.getCurrentTime !== "function" || currentIdRef.current !== id) return;
try {
const cur = p.getCurrentTime();
const dur = typeof p.getDuration === "function" ? p.getDuration() : 0;
if (cur > 0 && dur > 0) api.saveProgressBeacon(id, cur, dur);
} catch {
/* player tearing down */
}
};
window.addEventListener("pagehide", onHide);
return () => {
cancelled = true;
window.clearInterval(timer);
window.removeEventListener("pagehide", onHide);
// Final flush, then mark the feed + playlists stale so resume bars reflect this session on the next
// natural refetch — WITHOUT refetching every loaded feed page on each player close (PERF-2).
Promise.resolve(persist()).finally(() => {
qc.invalidateQueries({ queryKey: qk.feed(), refetchType: "none" });
qc.invalidateQueries({ queryKey: qk.playlist(), refetchType: "none" });
});
const p = playerRef.current;
if (p && typeof p.destroy === "function") {
try {
p.destroy();
} catch {
/* ignore */
}
}
playerRef.current = null;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeId]);
}
+103
View File
@@ -0,0 +1,103 @@
// @vitest-environment jsdom
//
// E5 S8: the crossfading art backdrop hook. Covers the pieces that don't need real layout — that a
// pool paints #root + isolates it (so the z-index:-1 overlay can paint above #root's own background),
// creates exactly one overlay layer when it may animate, and HARD-OFFS the rotation (no overlay, first
// image only) under prefers-reduced-motion, a single image, or when disabled. matchMedia is stubbed
// per-test; the hook reads it at effect time.
import { describe, it, expect, afterEach, vi } from "vitest";
import { render, cleanup } from "@testing-library/react";
import { useCrossfadeBackdrop } from "./plexDetailUi";
function mockReducedMotion(reduce: boolean) {
window.matchMedia = vi.fn().mockImplementation((query: string) => ({
matches: query.includes("prefers-reduced-motion") ? reduce : false,
media: query,
addEventListener: () => {},
removeEventListener: () => {},
addListener: () => {},
removeListener: () => {},
dispatchEvent: () => false,
})) as unknown as typeof window.matchMedia;
}
function Harness({
arts,
enabled = true,
animate = true,
}: {
arts: (string | null | undefined)[];
enabled?: boolean;
animate?: boolean;
}) {
useCrossfadeBackdrop(arts, enabled, animate);
return null;
}
function root() {
return document.getElementById("root")!;
}
// The hook paints #root, so it must exist in the document (main.tsx renders into it in the real app).
function withRoot() {
const el = document.createElement("div");
el.id = "root";
document.body.appendChild(el);
return el;
}
afterEach(() => {
cleanup();
document.getElementById("root")?.remove();
});
const overlays = () =>
Array.from(root().children).filter((c) => (c as HTMLElement).style.zIndex === "-1");
describe("useCrossfadeBackdrop", () => {
it("paints + isolates #root and creates one overlay when it can animate", () => {
withRoot();
mockReducedMotion(false);
render(<Harness arts={["/a.jpg", "/b.jpg", "/c.jpg"]} />);
expect(root().style.backgroundImage).toContain('url("/a.jpg")');
expect(root().style.isolation).toBe("isolate"); // needed for the z-index:-1 overlay to show
expect(overlays().length).toBe(1);
});
it("hard-offs the rotation under prefers-reduced-motion (static, no overlay)", () => {
withRoot();
mockReducedMotion(true);
render(<Harness arts={["/a.jpg", "/b.jpg"]} />);
expect(root().style.backgroundImage).toContain('url("/a.jpg")'); // first image still painted
expect(overlays().length).toBe(0); // but no crossfade layer
});
it("stays static for a single image (no overlay)", () => {
withRoot();
mockReducedMotion(false);
render(<Harness arts={["/only.jpg", null, undefined]} />);
expect(root().style.backgroundImage).toContain('url("/only.jpg")');
expect(overlays().length).toBe(0);
});
it("does not animate when animate=false (crossfade pref off)", () => {
withRoot();
mockReducedMotion(false);
render(<Harness arts={["/a.jpg", "/b.jpg"]} animate={false} />);
expect(root().style.backgroundImage).toContain('url("/a.jpg")');
expect(overlays().length).toBe(0);
});
it("clears #root when disabled and removes the overlay on unmount", () => {
withRoot();
mockReducedMotion(false);
const { rerender, unmount } = render(<Harness arts={["/a.jpg", "/b.jpg"]} enabled={false} />);
expect(root().style.backgroundImage).toBe("");
expect(overlays().length).toBe(0);
rerender(<Harness arts={["/a.jpg", "/b.jpg"]} enabled={true} />);
expect(overlays().length).toBe(1);
unmount();
expect(overlays().length).toBe(0); // cleaned up
expect(root().style.isolation).toBe("");
});
});
+272 -65
View File
@@ -1,10 +1,16 @@
import { useLayoutEffect, useRef, useState, type ReactNode } from "react";
import { qk } from "./queryKeys";
import { useTranslation } from "react-i18next";
import { useQueryClient } from "@tanstack/react-query";
import { SlidersHorizontal } from "lucide-react";
import {
ArrowLeft,
Check,
CheckCheck,
Play,
SlidersHorizontal,
type LucideIcon,
} from "lucide-react";
import { useDismiss } from "./useDismiss";
import { api } from "./api";
import { usePref, useSavePref } from "./usePref";
import type { PlexCard } from "./api";
// Shared "detail page" UI reused by the movie info page (PlexInfo) and the series show/season pages,
// so they look consistent (the standing glassy/HTPC brief): the faint fixed art backdrop, the two
@@ -12,45 +18,36 @@ import { api } from "./api";
// customize menu that toggles them.
export function useDetailPrefs() {
const qc = useQueryClient();
const prefs = (qc.getQueryData<{ preferences?: Record<string, unknown> }>(qk.me())?.preferences ??
{}) as {
plexInfoArtBg?: boolean;
plexInfoCast?: boolean;
plexInfoRelated?: boolean;
};
const [artBg, setArtBg] = useState(prefs.plexInfoArtBg !== false);
const [showCast, setShowCast] = useState(prefs.plexInfoCast !== false);
const [showRelated, setShowRelated] = useState(prefs.plexInfoRelated !== false);
const save = (patch: Record<string, unknown>) => {
api.savePrefs(patch).catch(() => {});
qc.setQueryData<{ preferences?: Record<string, unknown> } | undefined>(qk.me(), (m) =>
m ? { ...m, preferences: { ...(m.preferences || {}), ...patch } } : m,
);
};
// Three per-user detail-page toggles, each a server pref adopted on login (usePref). Default ON:
// undefined → true, only an explicit false hides. The detail pages mount after `me` is cached, so
// usePref seeds from the cache (no localStorage mirror).
const [artBg, setArtBg] = usePref<boolean>("plexInfoArtBg", true);
const [showCast, setShowCast] = usePref<boolean>("plexInfoCast", true);
const [showRelated, setShowRelated] = usePref<boolean>("plexInfoRelated", true);
// E5 S8: slowly cross-dissolve the art backdrop through the item's backdrops/related art. Independent
// of prefers-reduced-motion, which hard-disables the rotation regardless of this toggle.
const [crossfade, setCrossfade] = usePref<boolean>("plexInfoArtCrossfade", true);
const savePref = useSavePref();
return {
artBg,
showCast,
showRelated,
savePref: save, // expose the raw persister for callers with extra per-user prefs (e.g. strip sources)
toggleArtBg: () => {
const next = !artBg;
setArtBg(next);
save({ plexInfoArtBg: next });
},
toggleCast: () => {
const next = !showCast;
setShowCast(next);
save({ plexInfoCast: next });
},
toggleRelated: () => {
const next = !showRelated;
setShowRelated(next);
save({ plexInfoRelated: next });
},
crossfade,
savePref, // the raw persister for callers with extra per-user prefs (e.g. strip sources)
toggleArtBg: () => setArtBg(!artBg),
toggleCast: () => setShowCast(!showCast),
toggleRelated: () => setShowRelated(!showRelated),
toggleCrossfade: () => setCrossfade(!crossfade),
};
}
// A stored TMDB backdrop is a bare file path ("/xyz.jpg"); the CSP allows the TMDB image CDN (only
// frame-ancestors is set), so we render it straight from there — a rich multi-image pool for the
// crossfade even on a lone movie with no collection.
export function tmdbBackdropUrl(path: string): string {
return `https://image.tmdb.org/t/p/w1280${path}`;
}
// A metadata value that filters the grid when clicked (if onClick is wired), else plain text. Shared
// by the movie info page (PlexInfo) and the unified grid / show pages (PlexBrowse). The optional
// `title` gives a hover hint (e.g. "Filter by 1998"); callers that don't need it just omit it.
@@ -77,39 +74,109 @@ export function Filterable({
);
}
// Paint the item's art as a faint FULL-VIEWPORT backdrop (HTPC-style) so the glass chrome floats over
// it. Painted on the app root (#root) — which is exactly the viewport and sits BEHIND the transparent
// nav / header / content — so the art fills the whole page edge-to-edge (under the rail + floating
// header too), not just the inset <main> box. (A `position:fixed` layer at negative z-index does NOT
// work here: the body's opaque background is painted over it, so it's invisible; #root's background,
// being a real ancestor of the content, is the reliable full-viewport layer.) `attachment:fixed`
// keeps it viewport-anchored while the content scrolls. Cleared on unmount / disable / no art.
export function useArtBackdrop(art: string | null | undefined, enabled: boolean) {
// A faint full-viewport art backdrop that slowly cross-dissolves through a pool of images (E5 S8).
//
// Painting layer: the SETTLED image lives on the app root (#root) background — the proven full-viewport
// layer (#root is exactly the viewport, transparent by default so the art shows through the glassy nav
// / header / content that float over it; a fixed layer at negative z-index that is NOT a #root child is
// hidden by the body scheme-background). The incoming image cross-fades in on an OVERLAY layer that IS
// a `z-index:-1` child of #root: it paints ABOVE #root's own background (the settled art) but BELOW the
// content, so the dissolve is visible under the chrome. Once faded, the settled image is committed to
// #root and the overlay reset to transparent — a double buffer, so only ever ONE extra DOM node.
//
// The rotation is HARD-OFF under prefers-reduced-motion (shows only the first image, no movement) and
// when there are fewer than two images or it's disabled; `attachment:fixed` keeps it viewport-anchored
// while the content scrolls. Cleared on unmount / disable / empty pool.
const BACKDROP_FADE_MS = 1600;
const BACKDROP_INTERVAL_MS = 7000;
const BACKDROP_DIM =
"linear-gradient(color-mix(in srgb, var(--bg) 60%, transparent), " +
"color-mix(in srgb, var(--bg) 64%, transparent))";
export function useCrossfadeBackdrop(
arts: (string | null | undefined)[],
enabled: boolean,
animate = true,
) {
// Deduped, non-null pool, collapsed to a string dep so the effect only re-runs on a real change.
const key = Array.from(new Set(arts.filter((a): a is string => !!a))).join("|");
useLayoutEffect(() => {
const el = document.getElementById("root");
if (!el) return;
const s = el.style;
const clear = () => {
s.backgroundImage = "";
s.backgroundSize = "";
s.backgroundPosition = "";
s.backgroundRepeat = "";
s.backgroundAttachment = "";
const root = document.getElementById("root");
if (!root) return;
const paint = (url: string) => {
root.style.backgroundImage = `${BACKDROP_DIM}, url("${url}")`;
root.style.backgroundSize = "cover";
root.style.backgroundPosition = "center 20%";
root.style.backgroundRepeat = "no-repeat";
root.style.backgroundAttachment = "fixed";
// #root must be a stacking context for the z-index:-1 crossfade overlay to paint ABOVE #root's
// own background (the settled art) rather than behind it — without this the overlay is invisible.
root.style.isolation = "isolate";
};
if (enabled && art) {
// The faint --bg overlay keeps it subtle and text-safe (same tuning the old paint used).
s.backgroundImage =
`linear-gradient(color-mix(in srgb, var(--bg) 60%, transparent), ` +
`color-mix(in srgb, var(--bg) 64%, transparent)), url("${art}")`;
s.backgroundSize = "cover";
s.backgroundPosition = "center 20%";
s.backgroundRepeat = "no-repeat";
s.backgroundAttachment = "fixed";
} else {
const clear = () => {
root.style.backgroundImage = "";
root.style.backgroundSize = "";
root.style.backgroundPosition = "";
root.style.backgroundRepeat = "";
root.style.backgroundAttachment = "";
root.style.isolation = "";
};
const pool = key ? key.split("|") : [];
if (!enabled || pool.length === 0) {
clear();
return clear; // and clear again on unmount / dep-change (idempotent)
}
return clear;
}, [art, enabled]);
paint(pool[0]!);
const reduce =
typeof window !== "undefined" &&
typeof window.matchMedia === "function" &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
// Static case (one image / crossfade off / reduced motion): still MUST clear on unmount — otherwise
// #root keeps the painted backdrop after leaving the page (e.g. switching to another module).
if (!animate || reduce || pool.length < 2) return clear;
const layer = document.createElement("div");
Object.assign(layer.style, {
position: "fixed",
inset: "0",
zIndex: "-1",
backgroundSize: "cover",
backgroundPosition: "center 20%",
backgroundRepeat: "no-repeat",
backgroundAttachment: "fixed",
opacity: "0",
pointerEvents: "none",
});
root.appendChild(layer);
let idx = 0;
let fadeTimer: ReturnType<typeof setTimeout> | undefined;
const tick = () => {
const next = (idx + 1) % pool.length;
layer.style.transition = "none";
layer.style.opacity = "0";
layer.style.backgroundImage = `${BACKDROP_DIM}, url("${pool[next]}")`;
requestAnimationFrame(() => {
layer.style.transition = `opacity ${BACKDROP_FADE_MS}ms ease`;
layer.style.opacity = "1";
});
fadeTimer = setTimeout(() => {
paint(pool[next]!); // settle the new image on #root, then reset the overlay (no visible change)
layer.style.transition = "none";
layer.style.opacity = "0";
idx = next;
}, BACKDROP_FADE_MS);
};
const interval = setInterval(tick, BACKDROP_INTERVAL_MS);
return () => {
clearInterval(interval);
if (fadeTimer) clearTimeout(fadeTimer);
layer.remove();
clear();
};
}, [key, enabled, animate]);
}
export function DetailCustomizeMenu({
@@ -121,6 +188,8 @@ export function DetailCustomizeMenu({
showRelated,
onToggleRelated,
hasRelated,
crossfade,
onToggleCrossfade,
overlay,
extra,
}: {
@@ -132,6 +201,10 @@ export function DetailCustomizeMenu({
showRelated?: boolean;
onToggleRelated?: () => void;
hasRelated?: boolean;
// Crossfade toggle (E5 S8) — offered only when the art backdrop itself is on; omitted where a page
// doesn't wire it (e.g. the season page reuses the show's backdrop prefs without its own control).
crossfade?: boolean;
onToggleCrossfade?: () => void;
overlay?: boolean; // white-on-dark button styling when floated over the player
extra?: ReactNode; // additional toggles rendered after the built-in ones (e.g. strip-source buckets)
}) {
@@ -163,6 +236,13 @@ export function DetailCustomizeMenu({
style={{ background: "color-mix(in srgb, var(--surface) 58%, transparent)" }}
>
<PrefToggle label={t("plex.info.prefArtBg")} on={artBg} onClick={onToggleArtBg} />
{artBg && onToggleCrossfade && (
<PrefToggle
label={t("plex.info.prefCrossfade")}
on={crossfade ?? true}
onClick={onToggleCrossfade}
/>
)}
{hasCast && (
<PrefToggle label={t("plex.info.prefCast")} on={showCast} onClick={onToggleCast} />
)}
@@ -207,3 +287,130 @@ export function PrefToggle({
</button>
);
}
// --- Series detail-page shared UI (the show page + the season subpage share these) -------------------
// Moved out of PlexBrowse — small building blocks the show/season detail views render verbatim.
export function epLabel(ep?: PlexCard | null): string | undefined {
if (!ep) return undefined;
if (ep.season_number != null && ep.episode_number != null)
return `S${ep.season_number} · E${ep.episode_number}`;
return ep.title;
}
export function BackBtn({ onBack, label }: { onBack: () => void; label: string }) {
return (
<button
onClick={onBack}
className="glass-card glass-hover inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs mb-4"
>
<ArrowLeft className="w-4 h-4" />
{label}
</button>
);
}
export function ActionBtn({
onClick,
icon: Icon,
label,
sub,
primary,
disabled,
}: {
onClick: () => void;
icon: LucideIcon;
label: string;
sub?: string;
primary?: boolean;
disabled?: boolean;
}) {
return (
<button
onClick={onClick}
disabled={disabled}
className={`inline-flex items-center gap-2 rounded-xl px-3 py-2 text-sm font-medium transition disabled:opacity-50 ${
primary ? "btn-primary shadow-xs" : "glass-card glass-hover"
}`}
>
<Icon className="w-4 h-4 shrink-0" />
<span className="flex flex-col items-start leading-tight">
<span>{label}</span>
{sub && <span className="text-2xs font-normal opacity-70">{sub}</span>}
</span>
</button>
);
}
/** The shared detail-page hero shell used by BOTH the show page and the season subpage (they were
* copy-paste siblings). Owns the glass frame, the play/resume poster button, the customize-menu slot,
* and the flex info column; the caller passes the customize menu + the title/metadata/actions as
* `children`. Poster is slightly larger on the show page (default) than the season page. */
export function DetailHero({
thumb,
posterTitle,
posterDisabled,
showPlayOverlay,
onPosterClick,
posterSizeClass = "w-32 sm:w-44",
customizeMenu,
children,
}: {
thumb: string;
posterTitle: string;
posterDisabled: boolean;
showPlayOverlay: boolean;
onPosterClick: () => void;
posterSizeClass?: string;
customizeMenu: ReactNode;
children: ReactNode;
}) {
return (
<div className="glass relative rounded-2xl p-4 sm:p-5 mb-8 flex gap-4 sm:gap-5">
<div className="absolute right-3 top-3 z-base">{customizeMenu}</div>
<button
onClick={onPosterClick}
disabled={posterDisabled}
className={`group/poster relative ${posterSizeClass} shrink-0 self-start`}
title={posterTitle}
>
<img
src={thumb}
alt=""
className="w-full aspect-2/3 object-cover rounded-xl border border-border"
/>
{showPlayOverlay && (
<div className="absolute inset-0 rounded-xl bg-black/40 opacity-0 group-hover/poster:opacity-100 transition grid place-items-center">
<Play className="w-10 h-10 text-white" fill="currentColor" />
</div>
)}
</button>
<div className="min-w-0 flex-1">{children}</div>
</div>
);
}
/** The "mark whole show/season watched" hero action (icon + label flip on state). Verbatim in both
* the show and season heroes; the labels differ, so the caller passes both. */
export function WatchedToggle({
watched,
busy,
onToggle,
watchedLabel,
unwatchedLabel,
}: {
watched: boolean;
busy?: boolean;
onToggle: () => void;
watchedLabel: string;
unwatchedLabel: string;
}) {
return (
<ActionBtn
onClick={onToggle}
disabled={busy}
icon={watched ? CheckCheck : Check}
label={watched ? watchedLabel : unwatchedLabel}
/>
);
}
+22
View File
@@ -0,0 +1,22 @@
import type { TFunction } from "i18next";
import { api, type PlexCard } from "./api";
import { notifyActionError } from "./actionErrors";
// Persist a watch-state toggle from any Plex surface: flip via the caller's `optimistic` cache
// updater, call the API, surface a failure (the click used to look dead on 403/404/429), then
// `resync` (invalidate) — which corrects the optimistic flip to truth either way. Shared by the
// browse grid and the person page, each of which owns a different cache shape.
export async function toggleWatchState(
card: PlexCard,
t: TFunction,
handlers: { optimistic: (next: string) => void; resync: () => void },
) {
const next = card.status === "watched" ? "new" : "watched";
handlers.optimistic(next);
try {
await api.plexSetState(card.id, next);
} catch (e) {
notifyActionError(e, t("plex.watchedFailed"));
}
handlers.resync();
}
+6
View File
@@ -93,6 +93,12 @@ export const qk = {
plex: () => ["plex"] as const,
plexWatchLink: () => ["plex-watch-link"] as const,
plexLibrary: (...rest: unknown[]) => ["plex-library", ...rest],
// Discover surface (E5 S4): the rail payload keyed by scope+lang; `plexDiscover()` (no args) matches
// both for a prefix invalidation after a watched-toggle. Surprise is its own key so the ↻ reshuffle
// refetches it alone.
plexDiscover: (...rest: unknown[]) => ["plex-discover", ...rest],
plexSurprise: (scope?: string) =>
scope !== undefined ? (["plex-surprise", scope] as const) : (["plex-surprise"] as const),
plexSuggest: (...rest: unknown[]) => ["plex-suggest", ...rest],
// Optional trailing `lang` (E5 S6): a language switch remounts the detail with a new key so HU/EN
// metadata is cached separately. Omitting it (e.g. for a prefix invalidation) still matches both.
+52
View File
@@ -14,6 +14,58 @@ export interface ReleaseEntry {
}
export const RELEASE_NOTES: ReleaseEntry[] = [
{
version: "0.71.0",
date: "2026-08-21",
summary:
"A much richer Plex experience: a Discover home, detailed movie/show and cast pages with ratings, facts, keywords and crew, a gently crossfading backdrop, and searchable filters.",
features: [
"New Plex “Discover” home. Instead of opening straight into the full grid, Plex now greets you with rows — Continue watching, Recently added, browse-by-genre tiles, a “Surprise me” shuffle, and “Because you watched …” recommendations. The full grid is still one tap away under “Browse”.",
"Movie and show pages are much richer: several rating sources side by side (IMDb, TMDb, audience), a facts panel (release, runtime, certificate, budget, revenue, studios, countries), tappable keyword chips that filter your library, and the full key crew — click a name to open that person’s page.",
"Cast and crew pages now show a biography, birth facts, and a “Known for” row of that person’s titles you have in your library.",
"Detail and person pages gain a slow, gently crossfading backdrop drawn from the title’s artwork. It respects a reduced-motion preference and can be switched off from the page’s customise menu.",
"Clearer Plex filters: type-to-search pickers for genre, age rating and a new studio filter, plus the collection filter — each shows your choices as removable chips and is fully keyboard-operable.",
],
chores: [
"All the enriched data — ratings, keywords, crew, budgets, biographies and recommendations — is fetched from The Movie Database in the background and stored locally, so pages stay fast and keep working even when TMDB is unavailable. Reviewed with a standard review pass and a multi-agent deep review.",
],
},
{
version: "0.70.0",
date: "2026-08-20",
summary:
"The address bar now shows a real link for what you're viewing, so you can bookmark or share a channel, a Plex title, a playlist, or a filtered feed — and card buttons work on touch screens.",
features: [
"You can now bookmark or share the exact view you're on. The address bar reflects the open channel page, a Plex movie/show, a selected playlist, or the feed with its filters applied, so copying the link reproduces that view for you or anyone you send it to. This replaces the old “Share view” button, which is no longer needed. (The browser tab keeps showing the short page name.)",
],
fixes: [
"On touch screens the buttons on a video or Plex card — mark watched, save, add to playlist, info — are now always visible and tappable, instead of only appearing on mouse hover. Watched items also show a filled check, not colour alone, so the state is clear without relying on colour.",
"Opening and closing a dialog no longer closes a channel or Plex page you reached from a bookmarked or shared link.",
],
chores: [
"A large internal front-end refactor with no behaviour change: several oversized screens (the Plex browser, the video player, the downloads page) were split into focused files; account preferences and the address-bar handling were folded into single reusable hooks; the three filter sidebars now share one shell; and the pop-up dialogs gained proper keyboard focus handling. Covered by the existing tests plus two review passes — a standard review and a multi-agent deep review.",
],
},
{
version: "0.69.2",
date: "2026-08-19",
summary: "Download thumbnails now load for everyone, including multi-account sessions.",
fixes: [
"Fixed Library thumbnails staying blank when you're signed into more than one account. The poster image is loaded directly by the browser, which can't send the active-account header, so it resolved to your default account and returned “not found” for downloads owned by another of your accounts. The poster URL now carries the active account (the same way the file download and filmstrip already do), so the local poster loads regardless of which of your accounts is active.",
],
},
{
version: "0.69.1",
date: "2026-08-19",
summary:
"Smoother fast-scrolling in the feed, reliable download thumbnails, and a tidier channel About popover.",
fixes: [
"Download thumbnails are reliable again. The Library now shows the copy saved on the server instead of the source's own link — which for Facebook videos expires within days (and can be blocked by a browser tracker shield), leaving a blank thumbnail even though the image was on disk the whole time.",
"Fast-scrolling the feed no longer stalls. A quick flick of the scroll wheel used to outrun the loader and leave you waiting several seconds on “Load more”; the feed now keeps a couple of screens of videos loaded ahead, so the content is usually already there when you reach it.",
"The channel About popover stays on screen at higher browser zoom. A long description could push the top of the box above the edge of the window where you couldn't read it; it now shrinks to fit the available height and scrolls inside.",
"Channel descriptions are tidier: the runs of blank lines YouTube pads its contact/link blocks with are stripped, so the About box is more compact.",
],
},
{
version: "0.69.0",
date: "2026-08-19",

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