Commit graph

235 commits

Author SHA1 Message Date
npeter83
59aa727715 chore(release): 0.41.0 — admin audit log + channel About enrichment/fixes 2026-07-12 17:57:39 +02:00
npeter83
45d16452f2 feat(channel): enrich the About tab + fix the tab-switch header shift
- fix: reserve the scrollbar gutter (scrollbar-gutter:stable) on the channel
  page's scroll container, so switching between the tall Videos tab and the
  short About tab no longer shifts the banner/avatar/buttons a few px sideways
  (the vertical scrollbar was appearing/disappearing between the two tabs).
- About tab now shows Country (flag + localized name), Language, Topics
  (topicCategories → readable chips), and Keywords (brandingSettings keywords
  parsed into chips, quoted multi-word tags kept whole). country/language/topics
  were already stored; keywords is new (migration 0055_channel_keywords, mapped
  in apply_channel_details, returned by channel_detail).
- Discovery → channel page: the Channel-manager "Discover from playlists" tab now
  links each channel name to its in-app channel page (ChannelLink onView), so a
  discovered channel's About/videos can be inspected BEFORE subscribing (was
  subscribe-only, plain text before).

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

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

Frontend:
- new admin-only "Audit log" nav page (ScrollText) using the shared DataTable
  (first admin page to reuse it) — sort/filter/paginate/persist, action select
  filter, actor text filter, before→after detail, Clear-all with confirm.
- AuditLog.tsx + auditColumns.tsx + api.adminAudit/clearAudit + AuditRow type.
- trilingual audit + auditActions namespaces (HU/EN/DE) + nav + config-group +
  scheduler job labels; Audit config group (retention days).
2026-07-12 07:32:41 +02:00
npeter83
c1d38eea21 chore(release): 0.40.0 — deferred-bugs closeout 2026-07-12 06:54:49 +02:00
npeter83
3394dabbeb fix(deferred): address /code-review findings on the closeout diff
- channels CB3: sort discovery rows NULLs-last (title is None) to match the DB
  ORDER BY exactly — coercing NULL title to "" floated untitled channels to the top.
- AdminUsers SC1: track in-flight rows in a Set, not a single id — per-row disabling
  now lets the admin start concurrent row actions, and a shared id was cleared by
  whichever settled first (re-enabling a still-pending row → possible double-submit).
- ChatThread CT4: reset the incoming-count ref when partnerId changes — the Messages
  page reuses one ChatThread across conversation switches, so a same-count switch
  could skip the open-marks-read badge refresh.
- messages MB3: push a bare {type:"unread"} (drop the now-dead count query — the
  client re-fetches on the signal and ignored the number anyway).
- api.ts: extract the shared keepalive `beacon()` helper (saveProgressBeacon +
  plexProgressBeacon were near-verbatim copies).
2026-07-12 06:21:29 +02:00
npeter83
f0198f2e0a fix(deferred): close frontend UI/UX tails from the hygiene sweep
- player YB2: keepalive pagehide beacon (api.saveProgressBeacon) so the resume
  position isn't lost on F5/close within 5s of the last checkpoint (mirrors Plex).
- player YB4: bound consecutive unplayable items in auto-advance so an
  auto-advancing/loop=all queue can't spin over a run of dead videos.
- downloads B6 (client): localise structured quota/edit errors centrally in
  api.req() via localizeDetail() + Intl.NumberFormat (fixes HU/DE + decimal sep);
  + 3-locale download error strings.
- channels FB1: focusChannelToken bump so re-clicking the same channel re-seeds
  the search box; FB2: syncSubs invalidates feed/feed-count (set can change now).
- config CB2: reseed the ConfigPanel draft on a key-set dataVersion + explicit
  post-save token, so a mid-edit refetch can't clobber an in-progress edit.
- config AB3 (UI): a blank allow_empty field stores "" instead of resetting.
- admin SB2/SC2: confirm + success toast on demo-whitelist remove and deny-invite
  (+ trilingual strings); SC1: disable only the acted-on row (pendingId), not all;
  SB3: pause the 1s scheduler countdown ticker while the tab is hidden.
- messages CT4: only invalidate conversations/unread when the incoming-message
  count actually changed, not on every 20s poll; MB3 (client): react to the live
  unread ping (onUnread → invalidate); MC3: extract e2ee installKey (setup/unlock);
  CC2: hoist POLL_MS to messaging.ts; MB4: document the reload-scoped socket.
2026-07-12 05:59:25 +02:00
npeter83
182dddf5ed release: v0.39.1
OAuth scope-clobber fix (sign-out/in no longer drops YouTube access) + admin
access-request email accuracy/multi-admin + auth loose-ends (register/reset timing,
messages_ws dedup, revoke decrypt).
2026-07-12 04:57:06 +02:00
npeter83
56dfa9b427 release: v0.39.0
Auth security round: SB3 (email tokens out of URL query → fragment), SA4 (server-side
session revocation + 'Log out other sessions'), SA3 (trusted-proxy X-Forwarded-For so
rate limits can't be bypassed via a forged header).
2026-07-12 03:49:55 +02:00
npeter83
95d1549570 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
npeter83
a65915ea11 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
npeter83
9375b46bc8 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
npeter83
4dd1327b93 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
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
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
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
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
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
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
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
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
npeter83
186fdbb0e5 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
npeter83
46d5572e47 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
npeter83
fe024ab29d 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
npeter83
017be5f8ca 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
npeter83
736db017e4 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
npeter83
11b7558c6c 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
npeter83
f76b80a946 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
npeter83
50cef1a552 chore(release): 0.36.3 2026-07-10 17:30:50 +02:00
npeter83
86ef4ad64c chore(release): 0.36.2 2026-07-10 16:56:24 +02:00
npeter83
efd7a3d09d chore(release): 0.36.1 2026-07-10 16:27:43 +02:00
npeter83
cf204e18a8 chore(release): 0.36.0 2026-07-10 15:52:34 +02:00
npeter83
a2de72420b 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
npeter83
3a3ba17fb8 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
npeter83
57f521191e chore(release): 0.34.2 — watch-sync toggle UX polish 2026-07-09 15:34:52 +02:00
npeter83
cbe203391e chore(release): 0.34.1 — Plex watch import concurrency fix 2026-07-09 15:19:17 +02:00
npeter83
b09963be2b chore(release): 0.34.0 — Plex watch-state import (Phase A) 2026-07-09 14:43:22 +02:00
npeter83
a0475eb7bb 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
npeter83
8675e24663 chore(release): 0.33.1 — channel page network-error fix 2026-07-09 09:43:08 +02:00
npeter83
65b4949245 chore(release): v0.33.0
Plex player (copyts playback, resume, personalization, skip intro/credits,
audio/subtitle switching, seek drag, projected finish time, +25% UI) and
Plex search improvements (own ephemeral state; empty-with-filters hint).
2026-07-09 02:32:46 +02:00
npeter83
3c622fd44c feat(plex): multi-rendition audio (client-side switch) + reliable resume-on-F5
- Multi-audio items now ship every audio track as an HLS rendition in one session
  (stream.py var_stream_map -> master.m3u8), so hls.js switches audio CLIENT-SIDE with
  no ffmpeg restart, same timeline, no gap/drift. /session gains ?multi=1 (forces HLS
  even for direct-playable multi-audio files); K is probed from the video variant seg.
- Restore the selected audio track on AUDIO_TRACKS_UPDATED, not MANIFEST_PARSED (the
  renditions aren't parsed yet on MANIFEST_PARSED, so the set was dropped -> after F5 the
  UI showed the restored track but playback stayed on the default).
- Resume position now survives F5: a pagehide keepalive beacon (plexProgressBeacon)
  saves the current position on reload/close/navigate, since React effect cleanup does
  not run on a full reload; seekTo writes the target into absRef immediately so a save
  right after a seek is accurate.
2026-07-08 22:25:03 +02:00
npeter83
0a0703b769 feat(plex): rework player timing (copyts) + player settings & personalization
Streaming / subtitle sync (the core fix):
- stream.py: add -copyts so HLS segments carry the true absolute PTS, and measure
  the real keyframe start K from seg_0 (ffprobe) -> return it as the session start.
  Fixes the seconds-long subtitle lead caused by using the requested seek offset
  instead of the keyframe ffmpeg actually lands on with video stream-copy.
- Add -noaccurate_seek so the re-encoded audio starts at the same keyframe as the
  video (was starting (X-K)s later -> seconds of silence after each seek/audio switch).
- Compensate the fixed ~1.0s lag hls.js introduces for non-zero-start copyts streams,
  folded into the session start so the clock, seeking and the subtitle shift are all
  content-accurate.
- /subtitle gains an offset param; _shift_vtt shifts absolute cues onto the session's
  zero-based clock and DROPS fully-past cue blocks (collapsing them to 0->0 made every
  past cue active at currentTime 0 on resume -> a pile-up until playback advanced).

Audio:
- /session + stream.py gain an audio A/V-sync offset (-itsoffset, full +/-, second
  input only when non-zero).

Player settings & personalization (per-account, persisted -> survive F5):
- storage.ts: useAccountPersistedObject (per-account JSON prefs blob).
- PlexPlayer: volume/mute, audio+subtitle language (index-based match, fixes the F5
  audio-revert), sync offsets, seek steps, subtitle style, auto-hide, play intent.
- Hotkeys A (cycle audio) / S (cycle subtitle), mouse-wheel volume, Ctrl+arrow fine
  seek, per-user plain/fine seek-step + auto-hide toggle.
- Subtitle appearance: size / colour / vertical position / background via ::cue + line.
- UI: split into a Tracks quick-menu + a tabbed gear panel (Sync | Playback | Subtitle);
  both dismiss on outside-click; edge-aware control tooltips.
- i18n en/hu/de for all new strings.
2026-07-08 21:35:38 +02:00
npeter83
6232a66878 fix(share): let link-preview crawlers fetch /watch/ pages (robots.txt)
Shared links didn't unfurl in Messenger: robots.txt disallowed /watch/ for ALL
user-agents, so facebookexternalhit honoured it and got a blocked (403) fetch —
the OG tags were correct but never read. Add an allow group for the link-preview
bots (facebookexternalhit, Twitterbot, Discordbot, WhatsApp, Slack, Telegram,
LinkedIn, …) so they can fetch share pages; the wildcard still keeps /watch/ out
of search indexes. Release v0.32.1.
2026-07-07 22:56:38 +02:00
npeter83
b63b3334d6 fix(share): self-host og:image so link previews stay reliable
A remote thumbnail as og:image is unreliable for the crawler — Facebook's signed
CDN URL expires (so an old shared link's image later vanishes) and cross-origin
hotlinking is flaky. Record a self-hosted poster for EVERY download (ensure_poster
returns the existing <base>.jpg thumbnail sidecar, or cuts a frame) and point
og:image at our own /api/public/watch/{token}/poster.jpg. Backfill now fills
poster_path for all existing downloads, not just thumbnail-less ones.
2026-07-07 22:43:18 +02:00
npeter83
8b02b41059 chore(release): v0.32.0 2026-07-07 22:29:05 +02:00
npeter83
cb170dfd32 feat(downloads): clickable channels + poster fallback for thumbnail-less sources
Two visual gaps for non-catalog downloads:
- Channel link: YouTube already exposes channel_url; Facebook exposes none but a
  numeric uploader_id that resolves at facebook.com/<id>. `_uploader_url` derives
  it so the auto-detected channel renders as a real clickable link.
- Poster: a source with no thumbnail (e.g. a direct reddit HLS URL) showed a
  blank image box. The worker now cuts a representative frame with ffmpeg
  (`ensure_poster`) into the `<base>.jpg` sidecar and records `poster_path`
  (migration 0050). The card, the public watch page (<video poster> + og:image),
  and link previews fall back to it via new authed + public poster endpoints.

Adds `app.downloads.backfill` (one-off, re-run-safe) to fill uploader_url
(re-extract YouTube/Facebook metadata) and posters for pre-existing downloads.
2026-07-07 22:28:49 +02:00
npeter83
33fad8911b fix(downloads): clip long titles to fit display_name (VARCHAR 255)
A download whose source title exceeded 255 chars (e.g. a recipe baked into a
Facebook video's title) failed with a StringDataRightTruncation: the worker
auto-fills an empty display_name from the title, which overflows the 255-char
column. Clip the title to 255 at every point it flows into display_name (worker
completion paths + enqueue); the full title is untouched on the asset. Pre-existing
latent bug, surfaced by an unusually long title.

Release v0.31.1.
2026-07-07 21:40:15 +02:00