Commit Graph
100 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
peter fef8036d6c fix(downloads-ui): editor tail no-op + persisted-tab fallback (+ dead ternary)
Found during the Phase-2 Downloads-frontend review:
- VideoEditor: onLoaded now reconciles the still-pristine full-length segment to the
  REAL decoded duration (its guard `end<=0` was dead since the metadata srcDur is
  always >0). Without this the untouched last segment kept the rounded metadata
  length, so isFullSingle read false and "Create" was enabled on an unmodified
  video — producing a bogus edit that dropped the tail.
- DownloadCenter: fall back to the "queue" tab when a persisted tab (e.g. admin-only
  "system") isn't in the current set, instead of rendering a blank page.
- VideoEditor: reencode = cropOn || accurate (was a no-op `willJoin ? accurate : accurate`).

tsc clean. GUI-affecting → pending an E2E visual test (needs an authed localdev
session) before user UAT. Cross-cutting UI-consistency cleanup deferred to Phase 3.
2026-07-11 16:37:57 +02:00
peter 4b22a7fa23 Merge branch 'dev' into chore/code-hygiene 2026-07-11 16:22:39 +02:00
peter a24e77c693 Merge bugfix/downloads: repair 5 Downloads backend bugs (B1-B5)
Sticky errored asset re-download, ref_count leak on errored-job delete, admin
storage cap reads the DB value, and 400-not-500 on malformed edit/format specs.
Reviewed clean (a B2 concurrency race was caught + fixed). Local dev only — prod
publish waits for the end of the whole code-hygiene review.
2026-07-11 16:22:22 +02:00
peter 006f38382b fix(downloads): repair 5 confirmed backend bugs (B1-B5)
- B1 (sticky errored asset): get_or_create_asset now resets a reused status=='error'
  asset back to 'pending' (+clears the error), so a fresh enqueue/edit of a once-failed
  (source,format) pair actually re-downloads instead of the worker short-circuiting the
  new job with the stale error. Errored assets carry no expires_at, so without this the
  pair was permanently poisoned for all users until someone hit resume.
- B2 (ref_count leak): _release_asset counts 'error' as a holding state, so deleting an
  errored job decrements the ref_count that enqueue always incremented (the worker never
  decrements on failure). Errored rows are deliberately NOT deleted here — a concurrent
  B1 reuse could otherwise be lost-updated + FK-nulled; the row is fileless and harmless.
- B3: admin storage dashboard reads sysconfig.get_int(db,'download_total_max_bytes')
  (the admin-editable DB value GC enforces) instead of the raw env default.
- B4: the single-trim branch of normalize_edit_spec guards its float() coercion like the
  crop/segments branches — a malformed trim now yields a 400, not an unhandled 500.
- B5: formats.normalize guards int(max_height) → falls back to "best" instead of 500.

Reviewed (race in an earlier B2 draft caught + fixed); localdev boots, B4/B5 unit-verified.
2026-07-11 16:15:12 +02:00
peter 39748c394b chore(downloads): C3-C6 — DRY the source-URL, filename, and serialize helpers
- C3: `_reference_url` (downloads.py) and public.py's inline source-URL block were
  the same rule → extract `service.reference_url(job, asset)`; both surfaces now
  share it so the "downloaded from" link can't drift between them.
- C4: Content-Disposition filename derivation (ext pick + doubled-ext strip + join)
  was duplicated in download_file and watch_file → extract
  `storage.download_filename(display_name, container, path)`.
- C5: inline the one-line `_clean_basename` passthrough (folded into C4).
- C6: the `db.get(MediaAsset, job.asset_id) if job.asset_id else None; _serialize(...)`
  resolve-then-serialize dance was repeated across 6 single-job handlers → fold into
  `_serialize_job(db, job)`. (File-serving handlers that use the asset for their own
  checks keep their explicit resolve.)

Behavior-neutral; ruff/parse clean, localdev boots, downloads routes load.
2026-07-11 05:47:51 +02:00
peter efcac631de chore(downloads): C1+C2 — drop dead target_ext; centralize path-traversal guard
- C1: remove downloads/formats.py target_ext() — defined but never called (the
  worker derives the real extension from the produced file's suffix).
- C2: the download-root containment+existence guard was copy-pasted 6× across the
  file-serving endpoints (routes/downloads.py ×3, routes/public.py ×3). Extract
  storage.safe_abs_path(root, rel) -> Path|None so this security-sensitive check
  lives in one place; behavior identical (same containment test + messages). The
  extraction also made `pathlib.Path` unused in both route modules (removed).
2026-07-11 05:41:43 +02:00
peter 0b2740de0f chore: Phase 1 hygiene — drop unused imports/exports/types (behavior-neutral)
Machine-baseline harvest (ruff + knip), all tsc/parse-green, no runtime change:
- backend: remove 3 unused imports (channels/playlists/youtube) via ruff; drop the
  unused `job` binding in unshare_download (keep the _own_job ownership guard call).
- frontend: remove `export` from 23 internally-used-only symbols (knip "unused
  exports") to shrink the public surface; delete 2 genuinely-dead declarations
  (PlexBrowseResult — leftover from the removed /browse route; WIDGET_TITLES —
  hardcoded English titles superseded by i18n).

Held back for a decision (unused here = possibly-unwired, NOT dead — flagged, not
removed): e2ee.lock()/clearDevice() (security primitives never wired to logout / a
"forget device" feature) and loadDefaultViewFilters (App reimplements it inline — a
DRY issue). See siftlode-ops/CODE-HYGIENE.md.
2026-07-11 04:47:08 +02:00
peter 2bdccfb8c7 release: v0.37.0 — unified library, series view, adaptive filters, collections picker 2026-07-11 03:50:34 +02:00
peter 11bc8bf347 Merge feature/plex-series-view into dev
Plex series-view + unified-library epic + this session's follow-through:
- 3-level series view, unified movies+shows cross-library browser, adaptive facets
- code-review findings F1-F10 (F7 deferred): stale query keys, hidden-movie facet
  exclusion, best-effort enrichment, facet sort-key, optimistic episode toggle,
  bulk Plex-push batching, per-show enrichment cache+parallelize, dead-code + DRY
- Collections restored as a cross-library union picker
- genre facet offers only co-occurring genres in AND mode (+ empty-AND trap guard)

Local dev merge only — NOT published/shipped (prod stays v0.36.4).
2026-07-11 03:42:50 +02:00
peter 196f3bf2f9 fix(plex): genre facet offers only co-occurring genres in AND mode
The adaptive genre facet always self-excluded the genre filter ({**p,"genres":
None}). That's right for "any"/OR mode — each offered genre widens the result —
but wrong for "all"/AND mode, where adding a genre narrows: it kept showing
genres that don't co-occur with the current selection, so clicking them ANDed the
result to zero (dead-end chips). Now self-exclude only in "any" mode; in "all"
mode keep the filter applied so the facet returns only genres present on the
current result set.

Guard the empty-AND case: a zero-match combination would return facets.genres=[],
and the sidebar hides the whole genre section (chips + Any/All toggle) when it's
empty — trapping the user with no per-chip way to undo the selection. Always emit
the actively-selected genres (count 0 when dropped) so they stay visible and
removable.
2026-07-11 03:41:17 +02:00
peter b85d12e0c9 feat(plex): restore Collections filter as a cross-library union picker
The searchable Collections picker was dropped from the sidebar when browsing
went unified (movies+shows across libraries) because collections are per-library
and there's no single selected library. Bring it back library-agnostic: GET
/api/plex/collections `library` is now optional — without it the endpoint unions
collections across all enabled libraries (with it, the old single-library path the
collection editor uses). The sidebar re-adds the searchable list (chip when one is
active). Its query key is ["plex-collections","union",<search>] so a search term
equal to a library plex_key can't collide with the editor's ["plex-collections",<key>],
while the shared prefix keeps editor invalidation refreshing the picker.
2026-07-11 03:30:33 +02:00
peter 213145b7bd perf(plex): batch bulk pushes, cache show enrichment; remove dead code + DRY (F6/F8/F10)
F6: coalesce whole-show/season Plex watch-pushes into ONE background task
(push_bulk_state_to_plex) that reuses a single DB session + keep-alive Plex
client, instead of scheduling one task (own session + HTTP client) per episode.

F8: cache a show's live Plex enrichment (metadata + related) per rating_key for
a short TTL and fetch the two calls in parallel; repeat opens skip the network.
Raw payloads are cached; per-user shaping stays out. An empty related list is not
cached (plex.related swallows a transient failure as [] — don't pin it for the TTL).

F10: remove dead code — the pre-unified /browse route + browse(), the /people
route + people() + _person_photo(), api.plexPeople, interface PlexPerson, and the
orphaned plex.people i18n block. DRY: appendPlexFilters() shared by plexLibrary +
plexFacets; one exported plexDetailUi.Filterable (was Fil + Filterable); PlexInfo
migrated onto the shared plexDetailUi hooks/menu (DetailCustomizeMenu gained
overlay + extra props; useDetailPrefs exposes savePref; PrefToggle exported).

Reviewed (high) → clean. F7 (facet aggregate collapse) deferred: self-exclusion
gives each sub-aggregate a distinct WHERE, and no measurement shows /facets slow.
2026-07-11 03:10:02 +02:00
peter d46f55af83 fix(plex): address code-review findings (F1-F3, F5, F9)
- F1: refresh grid after playback/collection-edit — invalidate the renamed
  ["plex-library"] query key in PlexPlayer + PlexCollectionEditor (was stale
  ["plex-browse"], a dead no-op)
- F2: exclude hidden movies from facet counts/bounds — the movie facet base now
  always joins watch-state and mirrors unified_library's status branches, so
  facets match the visible grid exactly
- F3: show_detail/item_detail live-Plex enrichment degrades instead of 500ing —
  broaden the best-effort block to except Exception
- F5: don't re-run the heavy facet computation on sort-direction toggles — key
  the facets query only on fields plexFacets actually sends
- F9: optimistic watched-toggle also updates the search Episodes section and
  gives toggleEpisodeWatched an optimistic path (shared optimisticStatus helper)
2026-07-11 02:25:14 +02:00
peter 3c46bc4377 fix(plex): series-page UX fixes from UAT (7 items)
1. Series detail pages now match the movie info page: the customize menu lives in the
   hero panel (top-right), the Cast/Seasons/Related strips sit in glassy panels, and the
   menu is dynamic — Related shows can be toggled (new plexInfoRelated pref).
2. The Plex search box now survives F5 (persisted per-account like the filters).
3. Show + season hero posters get a hover Play/Resume overlay.
4. Card top-right watched toggle can now UN-watch: the watched/in-progress badges were
   intercepting the click (no pointer-events-none), so the toggle underneath never fired.
5. Episode card: the watched check no longer jumps on hover (badge now shares the
   toggle's box); every card's title/meta gets its own glassy translucent background and
   the Add-to-playlist button is always visible (not hover-only).
6. Season-card 'In progress' label is readable again (solid dark badge over the poster).
7. On a season page the show title is clickable → back to the show (history-correct).
2026-07-11 01:34:30 +02:00
peter 819e17536c feat(plex): series-page polish — season/episode quick actions + glassy art backdrop
- Season cards (show page) gain a hover Play overlay, a clickable title, a quick
  watched/unwatched toggle (whole season) and an add-whole-season-to-playlist button.
- Episode cards gain a hover watched/unwatched quick toggle (single episode) — in the
  season page and the search Episodes section.
- Series show + season pages now use the same glassy HTPC look as the movie info page:
  a faint fixed art backdrop + a customize menu (toggle the backdrop / hide the cast
  row), factored into a shared lib/plexDetailUi (useDetailPrefs / useArtBackdrop /
  DetailCustomizeMenu), reusing the movie info prefs (plexInfoArtBg/plexInfoCast) so the
  toggle state is consistent across movies and series.
2026-07-11 01:01:34 +02:00
peter cb93b698f4 fix(plex): facet + sort refinements
- Facets now respect the watch-state filter too (In progress / Watched / Unwatched),
  so e.g. the Year bounds match the visible set (was showing the library min, not the
  filtered min).
- Genre chips sorted alphabetically (was by count).
- Sort chips ordered alphabetically by their localized label.
- Ascending/Descending chips swapped (Ascending first) and the default sort direction
  is now Ascending.
- Default sort is now Title (ascending) instead of Recently added / Descending.
2026-07-11 00:45:05 +02:00
peter 59d6ad01b3 feat(plex): adaptive (faceted) filters — each filter narrows the others
The sidebar's filter chips/bounds now ADAPT to the active filters: /facets takes the
current filters and computes each group's available values with all the OTHER active
filters applied but not its own (standard faceted search). So picking e.g. IMDb rating
>=8 trims the genre/content-rating chips + year bounds to what's still reachable, and
picking a genre narrows them further — while a multi-select group (genres) still shows
options to add. Extracted _meta_filter_conds (shared by browse/unified/facets), and the
frontend passes the live filters to plexFacets (query keyed on them, previous chips kept
during refetch). Search text is intentionally not factored into facets.
2026-07-11 00:31:47 +02:00
peter b526b4d9cc feat(plex): unify movies + shows into one cross-library browser
Collapse the separate Movie/Show library sections into ONE unified library with a
shared search + shared filter sidebar and a Movies/Shows/Both scope selector.

Backend: new GET /api/plex/library — a cross-library UNION of movies (plex_items) and
shows (plex_shows) as one mixed, paginated, sorted feed, scoped movie|show|both, with
the shared filters (extracted into _apply_meta_filters, DRY), FTS search, and per-user
watch-state (a show's state = the aggregate of its episodes). On a search that also
matches episodes, matching episodes come back in a separate 'episodes' list (the grouped
'Episodes' section — Proposal 3). /facets is now scope-aware (merged across the scope's
libraries). /item and /show now return their library section key (for the admin
collection editor, since there's no single library prop in the unified view).

Frontend: PlexSidebar's library picker -> a scope selector (Both/Movies/Shows); facets +
browse follow the scope (App's plexLib repurposed to a validated scope, default both).
PlexBrowse uses the unified endpoint, renders a mixed Titles grid + an Episodes section
on search. Poster cards are generalized: a hover Play/Resume overlay on every card, a
clickable title (not just the poster), and a movie/show type tag. The quick watched
toggle is now optimistic so it reliably flips BOTH ways (fixes the movie card that could
mark but not un-mark). Cast/crew members and the show hero's meta (year/rating/genre/
content-rating) are clickable filters; clicking a person widens the scope to Both so the
result is a mixed movie+show feed. i18n plex.filter.scope*/plex.unified.* (en/hu/de).

Still pending from the polish list (next pass): season-card quick toggles (watched +
add-to-playlist), per-episode watched toggle, and the full glassy art-bg + hide-cast
customize menu on the series pages.
2026-07-11 00:15:49 +02:00
peter 5fa1c701c2 feat(plex): Plex-web-style 3-level series view (Phase 2 of series view)
Restructure TV browsing into show detail → seasons → season episodes → player, like
the Plex web app.

Backend: /show/{rk} now returns a rich show page — hero meta (rating/content_rating/
genres/studio + live IMDb), live Cast & Crew, Related shows (Plex 'related', mapped to
our mirrored shows so they're openable), and per-season cards with an aggregate
watch-state + on-deck episode, plus show-level resume (on-deck)/first(play-from-start)/
status rollup. New PlexClient.related(). New bulk-state endpoints POST /show/{rk}/state
and /season/{rk}/state mark every episode watched/unwatched for the user and mirror
each change to a linked Plex account in the background (best-effort, checked once).

Frontend: PlexShowView reworked into the show detail page (hero + Resume/Play-from-start/
Mark-show-watched/Add-to-playlist/[admin]Add-to-collection + season card grid + cast +
related strips); new PlexSeasonView season subpage (hero + Resume/Play/Mark-season/
Add-season-to-playlist + landscape episode grid). Both read the one cached ['plex-show']
payload (season page picks its season out of it — instant, no extra fetch). Player queue =
the whole show (from the show page) or the season (from the season page) so prev/next +
auto-advance follow order. New 'season' history subview; Backspace steps back one drill
level (grid←show←season, out of info/playlist) alongside browser/mouse Back. Season-level
'Add to collection' intentionally omitted (Plex collections hold whole shows, not seasons).
i18n plex.series.* (en/hu/de).
2026-07-10 22:08:04 +02:00
peter 56799e5bcc feat(plex): TV-show metadata sync + series filters (Phase 1 of series view)
Give TV shows the same filterable metadata as movies so the TV grid can be
filtered/sorted, not just library+sort. Backend: migration 0052 adds
rating/content_rating/studio/originally_available_at/genres/directors/cast_names/
people_text to plex_shows (+ GIN/indexes, people_text folded into search_vector);
_sync_shows populates them cheaply from the show section listing (no per-item
calls). /browse show-branch gains the movie filter set (minus duration) plus an
aggregate per-user watch-state (a show rolls up its episodes: all watched=watched,
any progress=in_progress, none=new) with year/rating/release sorts; /facets returns
show facets. Frontend: PlexSidebar renders the metadata filters + watch-state for TV
libraries (duration stays movie-only); show cards show a watched/in-progress badge.
i18n plex.inProgress (en/hu/de). Needs a Plex re-sync to populate the new columns.
2026-07-10 21:43:09 +02:00
peter 6397844863 Merge improvement/player-drop-hd-experiment: drop HD flash, clean notes (v0.36.4) 2026-07-10 18:14:32 +02:00
peter 8279f259bb revert(player): drop the experimental HD-unlock fullscreen flash
User-tested on prod: the flash fires (fullscreen → back to small) but the source
stays 360p — YouTube doesn't switch quality within the flash window and we can't set
it via the (dead) API, so the unlock has no effect. Give up on forcing windowed HD.
Restore PlayerModal to its 0.36.2 state (native-menu-yield + scroll-anywhere volume
kept). Also prune the failed HD-quality attempts from the release notes (the flash
and the transform-revert notes, plus the hollow 'higher quality' claim in 0.36.0) so
there's no user-facing trace of an effort that yielded nothing.
2026-07-10 18:14:32 +02:00
peter f785650269 Merge improvement/player-hd-unlock-flash: experimental windowed-HD unlock (v0.36.3) 2026-07-10 17:30:50 +02:00
peter aac03e2c2b chore(release): 0.36.3 2026-07-10 17:30:50 +02:00
peter fcd58f12c9 feat(player): experimental one-time fullscreen flash to unlock windowed HD
YouTube hard-caps a windowed embed to ~360p and only a real fullscreen lifts the
cap — after which the higher quality persists for the session. Since we can't set
quality via the (dead) API, coax it: on the first video opened per page session,
briefly enter fullscreen using the modal-open click's live user activation, then
exit back to the small player. Exits early once onPlaybackQualityChange reports the
quality actually rose (else after a ms cap). Module-scoped
hdUnlockDone flag makes it fire once per session (the unlock persists), with a
retry on the first overlay click if the open-click activation had expired. Cleaned
up on teardown so a mid-flash close can't leave the player stuck in fullscreen.
Best-effort — bandwidth still gates the actual bitrate.
2026-07-10 17:30:49 +02:00
peter 1d0c77b34a Merge improvement/player-revert-transform: drop ineffective HD hack (v0.36.2) 2026-07-10 16:56:24 +02:00
peter 967364b938 chore(release): 0.36.2 2026-07-10 16:56:24 +02:00
peter e678a267ae revert(player): drop the transform-scale HD hack (didn't beat YouTube's cap)
Confirmed on prod: rendering the iframe at 1920x1080 logical and CSS transform:
scale()-ing it down does NOT lift YouTube's quality cap — the embed stays ~360p in
the windowed player and manual HD still snaps back; only true fullscreen unlocks
1080p (which then persists for the session). YouTube caps by the on-screen size, not
the iframe's window.innerWidth, so the transform only shrank YouTube's native
controls for no benefit. Restore the plain 100%/100% mount.

Kept: scroll-anywhere volume (works), the native-menu-yield fix, max-w-6xl, and the
harmless vq=hd1080 hint.
2026-07-10 16:56:24 +02:00
peter 05e6554048 Merge improvement/player-windowed-hd: windowed 1080p + scroll-anywhere volume (v0.36.1) 2026-07-10 16:27:43 +02:00
peter 3a25f8341f chore(release): 0.36.1 2026-07-10 16:27:43 +02:00
peter d6ce4a0477 feat(player): unlock 1080p in the windowed player + scroll-anywhere volume
YouTube caps an embedded player's max quality to the iframe's OWN inner viewport
size, so the small windowed player was stuck at ~360p (manual HD selection snapped
back); only fullscreen unlocked 1080p. Render the player at a fixed 1920x1080
logical size and CSS transform: scale() it down to fit the stage — the transform
doesn't change the iframe's window.innerWidth, so YouTube keeps seeing a 1080p
viewport and lets you pick 1080p while we display it small. A ResizeObserver keeps
the scale fitting the stage in both windowed and fullscreen.

Also move wheel-to-volume from the small centre overlay to the whole modal, so
scrolling anywhere over the player window adjusts volume.
2026-07-10 16:27:42 +02:00
peter b8fcef4ed8 Merge improvement/player-quality-native-controls: native YT menu access + higher quality (v0.36.0) 2026-07-10 15:52:43 +02:00
peter bab1b3361a chore(release): 0.36.0 2026-07-10 15:52:34 +02:00
peter ba08f741b1 feat(player): open YouTube's native settings menu fully + push higher quality
The transparent interaction overlay (click=play/pause, wheel=volume, keeps
keyboard focus off the cross-origin iframe) also blocked YouTube's own settings
menu, which expands down over the video past the overlay: items below the first
few were unclickable, and 'More options' made it worse.

Now the overlay yields to native controls. Clicking a native control (gear/seek/
CC) moves focus into the player iframe — the only cross-origin signal available —
which we detect (window blur + getIframe() focus check) to drop the overlay's
pointer-events, so the whole menu is navigable at any height. A discreet badge
signals native mode; moving the pointer off the video (or the window regaining
focus) re-arms the click/scroll/keyboard shortcuts.

Quality: the IFrame API's setPlaybackQuality/suggestedQuality are hard no-ops now,
so the real lever is the rendered player size — bump the modal max-w-4xl -> 6xl so
YouTube's ABR targets a higher resolution — plus a best-effort 'vq=hd1080' URL hint.
i18n (en/hu/de) for the native-mode badge.
2026-07-10 15:52:34 +02:00
peter 1dd42f06a2 chore(release): 0.35.0 — two-way Plex watch sync (Phase B + C)
Completes the two-way Plex ↔ Siftlode watch-state sync: Phase B (Siftlode→Plex push, immediate)
and Phase C (incremental history/on-deck pull + daily full reconcile incl. un-watch). Also the
auto-advance watched-mark id-race fix.
2026-07-10 00:47:55 +02:00
peter bd6b50e435 Merge: Plex watch-state sync Phase C (incremental + full reconcile, 2-way complete) 2026-07-10 00:46:22 +02:00
peter 58447e19f0 feat(plex): Phase C — incremental + full Plex↔Siftlode watch reconcile
Completes the two-way watch-state sync with two scheduler jobs:

- plex_watch_sync (default 30m): pull recent Plex-side changes (watch history + on-deck, filtered
  to the owner account) into Siftlode under last-write-wins (_pull_apply + a _same_state ping-pong
  guard + skew tolerance), then re-push any still-unsynced local states.
- plex_watch_reconcile (default daily): full section rescan; uses synced_to_plex to settle what the
  incremental feed can't — notably propagating a Plex-side un-watch (clear a previously-mirrored row
  Plex no longer has) — while re-pushing never-synced local states and never touching hidden
  (Siftlode-only) rows. Union-preserving.

PlexClient gains accounts/watch_history/on_deck; _scan_plex_states is factored out and shared with
the one-time import. Owner accountID is resolved once and cached on the link. Both jobs are
registered in the scheduler (pause-skip, activity tracking, run-now, admin-tunable intervals) with
trilingual (HU/EN/DE) labels + descriptions. New config default plex_watch_reconcile_interval_min.

Verified live against the real Plex server: read feeds, last-write-wins, dirty re-push, the
incremental job end-to-end, and a full reconcile that cleared exactly the one un-watched item with
zero collateral across 17976 scanned.
2026-07-10 00:46:22 +02:00
peter a2092cc65d Merge: Plex watch-state sync Phase B (Siftlode→Plex push) + auto-advance watched-mark fix 2026-07-10 00:18:54 +02:00
peter 68ba887540 fix(plex): mark the finished episode on the correct id when auto-advancing
The player's watched-mark on completion keyed off the reactive `id`, which can run ahead of the
media actually playing: when the next episode fails to load (e.g. no local file), loadSession
returns without replacing the <video> source, so the old episode keeps playing and later fires
'ended' — but by then an auto-skip-credits jump has already advanced `id` to the next episode.
The result was the WRONG episode marked watched (and scrobbled to Plex), while the finished one
got nothing. This was latent before, made visible/harmful by the Phase B Plex push.

- go(): pause the outgoing media before switching, so a failed next-episode load can't leave the
  old media running into a stray 'ended' against the new id.
- auto-skip-credits (doSkip): mark the CURRENT id watched (captured now) before advancing — binge
  advance is a finish, and previously marked nothing.
- onEnded: ignore a spurious 'ended' from media that never played (absRef 0), so a failed next
  episode can't be marked or cascade another advance.

Manual Next/Prev still never mark watched (not a finish). Verified live: E06→E07 with a missing
E07 marks E06 watched (+ Plex), E07 nothing.
2026-07-10 00:18:44 +02:00
peter 9aea0a3c88 feat(plex): Phase B — Siftlode→Plex watch-state push
Mirror watched/unwatched/resume from Siftlode to a linked (owner) Plex account.

- PlexClient: scrobble / unscrobble / set_timeline (/:/scrobble, /:/unscrobble, /:/timeline).
- watch_sync: link_for_push() gates on owner + sync_enabled + initial_import_done; best-effort
  push_state_to_plex() runs as a background task with its own session, never raises (Plex being
  down must not break the user's action), and flips synced_to_plex on success so Phase C's pull
  won't bounce it back.
- item_state / item_progress schedule the push: watched/unwatched immediately, resume only on a
  "final" checkpoint (pause / pagehide / unmount) — not every 10s tick — so one watch doesn't spray
  /:/timeline at the server. `hidden` is Siftlode-only and never touches Plex.
- Frontend: plexProgress / plexProgressBeacon carry a `final` flag; the periodic checkpoint is
  non-final, pause/pagehide/leaving the player are final.

Verified live against the real Plex server (scrobble/unscrobble/timeline round-trip + restore;
link gating; synced_to_plex flip).
2026-07-10 00:17:56 +02:00
peter 9ecb3e6e81 chore(release): 0.34.2 — watch-sync toggle UX polish 2026-07-09 15:34:52 +02:00
peter e392c26aa5 fix(plex): optimistic toggle + 'importing' indicator for watch-sync
The watch-sync switch only flipped once the (multi-second) import request returned, so a click read as
'nothing happened' and invited repeat clicks. Reflect the toggle's target optimistically while the
mutation is in flight and show an 'Importing your Plex watch history…' spinner line, so it's obvious
the switch responded and is working.
2026-07-09 15:34:28 +02:00
peter 980f228561 chore(release): 0.34.1 — Plex watch import concurrency fix 2026-07-09 15:19:17 +02:00
peter c5d14ea00f fix(plex): make watch import concurrency-safe (upsert), guard toggle
The Plex watch import inserted plex_states via per-row ORM add against a pre-read 'existing' set,
which is not safe when the enable call fires more than once while the multi-second import runs (the
toggle wasn't disabled during it): overlapping imports raced to INSERT the same (user_id, item_id)
rows and all but the first hit a UniqueViolation -> 500 (seen on prod). Rewrite the write path as a
chunked PostgreSQL UPSERT (on_conflict_do_update, rows de-duped by item_id) — idempotent and
concurrency-safe, Plex still wins on the intersection and Siftlode-only states are untouched.

Also disable the Settings toggle (and ignore its onChange) while a sync mutation is pending, so it
can't be fired repeatedly; adds a reusable 'disabled' prop to the Switch primitive.
2026-07-09 15:18:19 +02:00
peter b67a14bd13 chore(release): 0.34.0 — Plex watch-state import (Phase A) 2026-07-09 14:43:22 +02:00
peter 795a8f3f5b Merge: Plex watch-state sync Phase A (Plex→Siftlode import) 2026-07-09 14:42:41 +02:00
peter 3485e1da90 feat(plex): Settings toggle for Plex watch-sync + one-click import
Admins get a 'Plex watch sync' section in Settings → Account (shown when the Plex module is on):
a two-way-sync toggle whose first enable imports the Plex watch history, a last-import line, and an
'Import from Plex now' button. api.plexWatch{Link,SetLink,Import}; trilingual EN/HU/DE strings.
2026-07-09 14:42:32 +02:00
peter 457eff3a60 feat(plex): Phase A — one-time Plex→Siftlode watch-state import
Plex records watch state per Plex account; Siftlode per user in plex_states. A new plex_link table
maps a Siftlode user to a Plex account; for the owner (MVP) the row uses the server admin token
(uses_admin=True), so no separate Plex login. app/plex/watch_sync.py reads the owner account's
viewCount/viewOffset/lastViewedAt (already present in the catalog mirror's section listing) and
upserts them into the owner's plex_states — 'Plex is master' on this first import, but only where
Plex has a watch record (Siftlode-only states are preserved; union on the intersection). Idempotent.

Admin routes: GET/POST /api/plex/watch/link (status + enable/disable; first enable runs the import)
and POST /api/plex/watch/import (re-run). Migration 0051_plex_link. Two-way push (Phase B) and the
incremental Plex→Siftlode reconcile (Phase C) build on this in later ships.
2026-07-09 14:42:18 +02:00
peter ccd5f21921 chore(release): 0.33.1 — channel page network-error fix 2026-07-09 09:43:08 +02:00
peter 8f6a8b4a15 Merge: YouTube client network-error handling (channel page 500 fix) 2026-07-09 09:42:21 +02:00