add video backend server, fix styling and more #54

Merged
quoije merged 131 commits from 2026-video.backend into prod 2025-12-22 05:42:04 -05:00
2 changed files with 218 additions and 32 deletions
Showing only changes of commit 88129ca5ac - Show all commits
+174 -31
View File
@@ -1,5 +1,10 @@
function decodeJwt(token) { function decodeJwt(token) {
return JSON.parse(atob(token.split('.')[1])); const parts = String(token || '').split('.');
if (parts.length < 2) throw new Error('Invalid token');
// JWT uses base64url (not base64)
const b64url = String(parts[1] || '').replace(/-/g, '+').replace(/_/g, '/');
const padded = b64url + '='.repeat((4 - (b64url.length % 4)) % 4);
return JSON.parse(atob(padded));
} }
function safeJsonParse(v) { function safeJsonParse(v) {
@@ -10,6 +15,14 @@ function progressStorageKey(userId, movieId) {
return `playback_progress:${String(userId || '')}:${String(movieId || '')}`; return `playback_progress:${String(userId || '')}:${String(movieId || '')}`;
} }
function normalizeUserId(raw) {
// Expect a Mongo ObjectId string in the token payload.
const v = raw?.id;
if (typeof v === 'string' && /^[a-f0-9]{24}$/i.test(v)) return v;
const s = v && typeof v === 'object' && typeof v.toString === 'function' ? v.toString() : String(v || '');
return /^[a-f0-9]{24}$/i.test(s) ? s : null;
}
async function fetchPlaybackProgress(movieId, token) { async function fetchPlaybackProgress(movieId, token) {
try { try {
const res = await fetch(`/api/movies/${encodeURIComponent(movieId)}/progress`, { const res = await fetch(`/api/movies/${encodeURIComponent(movieId)}/progress`, {
@@ -60,11 +73,13 @@ function setupVideoProgress({ videoEl, movieId, token, userId }) {
const key = progressStorageKey(userId, movieId); const key = progressStorageKey(userId, movieId);
let restored = false; let restored = false;
let restoreServerPromise = null;
let intervalId = null; let intervalId = null;
let lastSavedAt = 0; let lastSavedAt = 0;
let lastSavedTime = -1; let lastSavedTime = -1;
let lastSaveStatus = '—'; let lastSaveStatus = '—';
let seekSaveTimer = null; let seekSaveTimer = null;
let savedUiTimer = null;
function getSnapshot() { function getSnapshot() {
const t = Number(videoEl.currentTime); const t = Number(videoEl.currentTime);
@@ -76,12 +91,16 @@ function setupVideoProgress({ videoEl, movieId, token, userId }) {
}; };
} }
async function restoreProgressOnce() { async function tryRestoreProgress() {
if (restored) return; if (restored) return true;
restored = true;
const dur = Number(videoEl.duration);
// Don't mark restored until we have a usable duration: some sources report NaN/Infinity first.
if (!Number.isFinite(dur) || dur <= 0 || dur === Infinity) return false;
// Prefer server progress, fall back to localStorage if offline. // Prefer server progress, fall back to localStorage if offline.
const server = await fetchPlaybackProgress(movieId, token); if (!restoreServerPromise) restoreServerPromise = fetchPlaybackProgress(movieId, token);
const server = await restoreServerPromise;
let targetTime = server?.time ?? 0; let targetTime = server?.time ?? 0;
if (!Number.isFinite(targetTime) || targetTime < 0) targetTime = 0; if (!Number.isFinite(targetTime) || targetTime < 0) targetTime = 0;
@@ -91,19 +110,24 @@ function setupVideoProgress({ videoEl, movieId, token, userId }) {
if (Number.isFinite(lt) && lt > 1) targetTime = lt; if (Number.isFinite(lt) && lt > 1) targetTime = lt;
} }
const dur = Number(videoEl.duration);
if (!Number.isFinite(dur) || dur <= 0) return;
// Clamp to avoid seeking beyond the end. // Clamp to avoid seeking beyond the end.
const clamped = Math.min(Math.max(targetTime, 0), Math.max(0, dur - 3)); const clamped = Math.min(Math.max(targetTime, 0), Math.max(0, dur - 3));
restored = true;
if (clamped > 1) { if (clamped > 1) {
try { videoEl.currentTime = clamped; } catch (_) {} try { videoEl.currentTime = clamped; } catch (_) {}
lastSaveStatus = `Reprise à ${formatClock(clamped)}.`; lastSaveStatus = `Reprise · ${formatClock(clamped)}`;
setProgressStatus(lastSaveStatus); setProgressUi({ state: 'info', text: lastSaveStatus, showText: true, ariaText: `Reprise à ${formatClock(clamped)}` });
} else { } else {
lastSaveStatus = 'Lecture depuis le début.'; lastSaveStatus = 'Début';
setProgressStatus(lastSaveStatus); setProgressUi({ state: 'info', text: 'Début', showText: true, ariaText: 'Lecture depuis le début' });
} }
return true;
}
function scheduleRestore() {
if (!restored) setProgressUi({ state: 'preparing', text: 'Reprise', showText: true, ariaText: 'Prépare la reprise…' });
void tryRestoreProgress();
} }
async function persist({ keepalive, force } = {}) { async function persist({ keepalive, force } = {}) {
@@ -124,19 +148,25 @@ function setupVideoProgress({ videoEl, movieId, token, userId }) {
localStorage.setItem(key, JSON.stringify({ time: snap.time, duration: snap.duration, at: now })); localStorage.setItem(key, JSON.stringify({ time: snap.time, duration: snap.duration, at: now }));
} catch (_) {} } catch (_) {}
setProgressStatus('Sauvegarde…'); // 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, 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();
try { localStorage.removeItem('auth_token'); } catch (_) {} try { localStorage.removeItem('auth_token'); } catch (_) {}
lastSaveStatus = 'Connexion requise pour sauvegarder.'; lastSaveStatus = 'Connexion requise pour sauvegarder.';
setProgressStatus(lastSaveStatus); setProgressUi({ state: 'warning', text: 'Connexion', showText: true, ariaText: lastSaveStatus });
showAlertVariant('Session expirée. Reconnecte-toi pour continuer à sauvegarder la progression.', 'warning'); showAlertVariant('Session expirée. Reconnecte-toi pour continuer à sauvegarder la progression.', 'warning');
return; return;
} }
lastSaveStatus = `Sauvegardé à ${formatClock(snap.time)}.`; lastSaveStatus = `Sauvegardé à ${formatClock(snap.time)}.`;
setProgressStatus(lastSaveStatus); setProgressUi({ state: 'saved', text: '', showText: false, ariaText: lastSaveStatus });
if (savedUiTimer) window.clearTimeout(savedUiTimer);
savedUiTimer = window.setTimeout(() => {
// Settle back to a quiet state (no spam), keep detail in aria-label.
setProgressUi({ state: 'info', text: '—', showText: false, ariaText: lastSaveStatus });
}, 900);
} }
function startInterval() { function startInterval() {
@@ -173,7 +203,11 @@ function setupVideoProgress({ videoEl, movieId, token, userId }) {
if (document.visibilityState === 'hidden') void persist({ keepalive: true, force: true }); if (document.visibilityState === 'hidden') void persist({ keepalive: true, force: true });
}; };
videoEl.addEventListener('loadedmetadata', restoreProgressOnce, { once: true }); // Restore may require multiple tries (duration may be unknown at first).
videoEl.addEventListener('loadedmetadata', scheduleRestore);
videoEl.addEventListener('durationchange', scheduleRestore);
videoEl.addEventListener('loadeddata', scheduleRestore);
videoEl.addEventListener('canplay', scheduleRestore);
videoEl.addEventListener('play', onPlay); videoEl.addEventListener('play', onPlay);
videoEl.addEventListener('pause', onPause); videoEl.addEventListener('pause', onPause);
videoEl.addEventListener('seeking', onSeeking); videoEl.addEventListener('seeking', onSeeking);
@@ -186,11 +220,12 @@ function setupVideoProgress({ videoEl, movieId, token, userId }) {
document.addEventListener('visibilitychange', onVisibility); document.addEventListener('visibilitychange', onVisibility);
// If metadata is already available, try immediately. // If metadata is already available, try immediately.
if (videoEl.readyState >= 1) void restoreProgressOnce(); if (videoEl.readyState >= 1) scheduleRestore();
return () => { return () => {
stopInterval(); stopInterval();
if (seekSaveTimer) window.clearTimeout(seekSaveTimer); if (seekSaveTimer) window.clearTimeout(seekSaveTimer);
if (savedUiTimer) window.clearTimeout(savedUiTimer);
videoEl.removeEventListener('play', onPlay); videoEl.removeEventListener('play', onPlay);
videoEl.removeEventListener('pause', onPause); videoEl.removeEventListener('pause', onPause);
videoEl.removeEventListener('seeking', onSeeking); videoEl.removeEventListener('seeking', onSeeking);
@@ -198,6 +233,10 @@ function setupVideoProgress({ videoEl, movieId, token, userId }) {
videoEl.removeEventListener('ended', onEnded); videoEl.removeEventListener('ended', onEnded);
videoEl.removeEventListener('error', onError); videoEl.removeEventListener('error', onError);
videoEl.removeEventListener('timeupdate', onTimeUpdate); videoEl.removeEventListener('timeupdate', onTimeUpdate);
videoEl.removeEventListener('loadedmetadata', scheduleRestore);
videoEl.removeEventListener('durationchange', scheduleRestore);
videoEl.removeEventListener('loadeddata', scheduleRestore);
videoEl.removeEventListener('canplay', scheduleRestore);
window.removeEventListener('pagehide', onPageHide); window.removeEventListener('pagehide', onPageHide);
document.removeEventListener('visibilitychange', onVisibility); document.removeEventListener('visibilitychange', onVisibility);
if (lastSaveStatus) setProgressStatus(lastSaveStatus); if (lastSaveStatus) setProgressStatus(lastSaveStatus);
@@ -239,9 +278,58 @@ function setSourceLabel(text) {
} }
function setProgressStatus(text) { function setProgressStatus(text) {
const el = document.getElementById('progress-status'); // Back-compat: older calls pass a string. We render minimal UI.
if (!el) return; setProgressUi({ state: 'info', text: text || '—', showText: true });
el.textContent = text || '—'; }
function setProgressUi({ state, text, showText, ariaText } = {}) {
const iconEl = document.getElementById('progress-status-icon');
const textEl = document.getElementById('progress-status-text');
const wrapEl = document.getElementById('progress-status');
if (!iconEl || !textEl) return;
const s = String(state || '').trim().toLowerCase();
const t = typeof text === 'string' ? text : (text === null || text === undefined ? '' : String(text));
const shouldShowText = showText === undefined ? true : !!showText;
const a11y = typeof ariaText === 'string' ? ariaText : t;
// Reset icon
iconEl.classList.remove('progress-icon--spinner');
iconEl.innerHTML = '';
// Text
textEl.textContent = t || '—';
textEl.classList.toggle('is-hidden', !shouldShowText);
// A11y label lives on the wrapper span.
if (wrapEl) {
if (a11y) wrapEl.setAttribute('aria-label', a11y);
else wrapEl.removeAttribute('aria-label');
}
// Icon by state (no external deps)
if (s === 'saving' || s === 'preparing') {
iconEl.classList.add('progress-icon--spinner');
return;
}
if (s === 'saved') {
iconEl.innerHTML = `
<svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
<path d="M13.485 1.929a.75.75 0 0 1 .086 1.057l-6.5 8a.75.75 0 0 1-1.1.06l-3.5-3.5a.75.75 0 1 1 1.06-1.06l2.91 2.91 5.98-7.36a.75.75 0 0 1 1.064-.107z"/>
</svg>
`;
return;
}
if (s === 'warning' || s === 'error') {
iconEl.innerHTML = `
<svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
<path d="M8.982 1.566a1.13 1.13 0 0 0-1.964 0L.165 13.233c-.457.778.091 1.767.982 1.767h13.706c.89 0 1.438-.99.982-1.767L8.982 1.566zM8 5c.535 0 .954.462.9.995l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 5.995A.905.905 0 0 1 8 5zm.002 7a1 1 0 1 1 0-2 1 1 0 0 1 0 2z"/>
</svg>
`;
return;
}
} }
function formatClock(seconds) { function formatClock(seconds) {
@@ -423,6 +511,10 @@ function toVideoSessionUrl(videoSrc) {
} }
} }
// Best-effort cache to avoid spamming /api/video/session.
// Keyed by sessionUrl + token (token is already in-memory, not persisted here).
const _videoSessionCache = new Map(); // key -> expiresAt ms
async function ensureVideoSessionForSource(videoSrc, token) { async function ensureVideoSessionForSource(videoSrc, token) {
// Only needed for our protected /api/video/* streams. // Only needed for our protected /api/video/* streams.
if (!token) return; if (!token) return;
@@ -440,18 +532,64 @@ async function ensureVideoSessionForSource(videoSrc, token) {
} }
})(); })();
const cacheKey = `${sessionUrl}::${token}`;
const cachedUntil = _videoSessionCache.get(cacheKey) || 0;
if (Date.now() < cachedUntil) return;
try { try {
await fetch(sessionUrl, { await fetch(sessionUrl, {
method: 'POST', method: 'POST',
headers: token ? { 'Authorization': `Bearer ${token}` } : undefined, headers: token ? { 'Authorization': `Bearer ${token}` } : undefined,
...(isCrossOrigin ? { credentials: 'include' } : {}), // Always include credentials so the cookie is stored even if the browser
// treats this as cross-origin unexpectedly (hostname differences, etc).
credentials: 'include',
cache: 'no-store',
}); });
_videoSessionCache.set(cacheKey, Date.now() + 60_000); // 60s
} catch (_) { } catch (_) {
// best-effort // best-effort
} }
} }
async function playVodLink(vodLink) { function addTokenToApiVideoUrl(rawUrl, token) {
if (!rawUrl || !token) return rawUrl;
try {
const isRelative = String(rawUrl).trim().startsWith('/');
const u = new URL(String(rawUrl), window.location.origin);
u.searchParams.set('token', token);
return isRelative ? `${u.pathname}${u.search}${u.hash}` : u.toString();
} catch (_) {
// Fallback: naive append
const s = String(rawUrl);
const join = s.includes('?') ? '&' : '?';
return `${s}${join}token=${encodeURIComponent(token)}`;
}
}
async function probeApiVideoReadable(rawUrl) {
try {
const res = await fetch(rawUrl, {
method: 'GET',
headers: { Range: 'bytes=0-0' },
credentials: 'include',
cache: 'no-store',
});
return res.status !== 401;
} catch (_) {
return true; // don't block playback if probe fails (CORS/network/etc)
}
}
async function ensurePlayableApiVideoUrl(rawUrl, token) {
if (!isApiVideoUrl(rawUrl) || !token) return rawUrl;
// IMPORTANT: Prefer query-token auth for media elements.
// Cookie-based auth is unreliable cross-origin (and even across ports in some setups)
// and causes intermittent 401s + preflight noise. Both Node + Python servers already
// support ?token=... so we use it consistently.
return addTokenToApiVideoUrl(rawUrl, token);
}
async function playVodLink(vodLink, { token } = {}) {
const raw = normalizeVodLink(vodLink); const raw = normalizeVodLink(vodLink);
if (!raw) { if (!raw) {
showAlert('No VOD link configured for this movie.'); showAlert('No VOD link configured for this movie.');
@@ -482,11 +620,12 @@ async function playVodLink(vodLink) {
setSourceLabel('Server video stream (/api/video)'); setSourceLabel('Server video stream (/api/video)');
const videoEl = showVideoPlayer(); const videoEl = showVideoPlayer();
if (!videoEl) return; if (!videoEl) return;
videoEl.src = raw; const playable = await ensurePlayableApiVideoUrl(raw, token);
videoEl.src = playable;
videoEl.addEventListener('error', () => { videoEl.addEventListener('error', () => {
// If the browser can't play it (or CORS blocks), fall back to iframe. // If the browser can't play it (or CORS blocks), fall back to iframe.
setSourceLabel('Embed (fallback)'); setSourceLabel('Embed (fallback)');
showEmbedPlayer(raw); showEmbedPlayer(playable);
}, { once: true }); }, { once: true });
try { videoEl.load(); } catch (_) {} try { videoEl.load(); } catch (_) {}
return; return;
@@ -643,9 +782,7 @@ window.onload = async function () {
return; return;
} }
// Allow <video> to call protected /api/video/* sources without Authorization headers by using a cookie. // We use ?token=... for /api/video/* playback (more reliable than cookies cross-origin).
// If the source is on a different origin (Python video host), we call THAT host’s /api/video/session.
await ensureVideoSessionForSource(resolved.src, token);
if (resolved.isLegacy) { if (resolved.isLegacy) {
// Legacy source: never embed it in the player. // Legacy source: never embed it in the player.
@@ -656,15 +793,21 @@ window.onload = async function () {
return; return;
} }
await playVodLink(resolved.src); await playVodLink(resolved.src, { token });
// Only the <video> player supports reliable progress tracking (mp4/hls). // Only the <video> player supports reliable progress tracking (mp4/hls).
const videoEl = document.getElementById('video'); const videoEl = document.getElementById('video');
if (videoEl && !videoEl.classList.contains('d-none')) { if (videoEl && !videoEl.classList.contains('d-none')) {
setProgressStatus('Prépare la reprise…'); setProgressUi({ state: 'preparing', text: 'Reprise', showText: true, ariaText: 'Prépare la reprise…' });
setupVideoProgress({ videoEl, movieId: id, token, userId: decoded?.id }); const userId = normalizeUserId(decoded);
if (userId) {
setupVideoProgress({ videoEl, movieId: id, token, userId });
} else { } else {
setProgressStatus('—'); // 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." });
}
} else {
setProgressUi({ state: 'info', text: '—', showText: true });
} }
} catch (err) { } catch (err) {
showAlert(err.message || 'Network error.'); showAlert(err.message || 'Network error.');
+44 -1
View File
@@ -3,6 +3,8 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- IMPORTANT: the player may use ?token=... for cross-origin video streaming -->
<meta name="referrer" content="no-referrer" />
<title>Player</title> <title>Player</title>
<link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet"> <link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="./css/app.css" rel="stylesheet"> <link href="./css/app.css" rel="stylesheet">
@@ -60,6 +62,44 @@
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
/* Progress indicator: icon-first, minimal text */
.progress-indicator {
display: inline-flex;
align-items: center;
gap: .4rem;
min-height: 1rem;
}
.progress-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1rem;
height: 1rem;
color: rgba(0,0,0,.65);
flex: 0 0 1rem;
}
.progress-icon--spinner {
border: 2px solid rgba(0,0,0,.22);
border-top-color: rgba(0,0,0,.65);
border-radius: 50%;
animation: progressSpin .8s linear infinite;
}
.progress-icon svg {
width: 1rem;
height: 1rem;
display: block;
}
.progress-text {
opacity: .8;
white-space: nowrap;
}
.progress-text.is-hidden {
display: none;
}
@keyframes progressSpin {
to { transform: rotate(360deg); }
}
</style> </style>
</head> </head>
<body class="bg-light"> <body class="bg-light">
@@ -118,7 +158,10 @@
<div class="mt-2 d-flex flex-wrap gap-2 align-items-center"> <div class="mt-2 d-flex flex-wrap gap-2 align-items-center">
<span class="badge bg-secondary">Progress</span> <span class="badge bg-secondary">Progress</span>
<span id="progress-status" class="text-muted small">—</span> <span id="progress-status" class="text-muted small progress-indicator">
<span id="progress-status-icon" class="progress-icon" aria-hidden="true"></span>
<span id="progress-status-text" class="progress-text">—</span>
</span>
</div> </div>
</div> </div>