feat: Add Python video server and improve playback

Co-authored-by: mathieumoran1 <[email protected]>
This commit is contained in:
Cursor Agent
2025-12-22 05:25:19 +00:00
co-authored by mathieumoran1
parent 837e02ef4c
commit e6537884a3
8 changed files with 296 additions and 14 deletions
+20 -4
View File
@@ -32,7 +32,9 @@ async function fetchPlaybackProgress(movieId, token) {
async function savePlaybackProgress(movieId, token, { time, duration, keepalive } = {}) {
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',
headers: {
'Content-Type': 'application/json',
@@ -41,8 +43,15 @@ async function savePlaybackProgress(movieId, token, { time, duration, keepalive
body: JSON.stringify({ time, duration }),
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 (_) {
// best-effort
return true;
}
}
@@ -59,8 +68,9 @@ function setupVideoProgress({ videoEl, movieId, token, userId }) {
const t = Number(videoEl.currentTime);
const d = Number(videoEl.duration);
return {
time: Number.isFinite(t) && t >= 0 ? t : 0,
duration: Number.isFinite(d) && d > 0 ? d : null,
// Use whole seconds to keep saved values stable.
time: Number.isFinite(t) && t >= 0 ? Math.floor(t) : 0,
duration: Number.isFinite(d) && d > 0 ? Math.floor(d) : null,
};
}
@@ -107,7 +117,13 @@ function setupVideoProgress({ videoEl, movieId, token, userId }) {
localStorage.setItem(key, JSON.stringify({ time: snap.time, duration: snap.duration, at: now }));
} catch (_) {}
await savePlaybackProgress(movieId, token, { ...snap, keepalive: !!keepalive });
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 (_) {}
showAlertVariant('Session expirée. Reconnecte-toi pour continuer à sauvegarder la progression.', 'warning');
}
}
function startInterval() {
+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
+224
View File
@@ -0,0 +1,224 @@
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
import jwt
from bson import ObjectId
from fastapi import FastAPI, Header, HTTPException, Request, Response
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
JWT_SECRET = _env_str("JWT_SECRET")
MONGO_URI = _env_str("MONGO_URI")
VIDEO_FILES_DIR = Path(_env_str("VIDEO_FILES_DIR", str(Path.cwd() / "public" / "video"))).resolve()
COOKIE_MAX_AGE_SECONDS = int(_env_str("VIDEO_SESSION_MAX_AGE_SECONDS", "28800")) # 8h
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")
if not JWT_SECRET:
raise HTTPException(status_code=500, detail="JWT_SECRET is not configured")
try:
return jwt.decode(token, JWT_SECRET, algorithms=["HS256"])
except jwt.PyJWTError:
raise HTTPException(status_code=401, detail="Invalid or expired video auth token")
def _mongo_client() -> MongoClient:
if not MONGO_URI:
raise HTTPException(status_code=500, detail="MONGO_URI is not configured")
return MongoClient(MONGO_URI)
app = FastAPI()
@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_parts = [
f"video_auth={encoded}",
"Path=/api/video",
f"Max-Age={COOKIE_MAX_AGE_SECONDS}",
"SameSite=Lax",
"HttpOnly",
]
if secure:
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 = client.get_default_database()
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")
full_path = (VIDEO_FILES_DIR / video_file).resolve()
if not str(full_path).startswith(str(VIDEO_FILES_DIR) + os.sep) and full_path != VIDEO_FILES_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),
}
+3
View File
@@ -648,9 +648,12 @@ router.put("/:id/progress", authenticate, async (req, res) => {
let time = Number(rawTime);
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);
if (duration !== null && (!Number.isFinite(duration) || duration <= 0)) duration = null;
if (duration !== null) duration = Math.floor(duration);
if (duration !== null) {
// Clamp into [0, duration]
+4 -1
View File
@@ -76,6 +76,9 @@ function isAdminFromDecoded(decoded) {
function signUserToken(user) {
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(
{
id: user._id,
@@ -84,7 +87,7 @@ function signUserToken(user) {
mustChangePassword: !!user.mustChangePassword,
},
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);
// Cookie scoped to /api/video so it doesn't leak broadly.
// 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 = [
`video_auth=${encodeURIComponent(token)}`,
"Path=/api/video",