Fix cookie expiration, ranking by completion time, and checklist confirmation
Issues fixed: - #87: Extend video session cookie to 24h and add keep-alive refresh every 30 minutes during playback to prevent expiration while watching long movies - #88: Rank checklist completers by completion datetime (earliest first) in both the 100% tab and main stats ranking as tiebreaker - #89: Add confirmation modal when unchecking a movie from the checklist to prevent accidental removal Co-Authored-By: Claude Opus 4.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.5
parent
c81135a4c1
commit
7a629b6c91
+65
-12
@@ -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', {
|
||||
|
||||
+55
-3
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user