37 Commits
Author SHA1 Message Date
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 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 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 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 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 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 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 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 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 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