Commit Graph
100 Commits
Author SHA1 Message Date
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
peter 96413c3d5e release: 0.69.0 (improvement/e3-s1-plex-split)
gate / frontend (push) Successful in 59s
gate / backend (push) Successful in 1m51s
2026-08-19 00:14:03 +02:00
peter d9677e0e3a chore(release): 0.69.0 — E3 backend architecture consolidation
Bump VERSION + user-facing release notes for the E3 epic (Plex god-module split,
downloads owner-unification, backend contract & conventions). Observable wins:
Plex catalog prune, download reliability (no false stalls, fair queue, no
file-delete-under-live-download). 20 commits, four clean /code-review high rounds.
2026-08-19 00:13:00 +02:00
peter 2b804300d5 fix(scheduler): address /code-review R3 findings on the maintenance handler (3)
Round-3 review of the S3 config-fold, all in routes/scheduler.py:
- stale docstring: "Persisted to app_state" -> "Persisted via sysconfig" (the
  batch moved stores in B-core-11; the comment didn't follow).
- drop the manual min/max check in the PATCH — sysconfig.set_value already
  validates against the same spec bounds; catch its ValueError -> 400, matching
  the config PATCH pattern (bounds enforced in one place).
- fetch sysconfig.spec(...) once into a local in the GET instead of twice.

No behaviour change beyond the 400 message wording (untested path). 368 pass.
2026-08-18 23:54:32 +02:00
peter 3f047bbad1 refactor(config): config/state mutators flush, callers commit (E3 S3, B-core-10)
Restores CLAUDE.md's canonical transaction rule ("the handler commits; helpers
flush; only quota owns its session") on the config path: sysconfig.set_value /
sysconfig.reset and state.set_sync_paused now flush instead of committing, so a
change lands in ONE transaction with the audit row the route records for it —
previously the helper's internal commit split them, and a crash between left the
mutation persisted with no audit trail.

All callers already commit (the config PATCH/DELETE + audit, the sync pause/resume
+ audit, the scheduler maintenance PATCH); the setup wizard, which relied on the
internal commit, gets an explicit db.commit() after its loop. apply_interval is
deliberately left committing — it performs a live APScheduler reschedule that must
follow the DB persist, so it owns its own unit of work (a legitimate exception,
like the download service transitions). New test locks the flush-not-commit
contract (a rollback before the caller commits discards the change). 368 pass.
2026-08-18 23:46:48 +02:00
peter 8a97c79d37 refactor(config): fold maintenance_revalidate_batch from app_state into sysconfig (E3 S3, B-core-11)
The admin-tunable maintenance re-validation batch lived in its own app_state
column with a hand-rolled override-or-default reader in app.state — a fourth
config store duplicating the sysconfig SPECS pattern. Move it to a sysconfig key
(`maintenance` group, min=100 max=100_000): the reader (sync/maintenance.py) and
the Scheduler dashboard (routes/scheduler.py PATCH + GET) now go through
sysconfig.get_int / set_value / spec, and app.state loses get/set_maintenance_batch
+ the MIN/MAX constants + the settings import. Migration 0070 carries any existing
override into the system_config store before dropping the app_state column
(reversible; restores + copies back on downgrade). Reuses the existing
sysconfig.spec() for the dashboard's min/max, so the bounds have one source.

Full lane 365 pass (migration drift + reversibility exercised), ruff clean.
2026-08-18 23:40:45 +02:00
peter d37b6ef07f refactor(routes): delete 5 dead endpoints the frontend never calls (E3 S3, B-core-8)
None are referenced by the SPA or tests; the Scheduler dashboard's "run now"
covers every manual trigger. Removed: POST /api/sync/{rss,backfill,enrich}
(+ the now-dead _user_channels helper and three sync.runner imports),
POST /api/tags/recompute (+ its admin_user / run_autotag_all imports), and
GET /auth/me (a lesser duplicate of the canonical /api/me in routes/me.py).

This also retires a latent bug: POST /api/sync/backfill reset backfill_done /
backfill_cursor on already-fully-backfilled channels (tipping them back to
"needs full history") for up to 25 channels per curl. Full lane 365 pass.
2026-08-18 23:32:49 +02:00
peter 88c5d5c5cb docs: refresh 7 stale module headers that describe shipped work as "later phase" (E3 S3, B-core-12)
This codebase leans on "why" comments as its architecture doc, so a header that
claims the opposite of the code below it is worse than none. Fix the seven the
review flagged: channels.py (subscribe/unsubscribe shipped), playlists.py +
sync/playlists.py (YouTube two-way sync + write-back shipped), setup.py (drop the
"epic 6c" ref — the wizard steps exist), config.py ("auth._client_ip" ->
"routes.auth._client_ip"), db.py ("routes/plex.py" -> "routes/plex/" after the
package split), sysconfig.py (the "Phase C" note — the watch-sync job that reads
plex_watch_sync_interval_min has landed; clarified it's a boot default, not a knob).
Docs only.
2026-08-18 23:28:25 +02:00
peter cff8bed62b fix(downloads): heartbeat only when no write touched the row this interval (E3 review R2)
Round-2 review of the R1 fixes found that the two-independent-limiter progress
writer issued a redundant heartbeat every interval during an ACTIVE download: a
percent change writes progress (which already refreshes updated_at, the stall
watchdog's liveness source), then the sub-integer calls that follow fire the
separate beat budget — ~2 DB writes/interval instead of the throttle's intended 1.

Replace the second limiter with a shared `last_touch` clock (last DB write OR
beat). Progress writes keep their own `allow_write` budget (so a beat still can't
delay a real write — the R1 concern), but the heartbeat now fires only when
nothing touched the row for a full interval. Active download → ~1 write/interval;
a genuinely stuck re-encode → 1 heartbeat/interval. New regression test:
an unchanged-percent call in the same interval as a write does NOT heartbeat.

ruff clean, full DB-lane 365 pass.
2026-08-18 23:16:48 +02:00
peter bc79720e81 fix(e3): address /code-review high findings (5)
Round-1 review fixes on the E3 branch:

- prune partial-fetch (correctness): `_paginate` now reports whether it reached
  the server's reported `total`; `_prune_stale` prunes only on a COMPLETE
  non-empty listing. A truncated fetch (a mid-sequence empty/short page before
  total — a transient Plex hiccup) no longer deletes the rows it merely failed
  to list. New test: a listing that claims 5 but returns 1 prunes nothing.
- `_prune_stale` (robustness): `stats.get("pruned", 0) + 1` — no KeyError when a
  caller's stats dict omits the key.
- service transitions (conventions): pause/resume/redownload use the JobStatus
  StrEnum instead of loose "queued"/"paused"/"error" literals, matching cancel.
- ffmpeg progress writer (correctness): a stuck-percent heartbeat now runs on its
  OWN 1/interval budget, so it can't consume the progress-write slot and delay a
  real percent change by up to an interval.
- backend/app/README.md (conventions): reword the transaction-ownership rule to
  quote and extend CLAUDE.md's canonical ("handler/job-wrapper commits; helpers
  flush; only quota owns its session") instead of stating a contradicting
  "helpers MAY commit" carve-out.

ruff clean, full DB-lane 364 pass.
2026-08-18 22:47:25 +02:00
peter b02a9e37e8 docs: write the backend conventions + a development guide (E3 S3, B-core-20)
The layering rule ("request-only logic in routes/; scheduler+worker-shared logic
in the plex|downloads|sync packages; cross-cutting primitives as flat core
modules; extract at the SECOND consumer") was real but unwritten — it lived only
in three module docstrings and a one-line README mention. Collect it, plus the
transaction-ownership rule (a mid-operation helper stages, the caller commits),
the atomic-counter / JobStatus-StrEnum conventions, and the one-home-per-config
rule, into backend/app/README.md. Add docs/development.md (run locally + the gate
commands + branching), so a new session/contributor has one place to look instead
of copying whichever pattern they happen to find first.
2026-08-18 22:02:18 +02:00
peter b2013a079a refactor(downloads): split worker.py parent/child into pipeline.py + jobdb.py (E3 S2, B-sub-9)
worker.py (~1090 L) mixed the PARENT supervisor (claim + supervise + the process
loop) with the CHILD pipeline (the yt-dlp/ffmpeg download+edit that runs in the
spawned subprocess). Extract them:

  worker.py  1090 -> 475   parent: claim/supervise/loop
  downloads/pipeline.py  (new 592)  the child pipeline (_download/_process_edit +
                                    hooks/progress/ffmpeg helpers + _Aborted)
  downloads/jobdb.py     (new  73)  the short-lived-session helpers both sides
                                    share (_set_job/_job_status/_touch_job/
                                    _requeue_if_running/_reset_asset_pending +
                                    the staging-dir paths)

The graph is one-directional (runner -> pipeline -> jobdb; worker -> jobdb; no
worker<->pipeline edge) — the parent never calls the child directly, it spawns
runner.py as a subprocess. Consumers rewired: runner import, and two tests follow
the moved symbols (_media_info/_produced_file -> pipeline; the ffmpeg-callback
patches target pipeline where the names are looked up).

Pure code motion, verbatim bodies. ruff clean, full lane 363 pass (unchanged).
2026-08-18 21:58:23 +02:00
peter 0fab0ce779 refactor(auth): typed Pydantic request bodies for the 9 payload:dict handlers (E3 S3, B-core-6)
request-access / demo / register / verify / verify-resend / password-login /
password-reset(request|confirm) / set-password each took an untyped `payload:
dict` and hand-picked fields with `.get(...)`. Replace with five small BaseModels
(EmailIn, EmailPasswordIn, TokenIn, TokenPasswordIn, SetPasswordIn) so FastAPI
validates the body shape and publishes the schema. Fields are `str | None` to
preserve exact behaviour — a missing/null field still coerces via the existing
`(... or "")`, no stricter 422s. Green under the auth-session suite (7), ruff clean.
2026-08-18 21:53:57 +02:00
peter 4ace486618 refactor(downloads): service owns the job state transitions (E3 S2, B-sub-8)
pause/resume/cancel/delete/redownload each hand-rolled their asset bookkeeping
inline in the route (release, failed-asset reset, the 20-line fresh-asset
rebuild), duplicating logic the worker/GC also touch. Move each transition into a
service.* method (service.pause/resume/cancel/delete/redownload) that owns the
mutation + its commit; the routes keep only auth, the HTTP 409 guards, and
serialization. Drops the now-dead _set_status route helper and two stale imports.

Pure behaviour-preserving code motion (routes -56 lines). Green under the E3 S2
transition net + the full downloads suite (363 pass), ruff clean.
2026-08-18 21:46:52 +02:00
peter 3334a8e895 test(downloads): E3 S2 characterization net — job state transitions (B-sub-8)
Pins the observable HTTP behaviour of pause/resume/cancel/delete/redownload
(status changes, asset release, failed-asset reset, fresh-asset rebuild) before
the inline route logic moves into service.* methods. Green on the current routes.
2026-08-18 21:43:57 +02:00
peter e2362e1ed1 fix(downloads): heartbeat a slow re-encode so the stall watchdog can't false-kill it (E3 S2, B-sub-4)
The supervisor kills a job that writes nothing for download_stall_timeout_seconds
(150s) and labels it a "network" stall. But the ffmpeg progress writer only wrote
on an integer-percent CHANGE, so a re-encode that sits on one percent for minutes
(AV1/VP9 -> H.264 or a crop on the CPU-only prod host) produced no row write and
was killed mid-encode as "stalled" — and the retry stalled the same way.

Fix: the writer now heartbeats (`_touch_job`, bumping updated_at without touching
the progress bar) once per interval even when the whole percent is unchanged, so
a live-but-quiet re-encode keeps a fresh updated_at. Reorders the rate-limit gate
first so a burst still costs one DB op — either a progress write or a heartbeat.

Pure-logic test with an injected clock: three stuck-at-10% intervals write no new
progress but heartbeat each time. Full lane green, ruff clean.
2026-08-18 21:34:36 +02:00
peter 7d89bc8b8e fix(downloads): exclude in-flight assets from the claim, drop the busy-wait (E3 S2, B-sub-3)
Two jobs on the same shared asset (same video+format queued twice, or by two
users) starved the worker pool: worker A downloads the asset; worker B claims the
sibling job, finds the asset `downloading`, requeues it, sleep(2)s, and re-claims
it on the next tick — burning a whole worker slot behind one in-flight asset for
the entire download (at concurrency=2, the queue stalls).

Fix: the claim SQL now excludes any job whose asset is already `downloading`
(`NOT EXISTS … media_assets.status='downloading'`), so a competing sibling simply
waits until the asset settles and the workers pick up unrelated jobs meanwhile.
The `_process_job` busy/lost-race branches drop their `time.sleep(2)` — the
exclusion keeps a requeued job out of the claim window, so there's no spin.

TDD: the new test failed on the old claim (claimed the sibling), passes now.
Full lane green, ruff clean.
2026-08-18 21:31:18 +02:00
peter f0a0214d97 fix(downloads): atomic ref_count increment/decrement (E3 S2, B-sub-5)
MediaAsset.ref_count (how many jobs hold a shared cache asset) was a Python
read-modify-write at four sites (enqueue, enqueue_edit, release_asset, the
redownload route). Two concurrent enqueues of the same (source,format) both read
the same value and write the same +1 → the count ends up one short → the first
release drives it to zero and deletes the file out from under a live holder.

Replace all four with atomic DB ops: incr_ref_count (`ref_count = ref_count + 1`)
and decr_ref_count (`greatest(ref_count - 1, 0)` RETURNING the new value, so the
release decides whether to free the asset from the post-decrement count while
holding the row's write lock — Postgres serialises concurrent releases on that
lock, closing the double-free). Pure DB-atomicity change, no semantic shift.

Green under the E3 S2 ref_count net (5) + full lane (356 pass), ruff clean.
2026-08-18 21:27:39 +02:00
peter e824fe0ae9 test(downloads): E3 S2 characterization net — ref_count enqueue/release semantics (B-sub-5)
Locks the observable single-threaded ref_count accounting before the atomic-SQL
conversion: two enqueues of one (source,format) share a single asset and stack
ref_count to 2; release keeps the asset until the last holder then frees the
ready row; a non-holding (canceled) job's release is a no-op; the decrement
clamps at 0; an errored asset is kept at zero (only ready is freed). Green on the
current RMW code (5 pass) — the fix must keep every assertion.
2026-08-18 21:24:46 +02:00
peter 49011940d6 feat(plex): catalog prune — delete titles that disappeared from Plex (E3 S1, B-sub-6)
A sync was upsert-only: a movie/show/season/episode removed from Plex stayed
browsable forever and 404'd on playback. Each section now diffs the rating_keys
it just listed against the stored ones and deletes the stale rows (watch-state /
playlist links cascade via the existing FKs), mirroring the collections prune
that already shipped.

Safety: the prune is GUARDED on a non-empty listing (_prune_stale returns early
when `seen` is empty), so a transient Plex glitch that yields an empty page can
never mass-delete a library — the metadata is cheap to re-sync, an accidental
wipe is the only real risk. Shows prune leaf-first (episodes -> seasons -> shows)
so a stale row is deleted directly before its parent's cascade reaches it.

Out of scope (separate follow-up): library-level reconcile of a whole removed
section + retiring the dead `PlexLibrary.enabled` column.

Tests: movie prune + watch-state cascade, the empty-listing guard (mutation-
verified — removing the guard wipes everything), show/season/episode prune.
351 DB-lane pass, ruff clean.
2026-08-18 21:13:18 +02:00
peter 52b6535dc6 refactor(plex): split browse.py god-module into per-concern routers (E3 S1)
routes/plex/browse.py was a 1693-line god-module (libraries + facets +
collections + playlists + show/item detail + people). Split it into focused
sibling routers under the same /api/plex prefix — pure code motion, no SQL /
route-path / response-shape changes:

  browse.py  1693 -> 469  (list_libraries, unified_library, facets)
  collections.py  (new 253)  browse + admin collection editor; owns _collection_source
  playlists.py    (new 405)  the user's orderable playlists
  detail.py       (new 438)  show/item detail + enrichment; _lib_key lives here
  people.py       (new 139)  cast/crew chips + person filmography

Cross-module helpers _norm_lang + _content_libs move to _shared.py (their one
home); detail imports _collection_source from collections (one-way, no cycle).
__init__.py includes the four new routers. Three DB-lane tests follow the moved
symbols (import path for _content_libs; monkeypatch targets for PlexClient /
_show_enrichment now on detail).

Behaviour-equivalent: ruff clean, 348 DB-lane pass (unchanged), parity net green.
2026-08-18 21:06:04 +02:00
peter 9bf7da2cbe refactor(plex): dedup movie/show watch-state predicates into query.py (E3 S1)
The `show` scope (all|watched|in_progress|unwatched) filter and the status CASE
were hand-duplicated between unified_library (the grid) and facets.wsq — the
exact drift risk the review flagged (a hidden movie kept out of the grid must
not inflate facets). Extract them into four shared query.py helpers
(movie_status_case, show_status_case, apply_movie_state_filter,
apply_show_state_filter), model-agnostic (callers pass the joined state
expressions), and point all three call sites at them.

Pure behaviour-equivalence refactor: browse.py -67 lines, no query changes.
Green under the E3 S1 parity net + query_conversion/facets/field-scoped/lang
tests (348 DB-lane pass, ruff clean).
2026-08-18 20:52:39 +02:00
peter 3301976375 test(plex): E3 S1 characterization net — grid⟷facets watch-state parity
Coverage-first safety net for the E3 S1 Plex god-module split. The movie/show
watch-state predicates are currently hand-duplicated in unified_library (the
grid) and facets.wsq (the facet aggregation); this pins the invariant the
facets docstring promises — with no metadata filter active, a facet's genre /
content-rating counts equal the same counts over the grid's visible items — for
every (scope × show) combination, deriving the expected counts from the grid's
own response. Locks the two branch-sets together before they merge into one
shared helper, so the merge is provably behaviour-equivalent.

Mutation-verified: dropping the facets hidden-exclusion branch makes both tests
fail (Drama 3≠2, R 2≠1). Green on the current code (348 DB-lane pass).
2026-08-18 20:48:43 +02:00
peter fa176a3e6a release: 0.68.0 (improvement/e2-s6-job-index-hygiene)
gate / frontend (push) Successful in 52s
gate / backend (push) Successful in 1m37s
2026-08-18 19:20:43 +02:00
peter e8771f4d6b chore(release): 0.68.0 — E2 Feed & Search performance epic
Bump VERSION 0.67.0 -> 0.68.0 and add the user-facing release-notes entry for
the E2 epic (feed sort index, keyset/NOT NULL, search GIN + state inner-joins,
single /feed/summary pass, no card-action refetch storm, job partial indexes).
2026-08-18 19:20:18 +02:00
peter 94dbe4b228 fix(jobs): match the boolean partial predicates to the IS TRUE/IS FALSE queries (E2 S6 review)
The via_search / shorts_probed partials used `WHERE via_search` / `WHERE NOT
shorts_probed`, but the queries filter with `.is_(True)`/`.is_(False)` (SQL
`IS TRUE`/`IS FALSE`), which Postgres's partial-index prover does NOT match to a
bare-boolean predicate — so both fell back to a full seq scan (the shorts-probe
job kept full-scanning for its work queue, the exact thing PERF-3 meant to fix).

Align the predicates to the query form: `WHERE via_search IS TRUE` and
`WHERE shorts_probed IS FALSE`. Verified on the prod-sized localdev: both now
Index Only Scan (via_search 44,683 -> 3 buffers; shorts_probed 44,683 -> 1).
Gate green (346 pytest).
2026-08-18 18:31:38 +02:00
peter 0ed4ac6958 perf(jobs): partial indexes for the enrich/maintenance scanners + boolean cleanup (E2 S6, PERF-17/18/3)
Two scheduler jobs full-seq-scanned the 257k-row videos table every run:
- PERF-17 enrich_pending (`WHERE enriched_at IS NULL LIMIT N`, every few minutes):
  44,680 buffers even with 0 pending -> a partial ix_videos_enrich_pending makes
  it a 1-buffer index scan (empty when nothing pending).
- PERF-18 maintenance revalidate (`WHERE unavailable_since IS NULL ORDER BY
  last_checked_at NULLS FIRST LIMIT N`, ~300 pages/run): 44,754 buffers + top-N
  sort -> a partial ix_videos_recheck_order in that exact order = a 42-buffer
  index scan. The order_by is rewritten to `asc().nulls_first()` (equivalent to
  the old `(IS NULL) DESC, ASC`) so it rides the index; the unused plain
  ix_videos_last_checked_at is dropped.

PERF-3: drop the never-picked boolean btrees (is_short, via_explore) and slim the
useful ones to partials over the queried subset (via_search=true, NOT
shorts_probed, unavailable_since IS NOT NULL) — less write amplification on the
biggest table, same lookups. Model: those 6 columns lose their inline index=True
(the partials are migration-defined, like the search_vector GIN); live_status
left alone.

Measured on the prod-sized localdev reseed: enrich 44,680 -> 1 buffer (25.9 ->
0.02 ms), maintenance 44,754 -> 42 buffers (35.5 -> 0.17 ms); all 11 CONCURRENTLY
index ops on 257k rows in 0.75s. Gate green (346 pytest incl. drift + downgrade
round-trip).
2026-08-18 17:01:13 +02:00
peter 8b8b0ca5ab perf(feed): skip the empty IN() query in tags_by_category (E2 review round 2)
The tags_by_category extraction dropped feed_summary's `if selected:` guard, so
a /feed/summary request with no tag filter (the common case) ran a pointless
`SELECT ... WHERE id IN ()` round-trip. Early-return {} for empty tag_ids —
restores the no-query hot path and covers filtered_query + future callers too.
Gate green (346 pytest).
2026-08-18 16:49:29 +02:00
peter 6116ad4bf4 refactor(feed): address /code-review high findings on the E2 stack
Review round on the E2 stack (S1-S5). No correctness bugs; the surviving
low/medium findings addressed:

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

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

Gate green: ruff + 346 pytest; frontend tsc/knip/prettier (FE change is a comment).
2026-08-18 16:39:04 +02:00
peter 943143ef45 perf(feed): stop the card-action + player-close feed refetch storm (E2 S5, PERF-2)
Marking a card watched/hidden/reset and closing the player each fired a full
`invalidateQueries(qk.feed())`, refetching EVERY loaded infinite-query page — 10
pages x a few quick marks = ~50 feed refetches, each a full scan (x PERF-1). The
optimistic `overrides` layer + the matchesView filter already render the correct
state (a hidden/watched card drops out instantly), so the refetch was pure waste.

Switch these paths to `refetchType: "none"`: the queries are marked stale but not
refetched now; the next filter change / remount pulls fresh server data. Applies
to the two Feed card-action paths (setState/clearState) and the PlayerModal
close (feed + playlists — progress is already persisted server-side, only the
on-screen resume bar waits for the next natural refetch).

Left as immediate refetch on purpose: channel/tag/playlist mutations (one-off, on
pages where the main feed is unmounted anyway) and the onboarding / notification-
undo paths (which must reveal the changed content right away). Gate green:
frontend tsc/eslint/knip/prettier/222 vitest (backend unchanged, 344 pytest).
2026-08-18 15:49:49 +02:00
peter 21a3aae700 perf(feed): collapse count + facets into one /feed/summary pass (E2 S4, PERF-13)
The FE fired /feed/count (1 pass) + /facets (3 passes) on every filter change =
4 full scans of the subscribed set. New /api/feed/summary does ONE grouped query
(the base filter with tags stripped, GROUP BY channel_id -> per-channel video
counts) and derives the total count + every tag's distinct-channel facet count in
Python on the ~few-hundred-row channel map, faithfully mirroring filtered_query's
channel-based tag logic + the /facets drill-down (own category dropped, unless a
conjunctive topic-AND). Returns {count, facets} — the same numbers the two old
endpoints returned.

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

New equivalence test (test_feed_views, 8 filter combos incl. cross-category,
disjunctive/conjunctive topic modes, search): /feed/summary count == /feed/count
and facets == /facets, byte-identical. Structural win: 2 requests / 4-6 queries
-> 1 request / 2-3 queries (the review measured 530 -> 144 ms). Gate green:
344 pytest, frontend tsc/eslint/knip/prettier/222 vitest.
2026-08-18 05:01:27 +02:00
peter 6a35e83ded perf(feed): un-block the search GIN + inner-join the state views (E2 S3, PERF-12/16)
PERF-12: the search ORed `unaccent(channels.title) ILIKE '%q%'` straight into
the per-video predicate, which forced a full scan + detoast of every
search_vector and a per-candidate channel fetch. Pre-resolve the matching
channel ids (a few-hundred-row scan) and match `Video.channel_id.in_(ids)`
instead, so the planner keeps the search_vector GIN as a BitmapOr branch
(alongside ix_videos_channel_pub_desc). Empty match -> false() collapses the OR.

PERF-16: show=watched|hidden|in_progress used an outer join + coalesce(status,
'new'), so the planner couldn't start from the user's small video_states set.
Those views only return rows that HAVE a state, so inner-join video_states and
filter the raw column (status is NOT NULL on a present row, so it's identical).
'unwatched'/'all' keep the outer join. Dropped the now-dead position_expr.

New regression tests (test_feed_views): search returns both text- and channel-
name matches; the four watch-state views return the exact expected sets.

Measured on the prod-sized localdev reseed (prod planner, 0067/0068 applied):
- search /feed/count q=magyar: 663,463 -> 19,206 buffers (1,406 -> 31 ms; OLD
  seq-scan+detoast -> NEW BitmapOr GIN+channel index)
- search feed page: 2,908 -> 1,479 buffers (32.6 -> 1.4 ms)
- show=watched (user with 64 watched): 40,661 -> 264 buffers (~154x)
Gate green (336 pytest, incl. the 2 new equivalence tests).
2026-08-18 04:41:16 +02:00
peter 8c08ea3133 perf(feed): published_at NOT NULL + range-anchored keyset for deep pages (E2 S2, PERF-15)
A nullable published_at forced the feed keyset into a non-sargable
`... OR published_at IS NULL` disjunction: with the 0067 index the planner still
applied it as a FILTER and scanned from the top of the index, so a deep `newest`
page discarded every earlier row (O(depth)).

- Migration 0068: backfill any NULLs (0 in prod) -> SET NOT NULL + DEFAULT now().
- Model: Video.published_at is now non-Optional, nullable=False, server_default now().
- feedquery: the `pub` key is nullable=False, and keyset_predicate is rebuilt
  recursively so a non-nullable leading key gets a non-strict range bound
  (`published_at <= v`) as an AND conjunct — an index range Cond instead of a
  filter. Nullable keys keep the NULL-aware OR form (a `<= v` bound would drop
  their NULL group). Equivalence proven by test_feed_keyset (all 13 sorts,
  cursor-walk == unpaged).
- Ingest paths (stub_from_playlist_item, apply_video_details, playlists sync)
  fall back to now() so they never write an explicit NULL.

Measured on the prod-sized localdev reseed at a ~50k-deep cursor (0067 index +
prod planner): OLD `Rows Removed by Filter: 50001`, 25,687 buffers / 9.16 ms ->
NEW `Index Cond: published_at <= v`, 1 row filtered, 47 buffers / 0.037 ms. SET
NOT NULL on 257k rows: 0.32s. Gate green (334 pytest).
2026-08-18 04:30:07 +02:00
peter 3af3799b25 perf(feed): index the default published_at DESC NULLS LAST sort (E2 S1, PERF-1)
The feed's default `newest` sort is `published_at DESC NULLS LAST, id ASC`, but
the only published_at index was a plain ASC btree — read backwards it satisfies
DESC NULLS FIRST, not NULLS LAST — so every feed page fell back to a full
`videos` seq scan + top-N sort. Migration 0067 adds the two matching indexes
(global + per-channel) CONCURRENTLY and drops the now-redundant single-column
ix_videos_channel_id (the channel composite leads with channel_id, a strict
superset).

Measured on the prod-sized localdev reseed (257,312 videos, prod planner):
- global newest:  44,747 buffers / 154 ms  ->  60 buffers / 0.32 ms
- per-channel(12.6k): 7,604 buffers / 96 ms -> 54 buffers / 0.14 ms
CONCURRENTLY build of both on 257k rows: 1.7s total (~0 downtime). This is the
likely root cause of the "prod feed is slow" symptom.

Model: drop the inline index=True on Video.channel_id (the composite supersedes
it); the composites are migration-defined like the search_vector GIN. Gate green
(334 pytest incl. the migration drift + downgrade/re-upgrade round-trip).
2026-08-18 04:16:08 +02:00
peter aff5a9922c release: 0.67.0 (security/e1-s5-auth-abuse)
gate / frontend (push) Successful in 51s
gate / backend (push) Successful in 1m26s
2026-08-18 03:43:03 +02:00
peter cace9a004d chore(release): 0.67.0 — E1 Foundation epic
Bump VERSION 0.66.0 -> 0.67.0 and add the user-facing release-notes entry for
the E1 Foundation epic (data-safety fixes, account-switch + search-error fixes,
graceful key-mismatch degrade; plus the security/test-net/CI/repro chores).
2026-08-18 03:41:28 +02:00
peter bfc104a9b4 test(migrations): only restore schema in finally when the re-upgrade failed
Review round 2: the unconditional `command.upgrade(cfg, "head")` in finally ran
a redundant no-op on the success path and, on a genuinely broken re-upgrade,
replaced the try's original exception with its own. Gate the restore on a
`reupgraded` flag so it fires only when the upgrade didn't complete — the
cascade-prevention intent is kept without the double-run or error-masking.
2026-08-18 03:40:11 +02:00
peter 0340d0981c fix(e1): address /code-review high findings on the E1 stack
Review round on the whole E1 stack (main..HEAD). 6 findings, all low/medium,
none blocking; fixed for a clean gate-green handoff.

- plex/sync (correctness): the un-enriched cast guard compared the stored cast
  against THIS listing's length, so a trimmed Plex cast on a not-yet-enriched
  row was never applied. Compare against the listing CAP (_LISTING_CAST_CAP=20)
  instead: >cap = enriched (keep, survives a watermark reset), <=cap =
  listing-sourced (track edits in both directions). +2 regression tests.
- main (security): add X-Frame-Options: DENY as the legacy-browser fallback for
  the CSP frame-ancestors clickjacking guard.
- downloads/service (altitude): finalize_if_running now uses a SQLAlchemy 2.0
  update() construct instead of a hand-built text() UPDATE string — same atomic
  WHERE status='running', idiomatic, no identifier interpolation, no manual
  StrEnum coercion. Drops the now-unused text import.
- auth/admin (reuse): extract the SEC-10 "drop untrusted preset password + bump
  epoch" invariant into one helper (revoke_preset_password_if_unverified) so the
  Google-callback and admin-activation paths can't drift.
- tests/migrations (robustness): guarantee the shared schema is restored to head
  in a finally, so a downgrade/re-upgrade failure can't cascade into later tests.
- worker (simplification): align _finish_from_cache's display_name fill with the
  _download path's guard (no behavior change).

Gate green: ruff clean, 334 pytest (0 skip), frontend unchanged.
2026-08-18 03:30:23 +02:00
peter 18edbecb1e fix(auth): close the account pre-emption + wrong-account link holes (E1 S5, SEC-10/11)
SEC-10 (account pre-emption / "pre-hijack"): a password set on an account whose
email was never verified is untrusted — whoever registered the address (to
pre-empt the real owner) could have chosen it. Both the Google-adoption callback
and admin activation (_activate_user_by_email) now drop that pre-set password and
bump the session epoch when the account isn't email_verified, so a later Google
login / admin approval can't hand the pre-set password a working login. No-op for
a verified account (the legit no-SMTP flow). +2 tests.

SEC-11 (link/upgrade bound to the session default): the OAuth link/upgrade GETs
resolved to the session DEFAULT account, so a tab acting as a secondary account
would link Google / the YouTube grant to the wrong account. The frontend now
threads the active account through the navigation URL (withActiveAccount ->
?account=<active>), and _complete_link accepts any wallet account, not only the
default, so the identity lands on the account that started the flow.
2026-08-18 03:02:34 +02:00
peter 8ffa65a9da fix(security): bump cryptography 46.0.7 -> 50.0.0 to clear 4 CVEs
gate / frontend (push) Successful in 54s
gate / backend (push) Successful in 1m36s
The pip-audit step added in E1 S4 flagged cryptography 46.0.7 for PYSEC-2026-3552
/3553/3554 and GHSA-537c-gmf6-5ccf; all are fixed by 50.0.0. The requirements.txt
ceiling `<47` was blocking the upgrade — relaxed to `>=50.0.0,<51`. The lock is a
surgical bump (only the cryptography line changed; cffi 2.1.1 already satisfies
it, no other package moved). pip-audit -r requirements.lock is now clean.

Add tests/test_security.py (there was none for the crypto primitives): the
Fernet encrypt/decrypt round-trip, decrypt_optional degrading on garbage and on a
key mismatch (the B-core-2 path), and the argon2 password hash/verify round-trip
— so a future crypto-lib bump can't silently break the token-at-rest path. Gate
green: 330 pytest.
2026-08-18 02:47:05 +02:00
peter f54dd98129 test(release): lock the VERSION file and RELEASE_NOTES[0] together (E1 S4, DX-6)
gate / frontend (push) Successful in 1m9s
gate / backend (push) Successful in 1m27s
A vitest that fails the gate when the newest release-notes entry's version
diverges from the repo-root VERSION file (the two hand-edited version sources),
plus a no-duplicate-versions check. Catches the "bumped one, forgot the other"
release mistake.
2026-08-18 02:34:45 +02:00
peter 44d4e4876d build(deps): pin a reproducible lock + pip-audit; bump node 20->22 (E1 S4, SEC-5/DX-3)
requirements.txt stays the source (floor ranges); requirements.lock pins the
exact resolved versions (generated in the python:3.12-slim prod base, extras like
psycopg-binary/uvloop included). The prod Dockerfile, Dockerfile.test and CI now
install the lock, so the image is reproducible and the versions are the ones
pip-audit scans. CI gains a non-blocking pip-audit step over the lock. The
frontend build + CI move from the EOL node 20 to node 22.
2026-08-18 02:34:45 +02:00
peter e4ac061721 feat(security): add baseline security response headers (E1 S4, SEC-1)
A small, safe set applied to every response via middleware: X-Content-Type-Options
nosniff, Referrer-Policy strict-origin-when-cross-origin, CSP frame-ancestors
'none' (anti-clickjacking; unrelated to the YouTube/Plex embeds we host), and
Permissions-Policy disabling camera/mic/geolocation. A full CSP and HSTS are
deferred (HSTS belongs on the Caddy edge).
2026-08-18 02:34:45 +02:00
peter 54ba92bb05 ci(gitea): add the server-side gate mirror (E1 S3, DX-1)
gate / frontend (push) Successful in 51s
gate / backend (push) Successful in 1m13s
Add .gitea/workflows/check.yml so the quality gate runs on the lab act_runner,
machine-independent, on every push/PR — the review's "no server-side CI" gap.
Two parallel jobs mirror the two local lanes:
- frontend (node:20-bookworm, matching the prod build image): tsc + eslint +
  prettier --check + knip + vitest.
- backend (nikolaik/python-nodejs python3.12+node): ruff + the full pytest suite
  against a real postgres:16 service, with ffmpeg installed and
  SIFTLODE_TEST_REQUIRE_DB=1 so a DB/ffmpeg that failed to come up is a hard
  error, never a silent skip.

Notes for this runner: pytest runs natively against the service (the
docker-compose bind-mount is unreliable under a mounted-socket runner), and the
backend uses a baked python+node image rather than actions/setup-python, which
can't reach GitHub's python-versions download from the lab.
2026-08-18 01:59:36 +02:00
peter 472cefed55 test(migrations)+fix: widen drift check to column shape; notifications.created_at NOT NULL (S2 review #2)
The drift check dropped every diff except table/column presence, so a column
type or nullability change would slip through. Add modify_type/modify_nullable
to the ops it fails on. That immediately surfaced a real pre-existing drift:
notifications.created_at was created NULLABLE by migration 0017 but the model
has always declared it NOT NULL (Mapped[datetime] + server_default=func.now()).
Migration 0066 enforces NOT NULL to match the model — safe, since the
server_default has filled every row since day one.
2026-08-18 01:39:46 +02:00
peter e764ca4568 test(fixtures): make the TestClient fixtures isolated and composable (S2 review)
Address the /code-review findings on the S2 test-net:
- as_user no longer sets a global `lambda: user` override that two users in one
  test would collide on; the shared current_user override now resolves the caller
  from an x-test-user header, and each returned client tags its own requests, so
  as_user(a)/as_user(b) are independent (#1).
- override_dep records the prior value per dependency key and restores it on
  teardown instead of a blanket dependency_overrides.clear(), so fixtures compose
  without clobbering each other (#5).
- a _configured fixture bakes the post-setup state into client/as_user, and the
  per-test inline monkeypatch / autouse duplicates are removed (#4).
- the feed keyset walk asserts all seeded rows surfaced before comparing, so a
  seeding regression can't make the sorts pass vacuously (#3).
2026-08-18 01:39:45 +02:00
peter 296af2a498 test(auth): session/epoch/wallet/token security core (E1 S2, TEST-1)
The auth security mag had zero tests. Add a DB-lane suite over the real
auth/session stack (TestClient carries the signed cookie): password login ->
/api/me authenticated; a wrong password and an unknown email return a
byte-identical 401 (no enumeration oracle); an X-Siftlode-Account header naming
a non-wallet account falls back to the default (impersonation gate); logout-others
bumps the epoch so a second session is rejected (401 on a protected endpoint)
while the caller stays in; and password-reset/confirm is single-use (replay ->
400) and bumps the epoch so pre-reset sessions die.
2026-08-18 01:23:07 +02:00
peter 66c00327b1 test(feed): keyset paging reproduces the unpaged order for every sort (E1 S2, TEST-2)
The feed's cursor paging (sort_keys/order_by/keyset_predicate/encode/decode) is
the NULLS-LAST + tiebreaker algebra where a page-boundary bug hides as a
duplicated or skipped row on the busiest endpoint, and had zero tests. Seed 7
videos on 2 channels with duplicate sort values and NULLs across
published_at/view_count/duration/title/subscriber_count, then assert — for every
sort — that walking the feed in pages of 2 via the cursor reproduces exactly the
order a single unpaged query returns, with no duplicates.
2026-08-18 01:23:07 +02:00
peter 31879b01f4 test(migrations): model<->schema drift + downgrade/re-upgrade (E1 S2, TEST-3)
Prod only ever runs `alembic upgrade head`; nothing checks that the migrated
schema still matches Base.metadata, and the downgrade path never runs. Add a
DB-lane test that (a) asserts no structural (table/column) drift between the ORM
models and the head schema — filtering the index/constraint/sequence noise that
compare_metadata reports for migration-only objects — and (b) exercises the head
migration's downgrade + re-upgrade so a broken downgrade is caught the sprint it
lands.
2026-08-18 01:23:07 +02:00
peter 15a59f4695 test(infra): fail-loud DB/ffmpeg lane + client/as_user/make_user fixtures (E1 S2, TEST-4)
The DB-backed lane and the two ffmpeg tests skipped SILENTLY when the DB was
unreachable or ffmpeg was absent, so `siftlode check` could print green while
~60 tests never ran. Make the compose lane authoritative:
- SIFTLODE_TEST_REQUIRE_DB=1 in docker-compose.test.yml; conftest.pytest_configure
  turns a would-be skip (no Postgres, or no ffmpeg) into a hard UsageError.
- install ffmpeg in Dockerfile.test so the thumbnail/poster tests actually run.
- `-rs` in pytest.ini so skip reasons are always visible.

Add the shared TestClient fixtures the risk-core suites build on: `make_user`
(create + commit a login-ready User), `client` (real auth/session stack, no
current_user override — for login/wallet/epoch), and `as_user` (a client
pre-authenticated as a given user, for tests that aren't exercising login).
2026-08-18 01:23:06 +02:00
peter 0653203d81 fix(plex): make the cast gate self-correcting as well as non-shrinking (B-sub-2 review)
The union form fixed the downgrade but removed the per-sync self-healing: a name
Plex removed from an unenriched title lingered in cast_names/people_text until
enrichment. Replace the union with a length guard — take the fresh listing
unless it is shorter than what is stored. A stored cast longer than the ~20-cap
listing can only be an enriched fuller list (kept, so a watermark reset never
downgrades it); at or below the cap the stored value is itself listing-sourced,
so replacing it with the current listing stays self-correcting.
2026-08-18 00:58:13 +02:00
peter 4ce0a702f4 refactor(downloads): route worker terminal writes through an atomic finalize_if_running (B-sub-1 review)
Follow-up to the /code-review high on the S1 branch. The per-site
`if job.status == 'running'` guards were a non-atomic read-then-write (TOCTOU):
a cancel committing between the child's status read and its commit could still
be overwritten with done/error, re-opening the double-release window.

Introduce service.finalize_if_running(db, job_id, **fields): one atomic
`UPDATE download_jobs SET ... WHERE status='running'` that returns whether it
fired. It flushes pending asset writes first so the asset-then-job row-lock
order matches the cancel route (no deadlock), and runs in the caller's session
so it commits with the asset writes in one transaction. All five worker sites
(download/edit completion, both error paths, cache finish) now go through it,
so the invariant lives in one place. The 'job done' logs move inside the
finalized branch so a job stopped mid-completion no longer logs a false 'done'.
Add tests/test_downloads_finalize.py: running -> finalizes; canceled/paused ->
no-op.
2026-08-18 00:58:13 +02:00
peter 9845c8d0f7 fix(auth): epoch-check the target on account switch to avoid a full wallet logout (B-core-9)
POST /api/me/switch validated wallet membership, access and suspension but not
the target account's session epoch. Switching to an account revoked elsewhere (a
password reset/change or "log out everywhere" bumped its epoch) made it the
session default, so the next request's current_user hit the epoch mismatch on
the default identity and cleared the WHOLE wallet session — logging out every
account in the browser. Mirror the suspend guard: drop the stale account from
the switch list and return 403 instead of switching.
2026-08-18 00:22:15 +02:00
peter 7c431d73fb fix(config): degrade gracefully on a TOKEN_ENCRYPTION_KEY mismatch instead of a 500 (B-core-2)
A rotated or missing TOKEN_ENCRYPTION_KEY makes every stored secret undecryptable.
sysconfig._raw_override called security.decrypt() with no guard, and it sits on
hot paths (/api/me via google_enabled, login, every YouTube scheduler job,
account purge) — so a key mismatch raised InvalidToken and 500'd the whole app,
the SPA wouldn't even boot, with no documented recovery.

Catch the decrypt failure in _raw_override -> warn once per key and fall back to
the env/config default. The YouTube client's refresh-token decrypt now raises a
clear YouTubeError ("reconnect") that every caller already handles, and
purge_user uses decrypt_optional so a best-effort revoke can't 500 the delete.
Document that the key must never change once secrets are stored (recovery: clear
the affected system_config rows and re-enter).
2026-08-18 00:22:15 +02:00
peter 67d32fecd6 fix(search): return 422 not 502 on an upstream YouTube failure (B-core-1)
/api/search/youtube raised 502 on an upstream error, but the frontend treats
502/503/504 as a gateway dropout: it auto-retries the GET (a second quota-costed
search) and shows a "connection lost" banner instead of the real message. Every
sibling upstream-error site already returns 422 for exactly this reason. Reserve
5xx for the raw byte-proxies.
2026-08-18 00:21:59 +02:00
peter f39c5bb25c fix(plex): never shrink cast_names on sync so a watermark reset can't regress it (B-sub-2)
_apply_facet_fields seeded cast_names from the cheap top-~20 listing only while
people_enriched_at was NULL, relying on "a sync never clears the watermark" to
protect an enriched fuller cast. Migration 0065 broke that assumption by nulling
people_enriched_at on every movie/show row, so the first post-deploy sync
downgraded enriched casts back to the listing top-20 — silently dropping
person-page cast-filter names for weeks until the backfill caught up (the S5b
regression, re-opened by the migration).

Guard structurally instead of trusting the watermark: union the listing cast
onto whatever is stored, never dropping a name already present. A fresh row
still seeds from the listing; a row whose watermark was reset but whose fuller
cast survives keeps it. Robust to any future watermark reset, not just 0065.
2026-08-18 00:21:59 +02:00
peter 367bcebc9f fix(downloads): revive the worker cancel branch and guard terminal writes (B-sub-1)
The cancel route writes status 'canceled' but the supervisor compared against
'cancelled', so its "a route stopped it -> kill the child, free the asset"
branch was dead code. A canceled job's child ran to completion and wrote 'done'
unconditionally over the route's 'canceled'; since 'done' holds the asset but
'canceled' does not, a later delete released the asset a second time, dropping
ref_count below the live holder count and deleting a file still in use.

Add app/downloads/status.py as the single source of truth for the lifecycle
literals (JobStatus StrEnum + the HOLDING / STOPPED_BY_ROUTE sets) and use it at
the safety-critical sites: the supervisor stop check, the route cancel, and
release_asset's holding set. Make the worker's terminal done/error writes
conditional on the row still being 'running' so a child finishing just after a
route cancel/pause can no longer resurrect it. Add the missing 'canceled'
supervisor regression test.

(The full literal sweep across the downloads surface is left for the E3
downloads owner-unification; this is the data-safety subset.)
2026-08-18 00:21:43 +02:00
peter 429fbde866 docs: add CLAUDE.md working agreement for AI agents
The codebase is fully AI-authored and developed across many agent sessions; the
canonical conventions have lived only in a private memory store, which the
2026-08-17 review flagged as a real gap (unwritten rules → asymmetric fixes and
comment-vs-code drift, e.g. the cancelled/canceled literal bug).

Add a short, auto-loaded rules-of-the-road: the local gate (never merge/ship
red, no --skip), the trunk workflow and no-AI-co-author commit rule, the
one-way architecture shape, a canonical-pattern table (the review's §6
consistency choices), and the house rules learned from real bugs. Kept
deliberately thin; slims to a pointer once docs/development.md lands.
2026-08-17 23:33:01 +02:00
peter 4c013595b5 release: 0.66.0 (e5-s3-toolbar-relocation) 2026-08-17 22:19:23 +02:00
peter b605c28b32 release: 0.66.0 — E5 Plex epic (scoped search, HU localization, toolbar/header)
Ships the E5 sprint stack (S1–S7): field-scoped Plex search + typeahead, cast &
crew pages, the HU-overlay Hungarian metadata + language-aware serving, the
IMDb/audience rating split, the toolbar relocation, and the narrow/mobile header
fix — plus the underlying Plex-module refactors (feedquery/plex.cards/plex.query
extraction, db.query→2.0 select).

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

Merged main (0.65.2 download-gc hotfix) in first; no code conflicts (E5 is
Plex-only, the hotfix was notifications/downloads).
2026-08-17 22:18:07 +02:00
peter cee0bc95b6 Merge remote-tracking branch 'gitea/main' into e5-s3-toolbar-relocation 2026-08-17 22:14:50 +02:00
peter 5c6172754a refactor(header): move --hdr-h measurement into PageShell (S7 review)
Addresses the S7 mobile-fix code-review findings:

- (altitude) The header-height→content-pad contract lived across two files with
  an implicit DOM coupling: Header measured itself and wrote --hdr-h onto
  `el.parentElement`, assuming PageShell rendered it as a direct child of the
  padded column. Move the whole contract into PageShell, which owns BOTH the
  header slot and the padded content wrapper: Header is now forwardRef and
  PageShell attaches a ref (via cloneElement), measures the header with a
  ResizeObserver, and writes --hdr-h straight onto the content wrapper that
  consumes it — no parent assumption, producer and consumer together.
- (simplification) The `+ 8` gap is now documented as the header's own gap-y-2
  row gap reused below it, not a bare magic number.
- (test-coverage) PageShell.dom.test.tsx: stub ResizeObserver + the header's
  offset metrics and assert --hdr-h is published onto the content wrapper (and
  absent with no header).

Re-verified live at 440 (mobile, no overlap) and desktop (one row). FE gate:
tsc / knip / lint clean, 220 vitest pass.
2026-08-17 21:43:14 +02:00
peter 3575237382 fix(header): dynamic --hdr-h so a wrapped mobile header can't overlap content
The floating header pads the content below it by a FIXED `--hdr-h` (4.25rem,
sized for one row). On mobile the flex-wrap header grows to 2–3 rows, so the
content toolbar (sort / count / filter chips) slid up UNDER the header — the top
of the page was a pile of overlapping controls.

- Header.tsx: measure the real header box with a ResizeObserver and publish its
  height (offsetTop + offsetHeight + gap) as `--hdr-h` on the content column, so
  the content pad always tracks the actual (wrapped) header height at any width.
  Verified live at 440 (mobile) and desktop: the toolbar now sits cleanly below
  the header, no overlap; desktop unchanged (still ~4rem, one row).

Also addresses the S7 /code-review findings:
- App.tsx: note that `container-type: inline-size` makes the column a containing
  block for `position: fixed` descendants too (safe today — floating-ui menus
  body-portal, toasts are absolute).
- SearchBar.tsx: move the responsive-rules comment above `return` and note the
  rules are on the SHARED search box (every module, not just Plex).
- SearchBar.dom.test.tsx: lock the container-query classes (min-w floor +
  basis-full/order-last) so the wrap-not-collapse fix can't silently regress.

FE gate: tsc / knip / lint clean, 218 vitest pass.
2026-08-17 21:31:22 +02:00
peter 14913a6447 feat(header): S7 — container-query fix for the narrow Plex header
The floating header's search box collapsed to ~0 width when the filter sidebar
was open on a narrow window (~768px): the content column shrinks with the
SIDEBAR, not the viewport, so Tailwind viewport breakpoints can't see it, and
SearchBar's `flex-1 min-w-0` shrank rather than wrapped.

Fix with Tailwind v4 container queries (no plugin):
- App.tsx: mark the content column `@container/appmain` (container-type:
  inline-size) — the width that actually shrinks when the sidebar opens.
- SearchBar: once the column has room (≥420px) enforce a `min-w-[15rem]` floor
  so the box WRAPS to its own line via the header's flex-wrap instead of
  collapsing the input; on a narrow column (≤640px) it takes a full line of its
  own (basis-full) and sorts last, so the small pills (module name + Plex scope
  toggle / feed sync chip) share the line above it — a tidy two-row header.

Verified live at 375 / 768 / 1024 / 1280 on Plex (search usable, scope toggle
reachable at every width) and on Feed (shared SearchBar, no regression). Desktop
stays a single row. tsc / knip / lint clean.
2026-08-17 21:06:33 +02:00
peter 9fb4db2546 fix(plex): address S6 code-review — 5 findings
1. (correctness) Show hero rating regression: PlexBrowse's show hero rendered a
   rating only when show.imdb_rating was set, so after the S6 IMDb-only change a
   show with no imdb:// entry showed no rating. Now falls back to the audience
   `rating` like PlexInfo.
2. (correctness) ratingMin column mismatch: the rating filter and the /facets
   rating_max now key off coalesce(imdb_rating, rating) — the SAME "best score"
   the badge displays — so a ratingMin picked from a shown IMDb score narrows the
   right column instead of the raw audience `rating`.
3. (altitude) HU search gap: new query.vector_match() matches EITHER vector in HU
   (or_(search_vector_hu, search_vector)) so a row with no HU twin stays findable
   by its English title; ranking still uses the HU vector. field_recheck unions
   the HU+EN per-field tsvectors in HU, staying a strict subset of the prefilter.
4. (test-coverage) Extracted the badge logic to lib/plexRating.pickRatingBadge
   (shared by PlexInfo + the show hero) with a unit test (imdb-preferred, audience
   fallback, 0-score, none). Backend tests added for the HU union search + the
   IMDb-preferred rating filter.
5. (efficiency) Language switch no longer double-fetches the grid: browseQ is
   gated on a committedLang state that lags `lang` by one render, so the
   genre-clear effect settles into a single fetch instead of one throwaway fetch
   with stale-language genres plus a second.

DB-lane suite 295 passed / 2 skip; FE tsc/knip/lint clean, 217 vitest pass.
2026-08-17 20:41:13 +02:00
peter 2bfe7d53eb fix(plex): S6 — thread lang into the player's item_detail query
Found by the HU-overlay E2E: PlexPlayer fetched qk.plexItem(id) WITHOUT lang,
so the in-player title/summary/rich detail stayed English even in HU mode while
the grid showed Hungarian (e.g. the player header read "127 Hours" over a grid
of "127 óra"). The S6 FE pass threaded lang through the grid, facets, info-page
and show queries but missed the player's own detail fetch. Now keyed by
plexLang() like the rest, so the player matches the active language.

FE gate: tsc / knip / lint clean; queryKeys tests pass. Verified live — the
player header now reads "127 óra" in HU mode.
2026-08-17 20:21:29 +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 030ba7297a feat(plex): S6 backend — language-aware serving + IMDb-only rating
E5 S6 backend half (no migration — the *_hu / rich columns exist from S5a/S5b).

Language-aware serving (?lang=hu serves the HU overlay twin, EN fallback per row):
- query.py: column pickers title_col / genres_col / vector_col; field_recheck and
  meta_filter_conds take `lang`. DISPLAY/FILTER pickers coalesce(*_hu, *) (EN
  fallback); SEARCH pickers use the raw *_hu column mirroring the generated
  search_vector_hu (coalesce to '', not EN) so the field recheck stays a strict
  subset of the GIN vector (the S2 invariant). genres_col type_coerces the coalesce
  to JSONB so the GIN `.contains` survives.
- browse.py: `lang` param on /library, /facets, /item, /show (validated to en|hu).
  unified_library title/vector/rank/field-scope + meta filters are language-aware
  for both the movie and show branches; facets unnest the active-language genre
  vocabulary (jsonb_typeof guard kept); detail endpoints serve HU title/summary/
  genres/show_title with EN fallback. Episodes stay EN (not overlay-localized).

IMDb-only rating (folded-in S5b review #5):
- detail.py rich_detail: drop the audienceRating/rating fallback → imdb_rating is
  NULL when there's no imdb:// Rating entry (stop mislabelling a Plex audience score
  as IMDb). item_detail now also serves the `rating` column (audience score;
  show_detail already did) so the UI can show it when there's no IMDb score.

Tests: test_plex_lang.py (HU title serving + EN fallback, language-specific facet
vocabulary, genre-filter language matching, item_detail lang + rating split);
test_plex_detail updated for IMDb-only. DB-lane suite 293 passed / 2 skipped; ruff clean.
2026-08-17 19:07:43 +02:00
peter c0cda545e0 test(feed): cover malformed-cursor 400 + from-None on the re-raise
Second-round /code-review high follow-ups:

- test_feed_cursor.py: GET /api/feed?cursor=<garbage> must return HTTP 400
  (not 500) — locks the cursor-error path the refactor split across modules
  (feedquery.decode_cursor raises ValueError, get_feed translates to 400).
  Plus a no-cursor 200 sanity check. DB lane.
- get_feed: `raise HTTPException(...) from None` so the expected bad-input
  ValueError isn't chained as __context__.

DB-lane suite 289 passed / 2 skipped; ruff clean.
2026-08-17 18:46:48 +02:00
peter 2ab188d873 refactor: address code-review — layering + public API naming
Applies the three findings from the /code-review high pass on this stack:

1. to_tsquery_str moved from app.feedquery to app.utils — it's a generic
   tsquery sanitizer with no feed dependency; housing it in the feed
   read-model coupled the Plex browse module to the feed domain. Both feed
   and plex now import it from the neutral util module.

2. feedquery.decode_cursor no longer raises fastapi.HTTPException — it raises
   a plain ValueError, and routes/feed.get_feed translates it to the HTTP 400.
   The read-model no longer imports fastapi, matching its "no request state"
   docstring.

3. Dropped the leading underscore on the helpers that are now a cross-module
   public API: feedquery {filtered_query, sort_keys, order_by, keyset_predicate,
   encode_cursor, decode_cursor}; plex.query {TS_CONFIG, FIELD_COLUMNS,
   field_recheck, split_csv, added_cutoff, meta_filter_conds, apply_meta_filters,
   like_escape}; plex.cards {movie_card, show_card, episode_card, leaf_card}.
   Genuinely-internal helpers (_channel_url, _saved_expr, _ADDED_WINDOWS,
   _lib_key) keep their underscore.

Pure code motion + rename. DB-lane suite 287 passed / 2 skipped; ruff clean.
2026-08-17 18:23:03 +02:00
peter e7b5b8e87f refactor(plex): extract card serializers + query builder from browse.py
Second half of the 2026-08-17 review's "Plex query/cards extraction" — slim
the 1841-line browse.py god-module ahead of S6 (which reworks the library
query's column selection for the HU overlay).

- app/plex/cards.py: the pure grid/leaf card serializers (_movie_card,
  _show_card, _episode_card, _leaf_card).
- app/plex/query.py: the library query-shaping layer — field-scope tsvector
  rechecks (_TS_CONFIG, _FIELD_COLUMNS, _field_recheck) and the shared sidebar
  metadata filters (_csv, _added_cutoff, _meta_filter_conds, _apply_meta_filters,
  LibraryFilters, _like_escape). This is the seam S6 will touch.

browse.py imports both and keeps its route handlers; the entangled
collection/playlist card cluster (_collection_source/_collection_editable)
stays put deliberately. All helpers were browse-internal, so no external
importers change. Pure code motion — DB-lane suite 287 passed / 2 skipped;
ruff clean. browse.py 1841 -> 1667 lines.
2026-08-17 17:47:30 +02:00
peter 5f09259bbf refactor(feed): extract read-model to app.feedquery + admin_user sweep
B-core-3 (2026-08-17 review): decouple two things from router modules.

1. Feed read-model → new app/feedquery.py: the pure query-building and
   row-serialization layer (feed_columns, serialize, _filtered_query,
   to_tsquery_str, and the keyset-pagination helpers) moves out of
   routes/feed.py, which keeps only the route handlers, _feed_params, and
   the request models. The three external consumers (playlists, search,
   plex/browse) now import from app.feedquery instead of reaching into the
   feed router.

2. admin_user sweep: 7 modules imported admin_user via app.routes.admin
   (an incidental re-export of app.auth.admin_user). Repoint them all to
   the definition in app.auth (audit, channels, config, quota, scheduler,
   plex/admin, plex/browse); routes.admin keeps its own import for its use.

Pure code motion — no behaviour change. Full DB-lane suite 287 passed /
2 skipped; ruff clean.
2026-08-17 17:42:01 +02:00
peter c361fc2581 fix(gate): green the frontend gate at HEAD
- PlexPersonView.dom.test.tsx: variadic vi.fn mock for plexSetState so the
  spread call typechecks (TS2556 — zero-arg mock couldn't take ...args).
- plexSearch.ts: drop the unused `export` on PLEX_SEARCH_FIELDS (module-local
  only; knip flagged it as an unused export).

tsc / knip / lint all green; ruff clean.
2026-08-17 17:30:12 +02:00
peter 81827fcf28 fix(plex): S5b review — preserve enriched cast_names across full syncs
Address the /code-review high finding on S5b: gating the info-page cast enrich on
`people_enriched_at is None` removed the old per-view regrow of `cast_names`, so a routine full sync
that reset it to the listing top-20 (`_apply_facet_fields`) — while never clearing people_enriched_at —
left it permanently truncated, silently dropping cast past the top ~20 from the S4 person-page filter.

Root fix: `_apply_facet_fields` now only takes the listing's cast for a NOT-yet-enriched row; an
enriched row keeps its fuller stored cast (people_text still rebuilt from the updated directors + the
preserved cast). Makes the people_enriched_at flag the endpoints/backfill trust actually reliable.

Tests: cast_names preserved for an enriched row vs applied for a fresh one; show_detail lazy-fill
persists imdb/full_cast on first view. Full suite green.
2026-08-16 23:30:33 +02:00
peter 4e97fc1a84 feat(plex): S5b persist rich detail + serve item/show detail from DB
Persist the info-page rich fields that were previously read LIVE from plex.metadata() on every view —
imdb_rating (IMDb-specific, vs `rating`=audienceRating), imdb_id, tagline, and full_cast
([{name,role,thumb}]) — onto plex_items/plex_shows, and serve the detail endpoints from the row.

- migration 0065: ADD imdb_rating/imdb_id/tagline/full_cast (nullable → no rewrite/lock, unlike 0064);
  NULL people_enriched_at on movie/show rows so the existing cast backfill re-fetches each once and
  fills the new columns.
- new app/plex/detail.py: rich_detail(meta) (moved out of browse._rich_meta so sync + serve share one
  parser) + apply_rich_detail(row, meta); wired into _enrich_people_batch and the info-page lazy fill.
- item_detail: movies serve cast/IMDb/tagline/genres from the row and keep plex.metadata(markers=True)
  ONLY for markers + audio/subtitle streams (so detail survives a live-Plex outage); episodes (never
  enriched) still parse detail from that live meta.
- show_detail: serves cast/IMDb from the row; `related` stays live best-effort and also lazily fills a
  not-yet-enriched show.
- tests: rich_detail/apply_rich_detail parsing + persistence, backfill stamping, item_detail
  outage-resilience + lazy fill + episode-live, show_detail row serving. Full suite 285 pass / 2 skip;
  migration verified reversible (up→down→up).

Precondition for S6's pure-DB language-aware serving.
2026-08-16 23:11:15 +02:00
peter 02a2102523 fix(plex): S5a review round 2 — dup-HU guard + tests for dup-guid & guid-keep
- Correctness: _sync_overlay now first-wins on a guid (a `seen` set) so a duplicate HU entry
  sharing a guid can't overwrite the applied Hungarian metadata or double-count `overlaid`.
- Test-coverage: cover the earlier dup-guid fix (both editions of a shared-guid pair get *_hu,
  counted once) and the guid-keep guard (_apply_item preserves a prior guid when the listing
  carries none, still updates on a real one).

Full backend suite 276 passed / 2 skipped.
2026-08-16 22:38:59 +02:00
peter 704c6c35d4 fix(plex): address S5a review — overlay reconcile, dup-guid, guid-wipe, tests
- Correctness (stale HU): _sync_overlay is now a full reconcile — it clears every guid-bearing EN
  row's *_hu first, then repopulates the matches, so a title whose HU twin vanished or was re-guid'd
  falls back to EN instead of keeping stale Hungarian metadata (only rows that held HU data go dirty).
- Correctness (dup guid): by_guid maps a guid to the LIST of EN rows carrying it, so two editions of
  one film both receive the overlay instead of just the last-iterated one.
- Robustness (guid wipe): _apply_item/_sync_shows keep a prior guid when a listing carries none
  (`_canonical_guid(meta) or row.guid`), so a server ignoring includeGuids can't erase the match key.
- Reuse: merged _sync_overlay's movie/show branches (only the select + item_type differ).
- Test-coverage: a full-sync test drives sync() over a stub Plex (sections + section_items +
  overlay_map) asserting pass-2 designation (metadata_lang/overlay_of), guid capture, the HU write,
  and browse exclusion; plus a reconcile test that a vanished twin clears stale *_hu.

Full backend suite 274 passed / 2 skipped.
2026-08-16 22:31:08 +02:00
peter c959859d29 feat(plex): S5a HU-overlay backend — schema, guid, overlay sync
Adds the storage + sync for the Hungarian-metadata overlay (the browsable EN sections gain HU
twin fields written from a paired HU section, matched by guid; no browsable HU rows).

Schema (2 migrations, ADD-only; run before the first HU sync while *_hu is NULL so the generated
column build is trivial):
- 0063: plex_libraries.metadata_lang ('en' default) + self-FK overlay_of.
- 0064: plex_items/plex_shows.guid (indexed) + title_hu/summary_hu/genres_hu (+ GIN) + a STORED
  generated search_vector_hu mirroring the EN vector (title_hu A / people_text B / summary_hu C) + GIN.

Sync:
- includeGuids=1 on section_items so the canonical guid rides the cheap listing.
- _canonical_guid(meta) (tmdb > imdb > tvdb) captured onto every EN row in _apply_item/_sync_shows.
- Two-pass sync(): pass 1 mirrors browsable (non-overlay) sections; pass 2 reads plex_overlay_map
  ('huKey:enKey', new sysconfig), designates each HU section (metadata_lang='hu' + overlay_of), and
  _sync_overlay writes title_hu/summary_hu/genres_hu onto the guid-matched EN rows.

Browse: _content_libs now excludes overlay libraries (overlay_of is None); the unified_library and
facets inline library partitions fold into it, and list_libraries/collections exclude overlays too.

Tests: DB-lane coverage of the overlay write (matched vs unmatched rows, no browsable HU rows, the
generated search_vector_hu populates), the guid parse, and the browse exclusion. Full suite 272
passed; migrations verified reversible (upgrade→downgrade→upgrade). S5b (persist the live detail
fields + serve item/show detail from DB) is the follow-up.
2026-08-16 22:16:54 +02:00
peter 072d8fc88f fix(plex): address S4 review — cap filmography, dedupe toggle, scroll guard, tests
- Efficiency: cap each person section at _PERSON_SECTION_MAX (200) so a prolific person can't
  return an unbounded list the client mounts a card per row for.
- Reuse: extract the optimistic watch-toggle (flip → persist → resync) into a shared
  toggleWatchState helper; the grid and the person page each supply their own cache updater.
- Correctness (scroll): onOpenPerson now stashes infoScrollRef ONLY when leaving an info page
  (not from a show/grid context), and a jumped-to info page resets it — so a stale offset can't
  wrongly restore a fresh info page.
- Test-coverage: add PlexPersonView.dom.test.tsx (export CastStrip + PlexPersonView) covering
  the cast-row rewiring (open-person vs filter fallback) and the person page's sections + filter
  + empty state.

The name-as-identity finding is inherent to Plex cast carrying no per-person id (PlexPerson is
name-keyed too); documented in the endpoint docstring, not structurally fixable in S4.
2026-08-16 21:55:34 +02:00
peter 87ef075ea7 feat(plex): make the actor filter-chip open the person page
The person cards above the Plex grid (the active actor filters) now open that person's
filmography page on click, reusing S4's onOpenPerson; the ✕ still removes the filter. The
name/photo become a button with the viewPerson tooltip; the remove control stays separate.
2026-08-16 21:37:43 +02:00
peter 8b8c077a29 feat(plex): S4 actor detail page — filmography endpoint + person subview
Backend: new GET /api/plex/person/{name} returns a cast/crew member's cached headshot plus
the movies (PlexItem, kind=movie) and shows (PlexShow) in the enabled libraries whose
cast_names OR directors include the name — the inverse of the cast → filter link. Introduce a
shared _content_libs(db) helper (enabled-library partition) and reuse it in people_cards;
S5 will extend it with the HU-overlay exclusion. Watch-state comes from PlexState / rollup
(reusing _movie_card / _show_card / state_map). DB-lane tests cover cast+director credits,
library scoping, the kind filter, unknown-name empties, and the partition helper.

Frontend: a new `person` history-subview. A cast-row click (PlexInfo cast row + the show
page's CastStrip) now opens the person's filmography page instead of filtering; the filter
gesture moves to a "Filter library by {name}" button on the page itself. PlexPersonView
mirrors PlexInfoView's fetch-wrapper + glass hero (headshot + name + credit counts) and
renders Movies / Shows as PlexPosterCard grids, with an optimistic per-card watch toggle on
the person cache. New plexPerson query key + api client + PlexPersonDetail type; HU/EN copy.
2026-08-16 21:26:58 +02:00
peter 6465f0ce75 style: prettier-format the 0.65.2 release-notes entry
The release gate's prettier --check flagged the new entry; no content change.
2026-08-16 20:13:09 +02:00