Automatic checklist completion #50

Merged
quoije merged 1 commits from cursor/automatic-checklist-completion-27ca into 2026-video.backend 2025-12-22 04:39:52 -05:00
2 changed files with 35 additions and 5 deletions
Showing only changes of commit 4236ccd560 - Show all commits
+9 -5
View File
@@ -55,17 +55,21 @@ async function fetchPlaybackProgress(movieId, token) {
} }
} }
async function savePlaybackProgress(movieId, token, { time, duration, keepalive } = {}) { async function savePlaybackProgress(movieId, token, { time, duration, imdbId, keepalive } = {}) {
try { try {
// Retry once on rare upsert races (409). // Retry once on rare upsert races (409).
for (let attempt = 0; attempt < 2; attempt += 1) { for (let attempt = 0; attempt < 2; attempt += 1) {
const payload = { time, duration };
const safeImdb = typeof imdbId === 'string' ? imdbId.trim() : '';
if (safeImdb) payload.imdb_id = safeImdb;
const res = await fetch(`/api/movies/${encodeURIComponent(movieId)}/progress`, { const res = await fetch(`/api/movies/${encodeURIComponent(movieId)}/progress`, {
method: 'PUT', method: 'PUT',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
...(token ? { 'Authorization': `Bearer ${token}` } : {}), ...(token ? { 'Authorization': `Bearer ${token}` } : {}),
}, },
body: JSON.stringify({ time, duration }), body: JSON.stringify(payload),
keepalive: !!keepalive, keepalive: !!keepalive,
}); });
if (res.ok) return true; if (res.ok) return true;
@@ -80,7 +84,7 @@ async function savePlaybackProgress(movieId, token, { time, duration, keepalive
} }
} }
function setupVideoProgress({ videoEl, movieId, token, userId }) { function setupVideoProgress({ videoEl, movieId, token, userId, imdbId }) {
if (!videoEl || !movieId || !token || !userId) return () => {}; if (!videoEl || !movieId || !token || !userId) return () => {};
const key = progressStorageKey(userId, movieId); const key = progressStorageKey(userId, movieId);
@@ -162,7 +166,7 @@ function setupVideoProgress({ videoEl, movieId, token, userId }) {
// Icon-only saving indicator (avoid noisy text updates). // Icon-only saving indicator (avoid noisy text updates).
setProgressUi({ state: 'saving', text: '', showText: false, ariaText: 'Sauvegarde…' }); setProgressUi({ state: 'saving', text: '', showText: false, ariaText: 'Sauvegarde…' });
const ok = await savePlaybackProgress(movieId, token, { ...snap, keepalive: !!keepalive }); const ok = await savePlaybackProgress(movieId, token, { ...snap, imdbId, keepalive: !!keepalive });
if (ok === false) { if (ok === false) {
// Token expired mid-playback; stop spamming requests and prompt user. // Token expired mid-playback; stop spamming requests and prompt user.
stopInterval(); stopInterval();
@@ -803,7 +807,7 @@ window.onload = async function () {
setProgressUi({ state: 'preparing', text: 'Reprise', showText: true, ariaText: 'Prépare la reprise…' }); setProgressUi({ state: 'preparing', text: 'Reprise', showText: true, ariaText: 'Prépare la reprise…' });
const userId = normalizeUserId(decoded); const userId = normalizeUserId(decoded);
if (userId) { if (userId) {
setupVideoProgress({ videoEl, movieId: id, token, userId }); setupVideoProgress({ videoEl, movieId: id, token, userId, imdbId: movie?.imdb_id });
} else { } else {
// Avoid a "stuck" progress state if the token payload is unexpected. // Avoid a "stuck" progress state if the token payload is unexpected.
setProgressUi({ state: 'warning', text: '—', showText: true, ariaText: "Impossible d'identifier l'utilisateur pour sauvegarder la progression." }); setProgressUi({ state: 'warning', text: '—', showText: true, ariaText: "Impossible d'identifier l'utilisateur pour sauvegarder la progression." });
+26
View File
@@ -645,6 +645,7 @@ router.put("/:id/progress", authenticate, async (req, res) => {
const rawTime = req.body?.time; const rawTime = req.body?.time;
const rawDuration = req.body?.duration; const rawDuration = req.body?.duration;
const rawImdbId = req.body?.imdb_id;
let time = Number(rawTime); let time = Number(rawTime);
if (!Number.isFinite(time) || time < 0) time = 0; if (!Number.isFinite(time) || time < 0) time = 0;
@@ -660,6 +661,10 @@ router.put("/:id/progress", authenticate, async (req, res) => {
time = Math.min(Math.max(time, 0), duration); time = Math.min(Math.max(time, 0), duration);
} }
// Auto-check the movie in the checklist when the user reaches the "credit roll".
// Keep it simple: use the timestamp ratio (>= 95%).
const reachedCreditRoll = duration !== null && duration > 0 && time / duration >= 0.95;
// Best UX: if the user is basically at the end, reset to 0 so replay starts from the beginning. // Best UX: if the user is basically at the end, reset to 0 so replay starts from the beginning.
if (duration !== null && duration - time <= 3) { if (duration !== null && duration - time <= 3) {
time = 0; time = 0;
@@ -671,6 +676,27 @@ router.put("/:id/progress", authenticate, async (req, res) => {
{ upsert: true, new: true, setDefaultsOnInsert: true } { upsert: true, new: true, setDefaultsOnInsert: true }
).select("time duration updatedAt"); ).select("time duration updatedAt");
// If they hit the 95% threshold, mark as watched for checklist.
// Prefer imdb_id provided by the client; fall back to DB lookup if absent/invalid.
if (reachedCreditRoll) {
let imdbId = typeof rawImdbId === "string" ? rawImdbId.trim() : "";
if (!isValidImdbId(imdbId)) imdbId = "";
if (!imdbId) {
const movieDoc = await Movie.findById(id).select("imdb_id").lean();
const mImdb = typeof movieDoc?.imdb_id === "string" ? movieDoc.imdb_id.trim() : "";
if (isValidImdbId(mImdb)) imdbId = mImdb;
}
if (imdbId) {
// Only add if not already present; if user manually unchecked, it can be re-added on next 95% hit.
await User.updateOne(
{ _id: userId, "watchedMovies.imdb_id": { $ne: imdbId } },
{ $push: { watchedMovies: { imdb_id: imdbId, watchedDate: new Date() } } }
);
}
}
return res.status(200).json({ return res.status(200).json({
time: updated?.time ?? 0, time: updated?.time ?? 0,
duration: typeof updated?.duration === "number" ? updated.duration : null, duration: typeof updated?.duration === "number" ? updated.duration : null,