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:
parent
57f521191e
commit
3a3ba17fb8
5 changed files with 167 additions and 12 deletions
|
|
@ -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).
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue