- Delete dead top-level downloads.rename.* i18n block (en/hu/de); the
metadata modal moved to downloads.edit.*. Keep downloads.actions.rename
(still used by ProfileEditor).
- Rename misnamed state renameJob/setRenameJob -> metaJob/setMetaJob in
DownloadCenter (it opens MetaEditModal, not a rename dialog).
- Extract the duplicated [downloads]/[download-index]/[download-usage]
invalidation trio into lib/downloads.ts invalidateDownloads(qc);
used by DownloadCenter and DownloadDialog.
Behavior-neutral. tsc green, knip shows no new unused export, JSON valid.
Found during the Phase-2 Downloads-frontend review:
- VideoEditor: onLoaded now reconciles the still-pristine full-length segment to the
REAL decoded duration (its guard `end<=0` was dead since the metadata srcDur is
always >0). Without this the untouched last segment kept the rounded metadata
length, so isFullSingle read false and "Create" was enabled on an unmodified
video — producing a bogus edit that dropped the tail.
- DownloadCenter: fall back to the "queue" tab when a persisted tab (e.g. admin-only
"system") isn't in the current set, instead of rendering a blank page.
- VideoEditor: reencode = cropOn || accurate (was a no-op `willJoin ? accurate : accurate`).
tsc clean. GUI-affecting → pending an E2E visual test (needs an authed localdev
session) before user UAT. Cross-cutting UI-consistency cleanup deferred to Phase 3.
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.
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.
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.
- 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)
1. Series detail pages now match the movie info page: the customize menu lives in the
hero panel (top-right), the Cast/Seasons/Related strips sit in glassy panels, and the
menu is dynamic — Related shows can be toggled (new plexInfoRelated pref).
2. The Plex search box now survives F5 (persisted per-account like the filters).
3. Show + season hero posters get a hover Play/Resume overlay.
4. Card top-right watched toggle can now UN-watch: the watched/in-progress badges were
intercepting the click (no pointer-events-none), so the toggle underneath never fired.
5. Episode card: the watched check no longer jumps on hover (badge now shares the
toggle's box); every card's title/meta gets its own glassy translucent background and
the Add-to-playlist button is always visible (not hover-only).
6. Season-card 'In progress' label is readable again (solid dark badge over the poster).
7. On a season page the show title is clickable → back to the show (history-correct).
- Season cards (show page) gain a hover Play overlay, a clickable title, a quick
watched/unwatched toggle (whole season) and an add-whole-season-to-playlist button.
- Episode cards gain a hover watched/unwatched quick toggle (single episode) — in the
season page and the search Episodes section.
- Series show + season pages now use the same glassy HTPC look as the movie info page:
a faint fixed art backdrop + a customize menu (toggle the backdrop / hide the cast
row), factored into a shared lib/plexDetailUi (useDetailPrefs / useArtBackdrop /
DetailCustomizeMenu), reusing the movie info prefs (plexInfoArtBg/plexInfoCast) so the
toggle state is consistent across movies and series.
- 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.
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.
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.
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).
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.
User-tested on prod: the flash fires (fullscreen → back to small) but the source
stays 360p — YouTube doesn't switch quality within the flash window and we can't set
it via the (dead) API, so the unlock has no effect. Give up on forcing windowed HD.
Restore PlayerModal to its 0.36.2 state (native-menu-yield + scroll-anywhere volume
kept). Also prune the failed HD-quality attempts from the release notes (the flash
and the transform-revert notes, plus the hollow 'higher quality' claim in 0.36.0) so
there's no user-facing trace of an effort that yielded nothing.
YouTube hard-caps a windowed embed to ~360p and only a real fullscreen lifts the
cap — after which the higher quality persists for the session. Since we can't set
quality via the (dead) API, coax it: on the first video opened per page session,
briefly enter fullscreen using the modal-open click's live user activation, then
exit back to the small player. Exits early once onPlaybackQualityChange reports the
quality actually rose (else after a ms cap). Module-scoped
hdUnlockDone flag makes it fire once per session (the unlock persists), with a
retry on the first overlay click if the open-click activation had expired. Cleaned
up on teardown so a mid-flash close can't leave the player stuck in fullscreen.
Best-effort — bandwidth still gates the actual bitrate.
Confirmed on prod: rendering the iframe at 1920x1080 logical and CSS transform:
scale()-ing it down does NOT lift YouTube's quality cap — the embed stays ~360p in
the windowed player and manual HD still snaps back; only true fullscreen unlocks
1080p (which then persists for the session). YouTube caps by the on-screen size, not
the iframe's window.innerWidth, so the transform only shrank YouTube's native
controls for no benefit. Restore the plain 100%/100% mount.
Kept: scroll-anywhere volume (works), the native-menu-yield fix, max-w-6xl, and the
harmless vq=hd1080 hint.
YouTube caps an embedded player's max quality to the iframe's OWN inner viewport
size, so the small windowed player was stuck at ~360p (manual HD selection snapped
back); only fullscreen unlocked 1080p. Render the player at a fixed 1920x1080
logical size and CSS transform: scale() it down to fit the stage — the transform
doesn't change the iframe's window.innerWidth, so YouTube keeps seeing a 1080p
viewport and lets you pick 1080p while we display it small. A ResizeObserver keeps
the scale fitting the stage in both windowed and fullscreen.
Also move wheel-to-volume from the small centre overlay to the whole modal, so
scrolling anywhere over the player window adjusts volume.
The transparent interaction overlay (click=play/pause, wheel=volume, keeps
keyboard focus off the cross-origin iframe) also blocked YouTube's own settings
menu, which expands down over the video past the overlay: items below the first
few were unclickable, and 'More options' made it worse.
Now the overlay yields to native controls. Clicking a native control (gear/seek/
CC) moves focus into the player iframe — the only cross-origin signal available —
which we detect (window blur + getIframe() focus check) to drop the overlay's
pointer-events, so the whole menu is navigable at any height. A discreet badge
signals native mode; moving the pointer off the video (or the window regaining
focus) re-arms the click/scroll/keyboard shortcuts.
Quality: the IFrame API's setPlaybackQuality/suggestedQuality are hard no-ops now,
so the real lever is the rendered player size — bump the modal max-w-4xl -> 6xl so
YouTube's ABR targets a higher resolution — plus a best-effort 'vq=hd1080' URL hint.
i18n (en/hu/de) for the native-mode badge.
Completes the two-way Plex ↔ Siftlode watch-state sync: Phase B (Siftlode→Plex push, immediate)
and Phase C (incremental history/on-deck pull + daily full reconcile incl. un-watch). Also the
auto-advance watched-mark id-race fix.
Completes the two-way watch-state sync with two scheduler jobs:
- plex_watch_sync (default 30m): pull recent Plex-side changes (watch history + on-deck, filtered
to the owner account) into Siftlode under last-write-wins (_pull_apply + a _same_state ping-pong
guard + skew tolerance), then re-push any still-unsynced local states.
- plex_watch_reconcile (default daily): full section rescan; uses synced_to_plex to settle what the
incremental feed can't — notably propagating a Plex-side un-watch (clear a previously-mirrored row
Plex no longer has) — while re-pushing never-synced local states and never touching hidden
(Siftlode-only) rows. Union-preserving.
PlexClient gains accounts/watch_history/on_deck; _scan_plex_states is factored out and shared with
the one-time import. Owner accountID is resolved once and cached on the link. Both jobs are
registered in the scheduler (pause-skip, activity tracking, run-now, admin-tunable intervals) with
trilingual (HU/EN/DE) labels + descriptions. New config default plex_watch_reconcile_interval_min.
Verified live against the real Plex server: read feeds, last-write-wins, dirty re-push, the
incremental job end-to-end, and a full reconcile that cleared exactly the one un-watched item with
zero collateral across 17976 scanned.
The player's watched-mark on completion keyed off the reactive `id`, which can run ahead of the
media actually playing: when the next episode fails to load (e.g. no local file), loadSession
returns without replacing the <video> source, so the old episode keeps playing and later fires
'ended' — but by then an auto-skip-credits jump has already advanced `id` to the next episode.
The result was the WRONG episode marked watched (and scrobbled to Plex), while the finished one
got nothing. This was latent before, made visible/harmful by the Phase B Plex push.
- go(): pause the outgoing media before switching, so a failed next-episode load can't leave the
old media running into a stray 'ended' against the new id.
- auto-skip-credits (doSkip): mark the CURRENT id watched (captured now) before advancing — binge
advance is a finish, and previously marked nothing.
- onEnded: ignore a spurious 'ended' from media that never played (absRef 0), so a failed next
episode can't be marked or cascade another advance.
Manual Next/Prev still never mark watched (not a finish). Verified live: E06→E07 with a missing
E07 marks E06 watched (+ Plex), E07 nothing.
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).
The watch-sync switch only flipped once the (multi-second) import request returned, so a click read as
'nothing happened' and invited repeat clicks. Reflect the toggle's target optimistically while the
mutation is in flight and show an 'Importing your Plex watch history…' spinner line, so it's obvious
the switch responded and is working.
The Plex watch import inserted plex_states via per-row ORM add against a pre-read 'existing' set,
which is not safe when the enable call fires more than once while the multi-second import runs (the
toggle wasn't disabled during it): overlapping imports raced to INSERT the same (user_id, item_id)
rows and all but the first hit a UniqueViolation -> 500 (seen on prod). Rewrite the write path as a
chunked PostgreSQL UPSERT (on_conflict_do_update, rows de-duped by item_id) — idempotent and
concurrency-safe, Plex still wins on the intersection and Siftlode-only states are untouched.
Also disable the Settings toggle (and ignore its onChange) while a sync mutation is pending, so it
can't be fired repeatedly; adds a reusable 'disabled' prop to the Switch primitive.
Admins get a 'Plex watch sync' section in Settings → Account (shown when the Plex module is on):
a two-way-sync toggle whose first enable imports the Plex watch history, a last-import line, and an
'Import from Plex now' button. api.plexWatch{Link,SetLink,Import}; trilingual EN/HU/DE strings.
Seeking to a spot outside the current remux window restarts the hls.js
session, which detaches/re-attaches the media and fires 'emptied' — that
silently flips the element to paused WITHOUT a 'pause' event. Because the
play flag only tracked 'play'/'pause', the button kept showing 'playing'
while the video had actually stopped, and on a slow (proxied) load the
post-manifest play() could be rejected, leaving it stuck paused until a
click on the video.
Root cause confirmed by instrumenting the media events during a seek:
emptied(paused=true) with no accompanying pause event.
Fix: resync the play flag from video.paused on the settling events
(emptied/canplay/seeked) so the button can never contradict reality;
preserve the pre-seek play/pause intent through the session restart
(loadSession takes resumePlay); and retry play once on 'canplay' so a
rejected early play() still resumes. Verified on :8080 — backward seek
(session restart), forward seek (native) and manual pause all keep the
icon in sync (0 desync samples) and resume correctly.
- The seek-bar hover tooltip positioned itself with a px `left` derived
from the scaled getBoundingClientRect, so inside the transform:scale
player it got re-scaled and drifted right of the cursor (only 0 was
right). Position it by fraction (% of the bar) instead — it now sits
under the cursor at any point, like the volume tooltip already did.
- The tuning (gear) menu tabs wrapped to two lines at the +25% scale.
Widen the menu, keep tab labels on one line (whitespace-nowrap), and
shorten the labels: "Sync offset" → "Offsets", "Subtitle style" →
"Subtitles" (all three languages).
- Mouse/browser Back button now behaves like Backspace/Esc: while a
menu, the shortcuts sheet, the info overlay or an auto-skip countdown
is up, Back closes/cancels that (via a history entry) instead of
dropping straight to the grid; with nothing open it still leaves.
- Whole player UI is 25% larger for lean-back/HTPC legibility, applied
as transform: scale(1.25) on an 80vw x 80vh root (fills the viewport
exactly). transform, not zoom — under zoom, event clientX and
getBoundingClientRect fall into different spaces and the seek/volume/
hover math breaks; transform keeps them aligned. Skip buttons ride
this scale (reverted their separate enlargement so they're +25% too).
- Seek bar: drag the head to scrub, not just click-to-jump; the head
previews the target and the seek commits on release (pointer capture),
so a long drag doesn't restart the stream on every step.
- Volume bar: dropped the loudness gradient — back to the plain accent
fill; the 0-100 hover value tooltip stays.
- Harden both bars' pointer-capture calls (try/catch) so a capture
failure can't abort the click/drag.
Cancelling only blanked skipProgress and flagged the marker — it never
stopped the running interval, so the next tick (~100ms later) re-set the
progress and the skip fired anyway. Hold the interval id in a ref and
clear it on cancel (and null it on completion/cleanup), so Esc/Backspace
truly stops the countdown; the Skip button stays for a manual skip.
- Resume: closing with Back now leaves the resume point correct. The
position was saved server-side, but reopening the item in the same
session read react-query's cached detail (old position); patch that
cache on unmount so a reopen resumes where you left off (F5 was fine —
empty cache after a reload).
- Back cascade: the on-screen Back arrow and Backspace now close an open
tuning/tracks menu, then the shortcuts sheet, then the info overlay,
and only leave the player when nothing is layered on top (it used to
jump straight to the feed with a panel still open).
- Subtitle shadow: replace the single 0-offset blur (invisible even at
max) with an eight-direction outline plus glow that scales with the
slider — legible over any frame.
- Skip Intro/Credits: ~25% larger button and text; add a crisp bottom
progress bar to the auto-skip countdown; and freeze the countdown while
the video is paused so it no longer auto-seeks a paused video.
- Time readout: append the projected finish time (wall clock + time
remaining) in the clock's own 24h/12h format.
- Volume: replace the native slider with a bar that shows the 0-100 value
under the cursor (like the seek bar) and a loudness gradient — blue
(too quiet) → green (normal) → red (too loud).
The Plex library search box wrote to the shared, persisted feed filter
(filters.q). That leaked a Plex query into the feed search box and, being
persisted, restored a stale query after a reload — which then collided
with a persisted collection filter to produce a confusing empty grid.
Plex now has its own search state: kept across page switches within a
session, but not persisted and not shared with the feed, so a reload
starts clean.
A Plex search that returns nothing while a collection/rating filter is
active showed a bare "No matches", hiding that the filters — not the
query — were the cause. Show how many filters are narrowing the results
and offer a one-click Clear filters inline. Translated in all three
languages.
- Wall clock is now a Clock settings tab: show/hide, 24h↔12h (am/pm), size, colour, and
an optional date in two fixed styles (EN "08-JUL-2026 Wed" / HU "2026-JÚL-08 Sze").
- Subtitle text-shadow (blur strength + colour) in the Subtitle tab.
- Auto-skip intro/credits: per-user toggles + a shared 0–10s delay (default 5). Entering a
marker whose auto-skip is on runs a countdown shown as a fill on the Skip button; on
completion it skips (intro → marker end, credits → next item / binge). Esc/Backspace during
the countdown cancels it and does NOT navigate back. 0s = immediate.
- Seekbar hover shows a timestamp tooltip that follows the cursor.
- i18n en/hu/de for the new strings.
- 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.
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.
Shared links didn't unfurl in Messenger: robots.txt disallowed /watch/ for ALL
user-agents, so facebookexternalhit honoured it and got a blocked (403) fetch —
the OG tags were correct but never read. Add an allow group for the link-preview
bots (facebookexternalhit, Twitterbot, Discordbot, WhatsApp, Slack, Telegram,
LinkedIn, …) so they can fetch share pages; the wildcard still keeps /watch/ out
of search indexes. Release v0.32.1.
A remote thumbnail as og:image is unreliable for the crawler — Facebook's signed
CDN URL expires (so an old shared link's image later vanishes) and cross-origin
hotlinking is flaky. Record a self-hosted poster for EVERY download (ensure_poster
returns the existing <base>.jpg thumbnail sidecar, or cuts a frame) and point
og:image at our own /api/public/watch/{token}/poster.jpg. Backfill now fills
poster_path for all existing downloads, not just thumbnail-less ones.
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.