Video server and timestamp #45
+20
-4
@@ -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() {
|
||||
|
||||
@@ -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`
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,4 @@
|
||||
fastapi==0.115.6
|
||||
uvicorn[standard]==0.34.0
|
||||
pymongo==4.10.1
|
||||
PyJWT==2.10.1
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user