Commit Graph
692 Commits
Author SHA1 Message Date
peter db2257c5e7 feat(auth): SA3 — trusted-proxy X-Forwarded-For for rate limiting
_client_ip trusted the first X-Forwarded-For hop unconditionally, so anyone able to
reach the app port could forge XFF and dodge the login/register/reset/demo rate limits.
Now trust XFF ONLY when the request's socket peer is a configured reverse proxy
(settings.trusted_proxy_ips, e.g. the VPS Caddy's WireGuard peer IP), and take the
RIGHTMOST entry — the client our proxy actually saw and appended, immune to a client
pre-seeding a fake XFF. A request from any other peer (hitting the port directly) is
keyed on its real socket IP, so XFF can't be forged to bypass the limits.

New TRUSTED_PROXY_IPS env (empty default = no proxy, use the direct peer). Documented in
.env.example, docs/self-hosting.md, README. Unit-verified against spoof-through-proxy and
direct-bypass cases.
2026-07-12 03:42:41 +02:00
peter 4b3e83db74 Merge feature/auth-security: SB3 + SA4 (SA3 deferred)
SB3: reset/verify email tokens moved from URL query strings to the fragment (never
sent to the server / proxy logs / Referer); verify GET→POST with a legacy-GET fallback.
SA4: server-side session revocation via per-user session_epoch (migration 0053) — a
signed cookie records its epoch, current_user + the messages WS reject a stale-epoch
cookie. Bumped on password reset (all sessions), password change + a new 'Log out other
sessions' action (both keep the current session). E2E-verified; UAT passed.
SA3 (trusted-proxy for X-Forwarded-For) intentionally deferred to a prod-tested follow-up.
2026-07-12 03:25:54 +02:00
peter bfcee41f2a fix(auth): address SA4 review findings (WS epoch check, commit ordering, verify guard)
Adversarial re-review of the session-epoch work surfaced:
- WS auth (messages_ws) skipped the epoch check, so a revoked-but-unexpired cookie
  could still open the live push channel after a reset/logout-others. Now mirrors
  current_user: loads the user once, rejects a stale-epoch cookie before connecting.
- set_password + logout_others re-stamped the cookie BEFORE db.commit(); a failed
  commit would strand the current session at a newer epoch than the DB and wrongly
  401 it. Commit first, then re-stamp.
- Welcome verify effect could double-POST the single-use token (StrictMode/remount)
  and flip the banner to a false 'invalid'. Fire-once useRef guard.

Left as-is (low value, documented): the Plex image proxy authenticates without a DB
load / epoch check (poster/art fetches only); adding one would cost a DB hit per image.
2026-07-12 03:07:13 +02:00
peter bfcacb518f feat(auth): SA4 — server-side session revocation via per-user session epoch
Signed client-side session cookies had no server-side kill switch: logout + password
reset couldn't evict a stolen/copied cookie (valid until expiry). Add User.session_epoch
(migration 0053), record it in the cookie at every login, and reject in current_user any
cookie whose recorded epoch is behind the account's current one.
- Bump the epoch on: password reset (kills ALL sessions — a reset is a compromise response),
  password change + a new 'Log out other sessions' action (both re-stamp the CURRENT cookie
  so the caller stays signed in, evicting only the others).
- Per-account epoch map in the session so one account's revocation doesn't evict the other
  signed-in accounts in the same browser wallet.
- Missing epoch (pre-SA4 cookie) is treated as 0, so the first bump revokes grandfathered
  sessions too.
- New POST /auth/logout-others + a Settings → Account 'Active sessions' button (trilingual).
2026-07-12 03:00:16 +02:00
peter e99ec65721 fix(auth): SB3 — keep reset/verify tokens out of URL query strings
Secret email tokens now ride the URL fragment (#reset=/#verify=), never the query
(?reset=/?token=): a fragment isn't sent to the server, so the token can't leak into
proxy/access logs or a Referer header.
- Reset: link → /#reset=; the SPA reads the token from location.hash and POSTs it
  (unchanged /password-reset/confirm).
- Verify: link → /#verify=; new POST /auth/verify (token in body). The legacy GET
  /auth/verify?token= is kept so pre-deploy emails in flight still work until they
  expire. The SPA reads the fragment token, POSTs it, shows ok/invalid.
- Welcome: read secret tokens from the fragment, status flags from the query; strip
  both after capture so nothing lingers in history.
2026-07-12 02:48:40 +02:00
peter c7ef5a87b2 release: v0.38.0
Plex filter-sidebar collapse + infinite-scroll fix; the whole code-hygiene sweep
(Phases 1-4: dead-code/duplication cleanup, watch_sync/player fixes, guardrails).
2026-07-12 02:26:42 +02:00
peter 78fcbb651f Merge chore/code-hygiene: Phase 4 guardrails (noUnusedLocals + dead-code sweep + ruff.toml)
tsconfig noUnusedLocals/Parameters=true (permanent re-accumulation guardrail) + fixed
8 dead-code violations; backend/ruff.toml (ignore intentional E402) + 6 style fixes.
knip/tsc/ruff all green.
2026-07-12 02:16:07 +02:00
peter 5c29a383cf chore(hygiene): Phase 4 guardrails — noUnusedLocals/Parameters + dead-code sweep
Enable tsconfig noUnusedLocals + noUnusedParameters (permanent guardrail: every tsc/
build now flags re-accumulated dead code). Fixed the 8 violations that surfaced, all
genuine dead code:
- Sidebar: deleted unused SORT_IDS/SHOW_IDS/rollSeed consts + the dead Toggle component
  (+ its now-unused Switch import).
- PlexBrowse: dropped the unused onClearSearch prop (Props + destructure + App call site).
- VideoEditor: dropped the unused map index; notifications: deleted the dead back-compat
  toast(); storage: deleted the unused non-account usePersistedState (+ readJSON import).
- SavedViewsWidget: deleted the dead loadDefaultViewFilters export (App loads the default
  view inline; resolves the last knip unused-export → knip now clean).

Backend: added backend/ruff.toml (ignore E402 — intentional pre-import setup in main.py)
and fixed the 6 E702/E741 style items (_vtt_s_to_ts semicolons, ambiguous `l` loop vars)
so ruff is green. localdev boots; Feed/Plex/sidebar render; 0 console errors.
2026-07-12 02:15:52 +02:00
peter ebe77721cd Merge chore/code-hygiene: Plex filter-sidebar collapse button
Add the missing collapse trigger to the expanded PlexSidebar (state + collapsed rail
already wired via the shared filterCollapsed); mirrors the feed Sidebar. E2E-verified.
2026-07-12 01:55:25 +02:00
peter 2fcef981cd feat(plex): collapse button in the expanded Plex filter sidebar
The collapse feature was only half-present on the Plex page: App already passed the
shared filterCollapsed state + toggle to PlexSidebar, and PlexSidebar rendered the
CollapsedFilterRail when collapsed — but the EXPANDED PlexSidebar had no trigger, so
you could only collapse it by first collapsing on the feed page. Add the same
ChevronLeft 'Filters' collapse header the feed Sidebar has (reusing the existing
sidebar.* i18n keys). E2E-verified: collapse→rail→expand round-trips on the Plex page.
2026-07-12 01:55:10 +02:00
peter 0f21f6292f Merge chore/code-hygiene: Phase 3 — jscpd clones to zero
Extract the last 3 duplicated blocks: shared CollapsedFilterRail (both sidebars),
VideoCard textBlock (list+card layouts), subsColumn factory (Channels +
ChannelDiscovery). jscpd 8→0 clones. Behavior-neutral; E2E-verified (feed cards,
collapsed rail); tsc/knip green.
2026-07-12 00:50:09 +02:00
peter cddfb909da chore(ui): dedup last 3 jscpd clones → 0 (collapsed rail, video-card text block, subs column)
- CollapsedFilterRail: the byte-identical 31-line collapsed sidebar rail was in
  both Sidebar (feed) and PlexSidebar → one shared component.
- VideoCard: the title + channel-button + meta block was duplicated across the
  list-row and card layouts → a local `textBlock` element (only one layout branch
  renders, so reusing the element is safe).
- subsColumn<T>(t): the identical 'subscribers' DataTable column in Channels +
  ChannelDiscovery → a generic factory in channelColumns.tsx.

All behavior-neutral; jscpd now reports 0 clones (was 8 at Phase 0 baseline).
tsc + knip green.
2026-07-12 00:46:52 +02:00
peter 2169232d57 Merge chore/code-hygiene: Phase 3 — extract shared inputCls + btnCls to ui/form.tsx
Byte-identical className strings de-duplicated from 5 (inputCls) + 2 (btnCls)
components into ui/form.tsx. Behavior-neutral. Settings-family input style + A/B
unification deferred to the glass-consistency epic.
2026-07-12 00:35:48 +02:00
peter eabfb6e00a chore(ui): extract shared inputCls + btnCls to ui/form.tsx
The standard input style was copy-pasted verbatim into 5 components
(DownloadCenter/DownloadDialog/ProfileEditor/ShareDialog/VideoEditor) and the
text-button style into 2 (ShareDialog/VideoEditor). Export both from ui/form.tsx
and import them. Byte-identical strings → no visual change.

The settings-family panels (Settings/Config/Setup/Welcome) use a DIFFERENT input
style (bg-card/rounded-xl/focus:border-accent); unifying the two is a design call
left to the glass-consistency epic, not this DRY extract.
2026-07-12 00:29:04 +02:00
peter d1adec02ad Merge chore/code-hygiene: fix Plex infinite-scroll dead after drill-in→Back
Callback-ref the grid's IntersectionObserver sentinel so it re-attaches on the
grid's unmount/remount cycle (drill into info/show/season → Back), instead of
observing a detached old node and freezing the feed at the loaded pages.
2026-07-12 00:16:54 +02:00
peter 9a00bd8836 fix(plex): infinite-scroll dead after drill-in → Back (stale sentinel observer)
The unified-library grid unmounts when you open an item's info/show/season page
and remounts as a NEW element on Back. The IntersectionObserver effect keyed only
on [hasNextPage, isFetchingNextPage, fetchNextPage] — none of which change across a
pure navigation — so it never re-ran: the observer kept watching the detached old
sentinel node and fetchNextPage never fired again, leaving the feed frozen at the
already-loaded pages (e.g. 80/1227) even though more exist. Intermittent because a
coincident isFetchingNextPage toggle around the nav could re-run the effect.

Fix: observe via a callback ref (state) instead of useRef, so the effect re-runs on
every sentinel mount/unmount and always watches the live node.
2026-07-12 00:09:19 +02:00
peter ca99afe965 Merge chore/code-hygiene: Phase 2 #9 Plex backend pass (watch_sync bugs + backend dedup)
Bugs: PW1 watch_history pagination (owner rows past page 1 on busy servers),
PW3 push_state_to_plex lost-update guard. PW2 verified a non-bug (admin token =
account 1). Cleanup: PC2 LibraryFilters Depends, PS-C1 _upsert_collection,
PS-C2 _apply_facet_fields, PW-C1 _repush_dirty DB duration, PW-C2 _rk_to_id.
2026-07-11 23:53:29 +02:00
peter a12f1d8ef7 Plex backend pass (#9): watch_sync bugs + backend dedup
Closes the Plex #9 backend backlog (frontend was the prior follow-up). Behavior-
neutral cleanup + two two-way-sync bug fixes; PW2 verified a non-bug.

fix(plex): watch-sync PW1 pagination + PW3 lost-update guard
- PW1 watch_history: the history is a GLOBAL cross-account feed read as a single
  500-row page, then filtered to the owner — on a busy family server the owner's
  recent views can sit past page 1 and be missed. Now paginate (desc) until the
  since-cutoff, bounded by max_pages (daily reconcile is the backstop; logs if hit).
- PW3 push_state_to_plex: it flagged synced_to_plex=True unconditionally after a
  push, so a local edit landing between the push and the flag-set was marked clean
  and never pushed (lost update). Capture the row's freshest timestamp when the
  push is scheduled (_push_watch) and only settle the flag if the row is unchanged.
- PW2 was flagged as "accountID hardcoded to 1" but is NOT a bug: on a PMS account 1
  is reserved for the owner/admin and an owner link always holds the admin token —
  added a clarifying comment so it isn't re-flagged.

chore(plex): backend dedup
- PC2 unified_library + facets shared a ~13-field filter Query signature + p-dict →
  one LibraryFilters dataclass injected via Depends(); as_dict() feeds the builders.
- PS-C1 sync.py collection upsert dup (resync_collection ≈ _sync_collections) →
  _upsert_collection().
- PS-C2 sync.py 8-field filterable-metadata block dup (_apply_item ≈ _sync_shows) →
  _apply_facet_fields().
- PW-C1 _repush_dirty read duration from the mirrored PlexItem.duration_s instead of
  a per-row plex.metadata() round-trip.
- PW-C2 rk→id map built inline in two places → _rk_to_id() helper.
2026-07-11 23:48:35 +02:00
peter bce055b774 Merge chore/code-hygiene: Phase 2 #9 Plex follow-up (frontend player bugs + FE dedup)
Bugs: PP1 marker-skip reset on item change, PP2 lang-switch session reload from
stale position, PP3 tracks-menu wheel→volume, PS1 sidebar badge clamp.
Cleanup: formatRuntime dedup (PlexBrowse/PlexInfo), fmt→formatDuration, LS key
registration, dead wasPlaying pref, redundant onStateChange refetch.
2026-07-11 23:22:54 +02:00
peter 4d22349aa6 fix(plex): player marker-skip reset, lang-switch reload, tracks wheel, sidebar badge clamp
- PP1: reset cancelledMarkerRef on item change — a cancelled intro/credits
  auto-skip on one episode suppressed a same-offset marker on the next (auto-skip
  silently dead for the rest of a binge).
- PP2: loadSession no longer depends on the i18n `t` (read via a ref). A
  mid-playback language switch rebuilt loadSession → re-ran the detail effect →
  reloaded the session from the STALE saved resume position, losing live progress.
- PP3: tracks (audio/subtitle) menu now stopPropagation on wheel, like the gear
  menu — scrolling the list no longer changes volume.
- PS1: collapsed PlexSidebar filter badge clamps to "9+" (was a raw count),
  matching the feed Sidebar.
2026-07-11 23:13:10 +02:00
peter d7fbe01f81 chore(plex): dedup frontend formatters + LS key, drop dead wasPlaying pref
- format.ts: new formatRuntime() ("1h 30m"); PlexBrowse dur() + PlexInfo
  fmtDur() were byte-identical copies, now both call it.
- PlexPlayer fmt() delegates to formatDuration (keeps the NaN/negative clamp
  the live media clock needs); local reimplementation gone.
- Register LS.plexPlayerPrefs; PlexPlayer uses it instead of a bare string.
- Remove the dead PlexPlayerPrefs.wasPlaying field (written on play/pause, never
  read) + its two patchPrefs writes.
- PlexBrowse item page: drop the redundant onStateChange={() => q.refetch()} —
  PlexInfo.setState already invalidates ["plex-item", id], so q refetches itself.
2026-07-11 23:10:24 +02:00
peter ae8bc871f2 Merge chore/code-hygiene: Phase 2 #9 Plex (first pass — transcode config-drift + safe cleanups)
Fix: honor the admin max-concurrent-transcodes setting (was hardcoded). Cleanup:
dead _enabled guard, watch-toggle dedup, dead plex i18n keys. The large Plex
backlog (watch_sync sync-races, plex.py FilterParams dup, PlexPlayer bugs,
sidebar/format dedup) is DEFERRED to a focused follow-up pass. Verified: ruff,
tsc, re-review clean, localdev boots, Plex UI renders.
2026-07-11 21:53:25 +02:00
peter c9a249d922 chore(plex): drop dead _enabled guard, dedup watch-toggle, remove dead i18n
- plex.py: delete the no-op _enabled() (never wired as a Depends/called; the real
  gate is sysconfig plex_enabled).
- PlexBrowse.tsx: collapse toggleEpisodeWatched into toggleWatched — they were
  byte-identical (both take a PlexCard).
- Delete 6 dead plex.json keys (loadMore, playerSoon, filter.library,
  playlist.up/down/remove) from en/hu/de — verified 0 refs (the live
  playlist.removeShow/removeSeason keys are kept).

Behavior-neutral. tsc green, ruff clean on touched files, localdev boots, Plex renders.
2026-07-11 21:53:24 +02:00
peter 9347d52a54 fix(plex): honor the admin "max concurrent transcodes" setting (config drift)
stream.py enforced a hardcoded _MAX_SESSIONS = 4 and never read the
DB-overridable plex_max_transcodes ConfigSpec, so the Configuration → Plex →
"Max concurrent transcodes" knob was dead (same env-vs-DB drift class as the
Downloads/Admin fixes). _enforce_cap(cap) now takes the cap from
sysconfig.get_int(db, "plex_max_transcodes") (>=1 so playback can't be capped to
zero). Bumped the config default 1→4 so the effective default matches the old
hardcoded cap (no behavior change for non-overriders; the knob now works).
2026-07-11 21:53:24 +02:00
peter ce43db9a80 Merge chore/code-hygiene: Phase 2 #8 Auth security fixes (contained batch)
Last-admin lockout guard; access_token encryption at rest (SB1, E2E-verified);
password-login timing/enumeration; Google-adoption email_verified check; demo +
switch-account isolation. Architectural SA3 (proxy trust) + SA4 (session
revocation) deferred to the user. Verified: ruff, re-review clean, localdev boots,
YouTube sync works with encrypted token.
2026-07-11 21:26:39 +02:00
peter a61a281158 fix(auth): security hardening — token encryption, timing, adoption + isolation
From the auth /security-review (contained fixes; architectural SA3 proxy-trust +
SA4 session-revocation deferred to the user):
- SB1: encrypt the OAuth access_token at rest (was plaintext while refresh_token
  was encrypted) — a ~1h Google bearer credential. New security.decrypt_optional()
  falls back to a refresh for legacy plaintext tokens; column is unbounded String.
  E2E-verified: token refreshed → stored as Fernet ciphertext → 319-subscription
  YouTube sync succeeded.
- SA5: password_login is no longer a timing/enumeration oracle — it always runs
  argon2 (against a decoy hash for unknown/passwordless emails), so response time
  can't reveal whether an account exists.
- SB2: Google login only ADOPTS+activates a pre-existing password account when
  Google actually attests email_verified (defense-in-depth against takeover); and
  the email sync won't overwrite with a value another account owns (avoids a 500).
- demo_login clears the wallet first (demo can't switch to / act as a real account
  via the multi-account header); switch_account rejects a suspended target (which
  would otherwise clear the whole session on the next request).

Re-review clean; ruff clean; localdev boots; YouTube auth path E2E-verified.
2026-07-11 21:26:39 +02:00
peter 3fb345d825 fix(auth): last-admin guard counts only ACTIVE admins (prevents lockout)
set_user_role (demote) and admin_delete_user counted ALL admins incl. suspended
ones, so with e.g. 1 active + 1 suspended admin you could demote/delete the only
ACTIVE admin — leaving a single suspended admin who can never sign in → permanent
admin lockout needing DB surgery. Now both guards mirror the (already-correct)
suspend guard: block only when the target is an active admin AND it's the last one
(`not target.is_suspended and count_admins(active_only=True) <= 1`) — which also
correctly still allows removing a SUSPENDED admin while one active admin remains.
2026-07-11 21:26:39 +02:00
peter 18cf9cd82d Merge chore/code-hygiene: clear E2EE key on logout (user-approved) 2026-07-11 20:57:30 +02:00
peter 856f39642f fix(e2ee): clear the device's private key on logout (user-approved: every logout)
Resolves the deferred clearDevice security gap: logout never removed the E2EE
private key from IndexedDB, so on a shared machine the conversations stayed
decryptable after sign-out (ChatDock auto-unlocks from the persisted key next
visit). Per the user's decision (clear on EVERY logout), NavSidebar.logout() now
awaits e2ee.clearDevice(me.id) before reload — guarded so an IndexedDB failure
(e.g. private mode) can never block the logout. Re-entering the message
passphrase is required on the next visit.

Resolves the Phase-1 NEEDS-DECISION #3 (clearDevice) + retires the last e2ee
unused export (knip now only flags loadDefaultViewFilters). tsc green, re-review
clean (found + fixed an idb-open throw path blocking logout).
2026-07-11 20:57:30 +02:00
peter 022d8262db Merge chore/code-hygiene: Phase 2 #7 Messages (E2EE crypto fix + UI bugs)
Fix: AES-GCM nonce reuse in e2ee.setup() (+ drop redundant key_check oracle,
backward-compatible); silent 404/429 send failures now toast; scroll/render-churn
in ChatThread + Messages; chatDock LS helper; deleted dead lock(). Verified: tsc,
re-review clean, localdev boots, self-E2E green (unlock + full-thread decrypt on a
real existing key, user entered the passphrase).
2026-07-11 20:48:38 +02:00
peter c0f9ce732c fix(messages-ui): surface silent send failures, stop scroll/render churn (+cleanup)
- ChatThread send had no onError: a 404 (recipient gone) or 429 (rate-limited)
  send failed silently (api.req shows no modal for those "caller-handled" statuses).
  Added onError → toast for 404/429 (400/409/500 already raise the global dialog);
  draft is kept for retry. New trilingual messages.sendFailed key.
- ChatThread scroll-to-bottom effect keyed on `plain` too, so every 20s poll's
  decrypt pass yanked a scrolled-up reader back to the bottom. Key on items.length.
- ChatThread + Messages decrypt effects keyed on `items` (a fresh `?? []` array
  every render) → a render storm while loading. Key on q.dataUpdatedAt.
- chatDock.ts: use LS.chatDock(userId) instead of the bare "siftlode.chatDock.*"
  literal (matches the storage LS registry).

tsc green, re-review clean, localdev boots, Messages renders (locked-state) w/o console errors.
2026-07-11 20:16:14 +02:00
peter 52197b28fb fix(e2ee): stop reusing the AES-GCM nonce in setup; drop redundant key_check oracle
setup() encrypted BOTH the wrapped private key AND the key_check under the same
wrapKey with the SAME wrapIv — a GCM nonce-reuse bug (leaks keystream + enables
GHASH/tag forgery, which matters under E2EE's malicious-server threat model).
Fixes:
- setup() gives key_check its own independent nonce (checkIv).
- unlock() no longer decrypts key_check to verify the passphrase — that was
  redundant (a wrong passphrase already fails the wrapped_private_key GCM auth
  tag) AND was the second consumer of the reused nonce.

Backward-compatible: the uploaded bundle shape is unchanged, and existing users'
bundles still unlock (unlock only presence-checks key_check now; the private-key
decrypt path with wrap_iv is untouched). Also removed the unused, architecturally
ineffective lock() export (re-unlocks from IndexedDB on next mount; the meaningful
primitive is clearDevice). Re-review clean; tsc green.
2026-07-11 20:16:14 +02:00
peter 60d6fc3fa0 Merge chore/code-hygiene: Phase 2 #6 Admin/Settings/Scheduler (cleanup + bugs)
Fixes: env-vs-DB config drift for download layout/retention; Plex library toggle
inversion from the all-state; Purge-discovery now behind a confirm dialog.
Cleanup: token_users dedup, rss lambda, LS.configTab, SaveState type reuse.
Verified: ruff, tsc, re-review clean, localdev boots, focused E2E green (Plex
toggle + purge confirm).
2026-07-11 19:45:09 +02:00
peter e91369f827 fix(admin-ui): Plex library toggle inversion + Purge-discovery confirm (+ cleanups)
Bugs:
- ConfigPanel (CB1): unchecking a Plex library from the "all" (empty) state made
  that library the ONLY selected one instead of all-except-it, because the toggle
  ADDED the key to an empty list. Expand "all" to the explicit section set before
  toggling. E2E-verified: uncheck from all → all-except-one.
- Scheduler (SB1): "Purge discovery" bulk-deleted search videos/videos/channels
  immediately with no confirm (every AdminUsers destructive action uses useConfirm).
  Wrapped in a danger useConfirm dialog (+ trilingual purgeConfirmTitle key).
  E2E-verified: dialog appears, Cancel aborts.

Cleanups (behavior-neutral):
- Register LS.configTab; ConfigPanel uses it instead of the bare "siftlode.configTab".
- ConfigPanel + SettingsPanel import SaveState from ui/DraftSaveBar instead of
  redeclaring the union (PrefsSaveState is now an alias).

tsc green, re-review clean, localdev boots.
2026-07-11 19:44:53 +02:00
peter 1fc8214f8f chore(scheduler): dedup token-user query + drop pointless rss lambda
- Extract runner.token_users(db): the "all users with a stored refresh token"
  query was spelled 3× (get_service_user, run_subscription_resync,
  sync_all_playlists). get_service_user now reuses it ([0]); sync_all_playlists
  imports it from runner (no cycle — runner doesn't import playlists); the now-
  unused OAuthToken import is dropped there.
- scheduler.py: _job("rss_poll", lambda db: run_rss_poll(db)) → _job("rss_poll",
  run_rss_poll) — the lambda was dead indirection; all sibling jobs pass the
  callable directly and _job calls fn(db).

Behavior-neutral. ruff clean, localdev boots, re-review clean.
2026-07-11 19:44:53 +02:00
peter 7e18d9698f fix(config): honor DB-overridable download layout + retention (env-vs-DB drift)
download_layout and download_retention_days are registered ConfigSpec keys
(admin-editable on the Configuration page), but worker.py read them from
settings.* (env) at download/edit finalize time — so an admin's edit was a
silent no-op (files kept the old layout; TTL stayed at the env default). Read
them via sysconfig (_download_settings), which falls back to the env default
when there's no DB override. Same class as the Downloads B3 fix.

ruff clean, localdev boots, re-review clean.
2026-07-11 19:44:53 +02:00
peter 02d334825b Merge chore/code-hygiene: Phase 2 #5 YouTube player (1 bug + cleanups)
Fix: short videos were auto-marked watched almost immediately (negative finish
margin). Cleanups: extract format.formatDate, reuse goPrev/goNext for the in-bar
buttons, memoize the savedPrefs cache read. Verified: tsc, knip, localdev healthy.
2026-07-11 18:50:06 +02:00
peter 6648ba557a fix(player): short-video auto-watch + player cleanups
BUG (YB1): the auto-watch checkpoint marked a video watched once position
crossed `duration - FINISH_MARGIN` (10s). For clips shorter than ~10s that
threshold is negative, and for ~11-20s clips it's only a few seconds in, so the
5s checkpoint tick marked short videos watched almost immediately (clearing their
resume position). Cap the margin at half the clip: Math.min(FINISH_MARGIN, dur/2).

Cleanups (behavior-neutral):
- Extract format.formatDate(iso, lang) — the day/month/year toLocaleDateString
  option object was duplicated verbatim in PlayerModal (fullDate) and VideoCard.
  (ChannelPage's "joined" is month/year only, so it's left as-is.)
- The in-bar prev/next buttons now reuse goPrev/goNext instead of re-implementing
  the step + bounds inline (they already exist for the side arrows + keyboard).
- Memoize savedPrefs so the ['me'] cache read + spread runs once, not on every
  render (it only seeds the initial autoMode/loopMode state).

tsc green, knip clean, localdev boots healthy.
2026-07-11 18:49:56 +02:00
peter 7f35be1a78 Merge chore/code-hygiene: Phase 2 #4 Playlists (cleanup + bug fixes)
Cleanup (_remove_item dedup; drop dead Check import + plName shadow) plus
review-found bug fixes: reorder position-collision under concurrent edits,
push/sync/revert YouTube 403s now route to the Connect affordance, and
removeItem rolls back on failure. Verified: ruff, tsc, localdev boots healthy.
2026-07-11 18:33:28 +02:00
peter 09a6095363 fix(playlists-ui): surface YouTube 403s + rollback remove; drop dup name/import
BUGS:
- push/sync/revert (PF1/PF2/PF4) swallowed every failure into a generic warning,
  so a missing-write-scope 403 gave no hint and no "Connect your YouTube account"
  affordance (unlike Channels). Route all three through notifyYouTubeActionError
  (no wizard handle on this screen, so message-only for the 403).
- removeItem (PF5) reset the order optimistically then awaited the delete with no
  catch: a failed call left the row gone locally (and an unhandled rejection).
  Now resync via refreshAll on either path (api.req surfaces the error dialog).

CLEANUP:
- Drop the dead `Check` lucide import (PC6; only AddToPlaylist uses it).
- Use the module plName(detail) helper instead of the inline watch_later-vs-name
  ternary that shadowed it in push/revert (PC7).

tsc green, localdev boots healthy.
2026-07-11 18:33:16 +02:00
peter a7c8b3d0a6 fix(playlists): reorder position collision + dedup remove helper
BUG (PB1): reorder_items only repositioned the items present in the payload,
leaving any omitted item (e.g. one added concurrently in another tab) at its old
position — which then collides with the freshly assigned 0..N-1 range, so
get_playlist's order-by-position returns a nondeterministic/duplicated order.
Now the omitted items get fresh trailing positions (keeping their relative
order), guaranteeing unique contiguous positions.

CLEANUP (PC4): extract _remove_item(db, pl, video_id, mark_dirty=) mirroring the
existing _add_item — remove_watch_later and remove_item duplicated the same
find-by-(playlist,video)/delete/commit block.

Behavior-neutral for the normal full-payload reorder. ruff clean, localdev boots.
2026-07-11 18:33:16 +02:00
peter 2b2af2aca2 Merge chore/code-hygiene: Phase 2 #3 Channels (cleanup + 2 bug fixes)
Behavior-preserving cleanup (channels.py _is_blocked/_channel_summary helpers;
frontend notifyYouTubeActionError + formatCountOrDash dedup; LS-registered table
keys; 7 dead channels.json i18n keys removed) plus two review-found bug fixes
(ChannelPage subscribe silent-403 + stale my-status/discovered-channels caches).
Verified: tsc, ruff, knip, localdev boots healthy.
2026-07-11 18:11:37 +02:00
peter 1fb10895cc fix(channels): ChannelPage subscribe — surface 403 + fix stale caches
BUG (FB3): the channel page's Subscribe mutation had no onError, so a read-only
user (no YouTube write scope) clicking Subscribe got a silent 403 — nothing
happened, no toast. Wire notifyYouTubeActionError (no wizard handle here, so the
message without the Connect button). Block is a local-only action and can't 403
for scope, so it's left as-is.

BUG (FB4): subscribe.onSuccess invalidated channel/feed/channels but omitted
my-status and discovered-channels (which ChannelDiscovery invalidates for the
same action), so after subscribing from a channel page the Discovery list and
the manager stats stayed stale. Added both.

tsc green, localdev boots healthy.
2026-07-11 18:11:23 +02:00
peter c06e8462be chore(channels-ui): Phase 2 #3 frontend cleanup — dedup 403 handler, count cell, LS keys, dead i18n
- Extract notifyYouTubeActionError (new lib/youtubeErrors.ts): the "403 → Connect
  your YouTube account, else fallback toast" logic was duplicated in Channels.tsx
  and ChannelDiscovery.tsx. (Separate file, not lib/notifications, to avoid the
  api ↔ notifications import cycle.)
- Extract format.formatCountOrDash(): the `n != null ? formatViews(n) : "—"` count
  cell was repeated across the subs/videos columns in Channels + ChannelDiscovery.
- Register channelsTable / channelDiscoveryTable in storage.ts LS instead of bare
  "siftlode.*" string literals.
- Delete 7 dead channels.json keys (filterPlaceholder, tags.newTag/createTag,
  row.stored/subs/getFullHistory/getFullHistoryHint) from en/hu/de — verified 0
  refs (createTag matches were the unrelated api.createTag method, not the key).

Behavior-neutral. tsc green, knip no new unused, localdev boots.
2026-07-11 18:11:23 +02:00
peter e00fffd000 chore(channels): Phase 2 #3 backend cleanup — extract blocked + summary helpers
- Extract _is_blocked(db, user, channel_id): the BlockedChannel EXISTS query was
  copy-pasted in channel_detail, explore_channel, and block_channel.
- Extract _channel_summary(ch): the shared id/title/handle/thumbnail/subscriber/
  video_count projection was hand-rolled in list_channels and discover_channels
  (the detail dict interleaves these with extras, so it's left as-is).

Behavior-neutral. ruff clean, localdev boots healthy.
2026-07-11 18:11:23 +02:00
peter 939ca6ee6d Merge chore/code-hygiene: Downloads FE follow-up + Phase 2 #2 Feed (cleanup + 2 bug fixes)
Behavior-preserving cleanup (feed/search/channels/videos/client/models dedup +
Feed.tsx hoist/wrapper/cache-dedup + Downloads FE-C4) plus two review-found bug
fixes (search quota-per-page cap, Feed override flash on infinite-scroll).
Verified: tsc, ruff, localdev boots healthy.
2026-07-11 17:44:11 +02:00
peter 83f5b87424 fix(feed): repair 2 bugs found in the Phase 2 #2 review
BUG-1 (search.py, high): the scrape search source logged one VIDEOS_SEARCH
quota event PER continuation page inside the paging loop, but actions_today
counts events — so a single user search that pages N times consumed N against
search_daily_limit_per_user (its docstring even warns it only works for
once-per-action logging). Log exactly once per request after the loop instead;
the API source already logs once via record_usage (it never auto-pages).

BUG-2 (Feed.tsx, medium): the optimistic-override reset effect keyed on
query.dataUpdatedAt also fired on fetchNextPage (infinite scroll bumps
dataUpdatedAt while page 1 is unchanged), so a just-hidden card flashed back
mid-scroll. Guard with a page-count ref: only reset on a real refetch, not an
append. The two override-reset effects legitimately differ now (resolves the
would-be C-F1 "identical effects" cleanup).

tsc green, ruff clean, localdev boots healthy.
2026-07-11 17:43:18 +02:00
peter bafaa5192d chore(feed-ui): Phase 2 #2 frontend cleanup — hoist, drop wrapper, dedup caches
- Hoist withOverrides(loaded) into `merged` (was mapped twice per render for
  items and playerQueue).
- Remove the needless always-true {(<>…</>)} expression wrapper around the
  Source selector.
- Extract dropFeedCaches() — the feed/feed-count/facets removeQueries trio was
  duplicated across the back and clear-now handlers.

Behavior-neutral. tsc green.
2026-07-11 17:37:52 +02:00
peter 957396aee7 chore(feed): Phase 2 #2 backend cleanup — dead return, dup helpers, shared const
- feed.py _filtered_query: drop the dead 2nd return element (status_expr was
  used only internally for WHERE filters; all 3 callers discarded it as _status).
  Now returns (query, rank_expr); fixed the stale docstring.
- youtube/client.py: extract _iter_playlist_items() — iter_my_playlist_video_ids
  and iter_playlist_items_with_ids were near-identical playlistItems paging loops
  (jscpd [173-187]≈[267-281]); both now map over the shared generator.
- sync/videos.py: extract _apply_video_batch() with an on_missing callback —
  enrich_pending and refresh_live shared the fetch-map-apply skeleton, differing
  only in the query and how they retire rows YouTube omits.
- models.py: add LIVE_OR_UPCOMING = ("live","upcoming"); replace the 4 duplicated
  copies (feed.HIDDEN_LIVE, search._LIVE_HIDDEN, videos.py + channels.py inline).

Behavior-neutral. ruff clean on touched files, localdev boots, feed/worker healthy.
2026-07-11 17:37:52 +02:00
peter c9285b8c25 chore(downloads-ui): FE-C4 follow-up cleanup
- Delete dead top-level downloads.rename.* i18n block (en/hu/de); the
  metadata modal moved to downloads.edit.*. Keep downloads.actions.rename
  (still used by ProfileEditor).
- Rename misnamed state renameJob/setRenameJob -> metaJob/setMetaJob in
  DownloadCenter (it opens MetaEditModal, not a rename dialog).
- Extract the duplicated [downloads]/[download-index]/[download-usage]
  invalidation trio into lib/downloads.ts invalidateDownloads(qc);
  used by DownloadCenter and DownloadDialog.

Behavior-neutral. tsc green, knip shows no new unused export, JSON valid.
2026-07-11 17:10:15 +02:00