Commit graph

144 commits

Author SHA1 Message Date
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
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
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
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
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
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
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
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
aee1bdc85d Merge branch 'dev' into chore/code-hygiene 2026-07-11 16:22:39 +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
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
npeter83
9f8e410033 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
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
569d31235d 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
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
04fb3fb16e 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
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
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
8c86c6b4a8 feat(downloads): editable details with clickable channel and extra links
The edit (pencil) action now edits a download's full display metadata — title,
channel name, channel link and any number of extra reference URLs — instead of
just the name. The channel and links render as clickable links on the library
card, and the channel link is auto-filled from the source (yt-dlp channel_url)
when available. Shared watch pages resolve the same per-download overrides, so a
rename/channel/link edit is reflected on the public /watch page too, with every
link clickable.

Adds migration 0049 (media_assets.uploader_url; download_jobs.display_uploader,
display_uploader_url, extra_links) and generalizes the rename endpoint into a
metadata update with URL validation. EN/HU/DE strings included.
2026-07-07 20:19:18 +02:00
npeter83
e6b22f971a feat(plex): playlist bulk add/remove + group-aware playlist cards
Add POST /playlists/{id}/items/bulk and /items/remove-bulk so a whole
season or show can be added/removed in one call (input order preserved,
duplicates skipped). Extend GET /playlists with contains_group=<rk,rk,…>
returning per-playlist group_in + a top-level group_size for the bulk
add-to-playlist dialog. Include the show's rating_key (show_id) on
playlist episode cards so the client can group a playlist by show/season.
2026-07-06 22:14:40 +02:00
npeter83
25197ed817 feat(plex): Playlists Phase 1 — per-user ordered watch-lists (Siftlode-native)
Personal ordered lists of Plex items, kept in Siftlode's own DB (works for users
without a Plex account, like watch-state) — the "your own lists" counterpart to
shared collections. Plex-direction sync is a later phase (plex_rating_key
reserved).

Backend: migration 0048 (plex_playlists + plex_playlist_items with position),
PlexPlaylist/PlexPlaylistItem models, and per-user CRUD endpoints under
/api/plex/playlists (list [+?contains for the add dialog], create [seeded],
detail [ordered cards], rename, delete, add/remove item, reorder). _leaf_card
handles the movie/episode mix. Frontend: "Add to playlist" dialog from the movie
info page (all users), a Playlists section in PlexSidebar (list + create),
PlexPlaylistView (reorder up/down, remove, rename, delete, Play all), and
PlexPlayer gained an optional `queue` so play-through follows the list order
(prev/next + auto-advance). i18n en/hu/de. Verified end-to-end on localdev
(backend CRUD + the create→add→view→play-through UI flow).
2026-07-06 19:05:12 +02:00
npeter83
8291f06525 feat(plex): Collections Phase 2 — admin collection editing (write-back to Plex)
Admins can curate Plex collections from a movie's info page ("Collections"
button → PlexCollectionEditor dialog): create a collection (seeded with the
movie), add/remove the movie to/from editable collections, delete a collection,
and "take over" an existing plain Plex collection (mark it editable). All writes
go to Plex (POST/PUT/DELETE via new PlexClient methods) and are reflected on
every client; a targeted single-collection re-sync (sync.resync_collection /
delete_collection_local) updates the local mirror without the full ~4-min sync.

Gating (per the design decisions): editing is ADMIN-ONLY (collections are shared
library-wide); only plain manual collections are editable — smart + external
auto-lists (IMDb/TMDb/…) are always read-only (can_edit = editable && !smart &&
source=="collection"). New admin endpoints under /api/plex/collections
(create/items add+remove/rename/delete/editable). Verified end-to-end incl. the
live Plex write API (create/add/rename/remove/delete all 200, self-cleaned) and
the editor UI (create + delete with confirm) on localdev.
2026-07-06 17:33:16 +02:00
npeter83
c8c027d0fc fix(plex): play subtitles as WebVTT tracks (external sidecar subs no longer crash)
Selecting a subtitle restarted the HLS session with `-map 0:s:{ord}`, assuming an
EMBEDDED stream. Films whose subs are external sidecar .srt files (Plex reports
them, but they aren't in the mkv) matched no stream; the master-playlist's
declared subtitle group then made ffmpeg fail → "Playback couldn't start".

Subtitles now go through a new GET /api/plex/subtitle/{rk}/{ord} → text/vtt
(external subs fetched from Plex via the stream key + SRT→VTT; embedded text subs
extracted with ffmpeg; image subs → 415), served as native <video><track> that
the browser overlays. So choosing/switching a subtitle is instant with NO session
restart, and stream.py drops all subtitle muxing (`-sn`, no master playlist).
Image-based subs (PGS/VobSub) are marked text=false and hidden in the picker.
Verified on prod's Nymphomaniac Vol. II: HU sidecar → 1693 WebVTT cues, no crash.
2026-07-06 08:45:30 +02:00
npeter83
23fc67cb64 improvement(plex): serve right-sized poster/art images via Plex transcode
Posters/art were proxied at full resolution (many 1-7MB, some 9.5MB) into ≤176px
grid/info cells — heavy to fetch + decode, so fast scrolling lagged 1-2s even
with a warm cache (the cache removed the Plex round-trip, not the image weight).

Now PlexClient.image_bytes optionally hits Plex's /photo/:/transcode to resize
server-side; the /image endpoint requests thumb=400x600, art=1280x720 and keys
the cache by width (old full-size files orphaned, re-cached small). Measured:
a poster 5.5MB->50KB, art 8.1MB->137KB, at 400x600 (crisp on 2x DPR). Grid
scroll is near-instant and the disk cache shrinks ~8x.
2026-07-06 08:04:13 +02:00
npeter83
6dece18ae8 fix(plex): image proxy no longer exhausts the DB connection pool
A collections page fires 100+ concurrent poster requests. Both image proxies
(/image and /person-image) authenticated via current_user + Depends(get_db),
holding a pooled DB connection for the whole request — including the slow Plex/
CDN fetch. Under the burst, the 15-connection pool was exhausted → QueuePool
checkout timeouts → 502s → slow, partially-loaded grids on prod.

Fix: authenticate these two high-fan-out endpoints with a signed-session check
(no DB user-load), serve disk-cache hits with zero DB access, and on a cold miss
open a short session only to resolve the image key + Plex config, releasing it
BEFORE the fetch (image_bytes uses no DB). Also raise the pool (20 + 30 overflow)
as headroom above the sync-endpoint threadpool.
2026-07-06 07:14:28 +02:00
npeter83
8a8087ae99 feat(plex): classify collection-strip source (franchise/imdb/tmdb/smart)
Derive a `source` bucket per collection from the already-synced title + smart
flag (no schema change, no re-sync): external list providers (IMDb/TMDb/TVDb/
Trakt) by leading token, Plex smart lists via the smart flag, everything else a
genuine "collection". Returned on each info-page collection strip so the client
can show/hide each type independently. Adds the stripSource labels (en/hu/de).
2026-07-06 06:49:43 +02:00
npeter83
418a874851 feat(plex): collections — sync, browse, filter, and info-page strips (Phase 1, read)
Backend (migration 0047_plex_collections): a plex_collections table mirrors every Plex
collection (card metadata + smart flag + an editable flag reserved for Phase 2); membership
is stored as GIN-indexed collection_keys on member movies (plex_items) and shows (plex_shows).
The background sync (plex_sync) now fetches each library's collections + their children and
rebuilds membership — so ALL reads are local (Plex's dual-language collection queries are slow;
this trades a ~4-min background sync for instant reads). New /api/plex/collections endpoint;
/browse gains a combinable  filter; item_detail returns the movie's collection
'strips' (sibling titles as playable cards, smallest/most-specific collection first) — all pure
local lookups.

Frontend: PlexSidebar gains a searchable Collection picker + an active-collection chip;
PlexInfo renders the collection strips (playable posters + 'Browse collection' → sets the
filter); the collection is part of PlexFilters (persisted). i18n en/hu/de.

Phase 2 (create/edit collections with write-back to Plex) is separate.
2026-07-06 02:25:48 +02:00
npeter83
280c62dda8 feat(plex): search cast & crew names + clickable person cards
Backend (migration 0046_plex_people_search): a new people_text column (cast +
director names, de-duped) is folded into the generated search_vector at weight B,
so the top search box finds titles by an actor/director name and ranks them above
summary-only mentions. New /api/plex/people endpoint returns the cast/crew matching
the term (name prefix or word-start) with a film count + a headshot (pulled from one
representative film's live metadata; image bytes disk-cached).

Frontend: PlexBrowse shows matching people as virtual cards above the grid; clicking
one adds them to the actor/director filter (multi-value, from the C1 work) and clears
the search box so you land on exactly that person's films. Answers the 'why does
"drew" match Rambo?' confusion — it was matching the word in the synopsis; now names
are searchable. i18n en/hu/de.

⚠️ Prod needs a Plex re-sync after deploy to populate people_text (search_vector
regenerates automatically once the column has data).
2026-07-06 00:56:01 +02:00
npeter83
1982cfa7b9 feat(plex): sort direction, genre any/all, multi-person filters; fix back-nav + player stop
UAT follow-ups on the Plex filter epic:
- Sort now has an asc/desc toggle (sort_dir), applied to any sort field.
- Genre multi-select gains an Any/All mode (genre_mode: OR vs AND containment).
- Director/actor/studio become multi-value: people AND (titles featuring all selected),
  studios OR; clicking them on the info page stacks (unions) instead of replacing, and
  the sidebar shows each as a removable 'Active' chip.
- fix(history): clicking a metadata filter on the info page now pushes a fresh grid
  entry instead of history.back(), so browser Back returns to the info page rather than
  leaving the Plex module.
- fix(player): fully tear down the <video> + hls on unmount and guard late play() calls,
  so backing out of a just-started video no longer leaves audio playing in the background.
i18n en/hu/de (match any/all, sort direction).
2026-07-06 00:15:31 +02:00
npeter83
eefd7e3abd feat(plex): expanded metadata filters, clickable info page, image disk cache
Backend (migration 0045_plex_filter_meta): plex_items gains rating (audienceRating
~IMDb), content_rating, studio, originally_available_at, and GIN-indexed genres /
directors / cast_names — all mirrored from the cheap section listing (no per-item API
calls; they also seed a future watch-habit recommender). /browse gains genre / content-
rating / year / rating / duration / added-within / director / actor / studio filters
(@> containment, GIN) + sort by year|rating|duration|release; new /facets endpoint
returns available genres+ratings (with counts) and the year/rating/duration bounds. A
thin on-disk image cache (.plex-img-cache) serves posters/art/cast photos from local
disk after first fetch (~7-14x faster repeat loads).

Frontend: PlexSidebar grows the full filter set (facet-driven genre/age chips, rating
steps, year range inputs, duration buckets, added-within, active people/studio chips,
clear-all); filters persist per-account as one JSON blob. PlexInfo metadata (year,
genre, director, cast, studio, IMDb score) is clickable → sets the matching filter and
returns to the filtered grid (page variant only; the in-player overlay stays read-only
so a stray click can't stop playback). i18n en/hu/de.
2026-07-05 23:45:55 +02:00
npeter83
690e17611c feat(plex): rich media info — info page, in-player info overlay, IMDb + cast photos
Backend (no migration): item_detail now also returns IMDb score + id/url (from the
Plex Rating/Guid arrays), content rating, genres, director(s), studio, tagline, and
cast as {name, role, photo}. New host-whitelisted /person-image proxy serves cast
photos from Plex's public metadata CDN (keeps third-party requests off the browser).

Frontend: reusable PlexInfo component in two forms — a full info page opened from a
card's 'i' button (history subview, art backdrop, Play/Resume + watch controls) and a
lean overlay in the player toggled with 'I' (video keeps playing). Two per-user view
prefs (faint backdrop, cast row) persisted in preferences. Mark-unwatched / clear-resume
cover the watched-reset gap. i18n en/hu/de.
2026-07-05 22:57:18 +02:00
npeter83
2138f3c357 feat(plex): Test-connection media mount probe
Extend admin POST /api/plex/test to resolve one real media file through the
local mount (a mirrored sample, else a single leaf fetched from Plex) and report
whether it is readable. A missing media bind-mount or a wrong plex_path_map is
now caught at Test time rather than only surfacing later as a dead player.
2026-07-05 06:58:34 +02:00
npeter83
a06f87f5f6 feat(plex): P2 subtitle + audio track selector (closes P2)
Backend: item detail returns audio_streams + subtitle_streams (from Plex Part.Stream
ordinals). The HLS session accepts audio/subtitle stream ordinals — the selected audio
is mapped (transcoded to AAC), a selected subtitle is muxed in as a WebVTT rendition
via a MASTER playlist (-master_pl_name + -var_stream_map sgroup:subs) so ffmpeg keeps
it in sync per-session. Selecting a track forces the HLS path (even for direct files).
Generic /stream/{rk}/hls/{filename} endpoint serves the master/media/vtt/ts artifacts.

Frontend: gear menu lists real audio + subtitle tracks; changing one restarts the
session at the current position (seek-restart mechanism). hls.js subtitle enabled on
SUBTITLE_TRACKS_UPDATED (not just MANIFEST_PARSED, else the VTT is never fetched).
Cues auto-nudged to line 88% (the full-height <video> clips a default bottom-edge cue).
Controls no longer auto-hide while the menu is open. plex.player.* i18n en/hu/de.

Verified in a real browser (2 Broke Girls S1E1, 2 audio + 2 subs): menu shows the real
tracks, enabling English subtitles renders them correctly positioned; backend master
playlist + vtt segments validated over HTTP.
2026-07-05 06:08:44 +02:00
npeter83
29d306441a fix(plex): player + card polish from UAT
- Player: control tooltips now open ABOVE the button (no downward viewport clip);
  keyboard use also reveals the auto-hidden controls (wake() on keydown); added a
  Download button that downloads the ORIGINAL physical file (no re-encode/repackage,
  Content-Disposition attachment via GET /stream/{rk}/file?download=1).
- Plex cards: hover affordance shows what a click does (Play / Resume / Open show)
  + a quick watched/unwatched toggle (stopPropagation, no playback) — mirrors the
  YT-feed card quick-actions. New plex i18n keys en/hu/de.
- Verified in a real browser: card hover Play overlay + watched toggle (marks watched
  without opening), player download button present, tooltip-above, keyboard reveals
  controls, correct durations (Enola Holmes 3 = 1:48:04).
- NOTE: 'The Three Diablos' showing 13:05 was NOT a bug — it is a 13-min short (file
  ffprobe = 785s); the 1h42m film is 'The Last Wish'.
2026-07-05 05:26:16 +02:00
npeter83
86b86cb260 feat(plex): P2 player — rich full-page HLS/direct player + watch-state
- backend: GET /api/plex/item/{rk} detail (metadata, cast + intro/credit markers
  from Plex, episode prev/next, per-user resume+status); POST /item/{rk}/progress
  (resume checkpoint, near-end→watched) + /state (new|watched|hidden).
- frontend PlexPlayer.tsx (lazy, pulls hls.js): full-page player over the Plex
  module. Direct files stream raw <video>; remux via hls.js on the seek-restart
  session — custom controls map the ABSOLUTE timeline over the session offset, and a
  seek beyond the loaded region restarts the session at the target. Play/pause/resume
  /restart, ±10s seek, prev/next episode (Shift+←/→), volume, windowed/fullscreen,
  full keyboard, Skip intro/Skip credits from markers (shaded on the seekbar),
  auto-advance + watched-on-end, resume from saved position, 10s progress checkpoints.
- PlexBrowse: card/episode click opens the player as a history subview (browser Back
  returns to the grid/show). api client (plexItem/plexSession/plexProgress/plexSetState)
  + types; plex.player.* i18n en/hu/de. hls.js@^1.6.16.
- Verified over HTTP: detail w/ markers+cast+episode-nav, progress persist. Video
  playback itself is pending browser UAT. Subtitle/audio track SELECTION deferred.
2026-07-05 04:28:00 +02:00
npeter83
4e9b18cda9 feat(plex): P2 streaming backend — direct 206 + seek-restart HLS remux
Validated end-to-end on a real remux file (h264+ac3 mkv). The riskiest part of the
Plex epic — on-the-fly playback from the local file — now proven.

- app/plex/stream.py: seek-restart HLS session model (one ffmpeg per item, restarted
  at a seek offset via -ss). Video stream-COPY (I/O-bound, CPU-light — fine on the
  GPU-less prod host) + audio→aac only when not already aac. Maps first video+audio,
  drops subtitles (no VTT rendition clutter). Session cap + idle reaper.
- routes: POST /stream/{rk}/session?start= (direct→raw url, remux→HLS session,
  transcode→501 P3), GET /stream/{rk}/file (206 range, direct), /index.m3u8, /seg_n.ts.
- KEY FINDINGS (hard-won): (1) ffmpeg -hls_playlist_type VOD does NOT write the
  playlist mid-run (only at finalize) → use EVENT (append-only, appears immediately),
  which suits the seek-restart model; (2) naive per-segment copy + keyframe-indexed
  extraction are both unreliable (non-uniform/imprecise segments) — ffmpeg's own HLS
  muxer is the only correct segmenter; (3) dev slowness was the Windows /downloads
  bind-mount write + SMB read → PLEX_HLS_DIR env points scratch at fast container-local
  /var/tmp on dev (prod keeps download_root, fast local disk).
2026-07-05 04:16:45 +02:00
npeter83
219a935121 feat(plex): P1.5 — Plex as a nav module + integrated search + filter sidebar
Per user feedback: promote Plex from a hidden feed-Source option to a first-class
left-nav module, with its own filter sidebar and the shared top search box.

- Plex is now a nav-rail module (page='plex', gated on me.plex_enabled) — its own
  page → correct browser Back/Forward; removed the Feed Source-dropdown 'plex' hack.
- PlexSidebar.tsx: left filter column (library scope, watch-state for movies:
  all/unwatched/in_progress/watched, sort) — per-account persisted (App state).
- Header search box now also serves the Plex page (integrated search); the live
  YouTube-search escalation stays feed-only.
- PlexBrowse.tsx reworked: infinite scroll (IntersectionObserver, no 'Load more'),
  content-visibility for smoother long-list scroll, show drill-down rides history
  (useHistorySubview) so Back returns to the grid; dropped the stray 'YouTube feed'
  button on the show page.
- backend /browse gains a per-user watch-state filter (show=, movies only).
- plex i18n (navLabel + filter.*) en/hu/de.

Note: episode-title 'Episode N' on some shows (e.g. Westworld) is Plex-side
(unmatched show) — we mirror what Plex has; Match/Refresh in Plex + re-sync fixes it.
2026-07-05 03:29:20 +02:00
npeter83
62636319b1 feat(plex): P1 frontend — Plex feed source, browse/search, show drill-down
- PlexBrowse.tsx: self-contained Plex mode (library scope tabs, own search +
  sort, poster-card grid with watch state + playability tint, paged; show →
  seasons/episodes drill-down). Playback announces (P2 wires the real player).
- Feed source dropdown gains a 'Plex' option (gated on me.plex_enabled); App
  swaps <Feed> for <PlexBrowse> when librarySource==='plex' so the video-feed
  hooks don't run in Plex mode. me exposes plex_enabled.
- api client (plexLibraries/plexBrowse/plexShow/plexImageUrl) + types; librarySource
  union + share-URL restore gain 'plex'; new plex i18n namespace en/hu/de.
- Verified over HTTP (authed): libraries, browse+FTS, image proxy 200 image/jpeg.
2026-07-05 02:32:00 +02:00
npeter83
63c6e782c8 feat(plex): P1 backend — catalog sync + browse/search/show/image API
- app/plex/sync.py: full reconcile of enabled movie/show sections into plex_*
  (upsert by rating_key). Reuses Plex's own codec info to classify browser
  playability (direct/remux/transcode) — no ffprobe. Movies + episodes become
  playable leaves; shows/seasons mirrored for the drill-down.
- routes/plex.py: GET /libraries, /browse (FTS search + sort + paging, movie
  leaves or show cards), /show/{rk} (seasons+episodes, per-user state), /image
  (proxies Plex art, no duplication); admin POST /sync. All auth'd, demo-allowed.
- scheduler: plex_sync job (interval settings.plex_sync_interval_min).
- Verified against the real server: 2 libs, 3206 movies, 260 shows/961 seasons/
  14768 episodes synced in 13s; FTS search + drill-down work. Playability:
  ~1% direct, 89% remux, 6% transcode (informs P2/P3).
2026-07-05 02:20:23 +02:00
npeter83
a88254c841 feat(plex): P0 backend foundations — catalog model, config, client
Optional Plex module groundwork (design locked 2026-07-05): plays the LOCAL
physical file; Plex is metadata + optional watch-sync only.

- models + migration 0044: plex_libraries/plex_shows/plex_seasons/plex_items
  (playable leaf, ffprobe playability class, markers, weighted FTS search_vector)
  + plex_states (per-user watch state, mirrors video_states)
- sysconfig 'plex' group + config.py env defaults (server url/token[secret]/
  path-map/libraries/sync-interval/max-transcodes)
- app/plex/client.py (PlexClient httpx: server info, sections, items, metadata
  +markers, image) + app/plex/paths.py (Plex→local path map + file resolve)
- routes/plex.py admin POST /api/plex/test (verify connection + list sections)
2026-07-05 01:35:08 +02:00