feat: Add Python video server and improve playback
Co-authored-by: mathieumoran1 <[email protected]>
This commit is contained in:
co-authored by
mathieumoran1
parent
837e02ef4c
commit
e6537884a3
+20
-4
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,8 +68,9 @@ function setupVideoProgress({ videoEl, movieId, token, userId }) {
|
|||||||
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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,7 +117,13 @@ 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 });
|
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() {
|
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);
|
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
@@ -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
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user