Commit Graph
100 Commits
Author SHA1 Message Date
peter bca67a3547 Merge: promote dev to prod 2026-07-29 18:33:57 +02:00
peter 0d1f1c9fa5 Merge welcome-readme-refresh → dev (0.58.0 landing refresh) 2026-07-29 18:29:18 +02:00
peter 929a63b8ec chore(release): 0.58.0 — landing refresh + R7.5 API layer 2026-07-29 18:28:51 +02:00
peter 9dc7f80305 improvement(welcome): gallery landing, refreshed copy, name-free screenshots
Rebuild the landing preview into a per-module screenshot gallery (feed hero + channels, playlists, search, player, downloads, Plex), reusing the existing Preview/Lightbox. Rewrite the hero + feature copy in a plainer, concise tone that reflects the current modules (Download Center, Plex, two-way playlists), in EN and HU. Regenerate all screenshots name-free. Bring the README up to date: fix the broken landing image, add the Plex feature, and correct the now-temporary YouTube-search behaviour.
2026-07-29 18:28:51 +02:00
peter 18bfcba4ed Merge r7.5-api-layer → dev
R7.5 · api-layer redesign (Option 2a): lib/api.ts is now a spread-only façade
over 15 per-domain slice modules (types + methods co-located), guarded by
facade.test.ts against interleave regrowth. Frontend-invisible; every req/beacon
call byte-identical to pre-merge dev. Not shipped to prod (no user-facing change
to note); bundle into the next release.
2026-07-28 23:14:32 +02:00
peter 1fd4c2f7b3 docs(api): note the façade guard's intended scope boundary (type-name clashes are tsc-caught) 2026-07-28 23:14:32 +02:00
peter 95db151565 test(api): guard the spread-only façade against interleave regrowth (R7.5 S3)
Add facade.test.ts asserting the two invariants that keep lib/api.ts safe:
no method name is defined in two slices (a silent last-wins spread override),
and the `api` object is exactly the union of the slices (nothing added inline
in the barrel, nothing lost). Verified to have teeth — an injected duplicate
key fails the collision test with a named message.

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

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

Reunites the interleaved tags read/CRUD that motivated the epic.
2026-07-28 22:41:34 +02:00
peter 2c7c8e0f8a Merge: promote dev to prod 2026-07-28 03:30:44 +02:00
peter 81d0943fdc fix(watch): drop the Web Audio reroute, keep Media Session for iOS background audio (0.57.2)
Reported: on iPad Brave a shared video's audio still stopped when backgrounded, while youtube.com
keeps playing. Root cause: routing the element's audio through createMediaElementSource pulls it out
of the normal media pipeline the browser's background-playback feature (and iOS) hooks into, and iOS
then suspends the AudioContext on background — the opposite of the goal. YouTube uses a plain media
element + Media Session and lets the browser background it. So drop the Web Audio graph entirely and
keep only the Media Session metadata + play/pause handlers, leaving native background handling in
charge. Frontend-only.
2026-07-28 03:29:48 +02:00
peter 5e2195eb5a Merge: promote dev to prod 2026-07-28 03:21:39 +02:00
peter 9187ae56b1 fix(share): use the stable YouTube hqdefault thumbnail for OG on YT shares (0.57.1)
Facebook's scraper rejected our self-hosted poster as "Corrupted Image": the poster is an
ffmpeg-produced JPEG (non-standard marker order) that browsers/iOS/Twitter decode fine but FB's
stricter processor won't. For a YouTube source, point og:image at the video's stable, non-expiring
`i.ytimg.com/vi/<id>/hqdefault.jpg` (a Google-encoded JPEG FB decodes reliably) built from the id in
the stored thumbnail — instead of the stored `sqp`-signed variant, which can expire. Non-YouTube
sources keep the poster fallback (fine everywhere but FB). Declares 480×360 so FB includes it on the
first async scrape. og.py only — no schema change.
2026-07-28 03:20:31 +02:00
peter 53b599271d Merge: promote dev to prod 2026-07-28 03:01:00 +02:00
peter 922c18b7c3 Merge fixpack-downloads-channels → 0.57.0
Downloads & channels polish (S1–S4): queue-default/date/auto-link/thumb-fallback/clipboard-paste,
categorized download-failure dialog (migration 0061), channel full-history + honest count,
press-and-hold + inline-editable priority, iOS background audio on the watch page, FB OG image
dimensions. Two user-triggered /code-review high rounds (round 1: 5 findings → 4 fixed + 1 intended;
round 2: clean).
2026-07-28 02:59:19 +02:00
peter b367bc3126 chore(release): 0.57.0 — downloads & channels fixpack
Downloads UI quick wins, categorized download-failure dialog, channel full-history + honest
stored/total count, press-and-hold + inline-editable priority, iOS background audio on the shared
watch page, and Facebook OG image-dimension fix. Adds migration 0061 (download_jobs.error_code).
2026-07-28 02:59:04 +02:00
peter 7765d19813 fix: code-review round 1 — 4 findings
- og: guard safe_abs_path's None (missing/gc'd poster) before _jpeg_size, else the public /watch OG
  render crashed with an uncaught AttributeError
- channels: hoist the priority step button to module scope so it no longer remounts on every
  hold-repeat tick (which dropped the pointer capture and could let a hold run away on pointer drift)
- watch: only route audio through Web Audio on iOS (where a backgrounded <video> is suspended);
  other platforms keep their reliable native audio, avoiding a foreground-silence risk if the
  AudioContext can't resume
- share: drop the unstable `create` mutation object from the auto-create effect deps (it changes
  every render); the links.data trigger + autoTried ref already fire it exactly once

The clipboard-on-focus finding is intended (the user explicitly asked for auto-paste on focus; the
read is best-effort and browsers reject it without a gesture, so no real prompt fires).
2026-07-28 02:49:34 +02:00
peter d5c9fb9918 feat(watch): keep audio playing in the background on the shared watch page (S4)
A shared /watch video stopped the moment you backgrounded the browser on iOS (Safari/Brave suspend a
<video>), while an audio-only share kept playing — the user wanted YouTube-style background playback
for video too. Route the element's same-origin audio through a Web Audio graph (once that graph is
live, iOS keeps the audio session running in the background while the frame freezes) and add Media
Session metadata + play/pause handlers for lock-screen controls. Set up lazily on first play (the
gesture an AudioContext needs) and only once. Best-effort — any unsupported bit falls back to the
plain <video>. Background continuation itself is an iOS behaviour only real-device UAT can confirm.
2026-07-28 02:29:19 +02:00
peter 9d3d69f0e9 feat(channels): press-and-hold priority stepper + inline-editable value
The priority arrows changed one step per click, so setting a large value meant many clicks. Now a
press-and-hold auto-repeats at an accelerating rate (a quick tap still steps once; keyboard path
kept), and the PATCH is debounced per channel so a hold sends one request with the final value, not
one per notch (flushed on unmount). The value also sits LEFT of the arrows (cursor never covers it)
and is click-to-edit — type any integer directly, negatives allowed — via an absolutely-positioned
input that overlays without disturbing the table row layout.
2026-07-28 02:22:36 +02:00
peter eb922cb589 feat(channels): surface full-history pull + honest stored/total count on the channel page (S3)
The catalog only stores the recent-backfill/RSS subset of a channel's uploads (video_count is
YouTube's raw total), so a subscribed channel looked like it was "missing" videos. Now the channel
page header shows "{stored} / {total} videos" when there's a gap, and a "Full history" action queues
the deep backfill (the same deep_requested trigger the channel manager uses) — showing a loading
state until done. Backend: channel_detail now returns deep_requested/backfill_done.
2026-07-28 02:22:36 +02:00
peter ae14aa1c6a fix(share): declare og:image dimensions so the FB card includes the image on first scrape
The FB Sharing Debugger confirmed the real cause of the missing desktop-Messenger preview: the
scraper fetches og:image ASYNCHRONOUSLY on a URL's first scrape ("The provided 'og:image'
properties are not yet available… specify the dimensions using 'og:image:width'/'og:image:height'"),
so a freshly shared /watch link unfurled title-only. Read the poster JPEG's real dimensions from its
SOF marker (Pillow isn't a dep; the poster aspect differs from the video's, so the stored video
width/height won't do) and emit og:image:width/height. Only runs on a crawler hit. Unit-tested.
2026-07-28 01:54:14 +02:00
peter 40acf104a1 feat(share): spell out og:image:secure_url + type for the Facebook scraper
The iPad Messenger app unfurls a share link on-device (fetches + renders the OG tags itself),
but messenger.com desktop shows only what Facebook's server-side scraper cached — which is
pickier. Add og:image:secure_url (public HTTPS) and og:image:type=image/jpeg for our poster so
the card renders even when the scraper won't sniff the bytes. Reachability/stale-cache is a
separate, live-diagnosis step.
2026-07-28 01:40:22 +02:00
peter cdcffb8c91 feat(downloads): categorize download failures + a readable error dialog (S2)
Failed downloads stored the raw yt-dlp/ffmpeg text and dumped it as a truncated red
one-liner in the queue. Classify the failure into a category (unavailable / login_required /
geo_blocked / format_unavailable / drm / postprocess / source_missing / network / unknown)
and surface it properly:

- backend: app/downloads/errors.classify maps the raw message to a category code; the worker
  stores it in the new download_jobs.error_code (migration 0061) at both failure sites; resume
  clears it; the serializer exposes it. Raw text kept as technical detail.
- frontend: the queue row shows a clean localized reason + a "details" link opening a modal
  with the explanation, the raw text behind a disclosure, and a Retry only for transient
  categories (network/postprocess/unknown).
- tests: classify() unit coverage over verbatim yt-dlp/ffmpeg message shapes.
2026-07-28 01:40:22 +02:00
peter 3f15f35256 feat(downloads): S1 UI quick wins — queue default, dates, auto-link, thumb fallback
- Always land on the Queue tab (drop the persisted last-tab) so new work/failures are the default view
- Show the download date (relative) on Library rows
- Share dialog opens with a default link already generated when none exists; all link controls stay available
- Thumbnail falls back to the self-hosted poster when a remote (i.ytimg/fbcdn) thumbnail 404s
- Add a clipboard-paste affordance to the add-URL box (explicit button + best-effort on focus)
2026-07-28 01:21:50 +02:00
peter ab1272beb4 Merge: promote dev to prod 2026-07-28 00:25:52 +02:00
peter 0d655e6f88 fix(api): un-export types used only within their own module (knip gate)
The S3b api.ts split introduced 5 exported-but-never-imported types
(PlaylistKind/PlaylistSource, DownloadJobKind, YT internal option/data shapes).
The publish gate's knip lane flagged them (my per-step checks ran tsc/eslint/
prettier/vitest but not knip). They're used inside their own modules by the
interfaces, so make them local (consumers compare against literals, never name
the type).
2026-07-28 00:24:38 +02:00
peter 4d34322b7c Merge R7 · Frontend API layer → 0.56.0 2026-07-28 00:18:40 +02:00
peter 175e49b361 chore(release): 0.56.0 — R7 frontend API layer 2026-07-28 00:18:39 +02:00
peter 1223df6ccc refactor(api): code-review fixes (R7 S3b review round 1)
- barrel re-exports domains with `export type *` (not `export *`) so the
  internal downloadsApi/plexApi/messagingApi slices stay behind the façade;
  plex's runtime value exports (EMPTY_PLEX_FILTERS/plexFilterCount) re-exported
  explicitly
- App.tsx: drop the now-redundant `prefs as Record<string, unknown>` self-cast
  (Me.preferences is already that type) and its `prefsRec` alias
- plex.ts: comment why plexSetState keeps its status union inline (reusing
  VideoStatus would make an api.ts↔plex.ts type cycle)

Behaviour-identical. Gate green: typecheck, eslint 0, prettier, 74 vitest.
2026-07-27 23:45:02 +02:00
peter 926ebe7ffa refactor(api): extract the messaging domain to api/messaging.ts (R7 S3b·4)
The E2EE direct-message types (MessageUser/Message/Conversation/MyKeyBundle/
KeySetup) + the ~9 messaging methods move into a self-contained
api/messaging.ts exporting a `messagingApi` slice (imports only core's req).
api.ts spreads the slice and re-exports the types. api.ts 882 -> 819 lines; no
runtime change. Gate green: typecheck, eslint, prettier, 74 vitest.
2026-07-27 23:26:23 +02:00
peter 3232a53c46 refactor(api): extract the plex domain to api/plex.ts (R7 S3b·3)
All Plex types (browse cards, show/season/item details, collections, native
playlists, filters/facets, watch-link) + EMPTY_PLEX_FILTERS/plexFilterCount/
appendPlexFilters + the ~40 plex methods move into a self-contained api/plex.ts
exporting a `plexApi` slice (imports only core's req/beacon). api.ts spreads the
slice and re-exports the types via `export *`. api.ts 1352 -> 882 lines (halved
from the original 1793); no runtime change. Gate green: typecheck, eslint,
prettier, 74 vitest.
2026-07-27 23:23:31 +02:00
peter cc38d8bdfa refactor(api): extract the downloads domain to api/downloads.ts (R7 S3b·2)
The download-center types (DownloadJob/Spec/Profile/EditSpec/ShareLink/... and
the admin storage/quota shapes) + all ~30 download methods move into a
self-contained api/downloads.ts exporting a `downloadsApi` slice. api.ts spreads
the slice into the `api` façade and re-exports the types via `export *`, so the
53 call sites are unchanged. Cleanly separable — the domain's types reference
only each other and its methods only touch core's req/getActiveAccount. api.ts
1583 -> 1352 lines; no runtime change. Gate green: typecheck, eslint, prettier,
74 vitest.
2026-07-27 23:17:12 +02:00
peter 49f8c57c89 refactor(api): extract the shared HTTP machinery to api/core.ts (R7 S3b·1)
First step of splitting the api.ts god module behind its façade. Move req,
HttpError, localizeDetail, the connectivity toast, ReqConfig, the per-tab
active-account plumbing (getActiveAccount/setActiveAccount/clearActiveAccount/
beacon), setupHeaders, and setUnauthorizedHandler into lib/api/core.ts. api.ts
imports the machinery it uses and re-exports the public surface, so the 53 call
sites importing from "./api" are unchanged. api.ts 1793 -> 1583 lines; no
runtime change (pure move). Gate green: typecheck, eslint, prettier, 74 vitest.
2026-07-27 23:08:48 +02:00
peter 90749a9e45 refactor(fe): eliminate every explicit any; enforce no-explicit-any (R7 S3a·4)
Removes all 13 remaining `any`s and flips the ESLint rule from off to error so
new ones can't creep back:
- api.ts prefs/data JSON bags: Record<string, any> -> Record<string, unknown>,
  narrowed at consumers (App language guard; NotificationsPanel shapes the
  notification `data` per type behind its existing typeof guards)
- catch (e: any) -> catch (e) + `e instanceof HttpError` narrowing
  (SettingsPanel, SetupWizard, PlexPlayer)
- the YouTube IFrame API boundary: new lib/youtube.ts types (YTPlayer/
  YTPlayerEvent/YTNamespace + a Window augmentation) replace the 6 player anys;
  tsc surfaced the exact method surface to declare
- NotificationsPanel `t` props: any-options -> i18next TFunction

Gate green: typecheck (app+node+e2e), eslint 0 errors (rule now enforced),
prettier, 74 vitest.
2026-07-27 22:57:33 +02:00
peter 2d4ecdb6c5 refactor(api): honest FeedResponse; derive pageTitleKey from the registry (R7 S3a·2-3)
- FeedResponse claimed a required `limit`, but /api/search/youtube never
  returns it (only /api/feed does) and nothing in the app reads it. Drop the
  field so searchYoutube's Promise<FeedResponse> stops lying; documented for
  re-add if a consumer appears.
- pageMeta's hand-written 13-case Page->title switch now derives from the
  ModuleDef registry via a new optional `titleKey` (the longer tab title, e.g.
  "Channel manager"; falls back to the short nav labelKey). A new page is one
  touch (add it to MODULES) instead of also editing a switch. A test pins all
  13 title keys byte-identical to the old switch (suite 74).
2026-07-27 22:45:30 +02:00
peter 5de6be8fde refactor(api): literal-union the closed-value string fields (R7 S3a·1)
Video.status, Playlist.kind/source (+ PlaylistDetail), DownloadJob.job_kind
were bare `string`; type them as named unions verified against the backend
(VALID_STATES={new,watched,hidden}; kind user|watch_later; source local|
youtube; job_kind download|edit). VideoStatus then propagates through the
optimistic-override map, onState, and the VideoCard/VirtualFeed/PlayerModal
props — tsc now rejects a status typo (e.g. a stale "in_progress", which is a
derived filter, never a stored status) at every call site.
2026-07-27 22:40:11 +02:00
peter ca0312d1a1 test(fe): lock query-key factory shapes; unify nullable guard (R7 S2 review)
Code-review follow-ups on the S2 factory:
- add queryKeys.test.ts pinning every branch (bare vs param, null-segment
  preservation, single-id, variadic composite spreads) — the contract the
  epic exists to protect now has a test (suite 68 -> 73)
- unify feed/feedCount/facets on the `!== undefined` guard the rest of the
  factory already uses (behaviour-identical for the always-truthy FeedFilters,
  removes the collapse-on-null footgun)

Two findings skipped with rationale: variadic composite-key typing (heterogeneous
object/array segments make precise tuples high-effort; root centralized + shapes
now tested) and a pre-existing thread(null) no-op (unreachable in practice).
2026-07-27 22:24:48 +02:00
peter 34297b696f refactor(fe): migrate plex/messaging/admin/downloads/notifications to qk (R7 S2b)
Extend lib/queryKeys.ts to the remaining ~34 key roots and migrate every
remaining literal (queryKey: [...] AND the positional useLiveQuery keys) in
36 files. All ~200 React Query keys across the app now route through the
factory; zero raw key literals remain outside queryKeys.ts.

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

Pure substitution — byte-identical key arrays, so TanStack prefix matching
is unchanged. Gate green: typecheck (app+node+e2e), eslint (0 err),
prettier, 68 vitest. Runtime-verified on a fresh dev server: Feed + Plex
modules load, feed/facets/tags/my-status/plex-library/plex-facets/
plex-collections/plex-playlists all 200, full UI renders.
2026-07-27 22:10:03 +02:00
peter ad86d6d126 refactor(fe): query-key factory + migrate feed/channels/playlists (R7 S2a)
Introduce lib/queryKeys.ts as the single source of truth for React Query
keys — the R7 driver was ~200 hand-written key literals across 51 files
(`["feed"]` alone in 15+), where one typo silently orphans a key so an
invalidate never matches.

S2a migrates the three highest-traffic domains (feed/video, channels,
playlists) plus the shared me/my-status/tags roots: ~90 literal sites in
18 files now call qk.*(). Pure substitution — each factory fn returns the
byte-identical array the call sites used before, so TanStack's prefix
matching is unchanged (qk.feed() still invalidates qk.feed(filters)).
Co-invalidation clusters are deliberately NOT bundled into helpers (they
differ per site), so no query's refetch scope changes.

Nullable keys (playlist(selectedId), ytSearch(q)) accept null so a null
segment is preserved (["playlist", null]) while a no-arg call is the bare
prefix key — guard is `!== undefined`.

Gate green: typecheck, eslint (0 err), prettier, 68 vitest. Smoke: app
boots, feed/facets/tags/my-status all 200, no console errors. Factory
extended to plex/messaging/admin/downloads/notifications in S2b.
2026-07-27 21:48:35 +02:00
peter d0aa1cf9a7 refactor(api): code-review fixes (R7 S1)
- localizeDetail: don't leak FastAPI's snake_case `loc` field names into a
  translated 422 toast; return a clean, fully localized validation message
  (drops the loc filter/pop parsing entirely)
- me(): drop the dead `r &&` guard now that req<T> types r non-nullable
2026-07-26 22:17:04 +02:00
peter 16585d868d refactor(api): type req<T>() and tighten the error layer (R7 S1)
req() returned Promise<any>, so every api.* return annotation was an
unchecked assertion and the 17 action endpoints leaked `any` to callers.
Make req generic with a single audited cast at the boundary; T flows from
each method's declared return type via contextual inference, so no call
site needs a type arg except where none existed:

- me(): type the /api/me probe ({ authenticated } + Partial<Me>)
- deepAll / syncSubscriptions: real backend-verified response shapes
- HttpError.detailData, localizeDetail(d), inner detailData: any -> unknown

Also teach localizeDetail the FastAPI 422 detail-ARRAY shape (carried in
from R6 S1b review): now that malformed requests return 422, surface the
offending field via a localized errors.validation key instead of falling
through to the generic toast.

No runtime change on normal flows (req is await-identical); the 422 branch
only fires on genuinely malformed input. Gate green: typecheck, eslint,
prettier, 68 vitest. Smoke-tested localdev: /api/me + feed 200, no console
errors.
2026-07-26 21:51:24 +02:00
peter 8ee46aac09 Merge: promote dev to prod 2026-07-26 21:30:05 +02:00
peter cb925e3ab9 Merge R6 · API contract & transaction ownership → 0.55.0
Backend-heavy, frontend-invisible hardening over four sprints, all user-triggered
/code-review clean + UAT-passed on localdev:
- S1a: shared iso()/owned_or_404/require_write_scope helpers.
- S1b: all ~52 payload:dict endpoints → Pydantic request models (3 named prod-500s
  on garbage input become clean 422s); status-code unification.
- S3 (pulled ahead of S2): DB-backed pytest lane (ephemeral Postgres, alembic head)
  wired into the gate; /facets genre-null regression; migration 0060 normalises
  existing JSONB-null plex rows.
- S2: quota accounting owns its session (durable across a caller rollback, never
  commits the caller); Plex sync commits per-section. Proven by DB-lane tests.

Suite 159 -> 181 (incl. the new DB lane). db_revision 0060. No response-shape change.
2026-07-26 21:26:26 +02:00
peter c86a15c252 chore(release): 0.55.0 — R6 API contract & transaction ownership 2026-07-26 21:25:36 +02:00
peter 351eb5fd13 test(plex)+cleanup(quota): S2 code-review fixes (R6 S2)
/code-review high on S2 — 4 findings, 2 fixed, 2 no-change:
- test_plex_sync_txn: a faked PlexClient fails on the second library; asserts the
  first library's row survives (per-section commit). Mutation-checked — moving the
  commit back out of the loop turns it red ([] == ['1']).
- dropped the `[[quota-owns-its-session]]` memory slugs from quota.py docstrings
  (a dangling private-note reference in shipped code; the reasoning is already inline).
No change: record_usage's per-call second connection is a transient spike, fine for
this app's concurrency; the two-site `with SessionLocal()` duplication isn't worth a
helper until a third caller appears.

Gate: ruff clean; DB lane 181 green.
2026-07-26 19:08:29 +02:00
peter 5809f3e431 refactor(quota): own the quota session; Plex per-section commit (R6 S2)
Transaction ownership (C-A5):
- quota.record_usage / log_action write in their OWN SessionLocal(), committed
  immediately, and no longer take/commit the caller's db. The spend stays DURABLE
  regardless of the caller (read-only YouTube paths never commit; a failed job rolls
  back) — under-counting the shared daily budget would risk overspending the real
  YouTube quota — AND quota stops flushing a job's partial state mid-run behind its
  own `except: db.rollback()`. units_used_today switches to a scalar SELECT so
  measured()'s before/after diff sees the separate session's commits (READ COMMITTED).
  Call sites updated (youtube/client.py ×3, routes/search.py ×1).
- plex sync commits PER SECTION (moved the end-of-loop commit inside), so a PlexError
  on a later library keeps the ones already mirrored (idempotent; next run reconciles).

The sync jobs did NOT rely on record_usage's incidental commit for persistence — their
per-item functions (apply_rss_feed, backfill_channel_recent, import_subscriptions,
sync_user_playlists, apply_channel_autotags) all commit internally, so the in-loop
rollback handlers already isolate a failed item. No loop restructuring needed.

DB-lane tests (test_quota_ownership, 4): spend survives a caller rollback while the
caller's uncommitted row does NOT leak; accumulation; measured-style mid-txn read.
Gate: ruff clean; DB lane 180 green.
2026-07-26 15:32:46 +02:00
peter 921c2bf5a4 test(db): harden the DB lane per code-review (R6 S3)
/code-review high on S3 — 3 low findings, 2 fixed, 1 no-change:
- _db_reachable() now probes via a throwaway engine with connect_timeout=3, so a
  slow/firewalled DATABASE_URL can't hang the whole suite at import (the app engine
  has no connect timeout).
- test_plex_facets sets the "configured" gate flag via monkeypatch (auto-restored)
  instead of poking state._configured_cache in a manual finally — no leak risk, and
  the now-unnecessary mark_configured DB write is dropped.
No change: test_plex_jsonb_normalize copying migration 0060's SQL is deliberate —
migrations must stay self-contained (no app imports) and a shipped migration is
immutable, so there's no real drift to guard against.

Gate: ruff clean; DB lane 176 passed; pure-logic 172 passed / 4 skipped (fast).
2026-07-26 15:09:25 +02:00
peter 39f726a5e6 test(db): /facets regression + S3a jsonb-null normalization (R6 S3)
First real customers of the DB lane:
- test_plex_facets: GET /api/plex/facets?scope=show returns 200 (not the
  "cannot extract elements from a scalar" 500) for a show whose genres is a JSONB
  scalar `null` — the 0.53.1 read-guard's regression test. Mutation-checked:
  removing the jsonb_typeof='array' guard turns it red with that exact error.
- migration 0060 (S3a): one-time UPDATE …=NULL WHERE jsonb_typeof=… 'null' over the
  plex_items/plex_shows tag columns, so pre-0.53.1 JSONB-null rows become uniform SQL
  NULL and the read-guards go belt-only. test_plex_jsonb_normalize verifies scalar-null
  → SQL NULL while a real array is preserved.

Gate: ruff clean; DB lane 176 passed; pure-logic lane unchanged (skips the DB tests).
2026-07-26 13:54:42 +02:00
peter 4b6bc54aa8 test(db): stand up the DB-backed pytest lane (R6 S3b)
The suite was pure-logic only (R2 S2b deferred the DB lane). Add an opt-in `db`
fixture (tests/conftest.py) that hands a test a real session against an ephemeral
Postgres; schema is built with `alembic upgrade head` (the real prod schema needs
the unaccent extension + unaccent_simple TS config that create_all can't provide,
and this also proves the migration chain applies). Each test starts from a TRUNCATEd
clean DB so tests that COMMIT — the whole point, for R6 S2's transaction work —
don't leak.

backend/docker-compose.test.yml wires an ephemeral tmpfs pg + the test image. When
no Postgres is reachable (a plain `docker run`), the DB tests SKIP and the pure-logic
lane is unchanged: 172 passed, 2 skipped. With the DB: 174 passed.

test_db_lane.py smoke-proves commit round-trip + TRUNCATE isolation. The gate wiring
(siftlode.sh) lands in the ops repo.
2026-07-26 13:47:07 +02:00
peter 81d21fdeac fix(api): accept fractional progress seconds (R6 S1b code-review round 1)
/code-review high round 1 on S1b — 5 findings, 1 fixed, 4 by-design/deferred:
- FIXED: VideoProgressIn / ItemProgressIn position_seconds & duration_seconds
  typed `int` would 422 a raw float currentTime on the 10s progress checkpoint
  (the old `int(payload.get(...))` truncated it). Back to `float`, truncated in
  the handler; +test. The live frontend floors so this was latent, not live.
By-design / deferred (no change): the 422 detail-array isn't localized by the FE
(filed under R7); PATCH is-not-none (channels/quota, null-safe) vs model_fields_set
(nullable-clear) is a deliberate split; missing-field→422 is the point of the epic;
me/switch_account's numeric-string coercion is more correct and still session-gated.

Gate: ruff clean, pytest 172 green.
2026-07-26 05:39:32 +02:00
peter b0894a9994 test(api): request-model validation for the S1b 500-fixes (R6 S1b)
Pure-logic tests that the three ROADMAP-named 500-producers (channel priority,
tag_id, setup password) and admin quota now reject bad input at the model
boundary, and that the PATCH models separate absent from explicit-null via
model_fields_set. Mutation-checked: reverting a field type turns them red.
2026-07-26 05:09:35 +02:00
peter 66f4ac30d0 refactor(api): Pydantic request models for plex routes + status unification (R6 S1b)
All 14 plex payload:dict endpoints typed (watch-link, collections, native
playlists, item/show/season state & progress). Shared PlexItemKeysIn (bulk add/
remove/reorder) and WatchedIn (show/season). item_progress's manual int()+400
becomes a 422 on a non-number.

Status-code unification (ROADMAP S1):
- the 5 collection write-failure 502s → 422, matching the house "external write
  failed" convention (playlists' YouTube 422). The image/subtitle proxy-fetch 502s
  stay (genuine gateway fetches).
- the field-validation 422s ("title is required", "title and item_rating_key are
  required") → 400, matching every other module's required-field 400.

Ends S1b: no `payload: dict` remains in app/routes. Gate: ruff clean, pytest 164.
2026-07-26 05:08:27 +02:00
peter f4bf1bc18f refactor(api): Pydantic request models for downloads routes (R6 S1b)
All 11 download endpoints off payload:dict. Highlights:
- admin_set_quota: AdminSetQuotaIn — the `int(payload["max_bytes"])` 500 on a
  non-number is now a 422; each field set only when provided (is not None).
- profiles/enqueue/edit: CreateProfileIn / UpdateProfileIn / EnqueueDownloadIn /
  EnqueueEditIn; spec/edit_spec typed as dict (non-object → 422). _resolve_spec now
  takes (profile_id, spec) instead of the raw payload.
- meta/share/links: UpdateDownloadMetaIn / ShareDownloadIn / CreateLinkIn /
  UpdateLinkIn; _link_expiry takes an int|None instead of digging the payload.
PATCH endpoints read model_fields_set so an explicit null still clears a field.

Gate: ruff clean, pytest 164 green.
2026-07-26 05:02:49 +02:00
peter 8bf5026e7c refactor(api): Pydantic request models for playlists routes (R6 S1b)
CreatePlaylistIn / RenamePlaylistIn (PATCH via model_fields_set) / ReorderItemsIn,
plus a shared VideoRefIn(video_id) for both add-video endpoints (watch-later +
add-item). A non-string video_id is now a 422 instead of reaching db.get.

Gate: ruff clean.
2026-07-26 04:58:37 +02:00
peter ab83aae8bb refactor(api): Pydantic request models for setup & admin routes (R6 S1b)
- setup: SetupAdminIn (KILLS the 3rd named 500 — a non-string password reaching
  argon2/len()), SetupTestEmailIn; setup_config stays a typed dict[str, Any] (a
  genuinely dynamic key→value settings map, not a fixed schema).
- admin: AddInviteIn / SetRoleIn / SetSuspendedIn / AddDemoWhitelistIn. The
  role/status business-rule 400s are unchanged; type errors are now 422.

All 3 ROADMAP-named 500-producers now fixed (channels priority + tag_id, setup
password). Gate: ruff clean, pytest 164 green.
2026-07-26 04:56:55 +02:00
peter 99d65cea23 refactor(api): Pydantic request models for saved-views & feed state (R6 S1b)
- saved_views: CreateViewIn / UpdateViewIn / ReorderViewsIn. PATCH presence via
  model_fields_set; filters typed as dict (a non-object is now 422, an explicit
  null on update stays a 400 "filters must be an object").
- feed: VideoStateIn(status) / VideoProgressIn(position_seconds,duration_seconds) —
  the manual int()+400 in progress becomes a 422 on a non-number; the status
  membership 400 and the video 404 are unchanged.

Gate: ruff clean.
2026-07-26 04:53:45 +02:00
peter a622470a28 refactor(api): Pydantic request models for small route modules (R6 S1b)
payload:dict → typed request models across the single/double-endpoint modules:
- tags: TagCreateIn / TagUpdateIn (color:null clears → reads model_fields_set);
  _own_tag now delegates to owned_or_404.
- config: ConfigSetIn(value:Any) — keeps the "Missing 'value'" 400 via model_fields_set.
- me: SwitchAccountIn(user_id:int).
- scheduler: JobIntervalIn / MaintenanceSettingsIn — the manual int()+400 becomes a
  clean 422 on a non-number; the range checks stay 400.
- search: ClearSearchIn(video_ids:list[str]).
- public: WatchUnlockIn(password:str).

Gate: ruff clean, pytest 164 green.
2026-07-26 04:51:25 +02:00
peter d982e8b9ae refactor(api): Pydantic request models for channels routes (R6 S1b)
update_channel: payload:dict → ChannelUpdateIn (priority/hidden/deep_requested,
all optional for PATCH semantics). Kills the `int(payload["priority"])` 500 the
prod report hit — a non-numeric/garbage value now returns 422, not 500.
attach_tag: payload:dict → AttachTagIn(tag_id:int) — a non-int tag_id was a 500
in `db.get(Tag, ...)`; the personal-tag ownership check reuses owned_or_404.
2026-07-26 04:46:40 +02:00
peter 9fe050279e style(api): complete the S1a import-order fix (R6 S1a follow-up)
Round-1 review finding #4 named only audit.py/plex.py, but the same misplaced
`from app.utils import iso` sat in channels/downloads/feed/notifications too — the
round missed them (ruff enforces no import order here, so the gate was silent).
Move them to alphabetical position for consistency. No behaviour change.
2026-07-26 04:44:22 +02:00
peter cf8bbad8a1 refactor(api): code-review fixes — widen iso() to date, fix import order (R6 S1a)
/code-review high round 1 (5 findings):
- iso(dt) now typed `date | None` — MediaAsset.upload_date is a plain `date`, not a
  datetime; the helper's signature was too narrow for the type-contract epic. +test.
- restore alphabetical app.* import order in audit.py and plex.py (the new iso/_common
  imports had been dropped in mid-block).
The other 3 findings are deliberate design choices, left as-is for UAT: push/push-plan
now gate write-scope before the 404 (dependency ordering); the unified 403 message; and
owned_or_404's owner_attr kept for S1b's DownloadShare.to_user_id-style owners.

Gate: ruff clean, pytest 164 green.
2026-07-26 04:25:40 +02:00
peter c5533b0ac5 refactor(api): shared serializer + ownership + write-scope helpers (R6 S1a)
Establish the R6 API-contract infra before the S1b payload:dict migration:
- iso(dt) in utils.py replaces the ~16 hand-rolled `x.isoformat() if x else None`
  serializer idioms across the routes.
- get_or_404 / owned_or_404 in routes/_common.py — one 404 + existence-leak policy;
  plex's _own_playlist_or_404 and saved_views' _own_view now delegate to it.
- require_write_scope dependency in auth.py collapses the inline has_write_scope→403
  checks (channels subscribe/unsubscribe, playlists push/push-plan) that had drifted
  into three different messages into one; the conditional delete_playlist guard stays
  inline (its message was already canonical).

No response-shape change (iso == isoformat for a present value). Gate: ruff clean,
pytest 163 green (+ test_utils for iso).
2026-07-26 04:17:08 +02:00
peter edb0f8a4e1 Merge: promote dev to prod 2026-07-26 03:39:41 +02:00
peter a711b51224 Merge R5: data integrity (downloads & sync) + player big-view overhaul → 0.54.0 2026-07-26 03:38:40 +02:00
peter d3a3f41b63 fix(player): left-align the big-view control bar so overflow scrolls from the start
justify-center + overflow-x-auto stranded the left-end controls (Previous, the
N/M counter) when the bar overflowed a narrow window. justify-start makes the
overflow scroll from the start — and matches the conventional left-aligned
video-player control bar.
2026-07-26 03:34:05 +02:00
peter 375c814bb6 fix(player): dock the big-view control bar in its own strip (UAT follow-up)
The overlaid, auto-hiding bar fought YouTube's seek bar for the same bottom
pixels: while ours showed it covered YT's seek + buttons, and reaching them
meant moving off the area and waiting for our bar to fade — a fiddly, "random"-
feeling dance keyed on mouse movement.

Now the bar is DOCKED and always visible: the video area is inset by the bar's
height (a wrapper around the mount, since YT.Player replaces the mount element
with its iframe and would drop a class set on it), so the bar sits in its own
strip and never overlaps the video. YouTube's seek/controls stay reachable by
hovering the video's bottom edge, just above ours. No auto-hide, no guessing,
no fight. Verified in Chrome: iframe.bottom == bar.top (no overlap), bar opacity 1.
2026-07-26 03:18:15 +02:00
peter 51193105a7 fix(player): first-class maximise/fullscreen view (UAT round)
UAT feedback on the F-maximise view — three fixes:
- Own control bar in the big views (maximise + our fullscreen): previous/next +
  N/M, auto-advance, loop, add-to-playlist, download, mark-watched, exit, and a
  true-fullscreen toggle. Auto-hides on idle. Add/download are hidden in true
  fullscreen (their popovers portal to <body>, outside the fullscreen element).
- Wheel volume works in the big views again: the centre interaction overlay is
  now armed in maximise/fullscreen (it only yields to YouTube's own settings
  menu), so the wheel bubbles to our handler instead of the bare iframe. F also
  offers true fullscreen that WE own (Fullscreen API on the stage), so wheel/
  arrow volume and our controls work inside it too — YouTube's own fullscreen
  button, which we never surface, is not involved.
- Back (mouse/browser) steps out one level at a time via nested history layers
  (useBackToExit): fullscreen -> maximise -> the modal -> closed, instead of
  closing the whole player at once.

Verified in Chrome: wheel volume in maximise + fullscreen, the control bar, and
the full Back ladder (fullscreen -> maximise -> modal -> closed).
2026-07-26 02:48:46 +02:00
peter 475bd7fef8 chore(release): 0.54.0 — R5 data integrity (finalize version + release notes) 2026-07-26 01:40:43 +02:00
peter 4fe40a92e5 Merge dev (0.53.1 Plex hotfix) into r5-data-integrity 2026-07-26 01:39:52 +02:00
peter 3a982b8be8 Merge: promote dev to prod 2026-07-26 01:24:30 +02:00
peter 543842e7fb Merge: fix /facets 500 on a metadata-poor Plex library (0.53.1) 2026-07-26 01:23:09 +02:00
peter 58f836bcd8 fix(plex): stop /facets 500 on a metadata-poor library
A mirrored Shows library whose items have no genres stored `genres` as JSON
`null` — SQLAlchemy's JSONB persists a Python None as a JSON scalar, not SQL
NULL. /facets unnested it with jsonb_array_elements_text(), which raises
"cannot extract elements from a scalar", so a single bare library 500'd the
whole Plex module on open (the grid loads, the filter sidebar's facets call dies).

- Guard the /facets genre unnest with jsonb_typeof(genres)='array', mirroring
  the guard people.py already applies to the same columns.
- Persist the JSONB tag columns (genres/directors/cast_names/collection_keys on
  plex_items and plex_shows) with none_as_null=True, so an empty list round-trips
  as SQL NULL and self-heals on the next sync — closing the trap for any future
  unnest.

Verified against the real prod row (DumaTV show, genres = JSON null): the
unguarded query errors, the guarded one returns cleanly.
2026-07-25 18:22:07 +02:00
peter 5fe6d32d38 fix(plex): give the sweep's boot-time cutoff cross-clock slack
Found by running the round-8 fix in the real container instead of trusting the
unit test: a directory created 2ms AFTER `_PROCESS_START` was stamped with an
mtime 2ms BEFORE it, so the sweep deleted it as a leftover. The two sides come
from different clocks — a file's mtime from the kernel's coarse clock (one timer
tick of lag), `time.time()` from the fine one — and the unit test could not see it
because it supplies an artificial cutoff.

The window is small and lands exactly where it hurts: the sweep runs at boot, so
the session at risk is one started in the first instants after startup — the case
the guard exists for. Keep-side slack of 2s; the cost is that a leftover from the
last seconds before a restart survives until the next one.

Verified in the container: a backdated leftover is swept, a directory created by
this run is kept, and a registered live session with an old mtime is kept — 1 of 3.
Test pins the skew (mutation-checked: with the grace at 0 it goes red).
2026-07-24 04:52:41 +02:00
peter bdf7c03b59 fix(r5): address the eighth /code-review high round — 7 findings
The worst one was, again, the previous round's own fix.

- Round 7 stopped awaiting the HLS sweep so startup could serve immediately — which
  also made it run CONCURRENTLY with playback, and `sweep_orphans` snapshotted the
  live-session set BEFORE listing the root and rmtree'd outside the lock. Since
  `start_session` reuses a directory path for the same (key, offset, tag), a stale
  name can become a live session at any moment. Two guards now: nothing whose mtime
  is newer than this process's start is ever deleted, and the live check + rmtree
  happen together under the lock, one directory at a time.
- The sweep task is awaited after cancel, so shutdown can't leave it pending.
- `_rate_limiter` used a truthiness test on its timestamp, so a `now()` of exactly
  0.0 read as "never ran" — unreachable in production, but `now` exists to be
  injected and a test clock starting at 0 is the obvious choice.
- The RSS total-outage error now carries the first underlying error: "all N channels
  failed" is also what ONE dead feed id looks like on a small instance, and a red
  card the user can't act on is its own kind of dishonest.
- The WebKit fullscreen handling moved into `lib/fullscreen` and PlexPlayer uses it
  too — it had the same unprefixed-only reads in three places, including an Escape
  branch that would have closed the whole player instead of leaving fullscreen.
- The `--max` CSS comment claimed `inset: 0` did the sizing; Tailwind's `w-full` is
  still on the element and wins. Comment now states the real mechanism.

Verified in real Chrome (the pane can't composite or do native fullscreen):
- F fills the 1920x889 viewport; the exit pill renders top-right on the letterbox
  band and overlaps NO YouTube control — the collision this round suspected does not
  reproduce in this embed (its own buttons sit bottom-left / bottom-right).
- Four real ArrowDown presses while maximised: persisted volume 100 -> 80, with the
  level flash visible on screen.
- A real click on the exit pill un-maximises and returns focus to the card.
- lib/fullscreen driven from a real click: enter (innerHeight 889 -> 1024), the
  change listener fires exactly once per transition, exit returns to 889.

Backend suite 150 -> 158; the new sweep tests are mutation-checked, and they read
their cutoff from the filesystem rather than a clock (the container VM and the
bind-mounted host disagree by enough to make a clock-based assertion flaky).
2026-07-24 04:44:20 +02:00
peter a5336d20e0 fix(r5): address the seventh /code-review high round — 7 findings
Five of them sit where round 6 turned F from a Fullscreen API call into a CSS
maximise: the guards around it still asked `isFullscreen`, which that pivot made
permanently false.

- Arrow-key volume was dead in the maximised view: the scroll-deference checked
  only `isFullscreenRef`, so the (now hidden but still scrollable) card kept
  swallowing Up/Down. Both flags now count as "the video fills the screen".
  Measured: 87px of scroll room on the focused card, ArrowDown moves 80 -> 75
  and scrolls nothing.
- The maximised view had no way out once the pointer touched the video. Reaching
  YouTube's controls means clicking into the cross-origin iframe, which takes
  keyboard focus with it, and the re-arm paths (stage mouseleave, window focus)
  need the pointer to leave the browser entirely while the stage fills it. So
  the exit now lives on screen, in our DOM: a button that un-maximises AND pulls
  focus back, re-arming every shortcut.
- WebKit fires only `webkitfullscreenchange`/`webkitFullscreenElement`, so on
  Safari/iPadOS YouTube's own fullscreen never registered — no overlay yield, no
  focus reclaim. One `fullscreenEl()` reader, both event spellings.
- The stage claimed `z-index: 9999`, the glass tuner's reserved level, for a job
  that only needs to beat the modal chrome it is nested in (its siblings top out
  at z-menu). Dropped to rail-level; nothing escapes the modal's stacking
  context either way.
- `inset: 0` already sizes a fixed layer — the inherited `100vw`/`100vh`
  over-constrained it, which is how a classic scrollbar gets a horizontal
  overflow and a mobile URL bar hides the bottom edge (YouTube's control bar).
- The shortcut hint still promised "F: fullscreen" in both locales.

Backend, both "the fix stopped one layer short":
- The startup HLS sweep was awaited inside lifespan, so uvicorn still served
  nothing until it finished — a thread doesn't help when nothing is listening
  yet. Fire-and-forget task instead, cancelled on shutdown. Measured with 200
  planted segments: "Application startup complete" now precedes "swept 1".
- An RSS poll where every channel failed returned normally, so the scheduler
  recorded `ok` with the outage buried in the summary string. `failed == total`
  now raises: a run that reached nothing is a failed run.

Suites: backend 147 -> 150.
2026-07-24 03:41:29 +02:00
peter aae37591cb fix(player): reclaim keyboard focus when fullscreen ends
UAT found a dead end: F -> YouTube's fullscreen button -> Esc left the player
maximised with no way back, and a further F produced a second, control-less
fullscreen with the wheel driving YouTube's own volume box.

Measured cause: reaching YouTube's control bar means clicking INTO the player,
which moves keyboard focus into the cross-origin iframe — and it stays there.
Our window-level shortcuts then never fire (document.activeElement === IFRAME),
so F went to YouTube instead of toggling our maximise.

Fix: on fullscreenchange, when fullscreen ends, focus the modal card again.

Verified in real Chrome, step by step:
- F -> maximised, no browser lock, focus on our card
- YouTube's own FS button -> real fullscreen (innerHeight 889 -> 1024)
- leaving fullscreen -> focus back on our card (was: still the iframe)
- F -> back to the normal modal view (was: a second fullscreen with no controls)
2026-07-24 03:07:46 +02:00
peter 45654fc7ec fix(player): F maximises without the Fullscreen API, so YouTube's own controls work
Root cause, measured in Chrome/Brave/Firefox alike (so: spec behaviour, not a
browser bug): a cross-origin embed reads its fullscreen state from ITS OWN
document. While we hold the browser's fullscreen lock — on our wrapper OR on the
iframe element — the YouTube player never learns it is fullscreen, so its own
fullscreen button stays in the "enter" state and clicking it changes nothing
visible. Every other YouTube control works, which is exactly what the user saw.

So we stop taking the lock: F now toggles a CSS layer (`player-stage--max`) that
fills the viewport. The page stays non-fullscreen, so the embed's own fullscreen
button behaves natively, and our own overlays and shortcuts keep working because
our DOM is what's on screen. Escape steps out of maximise before closing.

Verified end to end in real Chrome:
- F -> stage 1920x889, document.fullscreenElement null (no lock taken)
- YouTube's own fullscreen button -> real fullscreen (innerHeight 889 -> 1024,
  fullscreenElement becomes the IFRAME) and its icon flips to "exit"
- clicking it again -> back to the maximised view, our class still applied
- wheel volume while maximised: 100 -> 85, with the level flash visible

Drops the "YouTube fullscreen" toolbar button added earlier today: with the lock
gone, YouTube's own button does that job correctly.
2026-07-24 02:46:42 +02:00
peter d8d82e47d0 fix(player): give YouTube's own fullscreen a working path + round-6 findings
The reported bug: after F, the embed's own fullscreen button is dead. Root cause
measured in Chrome — the browser's fullscreen lock sits on OUR wrapper, so
YouTube's button would need a nested request from a cross-origin frame. Handing
the lock to the iframe fixes it, but `iframe.requestFullscreen()` only resolves
from a CLICK (from a key press the promise stays pending forever, and awaiting
it spends the activation so a fallback is denied too).

So: F keeps fullscreening our wrapper (unchanged, our overlays stay visible) and
a new toolbar button hands the lock to the iframe — verified in real Chrome:
document.fullscreenElement is the IFRAME at 1920x1024, i.e. a plain YouTube
fullscreen where its own controls work.

Round-6 review findings, all fixed:
- one _rate_limiter + one _cancel_poll shared by the yt-dlp hook and run_ffmpeg
  (was three hand-rolled throttles; cancel latency differed 2s vs 1s)
- stepVolume takes {delta, fallback} — two adjacent number args were silently
  transposable
- volume gestures before the player is ready are honoured instead of dropped
- normalizeVolume's fallback is typed unknown (its guard was unreachable) and
  applyVolume takes a number again
- the throttle tests inject a clock instead of patching global time.monotonic
2026-07-24 02:16:58 +02:00
peter c4942eb590 fix(r5): address the fifth /code-review high round — 6 findings
- run_ffmpeg stops the process from ONE exit path (`except BaseException`), so
  an exception out of on_progress (a DB write) can no longer orphan a live
  ffmpeg after a 10s join on its pipes; _stop is now a no-op on a dead process
- both run_ffmpeg callbacks are throttled to ~1s (they were a DB round-trip per
  output line: ~10^5 queries on a long re-encode), mirroring the download hook
- volume arithmetic extracted to lib/playerVolume with unit tests; a corrupt
  level now falls back to the LAST GOOD one instead of full blast, and the
  sanitising happens where the stored value enters
- RSS is back to two except clauses (the shared tail already sits after the try)
- new tests: callback-exception cleanup, the two throttles; the fake Popen grew
  poll() and its wait count is now asserted

Mutation-checked: each new group goes red when its fix is reverted.
2026-07-24 01:50:50 +02:00
peter fc82c8b47a fix(r5): address the fourth /code-review high round — 6 findings
- Feed memoizes onClose (like Playlists already did): the inline closure re-ran
  PlayerModal's keydown effect on every Feed render, which since the debounce
  landed also flushed the volume write and re-stole focus
- the player reads its volume with readAccountMerged again — a partial stored
  object made the level undefined -> NaN -> a persisted null; applyVolume also
  refuses a non-finite level outright
- that read moved into a lazy useState initializer (was running per render)
- the two RSS except branches share one tail, so a counter can't drift again
- run_ffmpeg tests cover the SIGTERM->SIGKILL escalation and the lingering-
  process path; dropped an assertion that only restated the fixture
- storage.ts documents the actual { volume } shape (0 IS muted)
2026-07-24 00:45:42 +02:00
peter c0db80d333 fix(r5): address the third /code-review high round — 7 findings
- the sweep guard anchors the rating_key to digits: the round-2 widening had
  degenerated to "anything ending in _<digits>" (backup_2024, release_10)
- RSS counts apply-side failures too, so a read-only database no longer reports
  a wholly failed poll as "ok — new=0, failed=0"
- the player reads/writes its volume directly (readAccount/writeAccount): the
  persisted-object hook rendered nothing and cost a second write per settle
- new tests: the sweep guard's accept/reject table, and run_ffmpeg's threading
  (drain, cancel while silent, stall watchdog, stderr tail on a nonzero exit)
- the breaker sentinel is checked one way (isinstance) at all four sites
- run_rss_poll is typed dict[str, int]

All three new test groups were mutation-checked: reverting each fix turns them
red (or, for the cancel/stall pair, hangs — which is the bug itself).
2026-07-24 00:17:09 +02:00
peter 53ff61653d fix(r5): address the second /code-review high round — 7 findings
- the volume keys no longer defer to a scrollable card while in fullscreen
  (mirrored into a ref: the keydown handler is bound once and state would be stale)
- the debounced volume flush writes straight to storage; persisting inside a
  setState updater is not guaranteed to run for an unmounting component
- the HLS sweep also matches the original {key}_{start} directory naming, which
  is what the oldest leftovers on disk actually use
- run_rss_poll returns {new, failed} so an egress outage stops rendering as
  "ok — 0" in the scheduler card and the audit log; /api/sync/rss reports it too
- drop the unused poll_rss_channel (it silently gained a new exception)
- _Breaker.run is generic, so a call site keeps its item-id type
- test helper instead of the generator-throw idiom
2026-07-23 23:59:58 +02:00
peter 2a7f49c981 fix(r5): address the /code-review high round — 10 findings
- HLS sweep only deletes directories matching the session-name shape; the root
  is admin-configurable and may be a shared path
- playlist unwrap picks the first entry that actually downloaded, not entries[0]
- concat quoting keeps backslashes literal (ffmpeg treats them so inside '')
- ArrowUp/Down defer while the focused element can still scroll that way
- the staging fallback skips yt-dlp working files (.part-Frag/.ytdl/.temp/.fNNN)
- the boot HLS sweep runs off the event loop
- volume persistence is debounced (was a setItem per wheel notch) and a spin no
  longer loses notches to the iframe's lagging getVolume
- _Breaker.run owns the whole open/call/record protocol; four call sites shrink
- should_prune extracted and unit-tested, plus RSS error-vs-empty tests
- the worker exits non-zero when the schema never lands
2026-07-23 23:25:06 +02:00
peter 5130584d0a fix(player): R5 S3 — fullscreen controls, volume memory, Ctrl+wheel zoom
- the interaction overlay yields fully in fullscreen: YouTube only reveals its
  control bar while the iframe itself sees the mouse, so after pressing F the
  native fullscreen button was unreachable
- remember the volume per account (a YT.Player is created per video and always
  starts at 100), plus ArrowUp/ArrowDown volume keys that work in fullscreen
- Ctrl/Cmd+wheel falls through to the browser's zoom instead of the volume
2026-07-23 22:56:42 +02:00
peter 63a36db116 fix(downloads): probe codecs from ffprobe JSON, not its flat output
ffprobe prints codec_name BEFORE codec_type, so the line parser attributed every
name to the previous stream: a plain H.264+AAC mp4 probed as (aac, None) and the
browser-playability check re-encoded a perfectly good file on EVERY download.
Verified on localdev — the same source now stores without a re-encode pass.
2026-07-23 22:56:42 +02:00
peter 4209f6bb24 fix(sync): R5 S2 — sync guards
- never prune subscriptions on an empty fetch: one anomalous empty-but-200
  response wiped every subscription (and its deep_requested flag) in one run
- push_playlist: stop after 5 consecutive write failures — a quota-dead or
  scope-revoked account otherwise burns 50 units per remaining item
- RSS: raise RssError instead of returning [] on a transport/HTTP failure, so a
  failed poll no longer stamps last_rss_at and a 404 feed stops looking healthy
- worker: exit when the schema never appears instead of "continuing" into a
  crash one call later
2026-07-23 22:36:33 +02:00
peter 2f46947dcf fix(downloads): R5 S1 — downloads robustness
- pick the produced file safely: Path("") is Path("."), which passes .exists(),
  so a missing yt-dlp filepath skipped the staging fallback and crashed in
  with_name ("PosixPath('.') has an empty name") on non-YouTube sources
- unwrap a playlist/multi_video extraction to the entry that carries the media
- run_ffmpeg: drain stderr while it runs (a full 64KB pipe blocked ffmpeg
  forever and hung the worker thread), 1s cancel polling, stall watchdog and a
  bounded wait on exit
- escape apostrophes/backslashes in the ffmpeg concat list (pre-0.51.0 names)
- sweep orphaned HLS segment dirs at startup (nothing else ever reaped them)
2026-07-23 22:32:25 +02:00
peter 9556b33648 Merge: promote dev to prod 2026-07-23 04:29:12 +02:00
peter 08049d2aa5 release: 0.53.0 — R4 auth funnel & abuse hardening 2026-07-23 04:27:20 +02:00
peter 81fc6c791c Merge R4: auth funnel & abuse hardening
S1 dead ends (password-reset verifies email, resend-verification endpoint+CTA,
OAuth-cancel redirect + marker pop before exchange) · dev OAuth returns to the
origin that started it · S2 E2EE forgotten-passphrase reset (key-reset endpoint,
KeyGate danger-confirm, partner-key refresh on decrypt failure) · S3 abuse & leak
hardening (password_version share grants [migration 0059], _messageable filters
on share endpoints, GET /keys MB2 anti-enum, register IntegrityError no-op,
atomic scheduler single-run claim) · share-link password change/clear UI.
2026-07-23 04:24:11 +02:00
peter 10da69c1b9 fix(downloads): confirm before clearing a link password; fix placeholder
Review follow-up on the share-link password UI:
- Removing a link's password makes it publicly watchable, a protection
  downgrade; gate it behind a danger-confirm like the neighbouring Revoke,
  spelling out that anyone with the URL can then watch without a password.
- When setting a first password the input reused the create-form "Password
  (optional)" placeholder, but it's required here (Save stays disabled until
  non-empty); use the "Set a password" wording instead.
2026-07-23 04:15:39 +02:00
peter c8cbbb6189 feat(downloads): change/clear a share link's password from the UI
C-3.7 made a password rotation invalidate outstanding grants, but ShareDialog
only let you set a password at link creation — there was no way to rotate or
clear it afterward, so the protection wasn't reachable. Add a lock control to
each existing link row: set / change / remove the password via the existing
update_link endpoint (which bumps password_version and revokes old grants).
2026-07-23 04:03:11 +02:00
peter e49b77dbcd fix(scheduler): move the single-run claim into _job (cover both paths)
The atomic claim added in trigger_job only covered the manual path; a scheduled
fire sets running via _job without claiming, so a manual trigger racing it could
still double-run, and the claim (set outside _job) could leak if _job threw
before its own cleanup. Move _claim_running to the top of _job — the one point
both the scheduled fire and the manual trigger pass through — and release it in
_job's finally, so exactly one run wins whichever path it comes from and a
failure can never wedge the job "running". trigger_job's _is_running check is now
just a best-effort fast answer for the UI. Unit-test _claim_running (and
is_messageable_user).
2026-07-23 03:50:04 +02:00
peter 733b41fd86 feat(security): R4 S3 — abuse & leak hardening
- C-3.7: fold a per-link password_version into the signed watch grant (migration
  0059 adds the column) and bump it on every password change, so rotating a share
  link's password invalidates outstanding grants at once instead of letting them
  live out the 6h TTL. make_grant/check_grant take the version; update_link bumps.
- C-3.8: the share recipient picker and share-by-email now reuse the messageable
  filter (not-demo AND active AND not-suspended) instead of only excluding demo,
  so suspended/deactivated addresses aren't leaked or reachable as targets.
- C-3.9: GET /keys/{user_id} adopts get_thread's MB2 guard — a non-messageable
  target with no shared history returns the same "User not found" as a missing id,
  so it can't be probed to enumerate users or fetch suspended users' keys.
- C-3.12: _register_account catches IntegrityError on the insert (the select-then-
  insert TOCTOU) and treats it as the already-registered no-op.
- C-3.13: trigger_job claims the running flag atomically under _activity_lock
  (compare-and-set) instead of a bare check, closing the double-start window.
- Move _messageable/is_messageable_user into a shared app/userscope.py so downloads
  doesn't import the messages route module.
2026-07-23 03:38:57 +02:00
peter dce191f1c0 fix(messages): re-review — degrade gracefully when partner has no key
The decrypt-pass refactor hoisted partnerPub() above the loop and outside the
try, so a keyless partner (one who reset and hasn't re-set-up) or a fetch error
rejected the whole pass unhandled (setPlain never ran). Fetch the key lazily
inside the try and once, so an empty thread never fetches and a fetch failure
degrades to "can't decrypt" as before. Document the send-path staleness (a first
proactive send after a partner's key rotation uses the stale cached key) as a
known limitation alongside refreshPartnerPub.
2026-07-23 03:21:44 +02:00
peter 26e6562921 fix(messages): address S2 review — partner key refresh + reactive me
- On a decrypt failure, refresh the partner's cached public key (and drop the
  stale derived conversation key) once per pass and retry. A passphrase reset
  rotates a keypair, so a partner who cached the old public key would otherwise
  encrypt undecryptable messages until a reload; the first failed decrypt now
  self-heals reads AND future sends. (pubCache was assumed set-once.)
- KeyGate reads has_password via a reactive useQuery(['me'], enabled:false)
  passive observer instead of a one-shot getQueryData, so the reset password
  field stays correct even if me resolves after mount.
- Document the remaining multi-device staleness (a reset on another device
  leaves this one 'ready' with the dead key) as a known limitation in useKeyState.
2026-07-23 03:11:35 +02:00
peter 1119eb0218 feat(messages): R4 S2 — forgotten-passphrase reset for E2EE
The set-once key guard made a lost passphrase a permanent wall (409 on re-setup,
KeyGate offered only unlock/setup). Add an escape hatch:

- POST /api/messages/keys/reset drops the user's MessageKey so a new passphrase
  can be set. Because every conversation key is derived from the keypair,
  replacing it orphans all stored ciphertext in both directions, so the now-dead
  `user` messages are deleted too (clean slate; system messages untouched).
  Password accounts must re-verify (a hijacked session must not silently reset
  messaging and read future messages); Google-only accounts rely on the session
  + the client danger-confirm. Rate-limited per user.
- KeyGate gains a "Forgot passphrase?" path in unlock mode → a danger reset view
  (spells out the permanent history loss; a current-password field for password
  accounts) → resets and returns to a fresh setup.
- e2ee.resetLocal wipes this device's key + derived conversation keys + the
  IndexedDB copy; api.resetMessageKey; HU/EN strings.
2026-07-23 02:57:27 +02:00
peter 1af679bfc5 fix(dev): support 127.0.0.1 dev OAuth end-to-end
Re-add the 127.0.0.1 callbacks to the dev host allowlist (now registered in the
Google console alongside the localhost ones). Deriving the redirect_uri from the
request host keeps the whole round-trip — login, Google callback, and the
session/state cookie — on one origin; a cross-host fallback (127.0.0.1 → the
fixed localhost callback) would silently lose the OAuth state, since a session
cookie set on 127.0.0.1 is never sent to localhost. Clarify that in the comment
and cover all four hosts in the unit test.
2026-07-23 01:35:53 +02:00
peter bacef11e33 fix(auth): address second-round review findings
- OAuth-cancel feedback now keys on the live session, not link_uid: ANY
  signed-in cancel (add-another-account via /auth/login, link, or upgrade)
  redirects to /?oauth=cancelled and gets a neutral "Google sign-in was
  cancelled" toast — the add-account path set no marker and was silent, and
  the shared ?link= message wrongly called an upgrade "account linking"
- drop 127.0.0.1 from the dev OAuth host allowlist: only the localhost
  callbacks are registered in the Google console, so a 127.0.0.1 origin now
  falls back to the fixed callback instead of a redirect_uri_mismatch
- Welcome: extract the shared goResend handler (banner CTA + sign-in link) and
  merge the two adjacent signin-only blocks
2026-07-23 01:24:34 +02:00
peter c837b59373 fix(auth): address R4 S1 code-review findings
- link/upgrade OAuth cancel now redirects to /?link=cancelled (a channel App.tsx
  surfaces as a toast) instead of the pre-auth /?login=oauth_cancelled banner a
  signed-in user never sees; a plain sign-in cancel is unchanged
- the login banner (suspended / oauth_cancelled) is now state, cleared on the
  first deliberate action like the verify banner — no more stale lingering
- add a "Didn't get the verification email?" resend entry on the sign-in screen
  so a never-arrived verification mail isn't a dead end (was only reachable from
  the expired-link banner)
- unit-test _oauth_redirect_uri (dev-host derive, unknown-host fallback, prod
  ignores the Host header — the injection guard)
- collapse the 5-deep title/button ternaries into Record<Mode,string> maps
2026-07-23 01:04:09 +02:00
peter b4d2ad75e0 feat(dev): OAuth redirect returns to the origin that started it
A Google login started on the Vite dev server (:5173) always came back to the
fixed :8080 callback, because login/link/upgrade passed the static
settings.oauth_redirect_url. Derive the redirect_uri from the request origin
instead, but only in local dev and only for a known dev-host allowlist —
production still uses the fixed configured URL and never trusts the Host header
(host-injection guard). Set the Vite proxy to changeOrigin:false so it forwards
the real Host:localhost:5173 (it was rewriting it to the 127.0.0.1:8080 target).
Both callbacks must be registered in the Google console.
2026-07-23 00:48:20 +02:00