Fix: Prevent early client progress saves from overwriting server progress (#52)
Co-authored-by: Cursor Agent <[email protected]>
This commit was merged in pull request #52.
This commit is contained in:
co-authored by
Cursor Agent
parent
a75a024249
commit
1a460ca06c
+33
-1
@@ -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 = () => {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user