Autosave playback reset issue #52

Merged
quoije merged 1 commits from cursor/autosave-playback-reset-issue-5a7b into 2026-video.backend 2025-12-22 05:08:53 -05:00
2 changed files with 50 additions and 1 deletions
Showing only changes of commit f364ef57fd - Show all commits
+33 -1
View File
@@ -93,6 +93,8 @@ function setupVideoProgress({ videoEl, movieId, token, userId, imdbId }) {
const key = progressStorageKey(userId, movieId); const key = progressStorageKey(userId, movieId);
let restored = false; let restored = false;
let restoreInProgress = false;
let pendingRestoreTime = null; // number | null
let restoreServerPromise = null; let restoreServerPromise = null;
let intervalId = null; let intervalId = null;
let lastSavedAt = 0; let lastSavedAt = 0;
@@ -135,10 +137,16 @@ function setupVideoProgress({ videoEl, movieId, token, userId, imdbId }) {
restored = true; restored = true;
if (clamped > 1) { 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 (_) {} try { videoEl.currentTime = clamped; } catch (_) {}
lastSaveStatus = `Reprise · ${formatClock(clamped)}`; lastSaveStatus = `Reprise · ${formatClock(clamped)}`;
setProgressUi({ state: 'info', text: lastSaveStatus, showText: true, ariaText: `Reprise à ${formatClock(clamped)}` }); setProgressUi({ state: 'info', text: lastSaveStatus, showText: true, ariaText: `Reprise à ${formatClock(clamped)}` });
} else { } else {
pendingRestoreTime = null;
restoreInProgress = false;
lastSaveStatus = 'Début'; lastSaveStatus = 'Début';
setProgressUi({ state: 'info', text: 'Début', showText: true, ariaText: 'Lecture depuis le 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 } = {}) { 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 // Throttle to avoid spamming the API
const now = Date.now(); const now = Date.now();
if (!force && !keepalive && now - lastSavedAt < 1500) return; if (!force && !keepalive && now - lastSavedAt < 1500) return;
@@ -158,6 +174,16 @@ function setupVideoProgress({ videoEl, movieId, token, userId, imdbId }) {
const snap = getSnapshot(); const snap = getSnapshot();
if (!Number.isFinite(snap.time)) return; 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) // Only save if it meaningfully changed (>= 1s)
if (!force && lastSavedTime >= 0 && Math.abs(snap.time - lastSavedTime) < 1) return; 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); if (seekSaveTimer) window.clearTimeout(seekSaveTimer);
seekSaveTimer = window.setTimeout(() => { void persist({ force: true }); }, 400); 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 onEnded = () => { stopInterval(); void persist(); };
const onError = () => { stopInterval(); }; const onError = () => { stopInterval(); };
const onTimeUpdate = () => { const onTimeUpdate = () => {
+17
View File
@@ -667,6 +667,23 @@ router.put("/:id/progress", authenticate, async (req, res) => {
time = Math.min(Math.max(time, 0), durationCandidate); 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". // Auto-check the movie in the checklist when the user reaches the "credit roll".
// Keep it simple: use the timestamp ratio (>= 95%). // Keep it simple: use the timestamp ratio (>= 95%).
const reachedCreditRoll = durationCandidate !== null && durationCandidate > 0 && time / durationCandidate >= 0.95; const reachedCreditRoll = durationCandidate !== null && durationCandidate > 0 && time / durationCandidate >= 0.95;