From f364ef57fdd138bb268297299b323cf5d9af3f94 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 22 Dec 2025 10:04:57 +0000 Subject: [PATCH] Fix: Prevent early client progress saves from overwriting server progress Co-authored-by: mathieumoran1 --- public/js/app_player.js | 34 +++++++++++++++++++++++++++++++++- routes/movies.js | 17 +++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/public/js/app_player.js b/public/js/app_player.js index e7a081c..5328bc1 100644 --- a/public/js/app_player.js +++ b/public/js/app_player.js @@ -93,6 +93,8 @@ function setupVideoProgress({ videoEl, movieId, token, userId, imdbId }) { const key = progressStorageKey(userId, movieId); let restored = false; + let restoreInProgress = false; + let pendingRestoreTime = null; // number | null let restoreServerPromise = null; let intervalId = null; let lastSavedAt = 0; @@ -135,10 +137,16 @@ function setupVideoProgress({ videoEl, movieId, token, userId, imdbId }) { restored = true; if (clamped > 1) { + // Block any progress saves until the seek completes; otherwise we can overwrite + // server progress with an early "0s" save during reload/startup. + pendingRestoreTime = clamped; + restoreInProgress = true; try { videoEl.currentTime = clamped; } catch (_) {} lastSaveStatus = `Reprise · ${formatClock(clamped)}`; setProgressUi({ state: 'info', text: lastSaveStatus, showText: true, ariaText: `Reprise à ${formatClock(clamped)}` }); } else { + pendingRestoreTime = null; + restoreInProgress = false; lastSaveStatus = 'Début'; setProgressUi({ state: 'info', text: 'Début', showText: true, ariaText: 'Lecture depuis le début' }); } @@ -151,6 +159,14 @@ function setupVideoProgress({ videoEl, movieId, token, userId, imdbId }) { } async function persist({ keepalive, force } = {}) { + // Never persist until the resume logic finished; otherwise the first "0s" timeupdate + // on reload can overwrite the user's saved progress on the server. + if (!restored) { + void tryRestoreProgress(); + return; + } + if (restoreInProgress) return; + // Throttle to avoid spamming the API const now = Date.now(); if (!force && !keepalive && now - lastSavedAt < 1500) return; @@ -158,6 +174,16 @@ function setupVideoProgress({ videoEl, movieId, token, userId, imdbId }) { const snap = getSnapshot(); if (!Number.isFinite(snap.time)) return; + if (pendingRestoreTime !== null) { + // Some browsers may not fire seeked reliably. Only start saving once we + // observe that currentTime actually reached the restored position. + if (snap.time >= Math.max(0, Math.floor(pendingRestoreTime) - 1)) { + pendingRestoreTime = null; + } else { + return; + } + } + // Only save if it meaningfully changed (>= 1s) if (!force && lastSavedTime >= 0 && Math.abs(snap.time - lastSavedTime) < 1) return; @@ -211,7 +237,13 @@ function setupVideoProgress({ videoEl, movieId, token, userId, imdbId }) { if (seekSaveTimer) window.clearTimeout(seekSaveTimer); seekSaveTimer = window.setTimeout(() => { void persist({ force: true }); }, 400); }; - const onSeeked = () => { void persist({ force: true }); }; + const onSeeked = () => { + if (restoreInProgress) { + restoreInProgress = false; + pendingRestoreTime = null; + } + void persist({ force: true }); + }; const onEnded = () => { stopInterval(); void persist(); }; const onError = () => { stopInterval(); }; const onTimeUpdate = () => { diff --git a/routes/movies.js b/routes/movies.js index a7b280e..2221459 100644 --- a/routes/movies.js +++ b/routes/movies.js @@ -667,6 +667,23 @@ router.put("/:id/progress", authenticate, async (req, res) => { time = Math.min(Math.max(time, 0), durationCandidate); } + // Defensive guard: + // On some reload/startup sequences (especially on hosted environments), + // the client can emit an early "time=0" save *before* it has restored the + // user's previous progress (and before it knows duration). + // If we already have a meaningful progress saved, never let a "0 with unknown duration" + // overwrite it. + if (time === 0 && durationCandidate === null) { + const existing = await PlaybackProgress.findOne({ userId, movieId: id }).select("time duration updatedAt"); + if (existing && typeof existing.time === "number" && existing.time > 1) { + return res.status(200).json({ + time: existing.time ?? 0, + duration: typeof existing.duration === "number" ? existing.duration : null, + updatedAt: existing.updatedAt ? new Date(existing.updatedAt).toISOString() : null, + }); + } + } + // Auto-check the movie in the checklist when the user reaches the "credit roll". // Keep it simple: use the timestamp ratio (>= 95%). const reachedCreditRoll = durationCandidate !== null && durationCandidate > 0 && time / durationCandidate >= 0.95;