Commit graph

681 commits

Author SHA1 Message Date
npeter83
eba1c8f01a 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
npeter83
1efd089749 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
npeter83
1df6ddd154 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
npeter83
8cbaf1d731 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
npeter83
6cd14f1077 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
npeter83
28061353ec 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
npeter83
358142ca9a 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
npeter83
f90c12beaa 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
npeter83
97088d5393 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
npeter83
1595580e12 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
npeter83
f17bad9870 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
npeter83
3c7a8c7a14 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
npeter83
41a915f23c 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
npeter83
e92751dbce 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
npeter83
f5fac09833 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
npeter83
ab92831879 Merge chore/code-hygiene: clear E2EE key on logout (user-approved) 2026-07-11 20:57:30 +02:00
npeter83
7e562c6cb5 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
npeter83
01d55f3029 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
npeter83
5441ad203d 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
npeter83
40ddaa2c92 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
npeter83
f6cfd4028e 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
npeter83
3d00d75863 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
npeter83
a6ffcf9577 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
npeter83
4e68bdf920 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
npeter83
5550f9ac89 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
npeter83
31c1284eb7 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
npeter83
a84cb991e7 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
npeter83
c0e941bc0c 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
npeter83
ecb2e0b749 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
npeter83
2ebaa23f10 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
npeter83
970e725d39 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
npeter83
ee601363c2 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
npeter83
8fb41383e6 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
npeter83
58aac9e8ec 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
npeter83
94417ada72 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
npeter83
3cbb420ece 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
npeter83
505f0e5650 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
npeter83
477056bfa8 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
npeter83
94fd7ba9a6 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
npeter83
aee1bdc85d Merge branch 'dev' into chore/code-hygiene 2026-07-11 16:22:39 +02:00
npeter83
0da65985d0 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
npeter83
7a6f351e64 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
npeter83
cac5526399 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
npeter83
2a44db04d8 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
npeter83
c2a2c98f16 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
npeter83
bf8d4b94a0 release: v0.37.0 — unified library, series view, adaptive filters, collections picker 2026-07-11 03:50:34 +02:00
npeter83
a9edc48cb4 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
npeter83
4423e8464e 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
npeter83
5759deac20 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
npeter83
b3af04a997 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