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.
ESLint 9 flat config, pinned. Narrow by design: the load-bearing rule is
react-hooks (rules-of-hooks + exhaustive-deps) — 33 disable comments existed for a
rule nothing ran. tsc already owns unused vars / undefined / types, so those are
turned off here to report each finding once. no-explicit-any is off: all 19 live in
the api.ts god-module that R7 rewrites, and it flips the rule back on then.
reportUnusedDisableDirectives is an error, which immediately caught two inert
`eslint-disable` comments (App.tsx, AddToPlaylist.tsx) whose effects have stable
deps — removed. no-unused-expressions allows the side-effect ternary idiom the
codebase already uses (`v.paused ? v.play() : v.pause()`).
Errors: 0. Remaining 38 are warnings (28 react-refresh DX, 10 exhaustive-deps) —
pre-existing, visible in the lint output, tracked to their epics.
- DataTable: keep `pagerNode` NULLABLE. Extracting Pager made it an always-truthy
element, so the `controls` / `bottomControls` guards always fired and a table with
nothing to page rendered empty `my-3` bands (plus an empty in-band PageToolbar).
- ChannelsProvider: `setView` bails when the tab is unchanged (re-selecting the
active tab, or goToFullHistory forcing "subscribed", stacked dead Back entries),
and pushes a FRESH state like setPage does instead of spreading `_chan`/`_yt`
markers into the tab entry.
- Lift the layout into its own narrow context (`useChannelLayout`): App read the
whole ChannelsState for the fade, so every keystroke in the manager's search box
re-rendered the app root.
- Reset the card page when the row set or sort changes (both tabs) — the clamp only
hid a stale page, which came back when the filter was cleared.
- Sort + slice the card rows only in cards mode; table mode was copy-sorting ~300
rows per render for a result nothing read.
- Validate persisted `cardSize` / `sort` (`parseCardSize`, `parseSortState`), matching
the clamp-on-read convention the module's other persisted values already follow;
a corrupt size made the page count NaN and emptied the grid.
- Pager: page-size changes scroll to the top too (they re-slice from a new first item).
- ColumnSortControl: a stored sort naming a column this table no longer offers is
treated as no sort, instead of showing a direction arrow for a sort that isn't applied.
- Drop the stale "rows" layout comments (that variant was retired this sprint).
UAT round 3 feedback on the card layout:
- Add a pager + "Cards per page" to the card view (it infinitely-scrolled before).
Extracted the pager cluster out of DataTable into a reusable Pager component
(the table now renders it too — no behaviour change); the card view slices the
sorted rows and drives its own per-account page size (subscribed + discovery).
- Restore the top edge-fade in the card layout. The channels page registers
fadeTop:false for its sticky-header table; the card layout has no sticky header,
so App now re-enables the top fade when the channel layout is cards.
Replace App's page-render ternary and the three side-rail page conditionals with
a data-driven lookup: components/moduleRegistry maps each page to a prop-free
content component (+ optional side rail + shell fadeTop), and App renders
PAGE_CONTENT[page]/PAGE_RAIL[page] gated by moduleDef(page).gate (an unreachable
gated page falls back to the feed, as the old ternary did). Content modules and
the two rails are now prop-free: they read `me` via the new useMe() hook and
their module-local state from the scoped providers; FeedPage/MessagesPage are the
only wrappers (they wire the global feed scope / meId). App.tsx 796->431 lines,
zero `page===X` chrome conditionals — closes the Platform epic DoD. (Platform S6b.3)
Lift the four editable Settings prefs (theme/perf/hints/notifications), their
live-apply effects, the debounced auto-save + baseline echo-guard, and the
server-adopt-on-login into PrefsProvider, exposing the PrefsController via
usePrefs(). SettingsPanel and GlassTuner read it from the hook instead of props;
App's big adopt effect keeps only the shell prefs (panel layouts, rail collapse,
language). App.tsx 655->489 lines. E2E 17/17 incl. settings auto-save. (Platform S6b.3)
Lift the feed's view-mode state (server-synced: persists on pick, adopted from
prefs on login) out of App into FeedViewProvider. App reads it via useFeedView()
to hand to Feed/ChannelPage; the view adopt lines leave App's big prefs-adopt
effect. Removes another App-owned cross-cutting state ahead of the render flip.
(Platform S6b.3)
Lift the onboarding wizard's open state, first-login auto-open effect, and the
wizard render itself out of App into WizardProvider. Feed, Channels, and
SettingsPanel trigger it via useWizard() instead of an onOpenWizard prop; App
and ChannelPage drop the prop plumbing. First step of S6b.3 — removing the
per-module props that block a uniform registry render. (Platform S6b.3)
Lift the Channels manager's shared local state (name filter, status chip,
active tab, column-reset token, focus-a-channel intent) out of App into a
split-context ChannelsProvider, nested inside NavigationProvider so its
focusChannel/goToFullHistory actions can drive navigation. The Channels page,
the header search box + sync chip, the feed filter sidebar, and the
notifications inbox all read it via useChannels()/useChannelsActions() instead
of props drilled through App. Channels also internalises onViewChannel /
onFilterByTag / onFocusChannel (nav + feed-filters straight from providers) and
takes only { me, onOpenWizard } now. App loses five useStates, the focusChannel
helper, the focus-clear effect, and a large block of per-page prop plumbing.
(Platform S6b.2)
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)
Lift the Playlists module's shared local state (rail name filter + selected
playlist) out of App into a split-context PlaylistsProvider, mirroring
FeedFiltersProvider. Header, PlaylistsRail, and the Playlists detail pane read
it via usePlaylists()/usePlaylistsActions() instead of props drilled through
App; the modules take only a uniform `me` prop now. App loses two useStates and
the selected-playlist persistence helper. (Platform S6b.2)
Lift the feed's filter god-state (per-account persistence, the account-load
effect, share-link capture) out of App into a split-context provider mirroring
NavigationProvider. Sidebar/Header/NotificationsPanel read it via hooks instead
of prop-drilling; <Feed> keeps its filters prop since ChannelPage renders it with
its own channel-scoped state.
The sticky <thead> jumped up ~8px and picked up the scroller's top fade
the moment you scrolled 1px — so it read as 'not fixed'. Two causes:
- the table sat under a pt-2 gap, so its natural position was 8px below
the sticky top:0 it snapped to. Drop the top padding (the fixed band's
controls row already gives the visual gap) → no shift.
- the page scroller's top edge fade turned on with the first scroll and
masked the header's top. Add a fadeTop flag (PageShell → PageScroller →
useScrollFade), off for the Channels + Audit pages whose scroll region
starts with a sticky header. Feed/channel keep the top fade (it fades
cards under the fixed toolbar, as intended).
Verified: header stays at a fixed offset with no fade change from scroll 0
through scrolled, on both the manager and the audit log. Suite 15/15.
Address manager UAT:
- The DataTable controls row (status chips + pager) now portals into the
fixed band via a new controlsInBand prop, so it stays put above the
sticky header instead of scrolling away — and the pager fills the empty
space next to the status chips, on one row.
- The tab bar is always fixed chrome now (not just on Subscriptions), so
it doesn't jump when switching tabs; on Subscriptions the stats/intro/
tags ride with it.
- Blocked channels moves off the bottom of the Subscriptions list into its
own 'Blocked' tab (with an empty state), alongside Subscriptions and
Discover.
Verified with a 90-channel test account: everything above the rows stays
fixed while the rows scroll under the sticky header. Suite 15/15, tsc +
knip clean.
The shell owns the scroller, so it owns the scroll policy. PageScroller
now takes a scrollKey identifying the logical view and keeps a per-view
offset in a module-level map, recorded live on scroll:
- same key returning (feed -> channel -> Back, filters unchanged) restores
where you were (bug 1) -- the feed's scroller unmounts, but the saved
offset survives and is re-applied, retried over a few frames so the
virtualized list has time to reach its full height;
- a new key (feed filters changed) starts at the top of the fresh result
set (bug 3). q is normalised out of the feed key so typing doesn't reset.
Recording on scroll rather than at unmount matters: by cleanup time the
scroller's scrollTop is already zeroed, so a cleanup-read saved 0.
Also fixes a Maximum-update-depth loop this introduced: the combined
element/fade ref was an inline callback, so React re-attached it every
commit and re-ran useScrollFade's state setter. Bound it with useCallback.
Adds E2E locks for bugs 1, 3 and 4 (restore on Back, reset on filter,
toolbar stays pinned). Full suite 14/14 green on :5173; tsc + knip clean.
The channel page rendered its own PageScroller as a separate App branch —
the source of the app's two <main>s. Make it a headerless/rail-less
PageShell config (its banner scrolls from the top) so there is exactly
ONE page scroller. Verified: single <main>, banner scrolls, in-page and
browser Back both return to the feed; E2E 11/11.
Collapse App's hand-assembled normal-page chrome (floating Header +
top-padded wrapper + PageScroller + banners) into a single PageShell
contract with declared fixed vs scrollable regions. No behaviour change;
the channel page still renders its own scroller (dissolved next).
Groundwork for the S4 fixed-chrome/scroll-ownership seam.
VideoCard/PlayerModal read openChannel from useNavigationActions (the memo'd
VideoCard subscribes to the STABLE actions context, so a page/channel change
no longer re-renders it); NavSidebar/Header read page/setPage; ChannelPage
reads closeChannel; NotificationsPanel reads setPage. App sheds the matching
props, and Feed/VirtualFeed no longer thread onOpenChannel at all.
Move page/channelView/ytSearch and the history/popstate/title effects out of the
App god component into a new NavigationProvider (split state/actions contexts so
the memo'd VideoCard doesn't re-render on a page/channel switch). App consumes it
via useNavigation/useNavigationActions but still passes the props down for now;
the consumer migration that removes the prop-drilling follows.
Every Settings change now persists on its own (debounced ~500ms so a slider drag
is one PUT), with a small transient indicator; the draft/dirty-save UI and the
navigation leave-guard are gone. Also unblocks the navigation extraction — there
is no longer a cross-page "unsaved settings" guard to thread through routing.
The subdued edge-fade the rail and side panels use now hints at more content on
the page scroller too, which the feed, the channel page and Plex all scroll
inside. Both <main>s move into one PageScroller so the invariants they share —
the load-bearing scrollbar gutter, and the bar staying visible because hiding it
would zero that gutter — are stated once.
Two fixes the hook needed first: it set fresh state on every scroll event, which
above a page tree would re-render it per frame; and it watched a single child
captured at mount, so a lazy page swapping out its Suspense fallback left the
observer on a detached node and the fade frozen at its pre-load value (measured
on the Plex page: no bottom fade over a 3119px list).
The feed takes its column count from main's clientWidth and its
thumbnails are aspect-ratio boxes, so the two feed each other once the
content height sits within a scrollbar's width of the viewport: the bar
appears, 15px goes, thumbs shorten, the content fits, the bar leaves,
and round again — every frame. Measured: scrollbar width alternating
0/15 and clientWidth 987/972 on consecutive frames. Reserving the gutter
holds clientWidth constant and breaks it.
The view mode moves out of Settings into a switcher next to sort, on the
feed and channel pages alike. It leaves the Settings prefs draft for its
own eager save: a toolbar click must not mark that page dirty and trip
its leave-page guard.
lib/feedView.ts owns the vocabulary and maps the old grid/list prefs
forward on read, so nobody's saved choice is lost.
Sprint 5 made the channel filter multi-select, but this handler still wrote a
single-element array — so arriving from a second channel page silently dropped
the first. Union the id in (deduped, so repeating it for an already-picked
channel is a no-op).
It called closeChannel() before setPage("feed"). closeChannel runs
history.back(), and the popstate that restores the page fires AFTER the
synchronous setPage — so the restore won, dropping you back on whatever page the
channel was opened from. Opening from the feed hid it: the page it restored
happened to be the feed.
setPage already closes an open channel page and pushes a clean history entry, so
the closeChannel() call was both redundant and the bug.
Pick any number of channels; they OR together and every other filter applies
across the lot — "the last week's unwatched from these three".
`channelId`/`channelName` are replaced outright by `channelIds: string[]`, no
legacy reader (standing rule: no back-compat unless asked — a saved view written
against the old field simply loses its channel filter). The wire and the share
URL move with it: repeated `channel_ids` params, `?channel=a,b`. Backend ORs via
`Video.channel_id.in_()`; the count endpoint shares the same params object.
Two things the migration exposed:
- SavedViewsWidget fed DB blobs straight into serializers that now index an
array — the FeedFilters type says nothing about what an older build wrote, so
the blob is coerced where it enters. It white-screened the app before this.
- Chips carried channelName to label themselves. Without it they resolve names
from the channel cache, so they now fall back to the id, not a shared
"This channel" that would render N identical chips while the cache loads.
`filters.channelId` was settable by exactly one thing: a "?channel=" URL from a
shared view. The panel still rendered a CHANNEL card whose only control was
"remove" — a remove button for a filter the UI could not apply.
Two ways in now. The panel gets a real Channel group (searchable picker,
first-class alongside date/tags/language, so the chip row surfaces it for free);
normalizeLayout appends it to saved layouts, so nobody loses their arrangement.
And a subscribed channel's page gets "Show in my feed", which applies the filter
and keeps the rest of your filters — the point is "this channel, the way I
normally read", which the channel page itself can't answer (it runs show=all,
scope=all over the whole catalog).
The picker owns its channel query so a few-hundred-entry list loads when the
panel is actually opened, not on every feed visit, and says "showing 50 of N"
rather than quietly stopping.
The rail spent a two-row block plus a divider on per-user sync status. It's
global, not per-module, so it now rides the right end of the floating header as
a pill: the headline count at a glance, an accent dot when something wants you
(paused / missing history), and the counts, state, the channel-manager link and
pause in a popover.
Frees the rail's whole top block. Also drops SyncStatus's two now-dead layouts:
"bar" was a leftover of the top bar removed in 0.42.0, and "rail" lost its only
caller here — the component is chip-only, ~60 lines lighter, and the popover
reuses useDismiss.
The shared SidePanel now follows the nav rail's model, so Filters, Plex and
Playlists all inherit it: unpinned it sits as a small tab docked flush against
the rail (replacing the full-height vertical strip) and expands into the card on
hover or keyboard focus without pushing content; pinned it stays expanded
in-flow. The collapse toggle became pin/unpin, and panels now default to
unpinned. The tab carries the active-filter count and an accent ring.
The tab stays mounted under the open card on purpose: swapping it out from under
the pointer drops the hover that opened the card, and unmounting it while
focused strands keyboard focus.
Measured: feed content starts at 123px instead of 336px when unpinned.
Graduate the floating glass tuner from a localhost-only dev tool to a
user-facing opt-in: it's now gated on a per-user `theme.showTuner` pref (off by
default) with a "Show the glass tuner" switch in Settings → Appearance, riding
the existing theme draft-save. Softened the dev-only framing. Behaviour and look
are unchanged; the full preset system is a separate epic.
The rail now sits slim by default and expands to a labelled overlay on hover
or keyboard focus without pushing content; the manual toggle became a pin
(pinned = the old in-flow expanded rail). The header no longer reserves a fixed
504px left offset — it's absolutely positioned inside the content column, so it
tracks the actual left zone and reclaims the dead gap. The role badge is gone;
role now shows via a title tooltip on the username, and a logout quick-button
joins the language/about cluster.
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.
The wrapper that pads content below the fixed header lacked min-h-0, so as a
flex child with min-height:auto it grew to its content's height instead of
staying bounded to the viewport. That let the whole document scroll (the fixed
header/nav stayed put while content ran off-screen). Add min-h-0 so the wrapper
stays viewport-height and the inner <main> keeps its own overflow scroll.
Replace the full-width top bar with a fixed floating header of glass pills: a
fixed-position ModuleName (accent dot + label + prev/next cyclic module stepper)
and a shared SearchBar. The header's left edge is anchored as if the nav rail
and filter sidebar were both open, so it never shifts on collapse or module
change; the right edge leaves a 10% margin.
- ModuleName label width is measured live from the reachable modules' titles in
the current language, recomputing on a language or account change.
- Module names come from a shared moduleLabelKey (the same short labels as the
nav rail); NavSidebar and the header now read from one source (lib/modules.ts).
- The arrows step cyclically through moduleOrder(me) — dynamic, no hard-coded
targets, wrapping at both ends.
- Search is hoisted into the SearchBar for feed (YouTube search), plex, channels
(including the Discovery tab) and playlists (new); Go button, Enter triggers it.
- The Playlists left rail now reaches the top; header clearance via --hdr-h.
Add a light set of the 6 per-scheme backdrops (public/backdrops/light/*.svg) —
soft, pastel-mid, non-figurative washes tuned to each accent so they read through
the light translucent glass. Wire them up centrally:
- App: the backdrop is on whenever bgImage && !perf (was dark-only), so light
gets it too.
- index.css: per-scheme rules are now [data-theme]-qualified — dark uses the dark
set, light the /light/ set; shared cover/fixed size rule for both.
- Settings: the Background image toggle + Image-fade slider work in light now
(removed the dark-only gates); hint no longer says 'dark only'. Trilingual.
The light glass (62% translucent tier) refracts the light backdrop just like dark
refracts its own; one fade slider controls both.
Code review (public-repo rule): the GlassTuner + index.css/App.tsx comments named
the prod hostname and dev-process jargon (UAT/HMR/dev ports/epic), which ship in
the public bundle. Genericised the comments — no behaviour change.
Land the UAT-approved backdrop direction as a real feature (replacing the dev
thumbnail prototype):
- 6 generated abstract SVG backdrops in public/backdrops/, one tuned to each
accent scheme (dark, mixed-colour, few light areas). Non-figurative + generated
= repo-safe, tiny, accent-matched.
- New data-backdrop="on" state (dark theme + the setting + not perf) paints the
per-scheme image on <body> (app root now transparent so it shows app-wide and
the glass refracts it) AND switches the glass to its translucent tier (the
user's tuned values: surface 50 / card 60 / scrim 30 / blur 10 / edge 25).
Off / light / perf -> flat colour + solid glass (the readable fallback).
- Settings > Appearance 'Background image' toggle (bgImage pref, dark-only),
trilingual. Flat is the fallback + opt-out.
- Remove the FeedBackdrop prototype + its tuner controls; tuner baseline resynced
to the glass-over-image tier, add a Backdrop-fade slider.
GOTCHA fixed: the dark-mode ambient 'background' shorthand reset background-size
to auto (image rendered un-covered); out-specified it with [data-theme="dark"].
Foundation for the app-wide glass-consistency epic:
- index.css: every look-driving value (blur/saturate/dark-brightness/panel-card-
menu opacity/border/inset/edge/scrim/ambient + mosaic) is now a CSS custom
property defaulting to the historical hard-coded value — one-place tunable, no
visual change until a value is bumped.
- GlassTuner.tsx: dev-only (localhost-gated) always-on corner panel that live-
drives those vars, with treatment presets (Adaptive/Gradient+/Edge+scrim),
per-scheme palette editor, muted thumbnail-mosaic toggle, localStorage persist,
and Copy-CSS export. Deleted once the tuned finals are baked in.
- MosaicBackdrop.tsx: opt-in muted (blur+darken+desaturate) mosaic of on-screen
thumbnails so dark glass has real content to refract.
- player YB2: keepalive pagehide beacon (api.saveProgressBeacon) so the resume
position isn't lost on F5/close within 5s of the last checkpoint (mirrors Plex).
- player YB4: bound consecutive unplayable items in auto-advance so an
auto-advancing/loop=all queue can't spin over a run of dead videos.
- downloads B6 (client): localise structured quota/edit errors centrally in
api.req() via localizeDetail() + Intl.NumberFormat (fixes HU/DE + decimal sep);
+ 3-locale download error strings.
- channels FB1: focusChannelToken bump so re-clicking the same channel re-seeds
the search box; FB2: syncSubs invalidates feed/feed-count (set can change now).
- config CB2: reseed the ConfigPanel draft on a key-set dataVersion + explicit
post-save token, so a mid-edit refetch can't clobber an in-progress edit.
- config AB3 (UI): a blank allow_empty field stores "" instead of resetting.
- admin SB2/SC2: confirm + success toast on demo-whitelist remove and deny-invite
(+ trilingual strings); SC1: disable only the acted-on row (pendingId), not all;
SB3: pause the 1s scheduler countdown ticker while the tab is hidden.
- messages CT4: only invalidate conversations/unread when the incoming-message
count actually changed, not on every 20s poll; MB3 (client): react to the live
unread ping (onUnread → invalidate); MC3: extract e2ee installKey (setup/unlock);
CC2: hoist POLL_MS to messaging.ts; MB4: document the reload-scoped socket.
Two user-reported signin bugs:
1) A plain re-login (or logout→login) wiped the user's YouTube grant: login requests
only BASE_SCOPES and Google's returned `scope` can list just those, but _store_token
wrote it verbatim — dropping a previously-granted youtube.readonly/youtube scope, so
can_read flipped to false and the feed demanded a reconnect. The underlying grant
(refresh token) survives such a login, so UNION the scopes instead of narrowing; they
only shrink on full disconnect (purge deletes the token row).
2) Admin 'new access request' email: (a) it named the wrong menu ('Settings → Account'
instead of Users → Access requests); (b) the requester address was invisible so the
'reply reaches them' note looked wrong (Reply-To is in fact set to the requester) —
now spelled out + a deep-link straight to the approve view (?admin=access-requests,
handled in App); (c) it emailed only the static env ADMIN_EMAILS — now notifies the
ACTUAL admins (active role=admin users) unioned with env, so a UI-promoted admin (who
CAN approve — the approve UI is role-gated) is notified too.
1. Series detail pages now match the movie info page: the customize menu lives in the
hero panel (top-right), the Cast/Seasons/Related strips sit in glassy panels, and the
menu is dynamic — Related shows can be toggled (new plexInfoRelated pref).
2. The Plex search box now survives F5 (persisted per-account like the filters).
3. Show + season hero posters get a hover Play/Resume overlay.
4. Card top-right watched toggle can now UN-watch: the watched/in-progress badges were
intercepting the click (no pointer-events-none), so the toggle underneath never fired.
5. Episode card: the watched check no longer jumps on hover (badge now shares the
toggle's box); every card's title/meta gets its own glassy translucent background and
the Add-to-playlist button is always visible (not hover-only).
6. Season-card 'In progress' label is readable again (solid dark badge over the poster).
7. On a season page the show title is clickable → back to the show (history-correct).
- 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.
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.
The Plex library search box wrote to the shared, persisted feed filter
(filters.q). That leaked a Plex query into the feed search box and, being
persisted, restored a stale query after a reload — which then collided
with a persisted collection filter to produce a confusing empty grid.
Plex now has its own search state: kept across page switches within a
session, but not persisted and not shared with the feed, so a reload
starts clean.
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).
Backend (migration 0046_plex_people_search): a new people_text column (cast +
director names, de-duped) is folded into the generated search_vector at weight B,
so the top search box finds titles by an actor/director name and ranks them above
summary-only mentions. New /api/plex/people endpoint returns the cast/crew matching
the term (name prefix or word-start) with a film count + a headshot (pulled from one
representative film's live metadata; image bytes disk-cached).
Frontend: PlexBrowse shows matching people as virtual cards above the grid; clicking
one adds them to the actor/director filter (multi-value, from the C1 work) and clears
the search box so you land on exactly that person's films. Answers the 'why does
"drew" match Rambo?' confusion — it was matching the word in the synopsis; now names
are searchable. i18n en/hu/de.
⚠️ Prod needs a Plex re-sync after deploy to populate people_text (search_vector
regenerates automatically once the column has data).
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.
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.