Commit Graph
46 Commits
Author SHA1 Message Date
peter 840526b1b8 feat(plex): filter the feed by playback class (Direct Play / Direct Stream / Transcode HW/SW)
Adds a movie-scoped "Playback" filter to the Plex feed so a user can isolate items by how
they play — the groundwork for collecting HW-transcodable (or no-transcode) versions.

Four classes derived from the stored `playable` + a new `transcode_hw` facet:
- Direct Play (direct), Direct Stream (remux)
- Transcode · HW  — playable=transcode AND the iGPU can hardware-transcode it
- Transcode · SW  — playable=transcode, software libx264 only

The HW/SW split is precise, not codec-only: a 10-bit/HDR HEVC is HW-decodable yet breaks the
VAAPI nv12 pipeline (the same case plex/stream special-cases), so it must count as SW.

Backend:
- New PlexItem columns (migration 0077): transcode_hw, video_bit_depth, media_probed_at.
  Migration backfills transcode_hw codec-only so the filter works immediately.
- `plex.sync.transcode_hw_capable` is the single classifier; the HW-decodable codec set moves
  to sync as `HW_DECODABLE_CODECS` and plex.stream imports it, so the catalog facet and the
  runtime encoder choice can't disagree by codec.
- Bit-depth backfill (`_probe_media_batch`, wired into sync): probes only HW-decodable transcode
  items' video Stream for bit-depth + HDR and refines a 10-bit/HDR HEVC to software. Bounded
  per run, watermarked by media_probed_at. A codec change resets the watermark (stale probe).
- Filter + self-excluded facet on a shared `playback_class_expr` CASE (movie-leg only — a show
  is an aggregate with no single class). transcode_hw threaded onto the feed/detail/card JSON.

Frontend:
- Multi-select "Playback" Combobox in the sidebar (movie scope, facet-empty drop-out), active
  chips, 4-way corner-dot badge (green/blue/amber/red) + tooltip, and an info-page playback chip
  with a codec · bit-depth subtitle. New `bg-info` (blue) token, i18n HU/EN parity.
- Fix a regression where the card's playback dot lost its hover tooltip: the full-card reveal
  overlay swallowed the cursor; the dot now sits above it (z-20).

Gate: backend full lane + frontend typecheck/lint/knip/prettier/vitest all green. Browser-UAT'd
on localdev (filter narrows to the SW set, facet counts, badge colour + tooltip, info chip).
2026-08-30 04:45:11 +02:00
peter 70a74f8629 fix(plex): round-3 deep-review follow-ups
Third review round, scoped to the round-2 fix commit. The production ternary was
confirmed correct; two sibling-surface findings:

- The sidebar CollectionPicker's clear-all still wrote collectionTitles:{} while the
  chip-row remove (fixed in round 2) wrote undefined — a divergence that leaves the
  {}-vs-absent cache split on the picker path. Collapse an empty map to undefined there
  too, so both no-collection routes key-match the pristine state.
- The queryKeys lang test was near-tautological (a variadic-spread factory can't drop a
  passed arg), and nothing asserted the CALL SITE keys carry lang. Add a real guard in
  PlexDiscover.dom.test.tsx that inspects the registered query keys for scope+lang, and
  reword the factory test's comment to say it only shape-pins the segment pass-through.

Gate: tsc/lint/knip/prettier clean, 413 vitest.
2026-08-23 02:09:14 +02:00
peter a20e27800c feat(plex): port the saved-views (personal views) UI to Plex (frontend)
Plex now has the same personal-views system as the feed — the rail header icon
opens the quick-pick popover, the rail title opens the manager modal (list /
create / rename / delete / reorder / set-default / apply), and the active view
leads the chip row with a "modified" dot.

One SavedViewsProvider serves both modules: it switches `source` (feed ⟷ plex)
by the active page (both filter providers are global, so it reads whichever the
page owns) and runs the same logic on per-source wiring — the live filters,
apply, a stable signature (feed filtersToParams / plex plexFiltersSignature),
coercion (asFilters / coercePlexFilters), and what to strip on save. Moved below
PlexProvider in the tree so it can read plex state; resets sticky/modal on a
source switch. The shared popover/modal/chip are unchanged; the modal describes
a plex view's captured filters via a pure buildPlexActiveFilters (extracted from
usePlexActiveFilters — no duplication). api.savedViews/createView take a source;
`SavedView.filters` is now an opaque blob. New coercePlexFilters/plexFilters-
Signature (also dedups PlexProvider's collection migration). Empty-state copy
genericised (no longer says "feed"). A Plex view captures the rail filters (not
watch-state/sort/scope).

Verified end-to-end on localdev: create/apply/preview/default/delete on plex,
feed views stay separate + unregressed (402 vitest), no console errors.
2026-08-22 19:16:30 +02:00
peter a60164edc4 feat(plex): make the Collection filter multi-select with Any/All
A title can belong to several Plex collections, so the collection filter becomes
multi-select combining with Any (OR, default) / All (AND) — the same shape as the
genre/actor filters.

Backend (query.py): `collection` (single) → `collections` CSV + `collection_mode`
on LibraryFilters; meta_filter_conds ORs/ANDs `collection_keys.contains` per id.
Frontend: PlexFilters `collection`/`collectionTitle` → `collections` + optional
`collectionMode` + `collectionTitles` map; CollectionPicker is now a multi-select
Combobox with the any/all match footer; one chip per collection (usePlexActive-
Filters); the info-page "Browse collection" strip unions into the list; applyFilter
now shallow-merges label maps so adding one keeps the others' titles. PlexProvider
migrates a legacy persisted single `collection` into the new shape on read.

Test: test_plex_collections_filter (single / any-union / all-intersection).
2026-08-22 08:18:25 +02:00
peter 6c7038cf1f feat(plex): make the playlists rail widget a compact combobox
The playlists section used to render an always-open list plus a dedicated
create input, growing the rail taller with every playlist. Replace it with a
single Combobox field (reusing the shared component, like the feed's "Your
tags"): the list of playlists only appears when the dropdown is opened, and
typing a name that doesn't exist offers a "Create …" row. Picking a playlist
opens it (the existing playlistOpen hand-off); the create row creates then opens
the new one. `selected` stays empty — this picks an action, not a persisted value.
2026-08-22 07:59:41 +02:00
peter b4984dc38b feat(plex): move Watch-state out of the rail into a toolbar pill
The "Show" (watch-state) filter leaves the left rail and becomes a FilterSelect
pill next to the item count, mirroring the feed's Watch-state control — a
top-level "what am I looking at" choice, always in reach, not buried in the rail.
It still emits a removable chip when non-default (same as the feed).
2026-08-22 07:51:54 +02:00
peter 21c9b34540 feat(plex): port the filter bar to the YT-feed pattern
Bring the Plex module's filter surface in line with the feed's final shape:

- Flatten the rail: PlexSidebar passes `collapsible={false}` to FilterRail so
  the widgets become plain always-open label + control rows (no collapse
  chevrons; reorder/hide in edit mode stay). Drop the rail's own "Clear all" —
  the chip row above the grid already owns it.
- Move Sort + the Discover/Browse toggle into the applied-filter chip row,
  pinned right directly above the grid (a `sortView` fragment, like the feed's),
  and give ActiveFilterChips `flex-1` so it gets the shared responsive overflow
  (wide: fit + "+N" pill; narrow: single "N filters" count pill) — the chips can
  no longer crowd the controls off-screen.

ItemCount leads its own toolbar row, matching the feed. No behaviour change to
Discover, which keeps its standalone view toggle.
2026-08-22 07:23:20 +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 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 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 4169cfe4ff feat(plex): S6 frontend — language threading, rating badge, i18n parity
E5 S6 frontend half.

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

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

Tests: i18n parity.test.ts (en/hu key sets identical per namespace — currently
green, no drift). FE gate green: tsc / knip / lint clean, 213 vitest tests pass.
2026-08-17 19:19:03 +02:00
peter a702e72973 fix(plex): replace stray NUL byte with a space in the duration sentinel
PlexSidebar.tsx carried a literal 0x00 byte in the duration-filter
FilterChipGroup's off-bucket sentinel (`... ? "" : "\0"`), where a normal space
was intended. Functionally inert (both the NUL and a space match no bucket
option, so a set-but-off-bucket duration selects nothing), but a NUL in source
is corruption — replaced with an ASCII space. No NUL bytes remain in src.
2026-08-16 18:45:47 +02:00
peter d0f5559c49 feat(plex): S3 toolbar relocation — scope to header, sort above the cards
Move the two "what am I browsing" controls out of the filter sidebar to where
they're always in reach, matching the Feed's layout:

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

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

FE-only, no backend/migration. typecheck + eslint clean; 171 vitest green;
self-E2E verified (header scope drives the grid, sort control reorders, sidebar
no longer shows the two cards, no sort chip).
2026-08-16 18:30:48 +02:00
peter 80cdafdbfe fix(ui): address self-review findings (R13 S2c)
Three low-severity findings from the /code-review high self-pass:
- Plex duration: the "Any" chip no longer false-highlights when durationMin/Max
  hold a set-but-off-bucket value (stale/version-skew state) — it selects
  nothing then, matching the pre-sweep behaviour, instead of colliding on the
  empty-string sentinel.
- FilterChipGroup: count badge now guards on count > 0 (was `!= null`), matching
  the Tabs badge so a future count:0 can't render a stray "0" pill.
- Extract the shared CountBadge component; Tabs and FilterChipGroup now render
  the count-badge token from one source (was two near-identical inline spans).

gate green (tsc/eslint-0/vitest 136/knip/prettier). Duration self-E2E'd:
bucket select + Any toggle intact. Branch not merged.
2026-08-15 01:37:05 +02:00
peter f26e329ecf refactor(ui): unify Plex filter chips; retire local Chip/ChipRow (R13 S2c step 3)
PlexSidebar's bespoke chip system is gone. The media scope (both/movie/show)
now uses the shared SegmentedToggle (fullWidth bar, matching the feed
Mine/Library scope), and every filter chip renders the canonical rounded-full
look:
- show / rating / duration / added (single-select) -> FilterChipGroup, with
  the null/"" "Any" sentinels mapped to string option values
- genres / content-ratings (multi-select) -> plain buttons on filterChipClass
  in their own labelled group (same look, independent semantics)

The local Chip (glass-card resting) and ChipRow helpers are deleted; the
unused ReactNode import dropped. Self-E2E'd on Plex: scope bar, show + rating
chips toggle, zero leftover glass-resting chips. gate green (tsc/eslint-0/
vitest 136/knip/prettier). Branch not merged.
2026-08-15 00:48:09 +02:00
peter 61e848e63a refactor(ui): shared SegmentedToggle for MATCH controls (R13 S2c step 1)
New source-agnostic SegmentedToggle primitive (rounded-full pill track, one
active option, aria-pressed drives the paint via the .pill recipe). Migrates
the three hand-rolled MATCH segments onto it:
- feed tag any/all "Match" toggle (Sidebar)
- feed Mine/Library scope bar (Sidebar; fullWidth + icons + per-option tips)
- Plex any/all combine toggle (PlexSidebar MatchToggle is now a thin binding)

Caption mode (justify-between + label) covers the match toggles; fullWidth
+ size="md" covers the scope bar. Adds SegmentedToggle.dom.test.tsx (6 tests).
Part of the app-wide single-select control unification; branch not merged.
2026-08-14 22:58:42 +02:00
peter 665bc6f7b5 fix(plex,channels): address R13 S2 review findings
- Shared usePlexClearFilters hook replaces the duplicated clearAll (was copied in
  PlexBrowse and PlexSidebar) — one "reset every Plex narrowing" both call.
- The Plex duration chip's minutes unit is localized (plex.filter.durationUnit:
  "min"/"perc") instead of a hardcoded "m".
- The Plex collection chip falls back to the "Collection" label, not the raw
  rating_key id, when collectionTitle is missing.
- Dropped the dead rowClassName that dimmed hidden rows in the subscribed table
  (hidden channels are excluded there now — they live in the Hidden tab).
- Renamed the misleading `set` null-check predicate to `has`.

Gate green: tsc/eslint-0/prettier/knip clean, vitest 106.
2026-08-14 01:34:48 +02:00
peter 02c8c71c53 fix(sort): address R13 S1 review findings
- PlexSidebar activeCount now compares the HEALED sort, so an out-of-scope key
  (movie "duration" after switching to shows) no longer inflates the
  active-filter badge while the control + grid show the default.
- PlexProvider strips the legacy `sortDir` from the filters blob, so it stops
  leaking into the facets query key and being re-persisted on every change.
- PlexProvider sort migration guards the fall-through: valid JSON that isn't a
  SortState now falls back instead of using the raw JSON string as the key.
- New shared healPlexSort(sort, scope) is the single heal both PlexBrowse and
  PlexSidebar use (removes the noop-label plexSortOptions call + the duplication).
- Tests: useSortState.dom.test.ts (hydrate/persist/heal round-trip via renderHook)
  and SortControl.dom.test.tsx (options, pick, direction toggle, directionless,
  trailing). vitest 94 -> 105.

Gate green: tsc/eslint(0 err)/prettier/knip clean, vitest 105.
2026-08-14 00:00:31 +02:00
peter e4bdc5ea6a refactor(plex): unify Plex sort onto SortControl + a single SortState (R13 S1)
Plex sort was a bespoke chip-pill row plus a separate asc/desc chip pair, with
the key in PlexState.sort and the direction hidden in PlexFilters.sortDir. Now:
- sort is one SortState {key,dir}; sortDir is dropped from PlexFilters (and from
  the facet-key destructure). The wire call still splits it into sort + sort_dir
  at the server boundary, so the backend is untouched.
- The chip sort-row is replaced by the shared SortControl (select + arrow) — the
  same idiom the feed and channel manager use. New lib/plexSort.ts owns the
  scope-dependent vocabulary (movies add duration) + the title/asc fallback.
- PlexProvider migrates the old storage in place: a legacy bare-key plexSort
  string plus the old filters.sortDir become the {key,dir} state, so an existing
  sort preference survives the upgrade.
- PlexBrowse and PlexSidebar heal the stored sort against the current scope's
  options, so a movie-only key (duration) left over after switching to shows
  can't ask the server for an unavailable sort.

Gate green: tsc clean, eslint 0 errors, prettier clean, vitest 93.
2026-08-13 23:36:34 +02:00
peter 90b33fc325 refactor(ui): R10 S4 — complete the selection-paint sweep (all pill idioms)
The first S4 pass migrated a subset, which left same-idiom selection pills
hand-rolled and made adjacent controls inconsistent (a /code-review finding).
This completes the sweep so every selected-among-options control shares the
recipe:

- plain pills → .pill (+ aria-pressed): Channels status filters + view tabs,
  Sidebar filter-tag chips + date presets + custom-dates, Stats tabs + ranges.
- glass-card-resting pills → .pill with the resting `glass-card glass-hover` kept
  in the non-pressed branch (solid accent when pressed): PlexSidebar library
  scope + the facet Chip (retired the round-1 "deferred" note — the recipe
  already expresses this via the resting branch, no new variant needed).
- custom-dates aria-pressed is coerced with !! so a date string can't leak into
  the attribute and miss the [aria-pressed="true"] paint.
- drop the dead `border` on ChannelTags' chip-solid (accent border on accent bg).

Out of scope (a separate concern, NOT selection-among-options): primary/action
buttons that share the accent fill — Banner action, ConfirmProvider primary,
PlexBrowse primary, PlayerModal watched-toggle — these want a future .btn-primary
recipe. Left by-design: tableHeader filter options (PopoverItem + keepSelectedPaint,
owned by the popover platform).
2026-08-11 00:27:04 +02:00
peter dcb91066f5 refactor(ui): R10 S4 — unify selected-paint into .pill/.is-current/.chip recipes
The ~14 hand-rolled "this is the selected/current/active one" idioms each
repeated the same `sel ? "bg-accent text-accent-fg border-accent" : "…"` inline
ternary, which had drifted. Three attribute-driven recipes in @layer components
now own that paint, keyed on the ARIA the control already carries: .pill
(aria-pressed segments/toggles), .is-current (aria-current nav/list items), and
.chip / .chip-solid (tag & filter tokens). The JSX just toggles the ARIA; the CSS
paints, so one control's selected look can't diverge from the next's. No
focus-visible in the recipes — the @layer base rule already rings every button/a.

Migrated 15 sites (Feed show/content, Channels tag filter, Sidebar or-and +
scope, PlexSidebar match, Tabs, SettingsPanel tab-rail, PlexPlaylistView layout
[soft], NavSidebar nav + settings, ActiveFilterChips, PlexSidebar RemovableChip,
ChannelTags, ChannelPicker). Deliberately left: PlexSidebar facet Chip and
PlexPlaylistView already covered; SavedViews row (paint split across two
elements), the scheme swatch (border-only on a coloured swatch), and
ChannelPicker's option rows (text-only selection) — noted for a follow-up.
2026-08-09 21:06:58 +02:00
peter 0559e1a503 refactor(input): R10 S3b — one input recipe, ring focus everywhere
Unify text inputs onto the form.tsx inputCls look (surface bg, rounded-lg, 2px accent
RING focus), replacing the settings-family border-focus style app-wide:
- deleted 3 duplicate local `const inputCls` (KeyGate/SettingsPanel/SetupWizard) → import
  the shared one; a 4th (Welcome, py-2.5) kept local but re-styled to match.
- ~30 inline settings inputs: default full-size fields → inputCls; size variants (pager/
  table-filter/chat textarea/plex/glass-card) keep their width/radius/padding but swap
  bg-card→bg-surface and border-focus→ring-focus. glass-card/bg-transparent bgs left as-is.
- refreshed the form.tsx doc-comment (the old two-style split is gone).

The ring is the more visible / a11y-correct focus cue, matching R9's focus foundation.
2026-08-09 17:43:42 +02:00
peter ab873bbcaa feat(theme): R10 S2 — re-close the rem system (px-frozen labels/chrome -> rem)
The ~124 px-frozen text-[Npx] labels didn't track the Settings font-scale slider
while their rem-sized neighbours did. Add text-2xs (0.6875rem) / text-3xs
(0.625rem) font-size-ONLY tokens (no paired line-height -> matches the arbitrary
they replace, zero vertical-rhythm shift) and codemod every text-[Npx] to a rem
class (rare 8/9/12/13px -> rem arbitraries).

Also: --hdr-h 68px -> 4.25rem so header content clears the rem-sized pills at
scale 1.3 instead of sliding under; NavSidebar/SidePanel slot widths + their
icon/badge px sizes go rem so the rails keep pace with their (now-scaling)
labels; a w-24/h-[54px] thumb -> h-13.5 to preserve 16:9 at scale. Media
thumbnails, page-width caps and fixed container widths deliberately stay px.

Verified via getComputedStyle at scale 1.06/1.3: text-2xs 11.66->14.3px, header
clearance 72->88px, nav slot 246->302px — all scale proportionally.
2026-08-09 03:18:04 +02:00
peter 70bda98868 build(deps): migrate Tailwind CSS v3.4 → v4
Root-fixes the opacity-modifier seed bug behind R10 S0: the theme colors were
bare `var(--x)` hex with no <alpha-value>, so `bg-accent/40`, `ring-accent/40`,
`border-border/60`, `text-muted/70`, … (~99 sites across 45 files) emitted no
CSS rule at all — latent no-ops. v4 implements the opacity modifier via
`color-mix`, so the modifier now works with the existing hex vars, no per-scheme
token duplication needed.

- `npx @tailwindcss/upgrade`: `@tailwind` directives → `@import "tailwindcss"`,
  JS config → CSS `@theme` (colors, --font-sans, the z-index tokens preserved),
  classname renames (rounded/shadow/blur scale, outline-none→outline-hidden,
  ring→ring-2) across 56 files; PostCSS plugin → @tailwindcss/postcss,
  autoprefixer removed (v4 prefixes for you).
- `@theme` → `@theme inline` (load-bearing): the color vars are redefined at
  runtime under html[data-scheme]/[data-theme]; inline resolves them at the use
  site so per-scheme opacity tints track the active scheme (the shadcn v4 pattern).
- Revert one codemod false-positive: it rewrote the English word "rounded" →
  "rounded-sm" inside a releaseNotes.ts prose sentence.

Gate green: tsc(app+node) / eslint 0-err / prettier / vitest 79 / knip.
Verified in-browser: bg-accent/15 now computes a scheme-tracking color at 0.15
alpha across all 6 schemes × 2 themes; the hand-rolled glass border/shadow/
radius/backdrop-filter render unchanged under v4.
2026-08-08 00:50:05 +02:00
peter 34297b696f refactor(fe): migrate plex/messaging/admin/downloads/notifications to qk (R7 S2b)
Extend lib/queryKeys.ts to the remaining ~34 key roots and migrate every
remaining literal (queryKey: [...] AND the positional useLiveQuery keys) in
36 files. All ~200 React Query keys across the app now route through the
factory; zero raw key literals remain outside queryKeys.ts.

The heterogeneous composite plex keys (plex-library/facets/collections/
playlists carry a filters object, a ratingKeys array, or a union/group
discriminator) take a variadic pass-through — the factory centralizes the
ROOT string; single-id keys stay precisely typed. Nullable keys (thread,
playlist, ytSearch) accept null so a null segment is preserved.

Pure substitution — byte-identical key arrays, so TanStack prefix matching
is unchanged. Gate green: typecheck (app+node+e2e), eslint (0 err),
prettier, 68 vitest. Runtime-verified on a fresh dev server: Feed + Plex
modules load, feed/facets/tags/my-status/plex-library/plex-facets/
plex-collections/plex-playlists all 200, full UI renders.
2026-07-27 22:10:03 +02:00
peter e257e2aca0 style(frontend): Prettier config + one-time repo-wide format pass
Prettier 3 (pinned), printWidth 100, double quotes — chosen to match the existing
hand-formatting and minimise reflow. This commit is ONLY the mechanical format pass
(`prettier --write`) plus the config/ignore/scripts, isolated so it never pollutes a
feature diff. Verified behaviour-neutral: typecheck, eslint (0 errors), and vitest
all green before and after. See .git-blame-ignore-revs — `git blame` skips this sha.
2026-07-21 02:26:40 +02:00
peter 12d17ec7b0 polish(plex): match the feed rail's segmented-pill look for the any/all toggle
The genre + actor MATCH toggles were a boxed button row (rounded-lg border, no
track); restyle them as the feed rail's tag-match toggle — a rounded-full track
(border + bg-card + p-0.5) with an inset rounded-full active pill, plus a MATCH
label. Extracted a shared MatchToggle so both read identically. i18n
plex.filter.matchLabel (en/hu). (Plex cast-explore polish)
2026-07-19 04:31:02 +02:00
peter 6f28ce36a7 feat(plex): actor filter AND/OR toggle (default OR) (S2)
Multiple selected actors now combine as OR (any) by default — click several cast
and see films featuring ANY of them — instead of the old hardcoded AND that
emptied the grid. An AND (all) toggle in the ACTIVE filter section (shown for 2+
actors, mirroring the genre match toggle) narrows to films with ALL of them.
Backend actor_mode param mirrors genre_mode. Verified: OR John Goodman|Douglas
M. Griffin = 11 films; AND = 1 (10 Cloverfield Lane). (Plex cast-explore S2)
2026-07-19 03:37:57 +02:00
peter 6c32e5c8d6 refactor(plex): scope module state in PlexProvider
Lift the Plex module's shared local state (scope / watch-state / sort / expanded
filters / search term / open-playlist hand-off) out of App into a split-context
PlexProvider with stable useCallback setters. PlexSidebar, PlexBrowse, and the
header search box read it via usePlex()/usePlexActions() instead of props drilled
through App; PlexBrowse is now prop-free. App loses six useStates + the filters
JSON memo/serialise helpers. (Platform S6b.2)
2026-07-18 23:53:10 +02:00
peter 0ed58e3cb1 improvement(scroll): fade every capped list's clipped edges
The sweep's own three (the thread, the Plex collections filter, the tag manager)
plus the seven the spec missed — the playlist picker, the row tag menu, the Plex
playlist/collection dialogs, the player's track menu, the video description and
the table's multi-select filter. Same idiom throughout: a height-capped list that
silently clipped its content now says so, and hides its scrollbar like the rail
and the panels already do.

The table's option list is its own module-level component because it owns a hook:
FilterPopover is redeclared per DataTable render, so React remounts it.
2026-07-17 03:14:48 +02:00
peter facb3ac9cb feat(ui): unified floating side panels for filters and playlists
Replace the full-height filter rails with one shared floating glass SidePanel
used by Feed, Plex and Playlists. Each collapses to a slim tab beside the nav;
its sections are reorderable "islands" (drag / hide / per-group collapse) with a
per-panel saved layout; the body hides its scrollbar with a soft top/bottom edge
fade. The nav rail becomes a matching floating rounded card.

- New shared SidePanel / PanelGroup / PanelGroups + useScrollFade hook.
- Generalise the feed-only sidebarLayout into a per-panel panelLayout (feed keeps
  its storage key; plex/playlists get their own; persisted per account).
- Lift the Playlists rail to App level (selected playlist is now App state) so it
  floats and collapses like the filter rails; remove the old CollapsedFilterRail.
- The floating top header's fixed offset accounts for the panels' margins.
2026-07-13 05:13:40 +02:00
peter 89b6d8a7bb fix(glass): give light theme its own translucent tier + unify the Plex filter rail
Light theme has no background image, so the adaptive :root glass (94%) rendered
as flat snow-white islands, while the Plex filter rail (still on bg-surface/40)
showed the ambient tint — an inconsistency. Fix centrally, not per-component:
- index.css: one html[data-theme="light"] token override (surface 62 / card 74 /
  menu 86) so every .glass* surface is translucent in light and picks up the
  ambient tint, like it refracts the backdrop in dark.
- PlexSidebar: bg-surface/40 -> glass, so all filter rails bind to the same token.
One central lever now controls the light-theme glass translucency for the whole app.
2026-07-12 23:53:56 +02:00
peter 2fcef981cd feat(plex): collapse button in the expanded Plex filter sidebar
The collapse feature was only half-present on the Plex page: App already passed the
shared filterCollapsed state + toggle to PlexSidebar, and PlexSidebar rendered the
CollapsedFilterRail when collapsed — but the EXPANDED PlexSidebar had no trigger, so
you could only collapse it by first collapsing on the feed page. Add the same
ChevronLeft 'Filters' collapse header the feed Sidebar has (reusing the existing
sidebar.* i18n keys). E2E-verified: collapse→rail→expand round-trips on the Plex page.
2026-07-12 01:55:10 +02:00
peter cddfb909da chore(ui): dedup last 3 jscpd clones → 0 (collapsed rail, video-card text block, subs column)
- CollapsedFilterRail: the byte-identical 31-line collapsed sidebar rail was in
  both Sidebar (feed) and PlexSidebar → one shared component.
- VideoCard: the title + channel-button + meta block was duplicated across the
  list-row and card layouts → a local `textBlock` element (only one layout branch
  renders, so reusing the element is safe).
- subsColumn<T>(t): the identical 'subscribers' DataTable column in Channels +
  ChannelDiscovery → a generic factory in channelColumns.tsx.

All behavior-neutral; jscpd now reports 0 clones (was 8 at Phase 0 baseline).
tsc + knip green.
2026-07-12 00:46:52 +02:00
peter 4d22349aa6 fix(plex): player marker-skip reset, lang-switch reload, tracks wheel, sidebar badge clamp
- PP1: reset cancelledMarkerRef on item change — a cancelled intro/credits
  auto-skip on one episode suppressed a same-offset marker on the next (auto-skip
  silently dead for the rest of a binge).
- PP2: loadSession no longer depends on the i18n `t` (read via a ref). A
  mid-playback language switch rebuilt loadSession → re-ran the detail effect →
  reloaded the session from the STALE saved resume position, losing live progress.
- PP3: tracks (audio/subtitle) menu now stopPropagation on wheel, like the gear
  menu — scrolling the list no longer changes volume.
- PS1: collapsed PlexSidebar filter badge clamps to "9+" (was a raw count),
  matching the feed Sidebar.
2026-07-11 23:13:10 +02:00
peter b85d12e0c9 feat(plex): restore Collections filter as a cross-library union picker
The searchable Collections picker was dropped from the sidebar when browsing
went unified (movies+shows across libraries) because collections are per-library
and there's no single selected library. Bring it back library-agnostic: GET
/api/plex/collections `library` is now optional — without it the endpoint unions
collections across all enabled libraries (with it, the old single-library path the
collection editor uses). The sidebar re-adds the searchable list (chip when one is
active). Its query key is ["plex-collections","union",<search>] so a search term
equal to a library plex_key can't collide with the editor's ["plex-collections",<key>],
while the shared prefix keeps editor invalidation refreshing the picker.
2026-07-11 03:30:33 +02:00
peter d46f55af83 fix(plex): address code-review findings (F1-F3, F5, F9)
- F1: refresh grid after playback/collection-edit — invalidate the renamed
  ["plex-library"] query key in PlexPlayer + PlexCollectionEditor (was stale
  ["plex-browse"], a dead no-op)
- F2: exclude hidden movies from facet counts/bounds — the movie facet base now
  always joins watch-state and mirrors unified_library's status branches, so
  facets match the visible grid exactly
- F3: show_detail/item_detail live-Plex enrichment degrades instead of 500ing —
  broaden the best-effort block to except Exception
- F5: don't re-run the heavy facet computation on sort-direction toggles — key
  the facets query only on fields plexFacets actually sends
- F9: optimistic watched-toggle also updates the search Episodes section and
  gives toggleEpisodeWatched an optimistic path (shared optimisticStatus helper)
2026-07-11 02:25:14 +02:00
peter cb93b698f4 fix(plex): facet + sort refinements
- Facets now respect the watch-state filter too (In progress / Watched / Unwatched),
  so e.g. the Year bounds match the visible set (was showing the library min, not the
  filtered min).
- Genre chips sorted alphabetically (was by count).
- Sort chips ordered alphabetically by their localized label.
- Ascending/Descending chips swapped (Ascending first) and the default sort direction
  is now Ascending.
- Default sort is now Title (ascending) instead of Recently added / Descending.
2026-07-11 00:45:05 +02:00
peter 59d6ad01b3 feat(plex): adaptive (faceted) filters — each filter narrows the others
The sidebar's filter chips/bounds now ADAPT to the active filters: /facets takes the
current filters and computes each group's available values with all the OTHER active
filters applied but not its own (standard faceted search). So picking e.g. IMDb rating
>=8 trims the genre/content-rating chips + year bounds to what's still reachable, and
picking a genre narrows them further — while a multi-select group (genres) still shows
options to add. Extracted _meta_filter_conds (shared by browse/unified/facets), and the
frontend passes the live filters to plexFacets (query keyed on them, previous chips kept
during refetch). Search text is intentionally not factored into facets.
2026-07-11 00:31:47 +02:00
peter b526b4d9cc feat(plex): unify movies + shows into one cross-library browser
Collapse the separate Movie/Show library sections into ONE unified library with a
shared search + shared filter sidebar and a Movies/Shows/Both scope selector.

Backend: new GET /api/plex/library — a cross-library UNION of movies (plex_items) and
shows (plex_shows) as one mixed, paginated, sorted feed, scoped movie|show|both, with
the shared filters (extracted into _apply_meta_filters, DRY), FTS search, and per-user
watch-state (a show's state = the aggregate of its episodes). On a search that also
matches episodes, matching episodes come back in a separate 'episodes' list (the grouped
'Episodes' section — Proposal 3). /facets is now scope-aware (merged across the scope's
libraries). /item and /show now return their library section key (for the admin
collection editor, since there's no single library prop in the unified view).

Frontend: PlexSidebar's library picker -> a scope selector (Both/Movies/Shows); facets +
browse follow the scope (App's plexLib repurposed to a validated scope, default both).
PlexBrowse uses the unified endpoint, renders a mixed Titles grid + an Episodes section
on search. Poster cards are generalized: a hover Play/Resume overlay on every card, a
clickable title (not just the poster), and a movie/show type tag. The quick watched
toggle is now optimistic so it reliably flips BOTH ways (fixes the movie card that could
mark but not un-mark). Cast/crew members and the show hero's meta (year/rating/genre/
content-rating) are clickable filters; clicking a person widens the scope to Both so the
result is a mixed movie+show feed. i18n plex.filter.scope*/plex.unified.* (en/hu/de).

Still pending from the polish list (next pass): season-card quick toggles (watched +
add-to-playlist), per-episode watched toggle, and the full glassy art-bg + hide-cast
customize menu on the series pages.
2026-07-11 00:15:49 +02:00
peter 56799e5bcc feat(plex): TV-show metadata sync + series filters (Phase 1 of series view)
Give TV shows the same filterable metadata as movies so the TV grid can be
filtered/sorted, not just library+sort. Backend: migration 0052 adds
rating/content_rating/studio/originally_available_at/genres/directors/cast_names/
people_text to plex_shows (+ GIN/indexes, people_text folded into search_vector);
_sync_shows populates them cheaply from the show section listing (no per-item
calls). /browse show-branch gains the movie filter set (minus duration) plus an
aggregate per-user watch-state (a show rolls up its episodes: all watched=watched,
any progress=in_progress, none=new) with year/rating/release sorts; /facets returns
show facets. Frontend: PlexSidebar renders the metadata filters + watch-state for TV
libraries (duration stays movie-only); show cards show a watched/in-progress badge.
i18n plex.inProgress (en/hu/de). Needs a Plex re-sync to populate the new columns.
2026-07-10 21:43:09 +02:00
peter cb6fb60b08 feat(plex): Playlists Phase 1 — per-user ordered watch-lists (Siftlode-native)
Personal ordered lists of Plex items, kept in Siftlode's own DB (works for users
without a Plex account, like watch-state) — the "your own lists" counterpart to
shared collections. Plex-direction sync is a later phase (plex_rating_key
reserved).

Backend: migration 0048 (plex_playlists + plex_playlist_items with position),
PlexPlaylist/PlexPlaylistItem models, and per-user CRUD endpoints under
/api/plex/playlists (list [+?contains for the add dialog], create [seeded],
detail [ordered cards], rename, delete, add/remove item, reorder). _leaf_card
handles the movie/episode mix. Frontend: "Add to playlist" dialog from the movie
info page (all users), a Playlists section in PlexSidebar (list + create),
PlexPlaylistView (reorder up/down, remove, rename, delete, Play all), and
PlexPlayer gained an optional `queue` so play-through follows the list order
(prev/next + auto-advance). i18n en/hu/de. Verified end-to-end on localdev
(backend CRUD + the create→add→view→play-through UI flow).
2026-07-06 19:05:12 +02:00
peter 74ae722977 feat(plex): collections — sync, browse, filter, and info-page strips (Phase 1, read)
Backend (migration 0047_plex_collections): a plex_collections table mirrors every Plex
collection (card metadata + smart flag + an editable flag reserved for Phase 2); membership
is stored as GIN-indexed collection_keys on member movies (plex_items) and shows (plex_shows).
The background sync (plex_sync) now fetches each library's collections + their children and
rebuilds membership — so ALL reads are local (Plex's dual-language collection queries are slow;
this trades a ~4-min background sync for instant reads). New /api/plex/collections endpoint;
/browse gains a combinable  filter; item_detail returns the movie's collection
'strips' (sibling titles as playable cards, smallest/most-specific collection first) — all pure
local lookups.

Frontend: PlexSidebar gains a searchable Collection picker + an active-collection chip;
PlexInfo renders the collection strips (playable posters + 'Browse collection' → sets the
filter); the collection is part of PlexFilters (persisted). i18n en/hu/de.

Phase 2 (create/edit collections with write-back to Plex) is separate.
2026-07-06 02:25:48 +02:00
peter a1df9fdb72 feat(plex): sort direction, genre any/all, multi-person filters; fix back-nav + player stop
UAT follow-ups on the Plex filter epic:
- Sort now has an asc/desc toggle (sort_dir), applied to any sort field.
- Genre multi-select gains an Any/All mode (genre_mode: OR vs AND containment).
- Director/actor/studio become multi-value: people AND (titles featuring all selected),
  studios OR; clicking them on the info page stacks (unions) instead of replacing, and
  the sidebar shows each as a removable 'Active' chip.
- fix(history): clicking a metadata filter on the info page now pushes a fresh grid
  entry instead of history.back(), so browser Back returns to the info page rather than
  leaving the Plex module.
- fix(player): fully tear down the <video> + hls on unmount and guard late play() calls,
  so backing out of a just-started video no longer leaves audio playing in the background.
i18n en/hu/de (match any/all, sort direction).
2026-07-06 00:15:31 +02:00
peter 70a12e2cad feat(plex): expanded metadata filters, clickable info page, image disk cache
Backend (migration 0045_plex_filter_meta): plex_items gains rating (audienceRating
~IMDb), content_rating, studio, originally_available_at, and GIN-indexed genres /
directors / cast_names — all mirrored from the cheap section listing (no per-item API
calls; they also seed a future watch-habit recommender). /browse gains genre / content-
rating / year / rating / duration / added-within / director / actor / studio filters
(@> containment, GIN) + sort by year|rating|duration|release; new /facets endpoint
returns available genres+ratings (with counts) and the year/rating/duration bounds. A
thin on-disk image cache (.plex-img-cache) serves posters/art/cast photos from local
disk after first fetch (~7-14x faster repeat loads).

Frontend: PlexSidebar grows the full filter set (facet-driven genre/age chips, rating
steps, year range inputs, duration buckets, added-within, active people/studio chips,
clear-all); filters persist per-account as one JSON blob. PlexInfo metadata (year,
genre, director, cast, studio, IMDb score) is clickable → sets the matching filter and
returns to the filtered grid (page variant only; the in-player overlay stays read-only
so a stray click can't stop playback). i18n en/hu/de.
2026-07-05 23:45:55 +02:00
peter 1c12e983bc feat(plex): P1.5 — Plex as a nav module + integrated search + filter sidebar
Per user feedback: promote Plex from a hidden feed-Source option to a first-class
left-nav module, with its own filter sidebar and the shared top search box.

- Plex is now a nav-rail module (page='plex', gated on me.plex_enabled) — its own
  page → correct browser Back/Forward; removed the Feed Source-dropdown 'plex' hack.
- PlexSidebar.tsx: left filter column (library scope, watch-state for movies:
  all/unwatched/in_progress/watched, sort) — per-account persisted (App state).
- Header search box now also serves the Plex page (integrated search); the live
  YouTube-search escalation stays feed-only.
- PlexBrowse.tsx reworked: infinite scroll (IntersectionObserver, no 'Load more'),
  content-visibility for smoother long-list scroll, show drill-down rides history
  (useHistorySubview) so Back returns to the grid; dropped the stray 'YouTube feed'
  button on the show page.
- backend /browse gains a per-user watch-state filter (show=, movies only).
- plex i18n (navLabel + filter.*) en/hu/de.

Note: episode-title 'Episode N' on some shows (e.g. Westworld) is Plex-side
(unmatched show) — we mirror what Plex has; Match/Refresh in Plex + re-sync fixes it.
2026-07-05 03:29:20 +02:00