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.
- Explicit Stop button (stops playback + returns to the library).
- Keyboard cheat-sheet overlay toggled with 'H' (movie hides the episode-nav row).
- Backspace = stop & back to feed (HTPC-remote convention); closes the help first.
- Time-remaining readout beside the seek bar + a live wall clock in the top bar
(both fade with the controls).
- PlexBrowse restores the grid scroll position when returning from the player, so
Back lands on the same card instead of the top of the library.
- New plex.player i18n keys (stop/help/keys.*) in en/hu/de.
The player's queue came live from the feed query, which refetches under a
watch-state filter as each video is auto-marked watched — steadily shrinking
the list mid-session. Loop "All" could then never wrap back to the first
video (earlier items had dropped out), and prev/next drifted. Snapshot the
queue once at mount (useState lazy init) and step through that frozen list for
the whole session; the live prop is ignored thereafter. Supersedes the earlier
active-item pin with a list-level freeze that also fixes Loop All.
Auto-marking a video watched near its end invalidates the feed query; the
refetched/reordered queue can no longer contain the playing item, so
findIndex returned -1 and the active item fell back to queue[0] — silently
jumping playback to an unrelated video and defeating Loop "One" (and
auto-advance:off). Pin the last resolved video in a ref and keep playing it
when it drops out of the queue instead of snapping to queue[0].
The real docker-compose.home.yml + CLAUDE.md lived only in the working tree via a
fragile .git/info/exclude hack (leak-prone). They now live in a separate PRIVATE
devops repo (siftlode-ops). This repo keeps only a generic, placeholder-templated
docker-compose.home.yml.example plus the existing .env.example (extended with the
optional Plex keys), so nothing environment-specific can leak here. Exclude emptied.
PlexPlayer: when the stream session can't start, show why instead of an eternal
spinner — 404 (physical file unreachable: missing media mount / wrong path map),
501 (format needs transcoding), or a generic failure; also catch fatal hls.js
errors. ConfigPanel: render the Test-connection media_check (mount OK, or a
warning naming the unreadable local path). New plex/config i18n in en/hu/de.
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.
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.
- 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'.
- 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.
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).
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.
- 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.
- 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).
Lets the dev container play the physical media file locally (this box isn't the
Plex host). Host/share/credentials come from .env (gitignored) — no site-specific
values in the tracked compose. Mounts //${PLEX_SMB_HOST}/${PLEX_SMB_SHARE} at
/plex-media read-only in api + worker; harmless when the vars are unset. Prod (LXC
on the Plex host) uses a plain host bind-mount instead.
The Downloads config group was added to the registry without field translations,
so it showed raw keys. Adds label+hint for all 9 download_* fields, matching the
Plex group's treatment.
- ConfigPanel 'plex' group auto-renders; adds a Test-connection button + a
library-picker (checked sections write plex_libraries, applied on Save)
- api.testPlex + PlexTestResult/PlexSection types
- i18n config namespace: plex group + 7 fields + test strings (en/hu/de);
also fills the previously-missing 'downloads' group label
Two saved-to-account playback settings on the player's prev/next bar (any queued
player — feed or playlist): Auto-advance (Off/Next/Prev/Random — what plays when a
video ends) and Loop (Off / One = repeat the current video / All = wrap the list
at its ends; a single-item list repeats). Stored in users.preferences
(playerAutoAdvance/playerLoop), read from the cached me + written via savePrefs so
they apply everywhere and survive reloads.
The feed's player queue is now the live filtered feed with the watch-state filter
NOT applied — marking the current video watched keeps it in the sequence (no
reindex/reload mid-play), while a hidden video still drops out. Removes the old
frozen-queue workaround and the boolean autoAdvance prop. i18n en/hu/de.
loadAccountFilters preferred the starred default view's mirror over the stored
filters, so a reload always snapped back to the default even after you'd picked
another view. Now your last-applied filters (setFilters persists them on every
change) win; the default view only seeds a fresh account that has never stored
filters. Re-apply the default from the sidebar to return to it.
Replace the small centred prev/next arrow buttons with faint translucent glass
strips that span the modal's full height, hugging each side of the card (hidden
on narrow screens where there's no room). Same stepping behaviour + Shift+arrow
shortcut; just a larger, easier target that reads as part of the dialog.
Three in-app player refinements:
- Prev/next stepping: the modal now takes the feed's loaded order as a queue, with
faint arrow zones flanking the card and Shift+Left/Right shortcuts (plain arrows
seek ±5s). The queue is frozen at open so marking the current video watched can't
drop it and reload a different one; auto-advance-on-end stays off for the feed
(it isn't a playlist) — playlists keep theirs.
- Reachable native YouTube controls: the interaction overlay (wheel volume / click
pause / keyboard focus) now covers only the centre band (top-[12%] bottom-[22%]),
leaving the top-right cluster (volume/CC/settings) and the bottom bar (seek /
More videos / fullscreen) clickable. Verified live: settings menu + fullscreen work.
- Hover-intent description: the title popover now waits ~400ms so a quick pass no
longer flashes it.
A vertical/short clip rendered in a fixed full-width box with large black side
bars. The player container now uses w-fit + mx-auto and the video max-h/max-w, so
a portrait clip becomes a narrow centered player and a landscape clip fills the
width — no letterboxing either way.
Every download now records the clean source page URL and shows it on the
Downloads page (open in a new tab, or copy to clipboard). The worker stores
yt-dlp's canonical webpage_url on the asset (migration 0043 adds
media_assets.source_webpage_url); the serializer prefers it and falls back to a
URL derived from source_kind+source_ref, so YouTube, external YouTube links and
external URLs (e.g. Facebook reels) all get a correct reference, and queued/older
rows work before the worker fills it. Edit clips return null (a clip's source is
the user's own earlier download, not a web page). i18n en/hu/de.
The whole app shipped in one bundle, so the logged-out landing and every page
pulled all module code. Split into lazy chunks:
- main.tsx: lazy App + the public leaves (WatchPage, Privacy, Terms), so a
public /watch share link never downloads the authenticated app.
- App.tsx: each module page (Feed, Channels, Playlists, Stats, Scheduler,
Config, Users, Settings, Notifications, Messages, Downloads, ChannelPage) and
the About/ReleaseNotes/Onboarding modals load on demand behind <Suspense>.
- Heavy modals lazy in their parents: PlayerModal (Feed, Playlists),
DownloadDialog (DownloadButton — kept out of the feed chunk), and the
VideoEditor/ShareDialog/ProfileEditor (DownloadCenter).
- Extracted focusAccessRequestsTab + the tab constants to lib/adminUsersTab so
callers can pre-select the admin tab without statically importing the now
lazy-loaded AdminUsers page (which would defeat the split).
Build now emits ~25 chunks. Landing no longer downloads any module code
(~350 KB deferred); dev landing Perf 93->95. Verified in a real browser: every
page + the video editor load with no console errors.
Give the feed card thumbnail width/height (16:9) so the browser reserves the
aspect box before load (CLS), and decoding=async so image decode never blocks
the main thread. loading=lazy was already set.