Commit Graph
256 Commits
Author SHA1 Message Date
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 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 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 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
peter a851fb213b feat(auth): R4 S1 — fix auth dead ends
- password reset also sets email_verified (the mail proves the mailbox),
  rescuing an account that never clicked the verify link; is_active (admin
  approval) is deliberately left untouched
- add POST /auth/verify/resend (rate-limited, off-response-path, anti-enum)
  + a "resend" mode/CTA on the verifyInvalid banner, so an expired/lost
  verification link is no longer a dead end (re-register was a silent no-op)
- OAuth cancel/deny now redirects to /?login=oauth_cancelled with a friendly
  banner instead of a raw 400 JSON page; pop the oauth_link markers BEFORE the
  token exchange so an aborted link flow can't poison the next clean sign-in
2026-07-23 00:15:26 +02:00
peter fa35925cc6 fix(downloads): shell-safe names, refresh display_name on re-download, GC orphan sweep
Follow-up to user prod testing of 0.51.0:
- re-download now clears job.display_name so the card title + Content-Disposition filename are
  re-derived from the freshly-cleaned title (previously an auto-name from an older un-cleaned
  title — still carrying a "… views · … reactions |" prefix — survived the rebuild).
- on-disk AND served names are now a strict shell-safe slug: only [A-Za-z0-9._-] survive; every
  other char (the "!" the user saw, plus ( ) [ ] & $ ' " ; | ? * …) collapses to "_". The id no
  longer uses [brackets] (glob metacharacters). Shared _slug helper for sanitize + display_filename.
- GC gains a 5th pass: sweep on-disk files/dirs no ready asset owns (old accented channel dirs, a
  poster from a superseded naming scheme left after a re-download), skipping the dot system trees
  and anything newer than an hour so an in-flight download is never touched.
- Tests: slug strips !/$/()[]; rel_path is bracket-free; orphan-sweep keeps owned/system/fresh files.
2026-07-22 03:18:14 +02:00
peter ebfddf330a fix(downloads): second-round review fixes (fork on re-download, title fallbacks)
- redownload now ALWAYS rebuilds into a fresh private asset instead of ever reusing a shared
  cached file: the cache sig is salted with job_id + current asset_id, so the fork is distinct
  from both the dedup pool and the job's own current asset. This resolves the co-held silent
  no-op (finding #1) — a shared asset is left intact for its other holders while THIS job
  re-fetches its own copy. Resolving the fork BEFORE releasing the old hold (and the unique
  salted key) also removes the IntegrityError-rollback-after-file-delete window (finding #2).
- format_sig gains an optional `salt` for that fork identity.
- titles: don't consume the space after `|`, so a "stats | #hashtags" remainder keeps the
  leading whitespace _TRAILING_HASHTAGS needs — and fall back to the prefix+hashtag-stripped
  text, so neither the stats nor the SEO hashtags are resurrected when a title empties (#3).
- worker: early-fill asset.title falls back to the video id so a title that cleaned to empty
  doesn't show a blank card while downloading (#5).
- Tests for the salt fork and the hashtag-remainder case.

(Finding #4 — quota on re-download — intentionally not changed: a re-download reuses the
existing job and re-fetches ~the same bytes, so calling check_enqueue would wrongly reject on
the job-count cap while footprint is unchanged.)
2026-07-22 02:48:16 +02:00
peter cae5702fbb fix(downloads): address code-review findings on the R2.5 mini-epic
- redownload: never clobber a co-held (deduped) asset. Drop the force-reset-to-pending;
  _release_asset already deletes+recreates a solo asset (genuine re-fetch), while a shared
  ready asset is left intact and the re-queued job re-points to it via _finish_from_cache —
  so one user's re-download can't delete another's file mid-serve (finding #1).
- redownload: reset queue_pos to the tail so a re-download doesn't front-run newer queued
  jobs on the worker's `ORDER BY queue_pos` claim (finding #3). Made service.next_queue_pos public.
- formats: video-only mp4 selector prefers every video-only option before any progressive
  `best`, so a "Video only" download stays audio-free whenever a video-only stream exists (#2).
- titles: a title that is only the engagement prefix no longer resurrects the stripped stats
  (return the stripped text / empty, not the raw) (#4).
- worker: probe the file's duration when yt-dlp gave none, so a long re-encode shows real
  progress instead of a frozen 0% (#5); tighten the ensure_browser_playable docstring to its
  actual mp4-profile scope (#6).
2026-07-22 02:21:05 +02:00
peter e46f215ada feat(downloads): ASCII-slug names, universal browser playability, force re-download
Mini-epic "Downloads correctness & UX" (S1–S3), on top of the iOS-AV1 + 720p fixes.

S1 — naming & titles:
- ASCII-fold on-disk dir/file names AND the served (Content-Disposition) filename: no
  spaces (underscore-joined), no accents (á→a, ő→o, ß→ss), pure ASCII — portable and free
  of the `?` mojibake accented names show in non-UTF-8 tools. (Forward-only; existing files
  clean up via re-download.)
- Strip a leading social engagement prefix ("1.6M views · 66K reactions | …") from titles.

S2 — universal browser playability (generalizes the iOS re-encode):
- ensure_browser_playable guarantees the stored file is H.264 + AAC/MP3 in mp4 — the only
  combo every browser decodes. Re-encode just the offending stream(s) (video for AV1/VP9/HEVC,
  audio for Opus/etc), or remux when only the container is wrong. Gated on the mp4 compat
  profile (an explicit vcodec / non-mp4 custom profile opts out).

S3 — force re-download (preserves share links):
- POST /downloads/{job_id}/redownload keeps the job row so its public DownloadLink survives
  and resolves to the freshly-downloaded file; deletes the old file and re-fetches under the
  current rules. A failed re-download leaves the job in error (the intended broken-link case).
- Library "Re-download" action (icon + confirm), api method, hu/en strings.

Tests: naming/title-strip (test_naming), browser_transcode_flags matrix; existing
download_filename expectation updated for the underscore policy.
2026-07-22 02:04:27 +02:00
peter 8fd0049dce fix(downloads): make shared files iOS-playable + default to 720p
Shared /watch links showed a "broken play button" on iPad (any iOS
browser — all forced onto WebKit) while playing fine on desktop Chrome.
Root cause: the stored mp4's video codec was AV1 (confirmed via ffprobe),
which WebKit can't decode. The existing H.264 guard was only a
format_sort *preference*, so a non-YouTube source (Facebook) that ships
AV1 as its best video-only stream slipped through.

- B1: for the mp4 compat profile, prefer H.264 at the SELECTION level
  with a progressive fallback (bestvideo[vcodec^=avc1]+bestaudio /
  best[vcodec^=avc1] / anything) so an avc1 progressive is chosen over
  an AV1-only video-only stream.
- B2: re-encode the rare AV1/VP9-only result to H.264+AAC (+faststart)
  in the worker before storing, gated on the compat profile; corrects
  the asset codec metadata to match.
- Bump _SIG_VERSION 2->3 so cached AV1 assets re-derive.
- Default download preset is now 720p (migration 0058 re-seeds builtins
  720p-first; ProfileEditor BLANK 1080->720).
- Tests for the selector, compat opt-outs, and the transcode predicate.
2026-07-22 01:19:31 +02:00
peter f76cd6ef36 fix(quota): don't let the demo account persist deep_requested (S1, finding 2)
The demo account is shared, so writing deep_requested onto its subscription polluted
shared state (harmless today — the scheduler's deep paths are human-only — but a
future scheduler path trusting the flag would leak quota). Guard the flag WRITE at the
source (not user.is_demo), which also makes deep_turned_on always False for demo, so
the redundant is_demo check on the immediate backfill is removed.
2026-07-22 00:00:44 +02:00
peter cb4d06bb3d fix(downloads): cap the media filename by BYTES, not characters
/code-review (S1, finding 1): sanitize truncated the title at 120 CHARACTERS, but a
path component's hard limit is 255 BYTES and rel_path packs channel + date + id + ext
into that same component — so a long accented/multi-byte title (Hungarian titles are
common here) could already overflow to ENAMETOOLONG, and the new _a{id} suffix shrank
the margin further. sanitize now truncates on a UTF-8 boundary by byte length, so the
title (≤120B) + channel (≤80B) + fixed parts stay comfortably under 255B. Two tests:
byte-cap on accented input, and every rel_path component ≤255B for a pathological
long-accented channel+title+asset_id.
2026-07-22 00:00:44 +02:00
peter 0317f53b1e fix(downloads): disambiguate the media path by asset id (C-3.1)
A MediaAsset is unique on (source_kind, source_ref, format_sig), so the same video in
two formats is two assets — but rel_path keyed only on video_id, so they computed the
same path: the second download silently overwrote the first, and deleting either
removed the shared file. rel_path now takes an optional asset_id and appends "_a{id}"
(which Plex ignores — the .nfo sidecar carries the metadata); the worker passes the
asset id. Only affects NEW downloads (existing assets keep their stored rel_path).
Covered by a new test (two ids → two paths; omitted → the plain name, unchanged).
2026-07-21 21:47:45 +02:00
peter 0dfc5e9ea2 fix(quota): keep the demo account off three quota-burning paths (C-3.6)
The demo account could spend real YouTube quota where the human-only guard was
missing (the channel_detail enrichment already had it):
- channels PATCH: an immediate run_recent_backfill when deep_requested is turned on;
- discovery: the channel-detail enrich on the Discover tab;
- feed get_video_detail: the off-catalog videos.list lookup for a video we don't
  store (the DB-hit path stays free; demo now gets a 404 for unknown videos).
2026-07-21 21:47:45 +02:00
peter 3dfa35f396 perf(notifications): bulk-delete trimmed read notifications (C-4.1)
trim_read looped a db.get()+db.delete() per id (N+1). The ids are already in hand, so
one DELETE ... WHERE id IN (...) replaces the loop.
2026-07-21 21:47:26 +02:00
peter dd99206ed2 fix(worker): don't revive a cancelled job when requeuing a busy asset (C-3.10)
The asset-busy and claim-fail branches wrote status='queued' unconditionally, so a
pause/cancel the route had just written was clobbered back to queued and the job
silently revived. New _requeue_if_running does the requeue as a conditional UPDATE
(...WHERE status='running'), a no-op once the route has moved the job off 'running'.
2026-07-21 21:47:26 +02:00
peter 4f31ae797d fix(auth): rate-limit request-access + close two token races (C-3.5, C-3.11)
C-3.5: /auth/request-access had no rate limiter (unlike register/login/reset/demo)
and answered "approved" for an allow-listed email — a public enumeration oracle for
the allow-list, and an unthrottled invite/admin-mail firehose. It now rate-limits per
client IP and returns a uniform "pending" for every outcome (allowed, pending, new,
throttled); an already-allowed user can still just sign in with Google.

C-3.11: _consume_token was a read-check-write, so two concurrent requests could both
consume the same verify/reset token. It's now a single conditional UPDATE
(...WHERE used_at IS NULL AND expires_at >= now RETURNING user_id) — only the request
that flips used_at gets a user back.
2026-07-21 21:47:26 +02:00
peter 4bae9113e4 test(backend): pin the traversal guard against a string-prefix bypass
Review round 1: the two existing safe_abs_path escape tests both pass even against
the classic buggy `str(path).startswith(str(root))` containment check, so a
regression to that bug would have shipped green. Add the one input that
distinguishes the correct `root not in path.parents` guard: a sibling dir whose name
prefixes the root (/root vs /rootx). Mutation-verified — swapping the guard to
startswith now fails this test. Also corrected the requirements-dev header (installed
by Dockerfile.test, not a nonexistent `dev` stage).
2026-07-21 03:36:42 +02:00
peter 33dbf70313 test(backend): pure-logic pytest harness, run in an isolated test image
The backend had zero tests. This adds 33, all pure (no DB, no network), plus the
harness to run them in the gate:

- backend/Dockerfile.test: the app's deps + pinned pytest/ruff, code bind-mounted at
  run time so it always tests the working tree. Isolated from the prod Dockerfile, so
  the published image never carries test tooling.
- requirements-dev.txt: pytest==8.4.2, ruff==0.15.21 (the version the gate already
  runs) — the backend lane is now pinned, not just host-global.
- tests: normalize_title (de-shout, hashtag strip, emoji drop, trilingual letters
  survive); storage sanitize/rel_path/download_filename and the safe_abs_path
  traversal guard (../ and absolute-path escapes rejected — verified by mutation);
  links HMAC grants (round-trip, wrong-token, tampered sig/exp, expiry) + is_expired;
  a DB-free app-assembly smoke (every router wires up, OpenAPI generates).

DB-backed router smoke (auth/feed/downloads) needs a test Postgres + migrations —
deferred. useCardPager needs hook-test infra (jsdom/renderHook) — deferred.
2026-07-21 03:29:24 +02:00
peter 9db1d3fba3 feat(managers): About column with hover overlay on both channel tables (E4 S2)
- Backend: add channel description to the shared _channel_summary projection, so both
  the subscribed list and the discovery list carry it (no migration; description is
  already stored from the channels.list call)
- New AboutCell: one truncated line in the table + full text in a portalled, always-on
  glass hover/focus overlay (wider than Tooltip, flips above the anchor near the bottom,
  closes on scroll/resize)
- About column (after Channel) on both the subscribed and discovery tables; hidden in the
  mobile card fallback
2026-07-19 19:35:07 +02:00
peter af6faf476f feat(plex): full-library cast backfill via a batched rich re-sync (S1b)
Lazy enrichment only covers viewed titles, so an actor's count/results were
partial. Add a background backfill: each plex_sync rich-fetches a bounded batch
(60) of not-yet-enriched movies/shows and persists their FULL cast, stamping
people_enriched_at so each is fetched once — the whole library is covered over
successive runs (migration 0057 adds the marker). Extracted the enrichment into a
shared app/plex/people.py (cast_from_meta / enrich_row_people / prune_orphan_people
+ the person-image host/proxy) so the info-page lazy path and the batch share ONE
implementation — no duplication; the info page also stamps the marker so the batch
skips it. Verified: an 8-item batch grows those movies' cast_names 3->24 and fills
plex_people (32->224); the info-page cast is intact after the _rich_meta refactor.
(Plex cast-explore S1b)
2026-07-19 04:50:45 +02:00
peter d91f27a22e fix(plex): NULL-safe orphan-people prune (review)
Guard the prune's NOT IN against the classic NULL trap: a JSON-null cast array
element would surface as SQL NULL in the referenced set, making name NOT IN
(…, NULL) evaluate NULL for every row → silently prune nothing. Filter NULLs out
of the subquery. (Plex cast-explore S4 review)
2026-07-19 03:53:03 +02:00
peter 33c60965d9 feat(plex): prune orphaned people after each sync (S4)
The plex_people headshot cache can outlive its subjects — when a title leaves the
Plex library, cast/crew who appear in nothing else are orphaned. prune_orphan_people
deletes rows whose name no longer appears in ANY movie/show cast_names/directors,
run at the end of every plex_sync (best-effort; a jsonb_typeof='array' guard skips
scalar/null JSONB values). The filterable cast_names self-clean with their row;
this cleans the only per-person cache. Verified: an injected orphan is dropped, real
people kept. (Plex cast-explore S4)
2026-07-19 03:50:00 +02:00
peter 05105197e9 feat(plex): person cards in the grid for the filtered actors (S3)
The actors currently in the filter now render as cards above the poster grid —
headshot + name + in-scope title count + a remove x — so you SEE who you're
exploring, not just chips in the rail (generalises the old search-only person
cards to the applied filter). New GET /plex/people/cards (photo from the
plex_people cache, count live against cast_names); PlexBrowse fetches + renders
them; i18n en/hu. Verified: John Goodman (11) / Douglas M. Griffin (1) / Bradley
Cooper (14) with photos + counts. (Plex cast-explore S3)
2026-07-19 03:46:31 +02:00
peter 6f28ce36a7 feat(plex): actor filter AND/OR toggle (default OR) (S2)
Multiple selected actors now combine as OR (any) by default — click several cast
and see films featuring ANY of them — instead of the old hardcoded AND that
emptied the grid. An AND (all) toggle in the ACTIVE filter section (shown for 2+
actors, mirroring the genre match toggle) narrows to films with ALL of them.
Backend actor_mode param mirrors genre_mode. Verified: OR John Goodman|Douglas
M. Griffin = 11 films; AND = 1 (10 Cloverfield Lane). (Plex cast-explore S2)
2026-07-19 03:37:57 +02:00
peter 0030f32a5d feat(plex): lazy-enrich the full cast on info-page view (S1a)
The cheap section listing that drives the main sync only stores the top ~3 cast,
so any cast member below that was unfilterable (clicking them AND-emptied the
grid). Now the movie/show info fetch (/item, /show) — which already pulls the full
rich cast — persists it onto cast_names + people_text (so every displayed cast
member becomes filterable) and caches each headshot in the new plex_people table
(migration 0056). Idempotent (only grows a thinner stored set); best-effort (never
500s a read). Verified: 10 Cloverfield Lane 3->8 cast after viewing; plex_people
populated with photos. (Plex cast-explore S1a)
2026-07-19 03:31:34 +02:00
peter 0d10e0f0a5 feat(filters): filter the feed by several channels at once (OR)
Pick any number of channels; they OR together and every other filter applies
across the lot — "the last week's unwatched from these three".

`channelId`/`channelName` are replaced outright by `channelIds: string[]`, no
legacy reader (standing rule: no back-compat unless asked — a saved view written
against the old field simply loses its channel filter). The wire and the share
URL move with it: repeated `channel_ids` params, `?channel=a,b`. Backend ORs via
`Video.channel_id.in_()`; the count endpoint shares the same params object.

Two things the migration exposed:
- SavedViewsWidget fed DB blobs straight into serializers that now index an
  array — the FeedFilters type says nothing about what an older build wrote, so
  the blob is coerced where it enters. It white-screened the app before this.
- Chips carried channelName to label themselves. Without it they resolve names
  from the channel cache, so they now fall back to the id, not a shared
  "This channel" that would render N identical chips while the cache loads.
2026-07-16 00:54:22 +02:00
peter 07024de6c2 feat(i18n): retire the German interface language
Only English and Hungarian remain. Drops the de locale files, the LANGUAGES
entry, the Google-locale mapping in auth, and the German welcome-message
template. A legacy "de" preference falls back to English gracefully. Content-
language detection and subtitle languages are untouched (media, not UI).
2026-07-13 05:13:08 +02:00
peter 05aa70ed33 fix(channel): harden About-enrich edge cases from review
- apply_channel_details: (branding.get("channel") or {}) so a "channel": null
  in brandingSettings can't AttributeError-abort the enrich batch.
- topicLabels: drop empty labels so a malformed topic URL can't render a blank chip.
2026-07-12 16:17:17 +02:00
peter 0c26464c90 feat(channel): enrich the About tab + fix the tab-switch header shift
- fix: reserve the scrollbar gutter (scrollbar-gutter:stable) on the channel
  page's scroll container, so switching between the tall Videos tab and the
  short About tab no longer shifts the banner/avatar/buttons a few px sideways
  (the vertical scrollbar was appearing/disappearing between the two tabs).
- About tab now shows Country (flag + localized name), Language, Topics
  (topicCategories → readable chips), and Keywords (brandingSettings keywords
  parsed into chips, quoted multi-word tags kept whole). country/language/topics
  were already stored; keywords is new (migration 0055_channel_keywords, mapped
  in apply_channel_details, returned by channel_detail).
- Discovery → channel page: the Channel-manager "Discover from playlists" tab now
  links each channel name to its in-app channel page (ChannelLink onView), so a
  discovered channel's About/videos can be inspected BEFORE subscribing (was
  subscribe-only, plain text before).

Note: the channel info-card epic itself was already delivered in v0.19.0; this is
the About-tab enrichment follow-up + the header-shift fix. external_links stays
empty by design (YouTube removed the field from the Data API ~2023).
2026-07-12 16:12:39 +02:00
peter 201b8b00b3 fix(audit): address /code-review findings
- scheduler _run_changed: count a run as "changed" only on a truthy NUMERIC
  value — status-string no-op dicts like {"skipped":"disabled"} (Plex off, the
  default) or {"skipped":"no demo account"} were flooding the trail every interval.
- audit.record: truncate target_id to the column width (128) like summary[:255],
  so an over-long target (e.g. a 128+ char demo email) can't abort the mutation
  the audit row is committed alongside.
- config set/reset: redact URL userinfo (user:pass@) from logged non-secret
  values — a proxy setting can embed credentials that shouldn't land in the trail.
- config set: skip the audit row when a non-secret value didn't actually change
  (no-op re-save shouldn't add noise); secrets are write-only so always logged.
- audit page title (pageMeta): add the missing "audit" case so the browser tab
  reads "Audit log · Siftlode" instead of falling back to "Feed".
2026-07-12 07:47:07 +02:00
peter 911f5100d8 feat(audit): admin audit log (Notification P3)
Append-only who/what/when trail for admin actions + scheduler run summaries,
generalizing QuotaEvent. Logged at ACTION / run-summary granularity — never
per-row (a scheduler run touching thousands of videos is ONE row).

Backend:
- migration 0054_audit_log + AuditLog model (actor_id FK SET NULL, action,
  target_type/id, summary, before/after JSON, created_at).
- app/audit.py: AuditAction constants (single source of truth, i18n'd on the
  client) + record() (adds to the caller's txn) + gc(retention_days).
- producers wired: role change / suspend / delete / invite approve-deny-add /
  demo add-remove-reset / discovery purge (admin.py); config set/reset — secret
  VALUES never logged, key only (config.py); scheduler interval + maintenance
  tune (scheduler routes); sync pause/resume (sync.py); and the scheduler _job()
  choke point writes ONE run-summary row per run — manual runs + errors always,
  automatic runs only when they changed something (no no-op-poll spam).
- retention: audit_gc scheduler job prunes rows older than audit_retention_days
  (sysconfig, default 180; 0 = keep forever).
- admin API routes/audit.py (/api/admin/audit): recent-window list (actor emails
  resolved, System for background) + clear (leaves one tamper-evidence row).

Frontend:
- new admin-only "Audit log" nav page (ScrollText) using the shared DataTable
  (first admin page to reuse it) — sort/filter/paginate/persist, action select
  filter, actor text filter, before→after detail, Clear-all with confirm.
- AuditLog.tsx + auditColumns.tsx + api.adminAudit/clearAudit + AuditRow type.
- trilingual audit + auditActions namespaces (HU/EN/DE) + nav + config-group +
  scheduler job labels; Audit config group (retention days).
2026-07-12 07:32:41 +02:00
peter 64a97ede3d fix(deferred): address /code-review findings on the closeout diff
- channels CB3: sort discovery rows NULLs-last (title is None) to match the DB
  ORDER BY exactly — coercing NULL title to "" floated untitled channels to the top.
- AdminUsers SC1: track in-flight rows in a Set, not a single id — per-row disabling
  now lets the admin start concurrent row actions, and a shared id was cleared by
  whichever settled first (re-enabling a still-pending row → possible double-submit).
- ChatThread CT4: reset the incoming-count ref when partnerId changes — the Messages
  page reuses one ChatThread across conversation switches, so a same-count switch
  could skip the open-marks-read badge refresh.
- messages MB3: push a bare {type:"unread"} (drop the now-dead count query — the
  client re-fetches on the signal and ignored the number anyway).
- api.ts: extract the shared keepalive `beacon()` helper (saveProgressBeacon +
  plexProgressBeacon were near-verbatim copies).
2026-07-12 06:21:29 +02:00
peter 229502a61e fix(deferred): close backend correctness/efficiency tails from the hygiene sweep
Verified each deferred per-subsystem item against current source, then fixed the
real ones (tradeoffs left as-is, see siftlode-ops/CODE-HYGIENE.md closeout):

- search BUG-3: don't finalise shorts_probed on un-enriched stubs (enrichment
  failure/omitted rows) — restores the scheduler's enriched_at guard so a real
  Short can't leak into the feed and never get reclassified.
- downloads GC: 4th pass reaps orphaned errored, fileless, unreferenced assets
  (they carry no TTL, so the expiry passes never cleared them).
- downloads B6: quota/edit errors now return a structured {code,reason,limit}
  the trilingual client localises, instead of hardcoded English + dot-decimal GB.
- channels BB1: reset-backfill re-arms deep sync on every subscriber (bulk update)
  instead of 404ing when the admin isn't personally subscribed.
- channels BB2: enrich the stub on BOTH the normal and "already exists" desync
  path (nest the insert try inside the client `with`).
- channels CB3: drop the redundant second _discovery_rows read (identity-mapped
  rows are already enriched; re-sort in Python for the title tie-break).
- playlists PC1: read the live playlist once in push() and share it with plan_push
  + push_playlist (halves read-quota, closes the second TOCTOU window).
- playlists PC2/PC5: batch the cover thumbnails in one window query (was N+1);
  rename combines count+duration into one aggregate + a single cover lookup.
- messages MB2: get_thread only resolves a messageable partner OR one we already
  share a thread with (closes the user-enumeration oracle).
- messages MB3: push a live unread-count to the user's other tabs after mark-read.
- messages MC4: list_conversations uses DISTINCT ON + grouped COUNT instead of
  loading the whole message history into memory.
- config AB3: per-spec allow_empty so smtp_user/youtube_api_proxy can be blanked
  to disable them rather than snapping back to the env default.
2026-07-12 05:59:03 +02:00
peter 30e6ce74ff fix(auth): decrypt the access-token fallback before revoking on account deletion
purge_user's revoke used `decrypt(refresh) or tok.access_token`, but access_token is
stored ENCRYPTED — so a token row without a refresh token would send ciphertext to
Google's revoke endpoint and silently fail. Decrypt it. (Pre-existing; surfaced by the
review of the OAuth-scope fix.)
2026-07-12 04:52:31 +02:00
peter e667fe0776 fix(auth): don't let a re-sign-in clobber the YouTube grant (root cause)
Correcting the earlier scope-union-only fix. The real damage from a logout→login isn't
just the stored `scope` field — verified via a live token refresh that the stored refresh
token itself had been REPLACED with a base-only one: a plain sign-in requests only
BASE_SCOPES and Google handed back a fresh base-only access+refresh token, which
_store_token adopted verbatim, destroying the read/write grant (the old refresh token
stays valid on Google's side, so overwriting it is what loses access).

_store_token now detects when an exchange would NARROW our YouTube scopes and, in that
case, keeps the WHOLE existing grant (refresh token, access token, scopes) untouched.
Broader-or-equal exchanges (first grant, read→write upgrade) adopt + union as before.
Unit-verified across base-relogin / upgrade / new-user / base-user cases.
2026-07-12 04:44:37 +02:00
peter ca10b88bf5 fix(auth): preserve OAuth scopes across re-login + accurate multi-admin request emails
Two user-reported signin bugs:

1) A plain re-login (or logout→login) wiped the user's YouTube grant: login requests
   only BASE_SCOPES and Google's returned `scope` can list just those, but _store_token
   wrote it verbatim — dropping a previously-granted youtube.readonly/youtube scope, so
   can_read flipped to false and the feed demanded a reconnect. The underlying grant
   (refresh token) survives such a login, so UNION the scopes instead of narrowing; they
   only shrink on full disconnect (purge deletes the token row).

2) Admin 'new access request' email: (a) it named the wrong menu ('Settings → Account'
   instead of Users → Access requests); (b) the requester address was invisible so the
   'reply reaches them' note looked wrong (Reply-To is in fact set to the requester) —
   now spelled out + a deep-link straight to the approve view (?admin=access-requests,
   handled in App); (c) it emailed only the static env ADMIN_EMAILS — now notifies the
   ACTUAL admins (active role=admin users) unioned with env, so a UI-promoted admin (who
   CAN approve — the approve UI is role-gated) is notified too.
2026-07-12 04:34:07 +02:00
peter 42bc45a365 chore(auth): drop now-dead session guard in messages_ws epoch read
resolved_user_id already accesses ws.session unguarded just above (SessionMiddleware
covers the WS scope), so the '"session" in ws.scope' fallback was dead code — read
ws.session directly for consistency (review follow-up).
2026-07-12 04:17:02 +02:00