Commit graph

689 commits

Author SHA1 Message Date
npeter83
5998a80ae3 Merge: promote dev to prod 2026-07-10 18:16:15 +02:00
npeter83
f0bab315ad Merge improvement/player-drop-hd-experiment: drop HD flash, clean notes (v0.36.4) 2026-07-10 18:14:32 +02:00
npeter83
f76b80a946 revert(player): drop the experimental HD-unlock fullscreen flash
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.
2026-07-10 18:14:32 +02:00
npeter83
a11d3aa158 Merge: promote dev to prod 2026-07-10 17:32:43 +02:00
npeter83
5d3dce6214 Merge improvement/player-hd-unlock-flash: experimental windowed-HD unlock (v0.36.3) 2026-07-10 17:30:50 +02:00
npeter83
50cef1a552 chore(release): 0.36.3 2026-07-10 17:30:50 +02:00
npeter83
ca00644698 feat(player): experimental one-time fullscreen flash to unlock windowed HD
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.
2026-07-10 17:30:49 +02:00
npeter83
397afb4c01 Merge: promote dev to prod 2026-07-10 16:58:07 +02:00
npeter83
8856553981 Merge improvement/player-revert-transform: drop ineffective HD hack (v0.36.2) 2026-07-10 16:56:24 +02:00
npeter83
86ef4ad64c chore(release): 0.36.2 2026-07-10 16:56:24 +02:00
npeter83
71e0a40481 revert(player): drop the transform-scale HD hack (didn't beat YouTube's cap)
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.
2026-07-10 16:56:24 +02:00
npeter83
0dadfb1585 Merge: promote dev to prod 2026-07-10 16:29:30 +02:00
npeter83
42f112d4df Merge improvement/player-windowed-hd: windowed 1080p + scroll-anywhere volume (v0.36.1) 2026-07-10 16:27:43 +02:00
npeter83
efd7a3d09d chore(release): 0.36.1 2026-07-10 16:27:43 +02:00
npeter83
c4d8258065 feat(player): unlock 1080p in the windowed player + scroll-anywhere volume
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.
2026-07-10 16:27:42 +02:00
npeter83
ac11ff2f01 Merge: promote dev to prod 2026-07-10 15:54:24 +02:00
npeter83
a1dfa033a9 Merge improvement/player-quality-native-controls: native YT menu access + higher quality (v0.36.0) 2026-07-10 15:52:43 +02:00
npeter83
cf204e18a8 chore(release): 0.36.0 2026-07-10 15:52:34 +02:00
npeter83
9af0b90816 feat(player): open YouTube's native settings menu fully + push higher quality
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.
2026-07-10 15:52:34 +02:00
npeter83
e12da81082 Merge: promote dev to prod 2026-07-10 00:50:10 +02:00
npeter83
a2de72420b chore(release): 0.35.0 — two-way Plex watch sync (Phase B + C)
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.
2026-07-10 00:47:55 +02:00
npeter83
20c71c0b01 Merge: Plex watch-state sync Phase C (incremental + full reconcile, 2-way complete) 2026-07-10 00:46:22 +02:00
npeter83
bbbcf4ff5a feat(plex): Phase C — incremental + full Plex↔Siftlode watch reconcile
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.
2026-07-10 00:46:22 +02:00
npeter83
bdf35c3375 Merge: Plex watch-state sync Phase B (Siftlode→Plex push) + auto-advance watched-mark fix 2026-07-10 00:18:54 +02:00
npeter83
363b4d17fc fix(plex): mark the finished episode on the correct id when auto-advancing
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.
2026-07-10 00:18:44 +02:00
npeter83
3a3ba17fb8 feat(plex): Phase B — Siftlode→Plex watch-state push
Mirror watched/unwatched/resume from Siftlode to a linked (owner) Plex account.

- PlexClient: scrobble / unscrobble / set_timeline (/:/scrobble, /:/unscrobble, /:/timeline).
- watch_sync: link_for_push() gates on owner + sync_enabled + initial_import_done; best-effort
  push_state_to_plex() runs as a background task with its own session, never raises (Plex being
  down must not break the user's action), and flips synced_to_plex on success so Phase C's pull
  won't bounce it back.
- item_state / item_progress schedule the push: watched/unwatched immediately, resume only on a
  "final" checkpoint (pause / pagehide / unmount) — not every 10s tick — so one watch doesn't spray
  /:/timeline at the server. `hidden` is Siftlode-only and never touches Plex.
- Frontend: plexProgress / plexProgressBeacon carry a `final` flag; the periodic checkpoint is
  non-final, pause/pagehide/leaving the player are final.

Verified live against the real Plex server (scrobble/unscrobble/timeline round-trip + restore;
link gating; synced_to_plex flip).
2026-07-10 00:17:56 +02:00
npeter83
2a1ce896c6 Merge: promote dev to prod 2026-07-09 15:36:21 +02:00
npeter83
57f521191e chore(release): 0.34.2 — watch-sync toggle UX polish 2026-07-09 15:34:52 +02:00
npeter83
02837b9a4b fix(plex): optimistic toggle + 'importing' indicator for watch-sync
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.
2026-07-09 15:34:28 +02:00
npeter83
6140be18fc Merge: promote dev to prod 2026-07-09 15:20:48 +02:00
npeter83
cbe203391e chore(release): 0.34.1 — Plex watch import concurrency fix 2026-07-09 15:19:17 +02:00
npeter83
5592f60e85 fix(plex): make watch import concurrency-safe (upsert), guard toggle
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.
2026-07-09 15:18:19 +02:00
npeter83
1839264c99 Merge: promote dev to prod 2026-07-09 14:44:48 +02:00
npeter83
b09963be2b chore(release): 0.34.0 — Plex watch-state import (Phase A) 2026-07-09 14:43:22 +02:00
npeter83
a6b3020b4b Merge: Plex watch-state sync Phase A (Plex→Siftlode import) 2026-07-09 14:42:41 +02:00
npeter83
a0475eb7bb feat(plex): Settings toggle for Plex watch-sync + one-click import
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.
2026-07-09 14:42:32 +02:00
npeter83
04fb3fb16e feat(plex): Phase A — one-time Plex→Siftlode watch-state import
Plex records watch state per Plex account; Siftlode per user in plex_states. A new plex_link table
maps a Siftlode user to a Plex account; for the owner (MVP) the row uses the server admin token
(uses_admin=True), so no separate Plex login. app/plex/watch_sync.py reads the owner account's
viewCount/viewOffset/lastViewedAt (already present in the catalog mirror's section listing) and
upserts them into the owner's plex_states — 'Plex is master' on this first import, but only where
Plex has a watch record (Siftlode-only states are preserved; union on the intersection). Idempotent.

Admin routes: GET/POST /api/plex/watch/link (status + enable/disable; first enable runs the import)
and POST /api/plex/watch/import (re-run). Migration 0051_plex_link. Two-way push (Phase B) and the
incremental Plex→Siftlode reconcile (Phase C) build on this in later ships.
2026-07-09 14:42:18 +02:00
npeter83
9bd7e31463 Merge: promote dev to prod 2026-07-09 09:46:14 +02:00
npeter83
8675e24663 chore(release): 0.33.1 — channel page network-error fix 2026-07-09 09:43:08 +02:00
npeter83
ca12b3bc9d Merge: YouTube client network-error handling (channel page 500 fix) 2026-07-09 09:42:21 +02:00
npeter83
248782493c fix(youtube): map httpx transport errors to YouTubeError
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.
2026-07-09 09:24:09 +02:00
npeter83
93bca8ba74 Merge: promote dev to prod 2026-07-09 02:37:31 +02:00
npeter83
65b4949245 chore(release): v0.33.0
Plex player (copyts playback, resume, personalization, skip intro/credits,
audio/subtitle switching, seek drag, projected finish time, +25% UI) and
Plex search improvements (own ephemeral state; empty-with-filters hint).
2026-07-09 02:32:46 +02:00
npeter83
c53a65d9d4 merge: Plex search ephemeral state + empty-with-filters hint (dev-only) 2026-07-09 02:30:57 +02:00
npeter83
de29a20d7e merge: Plex player copyts rework + full player UAT round (dev-only) 2026-07-09 02:30:44 +02:00
npeter83
488e809f2b fix(plex): keep play state honest across a seek that restarts the session
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.
2026-07-09 02:20:04 +02:00
npeter83
2659991d84 fix(plex): seek tooltip tracks cursor under scale; tidy the tuning menu
- 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).
2026-07-09 01:32:04 +02:00
npeter83
23070279a3 feat(plex): player UAT round 2 — mouse-back, +25% UI, seek drag, volume revert
- 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.
2026-07-09 01:12:10 +02:00
npeter83
dcdf48a811 fix(plex): Esc/Backspace actually cancels the auto-skip countdown
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.
2026-07-09 00:37:13 +02:00
npeter83
089eab76e4 fix(plex): player UAT round — resume, back, subtitles, skip, ETA, volume
- 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).
2026-07-08 23:48:09 +02:00