Video server and timestamp #45

Merged
quoije merged 8 commits from cursor/video-server-and-timestamp-fc01 into 2026-video.backend 2025-12-22 01:50:21 -05:00
11 changed files with 559 additions and 36 deletions
+10 -1
View File
@@ -418,6 +418,7 @@
<thead> <thead>
<tr> <tr>
<th style="width: 1%;">Sel.</th> <th style="width: 1%;">Sel.</th>
<th style="width: 1%;">ID</th>
<th>Titre</th> <th>Titre</th>
<th style="width: 12%;">Player</th> <th style="width: 12%;">Player</th>
<th style="width: 20%;">Catégorie</th> <th style="width: 20%;">Catégorie</th>
@@ -426,7 +427,7 @@
</tr> </tr>
</thead> </thead>
<tbody id="admin-movies-body"> <tbody id="admin-movies-body">
<tr><td colspan="6" class="text-muted">Charge la liste…</td></tr> <tr><td colspan="7" class="text-muted">Charge la liste…</td></tr>
</tbody> </tbody>
</table> </table>
</div> </div>
@@ -679,6 +680,14 @@
<div class="modal-body"> <div class="modal-body">
<input type="hidden" id="edit_movie_id"> <input type="hidden" id="edit_movie_id">
<div class="mb-3">
<div class="text-muted small mb-1">Movie ID</div>
<div class="d-flex gap-2 align-items-center">
<code class="flex-grow-1" id="edit_movie_id_label">—</code>
<button type="button" class="btn btn-outline-secondary btn-sm" id="copy-movie-id">Copier</button>
</div>
</div>
<div class="row g-3"> <div class="row g-3">
<div class="col-md-6"> <div class="col-md-6">
<label for="edit_imdb_id" class="form-label">IMDB ID (ex: tt1234567)</label> <label for="edit_imdb_id" class="form-label">IMDB ID (ex: tt1234567)</label>
+52 -4
View File
@@ -98,6 +98,8 @@ window.onload = async function () {
const saveMovieChangesBtn = document.getElementById('save-movie-changes'); const saveMovieChangesBtn = document.getElementById('save-movie-changes');
const editMovieIdEl = document.getElementById('edit_movie_id'); const editMovieIdEl = document.getElementById('edit_movie_id');
const editMovieIdLabelEl = document.getElementById('edit_movie_id_label');
const copyMovieIdBtn = document.getElementById('copy-movie-id');
const editImdbIdEl = document.getElementById('edit_imdb_id'); const editImdbIdEl = document.getElementById('edit_imdb_id');
const editYearEl = document.getElementById('edit_year'); const editYearEl = document.getElementById('edit_year');
const editCategoryEl = document.getElementById('edit_category'); const editCategoryEl = document.getElementById('edit_category');
@@ -145,6 +147,17 @@ window.onload = async function () {
.replace(/'/g, '&#039;'); .replace(/'/g, '&#039;');
} }
async function copyToClipboard(text) {
const t = String(text || '').trim();
if (!t) return false;
try {
await navigator.clipboard.writeText(t);
return true;
} catch (_) {
return false;
}
}
function sanitizeHtml(raw) { function sanitizeHtml(raw) {
const html = String(raw || ''); const html = String(raw || '');
const withoutScripts = html.replace(/<script\b[\s\S]*?<\/script>/gi, ''); const withoutScripts = html.replace(/<script\b[\s\S]*?<\/script>/gi, '');
@@ -2021,6 +2034,7 @@ window.onload = async function () {
if (!movie) return; if (!movie) return;
editMovieIdEl.value = movie._id || ''; editMovieIdEl.value = movie._id || '';
if (editMovieIdLabelEl) editMovieIdLabelEl.textContent = movie._id || '—';
editImdbIdEl.value = movie.imdb_id || ''; editImdbIdEl.value = movie.imdb_id || '';
editYearEl.value = movie.year ? String(movie.year) : ''; editYearEl.value = movie.year ? String(movie.year) : '';
editCategoryEl.value = movie.category || ''; editCategoryEl.value = movie.category || '';
@@ -2147,12 +2161,12 @@ window.onload = async function () {
async function loadMoviesForManagement() { async function loadMoviesForManagement() {
const year = manageYearSelect.value; const year = manageYearSelect.value;
const url = year ? `/api/movies?year=${encodeURIComponent(year)}` : '/api/movies'; const url = year ? `/api/movies?year=${encodeURIComponent(year)}` : '/api/movies';
adminMoviesBody.innerHTML = `<tr><td colspan="6" class="text-muted">Chargement…</td></tr>`; adminMoviesBody.innerHTML = `<tr><td colspan="7" class="text-muted">Chargement…</td></tr>`;
try { try {
const res = await fetch(url, { method: 'GET' }); const res = await fetch(url, { method: 'GET' });
if (!res.ok) { if (!res.ok) {
adminMoviesBody.innerHTML = `<tr><td colspan="6" class="text-danger">Erreur lors du chargement (${res.status})</td></tr>`; adminMoviesBody.innerHTML = `<tr><td colspan="7" class="text-danger">Erreur lors du chargement (${res.status})</td></tr>`;
return; return;
} }
const movies = await res.json(); const movies = await res.json();
@@ -2160,7 +2174,7 @@ window.onload = async function () {
moviesById = new Map(movies.map((m) => [m._id, m])); moviesById = new Map(movies.map((m) => [m._id, m]));
if (!movies.length) { if (!movies.length) {
adminMoviesBody.innerHTML = `<tr><td colspan="6" class="text-muted">Aucun film.</td></tr>`; adminMoviesBody.innerHTML = `<tr><td colspan="7" class="text-muted">Aucun film.</td></tr>`;
updateSelectionUI(); updateSelectionUI();
return; return;
} }
@@ -2195,11 +2209,19 @@ window.onload = async function () {
.map((b) => `<span class="badge ${b.cls} me-1 mb-1">${b.text}</span>`) .map((b) => `<span class="badge ${b.cls} me-1 mb-1">${b.text}</span>`)
.join(''); .join('');
const movieId = String(m._id || '');
const shortId = movieId ? `${movieId.slice(0, 6)}…${movieId.slice(-4)}` : '—';
const tr = document.createElement('tr'); const tr = document.createElement('tr');
tr.innerHTML = ` tr.innerHTML = `
<td> <td>
<input type="checkbox" class="form-check-input" data-movie-id="${m._id}"> <input type="checkbox" class="form-check-input" data-movie-id="${m._id}">
</td> </td>
<td class="text-nowrap">
<button type="button" class="btn btn-sm btn-outline-secondary font-monospace" data-copy-movie-id="${escapeHtml(movieId)}" title="${escapeHtml(movieId)}">
${escapeHtml(shortId)}
</button>
</td>
<td> <td>
<div class="fw-semibold">${m.title || '(sans titre)'}</div> <div class="fw-semibold">${m.title || '(sans titre)'}</div>
<div class="text-muted small">${m.imdb_id || ''}</div> <div class="text-muted small">${m.imdb_id || ''}</div>
@@ -2224,9 +2246,22 @@ window.onload = async function () {
btn.addEventListener('click', () => openEditModal(btn.getAttribute('data-edit-movie-id'))); btn.addEventListener('click', () => openEditModal(btn.getAttribute('data-edit-movie-id')));
}); });
adminMoviesBody.querySelectorAll('button[data-copy-movie-id]').forEach((btn) => {
btn.addEventListener('click', async () => {
const id = btn.getAttribute('data-copy-movie-id');
if (!id) return;
const ok = await copyToClipboard(id);
if (ok) {
showResponse('success', 'Movie ID copié.');
} else {
showResponse('warning', 'Impossible de copier automatiquement. Copie manuellement.');
}
});
});
updateSelectionUI(); updateSelectionUI();
} catch (err) { } catch (err) {
adminMoviesBody.innerHTML = `<tr><td colspan="6" class="text-danger">${err.message || 'Erreur réseau'}</td></tr>`; adminMoviesBody.innerHTML = `<tr><td colspan="7" class="text-danger">${err.message || 'Erreur réseau'}</td></tr>`;
} }
} }
@@ -2281,6 +2316,19 @@ window.onload = async function () {
saveMovieChangesBtn.addEventListener('click', saveMovieChanges); saveMovieChangesBtn.addEventListener('click', saveMovieChanges);
} }
if (copyMovieIdBtn) {
copyMovieIdBtn.addEventListener('click', async () => {
const id = String(editMovieIdEl?.value || '').trim();
if (!id) return;
const ok = await copyToClipboard(id);
if (ok) {
showResponse('success', 'Movie ID copié.');
} else {
showResponse('warning', 'Impossible de copier automatiquement. Copie manuellement.');
}
});
}
// Initial load // Initial load
activeYear = await fetchActiveYear(); activeYear = await fetchActiveYear();
if (activeYear) { if (activeYear) {
+149 -21
View File
@@ -32,7 +32,9 @@ async function fetchPlaybackProgress(movieId, token) {
async function savePlaybackProgress(movieId, token, { time, duration, keepalive } = {}) { async function savePlaybackProgress(movieId, token, { time, duration, keepalive } = {}) {
try { try {
await fetch(`/api/movies/${encodeURIComponent(movieId)}/progress`, { // Retry once on rare upsert races (409).
for (let attempt = 0; attempt < 2; attempt += 1) {
const res = await fetch(`/api/movies/${encodeURIComponent(movieId)}/progress`, {
method: 'PUT', method: 'PUT',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@@ -41,8 +43,15 @@ async function savePlaybackProgress(movieId, token, { time, duration, keepalive
body: JSON.stringify({ time, duration }), body: JSON.stringify({ time, duration }),
keepalive: !!keepalive, keepalive: !!keepalive,
}); });
if (res.ok) return true;
if (res.status === 409) continue;
if (res.status === 401) return false;
return true; // best-effort; don't break playback on other errors
}
return true;
} catch (_) { } catch (_) {
// best-effort // best-effort
return true;
} }
} }
@@ -54,13 +63,16 @@ function setupVideoProgress({ videoEl, movieId, token, userId }) {
let intervalId = null; let intervalId = null;
let lastSavedAt = 0; let lastSavedAt = 0;
let lastSavedTime = -1; let lastSavedTime = -1;
let lastSaveStatus = '—';
let seekSaveTimer = null;
function getSnapshot() { function getSnapshot() {
const t = Number(videoEl.currentTime); const t = Number(videoEl.currentTime);
const d = Number(videoEl.duration); const d = Number(videoEl.duration);
return { return {
time: Number.isFinite(t) && t >= 0 ? t : 0, // Use whole seconds to keep saved values stable.
duration: Number.isFinite(d) && d > 0 ? d : null, time: Number.isFinite(t) && t >= 0 ? Math.floor(t) : 0,
duration: Number.isFinite(d) && d > 0 ? Math.floor(d) : null,
}; };
} }
@@ -86,19 +98,24 @@ function setupVideoProgress({ videoEl, movieId, token, userId }) {
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));
if (clamped > 1) { if (clamped > 1) {
try { videoEl.currentTime = clamped; } catch (_) {} try { videoEl.currentTime = clamped; } catch (_) {}
lastSaveStatus = `Reprise à ${formatClock(clamped)}.`;
setProgressStatus(lastSaveStatus);
} else {
lastSaveStatus = 'Lecture depuis le début.';
setProgressStatus(lastSaveStatus);
} }
} }
async function persist({ keepalive } = {}) { async function persist({ keepalive, force } = {}) {
// Throttle to avoid spamming the API // Throttle to avoid spamming the API
const now = Date.now(); const now = Date.now();
if (!keepalive && now - lastSavedAt < 2000) return; if (!force && !keepalive && now - lastSavedAt < 1500) return;
const snap = getSnapshot(); const snap = getSnapshot();
if (!Number.isFinite(snap.time)) return; if (!Number.isFinite(snap.time)) return;
// Only save if it meaningfully changed (>= 1s) // Only save if it meaningfully changed (>= 1s)
if (lastSavedTime >= 0 && Math.abs(snap.time - lastSavedTime) < 1) return; if (!force && lastSavedTime >= 0 && Math.abs(snap.time - lastSavedTime) < 1) return;
lastSavedAt = now; lastSavedAt = now;
lastSavedTime = snap.time; lastSavedTime = snap.time;
@@ -107,12 +124,28 @@ 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 (_) {}
await savePlaybackProgress(movieId, token, { ...snap, keepalive: !!keepalive }); setProgressStatus('Sauvegarde…');
const ok = await savePlaybackProgress(movieId, token, { ...snap, keepalive: !!keepalive });
if (ok === false) {
// Token expired mid-playback; stop spamming requests and prompt user.
stopInterval();
try { localStorage.removeItem('auth_token'); } catch (_) {}
lastSaveStatus = 'Connexion requise pour sauvegarder.';
setProgressStatus(lastSaveStatus);
showAlertVariant('Session expirée. Reconnecte-toi pour continuer à sauvegarder la progression.', 'warning');
return;
}
lastSaveStatus = `Sauvegardé à ${formatClock(snap.time)}.`;
setProgressStatus(lastSaveStatus);
} }
function startInterval() { function startInterval() {
if (intervalId) return; if (intervalId) return;
intervalId = window.setInterval(() => { void persist(); }, 10000); // Safety-net; primary saving happens via timeupdate throttling.
intervalId = window.setInterval(() => {
if (videoEl.paused || videoEl.seeking) return;
void persist();
}, 8000);
} }
function stopInterval() { function stopInterval() {
@@ -122,21 +155,32 @@ function setupVideoProgress({ videoEl, movieId, token, userId }) {
} }
const onPlay = () => startInterval(); const onPlay = () => startInterval();
const onPause = () => { stopInterval(); void persist(); }; const onPause = () => { stopInterval(); void persist({ force: true }); };
const onSeeked = () => { void persist(); }; const onSeeking = () => {
// While scrubbing, browsers fire many events; debounce a forced save so it lands quickly.
if (seekSaveTimer) window.clearTimeout(seekSaveTimer);
seekSaveTimer = window.setTimeout(() => { void persist({ force: true }); }, 400);
};
const onSeeked = () => { void persist({ force: true }); };
const onEnded = () => { stopInterval(); void persist(); }; const onEnded = () => { stopInterval(); void persist(); };
const onError = () => { stopInterval(); }; const onError = () => { stopInterval(); };
const onPageHide = () => { void persist({ keepalive: true }); }; const onTimeUpdate = () => {
if (videoEl.paused || videoEl.seeking) return;
void persist();
};
const onPageHide = () => { void persist({ keepalive: true, force: true }); };
const onVisibility = () => { const onVisibility = () => {
if (document.visibilityState === 'hidden') void persist({ keepalive: true }); if (document.visibilityState === 'hidden') void persist({ keepalive: true, force: true });
}; };
videoEl.addEventListener('loadedmetadata', restoreProgressOnce, { once: true }); videoEl.addEventListener('loadedmetadata', restoreProgressOnce, { once: true });
videoEl.addEventListener('play', onPlay); videoEl.addEventListener('play', onPlay);
videoEl.addEventListener('pause', onPause); videoEl.addEventListener('pause', onPause);
videoEl.addEventListener('seeking', onSeeking);
videoEl.addEventListener('seeked', onSeeked); videoEl.addEventListener('seeked', onSeeked);
videoEl.addEventListener('ended', onEnded); videoEl.addEventListener('ended', onEnded);
videoEl.addEventListener('error', onError); videoEl.addEventListener('error', onError);
videoEl.addEventListener('timeupdate', onTimeUpdate);
window.addEventListener('pagehide', onPageHide); window.addEventListener('pagehide', onPageHide);
document.addEventListener('visibilitychange', onVisibility); document.addEventListener('visibilitychange', onVisibility);
@@ -146,13 +190,17 @@ function setupVideoProgress({ videoEl, movieId, token, userId }) {
return () => { return () => {
stopInterval(); stopInterval();
if (seekSaveTimer) window.clearTimeout(seekSaveTimer);
videoEl.removeEventListener('play', onPlay); videoEl.removeEventListener('play', onPlay);
videoEl.removeEventListener('pause', onPause); videoEl.removeEventListener('pause', onPause);
videoEl.removeEventListener('seeking', onSeeking);
videoEl.removeEventListener('seeked', onSeeked); videoEl.removeEventListener('seeked', onSeeked);
videoEl.removeEventListener('ended', onEnded); videoEl.removeEventListener('ended', onEnded);
videoEl.removeEventListener('error', onError); videoEl.removeEventListener('error', onError);
videoEl.removeEventListener('timeupdate', onTimeUpdate);
window.removeEventListener('pagehide', onPageHide); window.removeEventListener('pagehide', onPageHide);
document.removeEventListener('visibilitychange', onVisibility); document.removeEventListener('visibilitychange', onVisibility);
if (lastSaveStatus) setProgressStatus(lastSaveStatus);
}; };
} }
@@ -190,6 +238,21 @@ function setSourceLabel(text) {
el.title = text || ''; el.title = text || '';
} }
function setProgressStatus(text) {
const el = document.getElementById('progress-status');
if (!el) return;
el.textContent = text || '—';
}
function formatClock(seconds) {
const s = Math.max(0, Math.floor(Number(seconds) || 0));
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
const sec = s % 60;
if (h > 0) return `${h}:${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}`;
return `${m}:${String(sec).padStart(2, '0')}`;
}
function setHeader(movie) { function setHeader(movie) {
const titleEl = document.getElementById('movie-title'); const titleEl = document.getElementById('movie-title');
const categoryEl = document.getElementById('movie-category'); const categoryEl = document.getElementById('movie-category');
@@ -336,6 +399,58 @@ function setOpenOriginal(raw) {
} }
} }
function isApiVideoUrl(raw) {
const s = String(raw || '').trim();
if (!s) return false;
if (s.startsWith('/api/video/')) return true;
try {
const u = new URL(s);
return u.pathname.startsWith('/api/video/');
} catch (_) {
return false;
}
}
function toVideoSessionUrl(videoSrc) {
const s = String(videoSrc || '').trim();
if (!s) return null;
if (s.startsWith('/')) return '/api/video/session';
try {
const u = new URL(s);
return new URL('/api/video/session', u.origin).toString();
} catch (_) {
return null;
}
}
async function ensureVideoSessionForSource(videoSrc, token) {
// Only needed for our protected /api/video/* streams.
if (!token) return;
if (!isApiVideoUrl(videoSrc)) return;
const sessionUrl = toVideoSessionUrl(videoSrc);
if (!sessionUrl) return;
// If sessionUrl is cross-origin, we need credentials so the cookie is stored for that domain.
const isCrossOrigin = (() => {
try {
const u = new URL(sessionUrl, window.location.origin);
return u.origin !== window.location.origin;
} catch (_) {
return false;
}
})();
try {
await fetch(sessionUrl, {
method: 'POST',
headers: token ? { 'Authorization': `Bearer ${token}` } : undefined,
...(isCrossOrigin ? { credentials: 'include' } : {}),
});
} catch (_) {
// best-effort
}
}
async function playVodLink(vodLink) { async function playVodLink(vodLink) {
const raw = normalizeVodLink(vodLink); const raw = normalizeVodLink(vodLink);
if (!raw) { if (!raw) {
@@ -362,6 +477,21 @@ async function playVodLink(vodLink) {
return; return;
} }
// App/Python protected video endpoint (no file extension, but it's still a direct <video> stream).
if (isApiVideoUrl(raw)) {
setSourceLabel('Server video stream (/api/video)');
const videoEl = showVideoPlayer();
if (!videoEl) return;
videoEl.src = raw;
videoEl.addEventListener('error', () => {
// If the browser can't play it (or CORS blocks), fall back to iframe.
setSourceLabel('Embed (fallback)');
showEmbedPlayer(raw);
}, { once: true });
try { videoEl.load(); } catch (_) {}
return;
}
// Direct video file // Direct video file
if (looksLikeVideoFile(raw)) { if (looksLikeVideoFile(raw)) {
setSourceLabel('Direct video file'); setSourceLabel('Direct video file');
@@ -504,15 +634,6 @@ window.onload = async function () {
setHeader(movie); setHeader(movie);
// Allow <video> to call /api/video/* without Authorization headers by using a cookie.
// Best-effort: if it fails, external video_src URLs can still work.
try {
await fetch('/api/video/session', {
method: 'POST',
headers: token ? { 'Authorization': `Bearer ${token}` } : undefined,
});
} catch (_) {}
const resolved = resolveMovieSource(movie); const resolved = resolveMovieSource(movie);
if (!resolved?.src) { if (!resolved?.src) {
setOpenOriginal(null); setOpenOriginal(null);
@@ -522,6 +643,10 @@ window.onload = async function () {
return; return;
} }
// Allow <video> to call protected /api/video/* sources without Authorization headers by using a cookie.
// 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.
setOpenOriginal(resolved.src); setOpenOriginal(resolved.src);
@@ -536,7 +661,10 @@ window.onload = async function () {
// 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…');
setupVideoProgress({ videoEl, movieId: id, token, userId: decoded?.id }); setupVideoProgress({ videoEl, movieId: id, token, userId: decoded?.id });
} else {
setProgressStatus('—');
} }
} catch (err) { } catch (err) {
showAlert(err.message || 'Network error.'); showAlert(err.message || 'Network error.');
+5
View File
@@ -115,6 +115,11 @@
<span class="badge bg-secondary">Source</span> <span class="badge bg-secondary">Source</span>
<span id="source-label" class="badge bg-light text-dark badge-src">—</span> <span id="source-label" class="badge bg-light text-dark badge-src">—</span>
</div> </div>
<div class="mt-2 d-flex flex-wrap gap-2 align-items-center">
<span class="badge bg-secondary">Progress</span>
<span id="progress-status" class="text-muted small">—</span>
</div>
</div> </div>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/js/bootstrap.bundle.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/js/bootstrap.bundle.min.js"></script>
+29
View File
@@ -0,0 +1,29 @@
# Python video server (Range streaming + JWT + Mongo)
This is a small standalone server that mirrors the app’s `/api/video/:id` behavior:
- Looks up `movies._id == :id` in MongoDB and reads the `video_file` field
- Streams the file from `VIDEO_FILES_DIR` with **HTTP Range** support (seeking works)
- Auth via the same JWT (`Authorization: Bearer ...`), plus a `video_auth` cookie for `<video>`
## Run
```bash
python -m pip install -r python_video_server/requirements.txt
export MONGO_URI="..."
export JWT_SECRET="..."
export VIDEO_FILES_DIR="/absolute/path/to/public/video" # optional (default: ./public/video)
export VIDEO_SESSION_MAX_AGE_SECONDS="28800" # optional (default: 8h)
uvicorn python_video_server.server:app --host 0.0.0.0 --port 8000
```
## Endpoints
- `POST /api/video/session`
Sets `video_auth` cookie (send `Authorization: Bearer <token>`).
- `GET|HEAD /api/video/{movieId}`
Streams the movie file (supports `Range: bytes=...`).
- `GET /healthz`
+4
View File
@@ -0,0 +1,4 @@
fastapi==0.115.6
uvicorn[standard]==0.34.0
pymongo==4.10.1
PyJWT==2.10.1
+291
View File
@@ -0,0 +1,291 @@
import mimetypes
import os
import posixpath
from datetime import datetime, timezone
from pathlib import Path
from typing import Generator, Optional, Tuple
from urllib.parse import quote
from urllib.parse import urlparse
import jwt
from bson import ObjectId
from fastapi import FastAPI, Header, HTTPException, Request, Response
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, StreamingResponse
from pymongo import MongoClient
def _env_str(name: str, default: str = "") -> str:
v = os.environ.get(name)
return v.strip() if isinstance(v, str) and v.strip() else default
def _jwt_secret() -> str:
# Read env at runtime (important on Windows/uvicorn worker reloads).
return _env_str("JWT_SECRET")
def _mongo_uri() -> str:
# Read env at runtime (important on Windows/uvicorn worker reloads).
return _env_str("MONGO_URI")
def _mongo_db_name() -> str:
# Prefer explicit env var, otherwise try to parse from URI path.
explicit = _env_str("MONGO_DB_NAME")
if explicit:
return explicit
uri = _mongo_uri()
if not uri:
return ""
try:
parsed = urlparse(uri)
# Path is like "/dbname"
db = (parsed.path or "").lstrip("/")
# Strip any extra segments just in case
db = db.split("/")[0].strip()
return db
except Exception:
return ""
def _video_files_dir() -> Path:
# Read env at runtime (important on Windows/uvicorn worker reloads).
return Path(_env_str("VIDEO_FILES_DIR", str(Path.cwd() / "public" / "video"))).resolve()
def _parse_bearer(authorization: Optional[str]) -> str:
raw = (authorization or "").strip()
if raw.lower().startswith("bearer "):
return raw[7:].strip()
return ""
def _is_safe_relative_file(p: str) -> bool:
if not isinstance(p, str):
return False
s = p.strip()
if not s or "\x00" in s:
return False
if s.startswith("/") or s.startswith("\\"):
return False
norm = posixpath.normpath(s.replace("\\", "/"))
if norm == ".." or norm.startswith("../"):
return False
return True
def _guess_content_type(file_path: Path) -> str:
ctype, _ = mimetypes.guess_type(str(file_path))
return ctype or "application/octet-stream"
def _parse_range(range_header: str, file_size: int) -> Optional[Tuple[int, int]]:
# Only supports a single bytes range: "bytes=start-end"
raw = (range_header or "").strip()
if not raw:
return None
if not raw.lower().startswith("bytes="):
return None
spec = raw[6:].strip()
if "," in spec:
return None
if "-" not in spec:
return None
start_s, end_s = spec.split("-", 1)
start = int(start_s) if start_s else 0
end = int(end_s) if end_s else (file_size - 1)
if start < 0 or end < 0 or start > end or start >= file_size:
return None
end = min(end, file_size - 1)
return start, end
def _file_iter(path: Path, start: int, end: int, chunk_size: int = 1024 * 1024) -> Generator[bytes, None, None]:
with path.open("rb") as f:
f.seek(start)
remaining = end - start + 1
while remaining > 0:
n = min(chunk_size, remaining)
data = f.read(n)
if not data:
break
remaining -= len(data)
yield data
def _get_token(req: Request, authorization: Optional[str]) -> str:
# Prefer cookie (for <video>), then Authorization header, then querystring token.
cookie_token = (req.cookies.get("video_auth") or "").strip()
if cookie_token:
return cookie_token
header_token = _parse_bearer(authorization)
if header_token:
return header_token
qs = (req.query_params.get("token") or "").strip()
return qs
def _verify_token(token: str) -> dict:
if not token:
raise HTTPException(status_code=401, detail="Missing video auth token")
secret = _jwt_secret()
if not secret:
raise HTTPException(status_code=500, detail="JWT_SECRET is not configured")
try:
return jwt.decode(token, secret, algorithms=["HS256"])
except jwt.PyJWTError:
raise HTTPException(status_code=401, detail="Invalid or expired video auth token")
def _mongo_client() -> MongoClient:
uri = _mongo_uri()
if not uri:
raise HTTPException(status_code=500, detail="MONGO_URI is not configured")
return MongoClient(uri)
app = FastAPI()
# CORS: needed so the app can call the Python host's /api/video/session
_cors_origins_raw = _env_str("CORS_ALLOW_ORIGINS", "*")
_cors_origins = [o.strip() for o in _cors_origins_raw.split(",") if o.strip()]
_cors_allow_origin_regex = None
if len(_cors_origins) == 0 or (len(_cors_origins) == 1 and _cors_origins[0] == "*"):
# With credentials, browsers can't accept Access-Control-Allow-Origin: *
# Using a regex makes Starlette reflect the requesting Origin instead.
_cors_origins = []
_cors_allow_origin_regex = ".*"
app.add_middleware(
CORSMiddleware,
allow_origins=_cors_origins,
allow_origin_regex=_cors_allow_origin_regex,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@app.post("/api/video/session")
def create_video_session(request: Request, response: Response, authorization: Optional[str] = Header(default=None)):
token = _parse_bearer(authorization)
_verify_token(token)
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_samesite = _env_str("VIDEO_COOKIE_SAMESITE", "Lax") # Lax|Strict|None
samesite = (cookie_samesite or "Lax").strip().lower()
if samesite not in ("lax", "strict", "none"):
samesite = "lax"
samesite_attr = "None" if samesite == "none" else ("Strict" if samesite == "strict" else "Lax")
cookie_parts = [
f"video_auth={encoded}",
"Path=/api/video",
f"Max-Age={cookie_max_age_seconds}",
f"SameSite={samesite_attr}",
"HttpOnly",
]
# Modern browsers require Secure when SameSite=None
if secure or samesite == "none":
cookie_parts.append("Secure")
response.headers["Set-Cookie"] = "; ".join(cookie_parts)
response.status_code = 204
return None
@app.api_route("/api/video/{movie_id}", methods=["GET", "HEAD"])
def stream_video(
movie_id: str,
request: Request,
authorization: Optional[str] = Header(default=None),
range: Optional[str] = Header(default=None), # noqa: A002
):
token = _get_token(request, authorization)
_verify_token(token)
try:
oid = ObjectId(movie_id)
except Exception:
raise HTTPException(status_code=400, detail="Invalid movie id")
with _mongo_client() as client:
db_name = _mongo_db_name()
if not db_name:
raise HTTPException(
status_code=500,
detail="Mongo DB name not configured. Add it to MONGO_URI (…/dbname) or set MONGO_DB_NAME.",
)
db = client[db_name]
movie = db["movies"].find_one({"_id": oid}, {"video_file": 1})
video_file = (movie or {}).get("video_file") or ""
if not isinstance(video_file, str) or not video_file.strip():
raise HTTPException(status_code=404, detail="No server video file configured for this movie")
video_file = video_file.strip()
if not _is_safe_relative_file(video_file):
raise HTTPException(status_code=400, detail="Invalid video_file path")
video_dir = _video_files_dir()
full_path = (video_dir / video_file).resolve()
if not str(full_path).startswith(str(video_dir) + os.sep) and full_path != video_dir:
raise HTTPException(status_code=400, detail="Invalid video_file path")
if not full_path.exists() or not full_path.is_file():
raise HTTPException(status_code=404, detail="Video file not found on server")
stat = full_path.stat()
file_size = stat.st_size
content_type = _guess_content_type(full_path)
# Shared headers (match the Node server behavior closely)
base_headers = {
"Accept-Ranges": "bytes",
"Content-Type": content_type,
"Cache-Control": "private, max-age=0, must-revalidate",
}
if request.method == "HEAD":
headers = dict(base_headers)
headers["Content-Length"] = str(file_size)
return Response(status_code=200, headers=headers)
r = _parse_range(range or "", file_size)
if r is None and (range or ""):
# Invalid Range
headers = dict(base_headers)
headers["Content-Range"] = f"bytes */{file_size}"
return Response(status_code=416, headers=headers)
if r is None:
# Full file
return FileResponse(
path=str(full_path),
media_type=content_type,
headers=base_headers,
)
start, end = r
chunk_size = end - start + 1
headers = dict(base_headers)
headers["Content-Range"] = f"bytes {start}-{end}/{file_size}"
headers["Content-Length"] = str(chunk_size)
return StreamingResponse(
_file_iter(full_path, start, end),
status_code=206,
media_type=content_type,
headers=headers,
)
@app.get("/healthz")
def healthz():
return {
"ok": True,
"time": datetime.now(timezone.utc).isoformat(),
"video_files_dir": str(_video_files_dir()),
"mongo_db_name": _mongo_db_name() or None,
}
+3
View File
@@ -648,9 +648,12 @@ router.put("/:id/progress", authenticate, async (req, res) => {
let time = Number(rawTime); let time = Number(rawTime);
if (!Number.isFinite(time) || time < 0) time = 0; if (!Number.isFinite(time) || time < 0) time = 0;
// Store whole seconds for stable, consistent resume behavior.
time = Math.floor(time);
let duration = rawDuration === undefined || rawDuration === null ? null : Number(rawDuration); let duration = rawDuration === undefined || rawDuration === null ? null : Number(rawDuration);
if (duration !== null && (!Number.isFinite(duration) || duration <= 0)) duration = null; if (duration !== null && (!Number.isFinite(duration) || duration <= 0)) duration = null;
if (duration !== null) duration = Math.floor(duration);
if (duration !== null) { if (duration !== null) {
// Clamp into [0, duration] // Clamp into [0, duration]
+4 -1
View File
@@ -76,6 +76,9 @@ function isAdminFromDecoded(decoded) {
function signUserToken(user) { function signUserToken(user) {
const isAdmin = user.role === 69; const isAdmin = user.role === 69;
const expiresIn = typeof process.env.JWT_EXPIRES_IN === "string" && process.env.JWT_EXPIRES_IN.trim()
? process.env.JWT_EXPIRES_IN.trim()
: "8h";
return jwt.sign( return jwt.sign(
{ {
id: user._id, id: user._id,
@@ -84,7 +87,7 @@ function signUserToken(user) {
mustChangePassword: !!user.mustChangePassword, mustChangePassword: !!user.mustChangePassword,
}, },
process.env.JWT_SECRET, process.env.JWT_SECRET,
{ expiresIn: "1h" } { expiresIn }
); );
} }
+4 -1
View File
@@ -89,7 +89,10 @@ router.post("/session", (req, res) => {
jwt.verify(token, process.env.JWT_SECRET); jwt.verify(token, process.env.JWT_SECRET);
// Cookie scoped to /api/video so it doesn't leak broadly. // Cookie scoped to /api/video so it doesn't leak broadly.
// httpOnly helps a bit, even though the JWT originally lives in localStorage. // httpOnly helps a bit, even though the JWT originally lives in localStorage.
const maxAgeSeconds = 60 * 60 * 8; // 8h 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
const cookie = [ const cookie = [
`video_auth=${encodeURIComponent(token)}`, `video_auth=${encodeURIComponent(token)}`,
"Path=/api/video", "Path=/api/video",