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).
This commit is contained in:
npeter83 2026-07-10 00:17:56 +02:00
parent 57f521191e
commit 3a3ba17fb8
5 changed files with 167 additions and 12 deletions

View file

@ -452,23 +452,31 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
const absRef = useRef(0);
absRef.current = abs;
useEffect(() => {
const flush = () => {
// `final` = a settled checkpoint (pause / page-hide / leaving the player) vs. the periodic tick.
// Only final checkpoints get mirrored to a linked Plex account (backend-side debounce), so Plex
// sees the resume position when the user actually stops — not a timeline call every 10 seconds.
const flush = (final = false) => {
if (absRef.current > 0 && durationRef.current > 0)
api.plexProgress(id, Math.floor(absRef.current), durationRef.current).catch(() => {});
api.plexProgress(id, Math.floor(absRef.current), durationRef.current, final).catch(() => {});
};
const iv = window.setInterval(flush, 10000);
// Pausing is a settled position — push it to Plex now rather than waiting for unmount/unload.
const onPauseFlush = () => flush(true);
const video = videoRef.current;
video?.addEventListener("pause", onPauseFlush);
// Save on page unload too (F5/close/navigate): React effect cleanup does NOT run on a full reload,
// so without this the resume point lags up to 10s — e.g. a seek right before F5 was lost. Uses a
// keepalive beacon so the POST survives the unload.
// keepalive beacon so the POST survives the unload (a final checkpoint — the user is leaving).
const onHide = () => {
if (absRef.current > 0 && durationRef.current > 0)
api.plexProgressBeacon(id, Math.floor(absRef.current), durationRef.current);
api.plexProgressBeacon(id, Math.floor(absRef.current), durationRef.current, true);
};
window.addEventListener("pagehide", onHide);
return () => {
window.clearInterval(iv);
window.removeEventListener("pagehide", onHide);
flush();
video?.removeEventListener("pause", onPauseFlush);
flush(true);
// Back (unmount) persists the position server-side via flush(), but reopening the same item in
// the SAME session read react-query's CACHED detail — whose position_seconds predated this
// watch — so the resume landed at the old spot (the progress GET could also race the POST).

View file

@ -1244,16 +1244,29 @@ export const api = {
// cue times onto the HLS session's zero-based clock so subtitles line up with speech after a seek.
plexSubtitleUrl: (id: string, ord: number, offset = 0): string =>
`/api/plex/subtitle/${encodeURIComponent(id)}/${ord}${offset ? `?offset=${offset}` : ""}`,
plexProgress: (id: string, position_seconds: number, duration_seconds: number): Promise<unknown> =>
// `final` marks a settled checkpoint (pause / page-hide / leaving the player) as opposed to the
// periodic 10s tick — the backend mirrors the resume position to a linked Plex account only on a
// final checkpoint, so a single watch doesn't spray Plex with timeline updates.
plexProgress: (
id: string,
position_seconds: number,
duration_seconds: number,
final = false,
): Promise<unknown> =>
req(`/api/plex/item/${encodeURIComponent(id)}/progress`, {
method: "POST",
body: JSON.stringify({ position_seconds, duration_seconds }),
body: JSON.stringify({ position_seconds, duration_seconds, final }),
}),
// Fire-and-forget progress save that SURVIVES page unload (F5/close/navigate): a normal fetch is
// cancelled on unload and React effect cleanup doesn't run on a full reload, so the last position
// (esp. right after a seek) would be lost. `keepalive` lets the POST complete during unload; we
// replicate req()'s credentials + account header (no retry — there's no page left to retry on).
plexProgressBeacon: (id: string, position_seconds: number, duration_seconds: number): void => {
plexProgressBeacon: (
id: string,
position_seconds: number,
duration_seconds: number,
final = false,
): void => {
const active = getActiveAccount();
try {
void fetch(`/api/plex/item/${encodeURIComponent(id)}/progress`, {
@ -1264,7 +1277,7 @@ export const api = {
"Content-Type": "application/json",
...(active != null ? { [ACTIVE_ACCOUNT_HEADER]: String(active) } : {}),
},
body: JSON.stringify({ position_seconds, duration_seconds }),
body: JSON.stringify({ position_seconds, duration_seconds, final }),
}).catch(() => {});
} catch {
/* ignore */