From d933d69b5fd42151fde79b6b78a3345982e1bf80 Mon Sep 17 00:00:00 2001 From: Mathieu Moran Date: Mon, 22 Dec 2025 04:39:52 -0500 Subject: [PATCH] feat: Add IMDb ID to playback progress and auto-mark as watched (#50) Co-authored-by: Cursor Agent --- public/js/app_player.js | 14 +++++++++----- routes/movies.js | 26 ++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 5 deletions(-) diff --git a/public/js/app_player.js b/public/js/app_player.js index 21ce919..c3fa252 100644 --- a/public/js/app_player.js +++ b/public/js/app_player.js @@ -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 { // Retry once on rare upsert races (409). 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`, { method: 'PUT', headers: { 'Content-Type': 'application/json', ...(token ? { 'Authorization': `Bearer ${token}` } : {}), }, - body: JSON.stringify({ time, duration }), + body: JSON.stringify(payload), keepalive: !!keepalive, }); 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 () => {}; const key = progressStorageKey(userId, movieId); @@ -162,7 +166,7 @@ function setupVideoProgress({ videoEl, movieId, token, userId }) { // Icon-only saving indicator (avoid noisy text updates). 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) { // Token expired mid-playback; stop spamming requests and prompt user. stopInterval(); @@ -803,7 +807,7 @@ window.onload = async function () { setProgressUi({ state: 'preparing', text: 'Reprise', showText: true, ariaText: 'Prépare la reprise…' }); const userId = normalizeUserId(decoded); if (userId) { - setupVideoProgress({ videoEl, movieId: id, token, userId }); + setupVideoProgress({ videoEl, movieId: id, token, userId, imdbId: movie?.imdb_id }); } else { // 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." }); diff --git a/routes/movies.js b/routes/movies.js index e3b7e58..6148618 100644 --- a/routes/movies.js +++ b/routes/movies.js @@ -645,6 +645,7 @@ router.put("/:id/progress", authenticate, async (req, res) => { const rawTime = req.body?.time; const rawDuration = req.body?.duration; + const rawImdbId = req.body?.imdb_id; let time = Number(rawTime); 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); } + // 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. if (duration !== null && duration - time <= 3) { time = 0; @@ -671,6 +676,27 @@ router.put("/:id/progress", authenticate, async (req, res) => { { upsert: true, new: true, setDefaultsOnInsert: true } ).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({ time: updated?.time ?? 0, duration: typeof updated?.duration === "number" ? updated.duration : null,