diff --git a/public/checklist.html b/public/checklist.html
index acbed0e..42faa1c 100644
--- a/public/checklist.html
+++ b/public/checklist.html
@@ -149,6 +149,25 @@
+
+
diff --git a/public/js/app_checklist.js b/public/js/app_checklist.js
index 77e4d54..6d7db09 100644
--- a/public/js/app_checklist.js
+++ b/public/js/app_checklist.js
@@ -475,18 +475,71 @@ window.addEventListener('DOMContentLoaded', async function () {
const watchedDateCell = document.getElementById(`watched-date-${movie.imdb_id}`);
const row = event.target.closest('tr'); // Get the parent row
- if (isChecked) {
- watchedDateCell.textContent = new Date().toLocaleString();
- if (row) {
- row.classList.add('table-success');
- row.classList.remove('table-danger');
- }
- } else {
- watchedDateCell.textContent = '';
- if (row) {
- row.classList.add('table-danger');
- row.classList.remove('table-success');
- }
+ // If unchecking, show confirmation modal first
+ if (!isChecked) {
+ // Revert the checkbox temporarily until confirmed
+ event.target.checked = true;
+
+ // Show confirmation modal
+ const uncheckModal = new bootstrap.Modal(document.getElementById('uncheckConfirmModal'));
+ const movieTitleEl = document.getElementById('uncheckMovieTitle');
+ const confirmBtn = document.getElementById('confirmUncheckBtn');
+
+ if (movieTitleEl) movieTitleEl.textContent = movie.title;
+
+ // Remove any previous event listener to avoid duplicates
+ const newConfirmBtn = confirmBtn.cloneNode(true);
+ confirmBtn.parentNode.replaceChild(newConfirmBtn, confirmBtn);
+
+ newConfirmBtn.addEventListener('click', async () => {
+ uncheckModal.hide();
+
+ // Now actually uncheck
+ event.target.checked = false;
+ watchedDateCell.textContent = '';
+ if (row) {
+ row.classList.add('table-danger');
+ row.classList.remove('table-success');
+ }
+
+ await fetch('/api/movies/users/updateWatchedMovies', {
+ method: 'PATCH',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Authorization': `Bearer ${token}`
+ },
+ body: JSON.stringify({ imdb_id: movie.imdb_id, isChecked: false })
+ });
+
+ const updatedWatchedMoviesRes = await fetch('/api/movies/watchedMovies', {
+ method: 'GET',
+ headers: { 'Authorization': `Bearer ${token}` },
+ cache: 'no-cache',
+ });
+
+ if (updatedWatchedMoviesRes.ok) {
+ const updatedWatchedMovies = await updatedWatchedMoviesRes.json();
+ watchedMoviesInYear = updatedWatchedMovies.filter((wm) => movieImdbIds.has(wm.imdb_id));
+ const updatedWatchedCount = watchedMoviesInYear.length;
+ const updatedPct = totalMoviesCount > 0 ? ((updatedWatchedCount / totalMoviesCount) * 100).toFixed(1) : '0.0';
+ document.getElementById('watched-ratio').innerText = `Vu: ${updatedWatchedCount} / ${totalMoviesCount} (${updatedPct}%)`;
+ updateProgressBar(updatedWatchedCount, totalMoviesCount);
+
+ const moviesLeft = totalMoviesCount - updatedWatchedCount;
+ const moviesPerDay = daysLeft > 0 ? (moviesLeft / daysLeft).toFixed(2) : moviesLeft;
+ document.getElementById('movies-per-day').textContent = `À voir par jour: ${moviesPerDay} films`;
+ }
+ });
+
+ uncheckModal.show();
+ return;
+ }
+
+ // Checking a movie - no confirmation needed
+ watchedDateCell.textContent = new Date().toLocaleString();
+ if (row) {
+ row.classList.add('table-success');
+ row.classList.remove('table-danger');
}
await fetch('/api/movies/users/updateWatchedMovies', {
diff --git a/public/js/app_player.js b/public/js/app_player.js
index e9f15f6..25105f0 100644
--- a/public/js/app_player.js
+++ b/public/js/app_player.js
@@ -352,6 +352,11 @@ function setupVideoProgress({ videoEl, movieId, token, userId, imdbId }) {
const onPlay = () => {
startInterval();
+ // Start session keepalive to prevent cookie expiration during long playback
+ const videoSrc = videoEl.currentSrc || videoEl.src;
+ if (videoSrc && token) {
+ startSessionKeepalive(videoSrc, token);
+ }
// Ensure we create/refresh a progress row as soon as playback starts
// (so the movies page can show a "resume" state quickly).
if (!startedOnce) {
@@ -360,7 +365,7 @@ function setupVideoProgress({ videoEl, movieId, token, userId, imdbId }) {
startSaveTimer = window.setTimeout(() => { void persist({ force: true }); }, 650);
}
};
- const onPause = () => { stopInterval(); void persist({ force: true }); };
+ const onPause = () => { stopInterval(); stopSessionKeepalive(); void persist({ force: true }); };
const onSeeking = () => {
// While scrubbing, browsers fire many events; debounce a forced save so it lands quickly.
if (seekSaveTimer) window.clearTimeout(seekSaveTimer);
@@ -373,8 +378,8 @@ function setupVideoProgress({ videoEl, movieId, token, userId, imdbId }) {
}
void persist({ force: true });
};
- const onEnded = () => { stopInterval(); void persist(); };
- const onError = () => { stopInterval(); };
+ const onEnded = () => { stopInterval(); stopSessionKeepalive(); void persist(); };
+ const onError = () => { stopInterval(); stopSessionKeepalive(); };
const onTimeUpdate = () => {
if (videoEl.paused || videoEl.seeking) return;
void persist();
@@ -405,6 +410,7 @@ function setupVideoProgress({ videoEl, movieId, token, userId, imdbId }) {
return () => {
stopInterval();
+ stopSessionKeepalive();
if (seekSaveTimer) window.clearTimeout(seekSaveTimer);
if (savedUiTimer) window.clearTimeout(savedUiTimer);
if (startSaveTimer) window.clearTimeout(startSaveTimer);
@@ -805,6 +811,8 @@ function toVideoSessionUrl(videoSrc) {
// Keyed by sessionUrl + token (token is already in-memory, not persisted here).
const _videoSessionCache = new Map(); // key -> expiresAt ms
let _currentMovie = null;
+let _sessionKeepaliveInterval = null; // interval ID for session keep-alive
+const SESSION_KEEPALIVE_INTERVAL_MS = 30 * 60 * 1000; // 30 minutes
async function ensureVideoSessionForSource(videoSrc, token) {
// Only needed for our protected /api/video/* streams.
@@ -842,6 +850,50 @@ async function ensureVideoSessionForSource(videoSrc, token) {
}
}
+// Force-refresh the video session cookie, bypassing cache.
+// Used for keep-alive during long playback sessions.
+async function forceRefreshVideoSession(videoSrc, token) {
+ if (!token) return;
+ if (!isApiVideoUrl(videoSrc)) return;
+ const sessionUrl = toVideoSessionUrl(videoSrc);
+ if (!sessionUrl) return;
+
+ try {
+ await fetch(sessionUrl, {
+ method: 'POST',
+ headers: token ? { 'Authorization': `Bearer ${token}` } : undefined,
+ credentials: 'include',
+ cache: 'no-store',
+ });
+ // Update the cache
+ const cacheKey = `${sessionUrl}::${token}`;
+ _videoSessionCache.set(cacheKey, Date.now() + 60_000);
+ } catch (_) {
+ // best-effort
+ }
+}
+
+// Start periodic session keep-alive while watching a movie.
+// This prevents the video_auth cookie from expiring during long playback.
+function startSessionKeepalive(videoSrc, token) {
+ stopSessionKeepalive();
+ if (!token || !isApiVideoUrl(videoSrc)) return;
+
+ _sessionKeepaliveInterval = window.setInterval(() => {
+ void forceRefreshVideoSession(videoSrc, token);
+ }, SESSION_KEEPALIVE_INTERVAL_MS);
+
+ // Also refresh immediately when starting
+ void forceRefreshVideoSession(videoSrc, token);
+}
+
+function stopSessionKeepalive() {
+ if (_sessionKeepaliveInterval) {
+ window.clearInterval(_sessionKeepaliveInterval);
+ _sessionKeepaliveInterval = null;
+ }
+}
+
function addTokenToApiVideoUrl(rawUrl, token) {
if (!rawUrl || !token) return rawUrl;
try {
diff --git a/public/js/app_user-stat.js b/public/js/app_user-stat.js
index bb23626..538e2c7 100644
--- a/public/js/app_user-stat.js
+++ b/public/js/app_user-stat.js
@@ -300,11 +300,21 @@ window.addEventListener('DOMContentLoaded', async function () {
} else {
const wrap = document.createElement('div');
wrap.className = 'd-flex flex-wrap gap-1';
- completers.forEach((u) => {
- const name = document.createElement('span');
- name.className = 'badge bg-light text-dark border';
- name.textContent = u?.name || '(sans nom)';
- wrap.appendChild(name);
+ completers.forEach((u, idx) => {
+ const rank = idx + 1;
+ const medal = rank === 1 ? '🥇' : rank === 2 ? '🥈' : rank === 3 ? '🥉' : '';
+ const badge = document.createElement('span');
+ badge.className = 'badge bg-light text-dark border';
+ // Show rank, medal (if top 3), and name
+ const rankPrefix = medal ? `${medal} ` : `#${rank} `;
+ // Format completion date if available
+ let dateStr = '';
+ if (u?.completedAt) {
+ const date = new Date(u.completedAt);
+ dateStr = ` (${date.toLocaleDateString('fr-CA')})`;
+ }
+ badge.textContent = `${rankPrefix}${u?.name || '(sans nom)'}${dateStr}`;
+ wrap.appendChild(badge);
});
tdNames.appendChild(wrap);
}
@@ -663,9 +673,16 @@ window.addEventListener('DOMContentLoaded', async function () {
bonPicksHeader.style.display = showBonPicksColumn ? '' : 'none';
}
- // Sort by total points (descending)
+ // Sort by total points (descending), then by last watched date (earlier = better rank)
stats.sort((a, b) => {
- return (b.totalPoints || 0) - (a.totalPoints || 0);
+ const pointsDiff = (b.totalPoints || 0) - (a.totalPoints || 0);
+ if (pointsDiff !== 0) return pointsDiff;
+ // Tiebreaker: earlier completion wins (user who finished first ranks higher)
+ const aDate = a.lastWatchedAt ? new Date(a.lastWatchedAt).getTime() : Infinity;
+ const bDate = b.lastWatchedAt ? new Date(b.lastWatchedAt).getTime() : Infinity;
+ if (aDate !== bDate) return aDate - bDate;
+ // Final tiebreaker: alphabetical
+ return String(a.name || '').localeCompare(String(b.name || ''), 'fr', { sensitivity: 'base' });
});
// Clear previous rows to prevent duplication
diff --git a/python_video_server/server.py b/python_video_server/server.py
index ff2a48c..d90c6c3 100644
--- a/python_video_server/server.py
+++ b/python_video_server/server.py
@@ -228,7 +228,7 @@ def create_video_session(request: Request, response: Response, authorization: Op
secure = request.url.scheme == "https" or (request.headers.get("x-forwarded-proto") == "https")
encoded = quote(token, safe="")
- cookie_max_age_seconds = int(_env_str("VIDEO_SESSION_MAX_AGE_SECONDS", "28800")) # 8h
+ cookie_max_age_seconds = int(_env_str("VIDEO_SESSION_MAX_AGE_SECONDS", "86400")) # 24h (longer default to avoid expiration during long movie sessions)
cookie_samesite = _env_str("VIDEO_COOKIE_SAMESITE", "Lax") # Lax|Strict|None
samesite = (cookie_samesite or "Lax").strip().lower()
if samesite not in ("lax", "strict", "none"):
diff --git a/routes/users.js b/routes/users.js
index 051cba9..3e8d9db 100644
--- a/routes/users.js
+++ b/routes/users.js
@@ -232,6 +232,17 @@ router.get("/stats", verifyToken, async (req, res) => {
const watchedCount = watchedMoviesDetails.length;
const watchedRatio = ((watchedCount / totalMoviesCount) * 100).toFixed(1);
+ // Calculate the last watched date (used for ranking tiebreakers - earlier completion = better)
+ let lastWatchedAt = null;
+ watchedMoviesDetails.forEach((wm) => {
+ if (wm.watchedDate) {
+ const d = new Date(wm.watchedDate);
+ if (!lastWatchedAt || d > lastWatchedAt) {
+ lastWatchedAt = d;
+ }
+ }
+ });
+
// Calculate points
const userId = String(user._id);
const correctPicks = picksByUserId.get(userId) || 0;
@@ -251,7 +262,8 @@ router.get("/stats", verifyToken, async (req, res) => {
moviePoints: moviePoints,
pickPoints: pickPoints,
totalPoints: totalPoints,
- pointsConfig: pointsConfig
+ pointsConfig: pointsConfig,
+ lastWatchedAt: lastWatchedAt ? lastWatchedAt.toISOString() : null,
};
});
@@ -300,7 +312,7 @@ router.get("/completions", verifyToken, async (req, res) => {
totals[String(y)] = set ? set.size : 0;
}
- const users = await User.find().select("name watchedMovies.imdb_id").lean();
+ const users = await User.find().select("name watchedMovies.imdb_id watchedMovies.watchedDate").lean();
const completersByYear = {};
for (const y of years) {
@@ -314,27 +326,55 @@ router.get("/completions", verifyToken, async (req, res) => {
const completers = [];
users.forEach((u) => {
const watched = Array.isArray(u?.watchedMovies) ? u.watchedMovies : [];
- const watchedSet = new Set(
- watched
- .map((wm) => (typeof wm?.imdb_id === "string" ? wm.imdb_id : ""))
- .filter(Boolean)
- );
- let count = 0;
- for (const imdb of watchedSet) {
- if (yearSet.has(imdb)) count += 1;
- }
+ // Build a map of imdb_id -> watchedDate for this user's movies in this year
+ const watchedInYear = [];
+ watched.forEach((wm) => {
+ const imdb = typeof wm?.imdb_id === "string" ? wm.imdb_id : "";
+ if (!imdb || !yearSet.has(imdb)) return;
+ watchedInYear.push({
+ imdb_id: imdb,
+ watchedDate: wm?.watchedDate ? new Date(wm.watchedDate) : null,
+ });
+ });
+
+ // Count unique movies watched in this year
+ const uniqueImdbIds = new Set(watchedInYear.map((w) => w.imdb_id));
+ const count = uniqueImdbIds.size;
if (count === total) {
+ // Find the completion date (the latest watchedDate among movies for this year)
+ // This is when the user completed 100% of the checklist
+ let completedAt = null;
+ watchedInYear.forEach((w) => {
+ if (w.watchedDate && (!completedAt || w.watchedDate > completedAt)) {
+ completedAt = w.watchedDate;
+ }
+ });
+
completers.push({
name: u?.name || "(sans nom)",
watchedCount: count,
totalMoviesCount: total,
+ completedAt: completedAt ? completedAt.toISOString() : null,
});
}
});
- completers.sort((a, b) => String(a.name).localeCompare(String(b.name), "fr", { sensitivity: "base" }));
+ // Sort by completion date (earliest first = higher rank), then alphabetically for ties
+ completers.sort((a, b) => {
+ // Users with a completion date come before those without
+ if (a.completedAt && !b.completedAt) return -1;
+ if (!a.completedAt && b.completedAt) return 1;
+ // Both have dates: earliest first
+ if (a.completedAt && b.completedAt) {
+ const dateA = new Date(a.completedAt).getTime();
+ const dateB = new Date(b.completedAt).getTime();
+ if (dateA !== dateB) return dateA - dateB;
+ }
+ // Tie-breaker: alphabetical
+ return String(a.name).localeCompare(String(b.name), "fr", { sensitivity: "base" });
+ });
completersByYear[String(y)] = completers;
}
diff --git a/routes/video.js b/routes/video.js
index 8b77be9..b3b1ace 100644
--- a/routes/video.js
+++ b/routes/video.js
@@ -92,7 +92,7 @@ router.post("/session", (req, res) => {
const maxAgeSecondsRaw = typeof process.env.VIDEO_SESSION_MAX_AGE_SECONDS === "string" ? process.env.VIDEO_SESSION_MAX_AGE_SECONDS.trim() : "";
const maxAgeSeconds = Number.isFinite(Number(maxAgeSecondsRaw)) && Number(maxAgeSecondsRaw) > 0
? Math.floor(Number(maxAgeSecondsRaw))
- : 60 * 60 * 8; // 8h
+ : 60 * 60 * 24; // 24h (longer default to avoid expiration during long movie sessions)
const cookie = [
`video_auth=${encodeURIComponent(token)}`,
"Path=/api/video",