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).
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue