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.
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.
From UAT feedback on the collections info page:
- Per-source show/hide toggles in the customize menu, one per collection-strip
type present on the item (Collections/IMDb/TMDb/…), persisted per account.
- Customize menu closes on Escape / outside click (reuses useDismiss).
- Preserve the info-page scroll position when returning from "Browse collection".
- Glassy refresh: content floats as .glass/.glass-card panels over a faint FIXED
art backdrop on the <main> scroller (HTPC-style, toggleable); frosted menu;
wider ~90% layout. Uses the existing glass surface system.
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).
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.
The Plex catalog sync job showed its raw id 'plex_sync' with no tooltip because it was
missing from the scheduler i18n. Add jobs.plex_sync + jobDesc.plex_sync (en/hu/de).
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).
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).
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.
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.
Lighthouse a11y across the authenticated module pages flagged: the clickable
Siftlode logo had aria-label='Feed' (accessible name didn't include its visible
text); the shared Switch, theme swatches, sort selects and several number/range
inputs had no accessible name/label. Give Switch role='switch'+aria-checked and
an optional label (passed at every call site), aria-label the swatches, selects
(feed/playlists sort) and the settings/config/scheduler inputs, and drop the
mismatched logo aria-label so its visible text is the name. All 11 module pages
now score 100 accessibility (settings 86->100, playlists 89->100, others 92-95->100).
The logged-out landing probed /api/me, which 401'd, and the browser logs every
non-2xx fetch as a console error regardless of how the app handles it. The
bootstrap probe now returns 200 with {authenticated:false} via a new
optional_current_user dependency; api.me() maps that to null. The render gate
treats no-data as 'signed out' -> the landing, and a thrown error as a real
network/5xx failure. Other protected endpoints still 401, so mid-session expiry
is still caught by the global handler. Lighthouse (dev): Best Practices 96->100.
The public landing had no meta description and no robots.txt (the SPA fallback
returned index.html for /robots.txt, so crawlers saw an invalid file). Add a
description + basic OG tags to index.html and a real robots.txt that allows the
landing but disallows the API/auth/watch surfaces. Lighthouse (dev): SEO 83->100.
The 3 landing screenshots were 2.3 MB of PNG (feed.png alone 1.6 MB, decoded
at 2400x1350 for a ~400px slot) — the biggest LCP/transfer cost on the public
page. Re-encoded to WebP capped at 1600px (~284 KB total, feed 91% smaller).
Also set Cache-Control: content-hashed /assets/* are immutable for a year,
other SPA-root static files (welcome images, favicon, robots) get a 7-day TTL;
index.html stays no-cache. Register image/webp+avif mimetypes so FileResponse
serves the right Content-Type. Lighthouse (dev): Perf 80->93, LCP 1.8s->1.2s.
mp4 downloads used yt-dlp's default best video+audio, which on YouTube is
VP9+Opus at 1080p. Chromium plays that, but iOS/Safari WebKit (which every
iOS browser is forced to use) decodes only H.264/H.265+AAC inside mp4 — so a
shared /watch link showed a broken player on iPhone/iPad while the download
still worked. Rank vcodec:h264 + acodec:aac above resolution for mp4 output
(unless a custom profile pins a vcodec), keeping VP9/AV1 as a graceful
fallback. Bump the format signature (v2) so an identical spec gets a fresh
cache identity instead of hitting a stale VP9 asset.
The worker and API start in parallel; the API applies migrations on its own startup, so on a fresh
deploy the worker hit a missing download_jobs table and crash-looped until migrations landed. The
worker now polls for the schema before recovering orphans, and (belt-and-suspenders) the compose
files gate it on the API being healthy. Fixes the observed prod restart-loop on the v0.22.0 deploy.
Prep the Download Center epic (Phase 1 + editor + share) for prod/self-host:
- Dockerfile: create /downloads owned by appuser so a named-volume mount is writable (prod Linux).
- docker-compose.{home,selfhost,yml}: add the 'worker' (yt-dlp/ffmpeg job loop) + 'bgutil-pot'
(PO-token) services + a downloads mount (DOWNLOAD_ROOT, WORKER_ENABLED). Media defaults to a
named volume; DOWNLOAD_HOST_PATH points it at a host dir (e.g. a Plex-readable folder).
- README / docs/self-hosting.md / .env.example / install.{sh,ps1}: document the Download Center,
the two extra containers, and DOWNLOAD_HOST_PATH.
- VERSION 0.22.0 + releaseNotes entry.
Session-close cosmetics:
1. Favicon — an 'S' monogram (indigo→violet rounded square, path-drawn so it's font-independent)
at public/favicon.svg, linked in index.html (tab had none before).
2. Dynamic document.title — reflects the current module ('Downloads · Siftlode'); Feed = brand
only; an open channel page shows the channel name. Shared pageTitleKey() in lib/pageMeta.ts
keeps the tab title and the top-bar header in lockstep.
3. The 'Siftlode' logo already navigated to Feed but read as static text — added a hover
background + cursor affordance so it's clearly clickable.
4. Module header — new shared PageTitle component (used by every non-feed module via Header) with
a tracked small-caps 'eyebrow' + leading accent dot (user-picked style B), styled in one place.
Verified in a real browser: favicon served (image/svg+xml), title updates per module, logo→feed,
header restyled; no console errors.
The 'Your storage' footprint used a one-shot query, refetched only at enqueue time — when the job
is still queued (0 bytes) and the file lands asynchronously via the worker. So the footprint stayed
stale (0 B) until a manual reload. Switch the UsageBar to useLiveQuery(2s), like the library list,
so the footprint updates in lockstep the moment a clip/download completes (and after a delete).
A shared-with-me item only had a download button. Add two actions (Share is intentionally NOT
offered — no chain re-sharing of someone else's file):
- Edit: the editor now accepts an accessible (owned OR shared) source, so editing a shared video
produces the editor's OWN clip in their library (counts against their quota, fully theirs
including share); the source file is only read. Route uses _accessible_job.
- Remove from my list: DELETE /api/downloads/shared/{job_id} deletes only the recipient's share
grant — the owner's job and physical file are untouched (per-user dismissal).
i18n en/hu/de. Verified in a real browser (edit a shared 70-min video → own 2:31 clip; remove
confirm shows 'won't delete the owner's file').
The admin System tab only listed users with an existing footprint, so a quota could be set only
after someone downloaded something. Add a 'Set a user's quota…' picker (over /api/admin/users,
excl. demo) beside the Per-user footprint heading that opens the quota editor for ANY user — the
quota GET/PUT endpoints already resolve defaults + create a row on demand, so no backend change.
Also show an empty-state line when no one has a footprint yet. i18n en/hu/de.
The download-center GC job showed its raw id 'download_gc' with a raw-key tooltip, unlike every
other job. Add its label ('Download cleanup') + description to scheduler i18n (en/hu/de), and make
the job tooltip fall back to empty for any job lacking a description (so a future/edge job never
shows a raw 'scheduler.jobDesc.*' key — also fixes demo_reset).
The tooltip clamped its centre to a fixed 92px margin, but the caption is up to 240px wide
(120px half), so a left-edge anchor (e.g. the nav-rail counts icon at x~40) pushed the centred box
~28px off the left of the viewport. Clamp with the caption's max half-width so it can't overflow on
first paint, then a useLayoutEffect re-centres on the anchor using its actual width. Verified: the
nav-rail counts tooltip now sits fully on-screen.
In the left-nav rail SyncStatus, the pause button rendered as its own bottom block, so with only
deep-history pending (idle) it dropped to a lonely line under 'N without full history'. Now it sits
right-aligned on the primary status row — the sync-state line when there is one, otherwise the
'N without full history' row — via flex justify-between. No change to the all-synced state.
Rework the share dialog into two clear modes and add the public /watch player page:
- ShareDialog: (A) 'Share with a user' — autocomplete picker over registered users (was a blind
email box that 404'd on non-users); (B) 'Share a link' — create/list/copy/revoke public links
with allow-download toggle, optional expiry (1/7/30d), optional password; per-link view count.
- WatchPage: standalone login-free player at /watch/<token> (routed in main.tsx like /privacy),
self-contained mini-i18n (en/hu/de by browser language); password gate → unlock → play; shows a
Download button only when the link allows it.
- api: ShareLink/ShareRecipient types + link CRUD + recipients; share i18n (en/hu/de).
Verified end-to-end in a real browser: user picker, link create, public playback, stream-only vs
downloadable, password gate + unlock, no console errors.
Share a download by a capability URL (/watch/{token}) that anyone can play on a login-free page —
distinct from the registered-user ACL share. Per-link controls: optional expiry, allow-download
toggle (stream-only vs downloadable), optional argon2 password. Revoke = delete.
- migration 0042 + DownloadLink model; app/downloads/links.py (token, HMAC grant sign/verify)
- owner endpoints (POST/GET/PATCH/DELETE links) + /recipients for the internal user picker
- public router (no auth): watch meta / password unlock→signed grant / range-aware file serve
(inline vs attachment); unlock is rate-limited; meta verifies the file exists on disk
Verified end-to-end: 206 range, inline vs attachment, wrong-password 403, tampered-grant 403.
The hover-scrub thumbnail was centered on the cursor with a fixed -translate-x-1/2, so near the
filmstrip's right (or left) edge it overflowed the modal and triggered a horizontal scrollbar. Now
its left edge is clamped to [0, trackW - popoverW] so it shifts inward at the edges and stays fully
within the viewport. Verified at both edges in a real browser.
Rework VideoEditor around a segment cut-list: N draggable cut markers → segments, per-segment
keep/drop (eye toggle + dropped hatch), output = Separate files (one job per kept segment) or
Join into one (segments cut-list → one concatenated file). Per-segment numeric Start/End inputs.
Filmstrip fixed: aspect-correct tiles (no vertical squish) + YouTube-style hover-scrub thumbnail
from the sprite. EditSpec.segments type; editor v2 i18n (en/hu/de). Verified end-to-end in a real
browser (3-segment split, drop middle, join → 0:12 clip from a 0:19 source).
Editor v2 backend: an edit_spec can carry a 'segments' cut-list that concatenates the kept
ranges into ONE file (accurate=filter_complex trim+concat re-encode; fast=concat-demuxer
stream-copy with per-segment inpoint/outpoint). normalize/edit_sig/clip_duration/needs_reencode
handle segments; worker writes the concat list + runs build_concat_plan. Single-trim path (used by
'separate files' export) unchanged.
VideoEditor modal on a finished Library download: HTML5 <video> scrubber, filmstrip timeline
(lazy server storyboard sprite) with draggable in/out handles, draggable/resizable crop overlay,
per-edit Precise (re-encode) vs Fast (stream-copy) cut toggle, split-into-N fan-out (N trim jobs),
optional clip name. Edited clips show a 'Clip' badge; 'editing' phase gets a % bar. New api
enqueueEdit/downloadStoryboard/storyboardImageUrl + EditSpec type; editor i18n (en/hu/de).
YouTube returns flaky per-client responses: the web clients intermittently mislabel normal
videos as 'DRM protected' (like tv did), and android_vr can get bot-flagged under heavy load.
Neither client alone is reliable. So:
- PRIMARY player client = android_vr: high-quality DASH (399+251), no PO token / Deno needed,
reliable for normal use
- on a bot/DRM/format failure the worker retries with the FALLBACK set (web_safari,web,android
+ POT sidecar + Deno) — bypasses bot-detection when android_vr is flagged
- both client sets admin-tunable (DOWNLOAD_PLAYER_CLIENTS[_FALLBACK])
Verified: the previously DRM-failing video now downloads steadily via android_vr, no DRM error.
The web player clients need two things beyond the PO token: (1) a JavaScript runtime for
YouTube's 'n' signature challenge — install Deno (v2.9.1) + yt-dlp[default] (bundles yt-dlp-ejs
solver scripts); auto-detected on PATH. (2) an audio source — the web clients only expose
video-only DASH, so add the 'android' client (also PO-token-backed) for audio/muxed formats.
Player clients: web_safari,web,android (dropped tv — it falsely reports normal videos as DRM).
End-to-end verified in the worker: extraction (POT + Deno n-challenge) + download completes,
no bot/DRM/format errors. Full YouTube stack now: force-ipv4 + bgutil POT sidecar + Deno.
YouTube increasingly demands a Proof-of-Origin token (or shows 'confirm you're not a bot').
Instead of manual cookies, run the bgutil POT provider as a sidecar that mints tokens on demand:
- compose: bgutil-pot service (brainicism/bgutil-ytdlp-pot-provider:1.3.1, port 4416, init)
- requirements: bgutil-ytdlp-pot-provider==1.3.1 plugin (version pinned to the image)
- config: DOWNLOAD_POT_BASE_URL (http://bgutil-pot:4416) + DOWNLOAD_PLAYER_CLIENTS
(web_safari,web,tv — POT-capable; the default android_vr client never requests a token)
- formats.build_ydl_opts wires extractor_args youtube:player_client + youtubepot-bgutilhttp:base_url
Verified: the sidecar mints a valid PO token (BotGuard challenge solved) and yt-dlp fetches it
from the provider. NOTE: end-to-end download couldn't be confirmed here because the test IP got
hard-flagged by YouTube from heavy testing (~16 GB + dozens of extractions) — a hard abuse block
needs cooldown/cookies, which POT doesn't lift; POT bypasses the normal soft bot-check.
Embedding (thumbnail/chapters) rewrote the whole file — a 10 GB video spent minutes making a
10 GB temp copy — while the Plex .nfo + poster sidecars already carry that metadata. So:
- builtin presets no longer embed (migration 0040 re-seeds them; formats._DEFAULTS embed off);
only the tiny FFmpegThumbnailsConvertor (for poster.jpg) remains, so downloads go
video -> audio -> merge -> done with no full-file rewrite
- default preset is now 1080p (Best stays available, second in the list) so a long video
doesn't silently pull ~10 GB and blow the quota
Users who want self-contained files can enable embedding in a custom profile.
ffmpeg post-steps have no byte-progress, so instead of a silent 'Processing' the row now names
the current step (Merging / Extracting audio / Embedding thumbnail / Removing sponsors / Writing
metadata) with an indeterminate pulse. Byte-progress phases (video/audio) keep the % bar.
i18n en/hu/de.
Root cause of the failing downloads: containers usually have no working IPv6 route, but
googlevideo CDN hosts advertise AAAA records, so the downloader tried IPv6 and failed with
'[Errno -5] No address associated with hostname'. Small clips happened to use IPv4; long videos
(more CDN requests / the ffmpeg path) hit IPv6 and died.
- yt-dlp source_address=0.0.0.0 forces IPv4 for every connection (== --force-ipv4). Verified: the
full video+audio download completes cleanly.
- Also: retry (resume) now resets the shared asset from 'error' to 'pending', so it actually
re-downloads — previously the requeued job instantly re-inherited the asset's stale error
because the worker short-circuits a job whose asset already errored.
- Dropped the localdev DNS override (wrong hypothesis; the issue was IPv6, not the resolver).
- Kept yt-dlp retries/fragment_retries/socket_timeout for transient blips.
Verified end-to-end in the worker: two previously-failing long videos now download to done.
_insert_stubs previously stored the raw title, so a brand-new video showed its unnormalized
title until enrichment caught up. Normalize (and stash original_title) at insert as well, so
every video-title write path is covered: stub insert, enrichment, live search, and the download
worker. Every newly imported feed video is normalized from the first insert.
Noisy YouTube titles are cleaned for display + storage, raw kept in videos.original_title:
- app/titles.normalize_title: strip emoji/symbols (keep accents), remove trailing SEO hashtag
clusters (keep numeric #3 episode markers), context-aware de-shout (mostly-ALL-CAPS titles ->
Title Case with an acronym whitelist + function-word lowercasing; otherwise only long all-caps
words), collapse repeated punctuation
- applied at enrichment (sync/videos.py) and in the download worker (ad-hoc yt-dlp titles);
catalog downloads inherit the normalized title automatically
- migration 0039: add original_title, preserve raw, rewrite title (generated search_vector
regenerates); reversible via original_title
Backfill on localdev: 122115/273417 titles normalized in ~2 min. Verified in the feed + on
real messy samples (emoji/de-shout/hashtags), accents + acronyms (PS5/AI/USA/PC) preserved.
The progress bar looked broken (95% -> 5% -> recount) because yt-dlp downloads the video and
audio streams separately (each 0->100%) then merges — with no indication of which step is
running. Now:
- worker labels the phase from the stream codecs (video / audio) and via a postprocessor hook
(merging / processing), so the user sees what's happening
- DownloadCenter shows the phase as the status and renders an indeterminate pulse (no bogus %)
during merge/processing; phase i18n en/hu/de
- yt-dlp retries (3) + fragment_retries (5) so transient network blips self-heal instead of
failing the job (seen once as a DNS 'No address associated with hostname' error)
Verified: phase transitions queued -> video -> processing -> done.
A queued/downloading row previously showed the bare video id and no thumbnail because asset
metadata was only filled when the download completed. Now:
- service.populate_from_catalog fills title/uploader/thumbnail/date/duration at enqueue from
our own Video/Channel catalog (0 network cost) — feed downloads look right instantly
- worker fills the same from yt-dlp's info_dict on the first progress event (covers ad-hoc
URLs / catalog misses), best-effort, never fails a download
Verified: a paused feed download now shows its real title, channel, thumbnail and duration.
Two UAT findings:
1. The device download filename (Content-Disposition) kept emoji/symbols from the video title.
Add storage.display_filename (drops emoji/symbol/control unicode, keeps spaces + accents)
and use it for the download name — readable and clean ("…alapján!.mp4", no emoji).
2. Deleting/canceling a download removed the job but the shared MediaAsset (and its file) lingered
as cache, so 'Ready files' stayed inflated and disk wasn't freed. Rework: _release_asset drops
the hold and, once no job holds the asset, deletes the file + row immediately (the cache only
needs to span overlapping holders). Also fixes cancel never decrementing (it flipped status to
'canceled' before releasing, tripping the holding-state guard).
Verified: filename emoji-stripped; enqueue→delete removes the asset row + file from disk.
A plain <a> file download can't send the X-Siftlode-Account header, so current_user resolved
it to the session-default account — 404 'Unknown download' when the tab acts as a non-default
wallet account that owns the file. resolved_user_id now also honours a ?account= query param
(the same wallet-gated selection the WebSocket already uses), and downloadFileUrl appends the
active account id. Verified: default account -> 404, ?account=<owner> -> 206 with the right
Content-Disposition.
The .gitignore 'downloads/' pattern for the DOWNLOAD_ROOT bind mount also matched the
backend/app/downloads source package, so formats.py/storage.py/service.py never got
committed with M2. Anchor the ignore to '/downloads/' (repo root only) and add the package.
storage.sanitize() now handles arbitrarily messy titles (emoji, ZWJ, fullwidth, clickbait
punctuation): NFKC-normalize, drop emoji/symbol/control unicode categories, collapse
repeated punctuation, underscore-join words -> space-free paths. Accents (HU/DE) preserved,
no ASCII folding. Plex layout uses _-_ separators + Season_{year}.