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.
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.
The YouTubeClient issued httpx requests directly, so a network/transport
failure (egress proxy unreachable, DNS, timeout, connection reset) escaped
as a raw httpx.ConnectError. Callers only guard against YouTubeError, so such
a fault propagated uncaught and surfaced as a 500 — e.g. opening an
un-enriched channel's page (GET /api/channels/{id} lazily enriches About
data) popped a blocking "Server error (500)" modal whenever the fixed-IP
egress proxy was down.
Route all client HTTP through a _send() helper that wraps httpx.HTTPError in
YouTubeError, so every existing 'except YouTubeError' degrades gracefully:
channel detail returns un-enriched (200), explore returns 422 (quiet), and
scheduler jobs log-and-continue instead of crashing.
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.
Pressing the mouse inside a dialog (e.g. to select text in an input), dragging
out, and releasing on the backdrop wrongly dismissed it: the click event fires
on the common ancestor of mousedown+mouseup (the backdrop), so the outside-click
handler ran. Track whether the press started on the backdrop and only close when
it both starts and ends there. Fixes all shared-Modal dialogs at once (downloads
editor/share, confirm, Plex collections + add-to-playlist, …).
A download whose source title exceeded 255 chars (e.g. a recipe baked into a
Facebook video's title) failed with a StringDataRightTruncation: the worker
auto-fills an empty display_name from the title, which overflows the 255-char
column. Clip the title to 255 at every point it flows into display_name (worker
completion paths + enqueue); the full title is untouched on the asset. Pre-existing
latent bug, surfaced by an unusually long title.
Release v0.31.1.
A shared /watch/{token} link is a client-rendered SPA, so a social crawler only
saw the generic index.html — a blank link card. The server now injects per-video
Open Graph / Twitter tags (title, channel, thumbnail) into the served HTML for
that route, so links unfurl richly in Messenger and other chat apps; real
browsers ignore the extra tags and hydrate the page as usual. Password /
expired / invalid links fall back to the generic card with no metadata leak.
Also shortens the generic site description used for search engines and link
previews.
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.
Playlist view now groups a run of episodes from the same show into a
collapsible season/show block so a whole series doesn't sprawl into an
endless flat list; movies stay standalone (larger poster card in the
accordion). Two per-account layouts — Accordion and Tree — persisted via
LS.plexPlaylistLayout; show groups start collapsed. Reorder is drag &
drop (@dnd-kit): a show block moves as one unit, episodes reorder within
their show, keyboard-draggable via KeyboardSensor. Remove works per item,
per season, or per whole show. The add-to-playlist dialog is generalised
to a single leaf or a whole group (tri-state none/some/all with an in/size
count); the show page gains add buttons for an episode, a season, and the
whole show. i18n en/hu/de.
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.