Video file serving app #17

Merged
quoije merged 4 commits from cursor/video-file-serving-app-0ea7 into 2026 2025-12-20 20:16:33 -05:00
5 changed files with 148 additions and 15 deletions
Showing only changes of commit c1f6b139e7 - Show all commits
+14
View File
@@ -22,6 +22,20 @@ This repo also includes a small Python server that serves local video files with
- Served as: `http://localhost:8001/media/<filename>`
- List endpoint: `http://localhost:8001/api/media`
### Authentication (required)
By default, the Python server requires a valid Oscar-Pool JWT (same `JWT_SECRET` as your Node app).
- The server loads `./.env` automatically (so it can reuse `JWT_SECRET`)
- The web player sends auth automatically:
- direct `/media/*` and `/hls/*` requests use `?token=<jwt>` (needed for the `<video>` tag)
- `/api/hls` uses `Authorization: Bearer <jwt>`
To disable auth (not recommended):
```bash
export VIDEO_AUTH_REQUIRED=0
```
### HLS (for better browser compatibility, especially MKV)
The Python server can also **generate and serve HLS** (`.m3u8` + `.ts`) using `ffmpeg`, and the web player will automatically try this when a `/media/*.mkv` file is used (or when direct playback fails).
Binary file not shown.
+31 -6
View File
@@ -90,6 +90,25 @@ function looksLikeVideoFile(url) {
return /\.(mp4|mkv|webm|ogg|ogv|mov|m4v)(\?|#|$)/.test(s);
}
function getAuthToken() {
return localStorage.getItem('auth_token');
}
function withAuthTokenQuery(url) {
const token = getAuthToken();
if (!token) return url;
try {
const u = new URL(url, window.location.origin);
const p = u.pathname || '';
// Only attach tokens to our python video endpoints.
if (!(p.startsWith('/media/') || p.startsWith('/hls/'))) return url;
if (!u.searchParams.has('token')) u.searchParams.set('token', token);
return u.toString();
} catch (_) {
return url;
}
}
function isMkv(url) {
try {
const u = new URL(url, window.location.origin);
@@ -109,12 +128,17 @@ async function tryGetGeneratedHlsPlaylist(rawUrl) {
const api = new URL('/api/hls', u.origin);
api.searchParams.set('source', rel);
const res = await fetch(api.toString(), { method: 'GET' });
const token = getAuthToken();
const res = await fetch(api.toString(), {
method: 'GET',
headers: token ? { 'Authorization': `Bearer ${token}` } : undefined,
});
if (!res.ok) return null;
const data = await res.json().catch(() => null);
const playlist = data?.playlist_abs_url || data?.playlist_url;
if (!playlist) return null;
return playlist.startsWith('http') ? playlist : new URL(playlist, u.origin).toString();
const abs = playlist.startsWith('http') ? playlist : new URL(playlist, u.origin).toString();
return withAuthTokenQuery(abs);
} catch (_) {
return null;
}
@@ -221,7 +245,7 @@ async function playVodLink(vodLink) {
}
// "Open original" button
setOpenOriginal(raw);
setOpenOriginal(withAuthTokenQuery(raw));
// Prefer known embed providers
const yt = toYoutubeEmbed(raw);
@@ -252,7 +276,7 @@ async function playVodLink(vodLink) {
setSourceLabel('Direct video file');
const videoEl = showVideoPlayer();
if (!videoEl) return;
videoEl.src = raw;
videoEl.src = withAuthTokenQuery(raw);
videoEl.addEventListener('error', () => {
// If the browser can't play it (or CORS blocks), try HLS from the python server, then fall back to iframe.
(async () => {
@@ -275,10 +299,11 @@ async function playVodLink(vodLink) {
setSourceLabel('HLS stream (.m3u8)');
const videoEl = showVideoPlayer();
if (!videoEl) return;
const hlsUrl = withAuthTokenQuery(raw);
// Native HLS (Safari) first
if (videoEl.canPlayType('application/vnd.apple.mpegurl')) {
videoEl.src = raw;
videoEl.src = hlsUrl;
try { videoEl.load(); } catch (_) {}
return;
}
@@ -288,7 +313,7 @@ async function playVodLink(vodLink) {
const hls = new window.Hls({
enableWorker: true,
});
hls.loadSource(raw);
hls.loadSource(hlsUrl);
hls.attachMedia(videoEl);
hls.on(window.Hls.Events.ERROR, function (_evt, data) {
if (data?.fatal) {
+2
View File
@@ -1,2 +1,4 @@
fastapi==0.115.6
uvicorn[standard]==0.34.0
PyJWT==2.10.1
python-dotenv==1.2.1
+97 -5
View File
@@ -5,9 +5,11 @@ import shutil
import subprocess
import time
import hashlib
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
from pathlib import Path
from typing import Generator, Iterable, Optional, Tuple
import jwt
from fastapi import FastAPI, HTTPException, Query, Request, Response
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse, StreamingResponse
@@ -17,11 +19,22 @@ BASE_DIR = Path(__file__).resolve().parent
MEDIA_DIR = BASE_DIR / "media"
HLS_CACHE_DIR = Path(os.getenv("HLS_CACHE_DIR", str(BASE_DIR / ".hls_cache"))).resolve()
# Load repo .env (shared with the Node app) if present.
try:
from dotenv import load_dotenv # type: ignore
load_dotenv(BASE_DIR / ".env")
except Exception:
pass
# Allow common video types (what browsers can play is separate from what we serve).
ALLOWED_EXTS = {".mp4", ".mkv"}
RANGE_RE = re.compile(r"^bytes=(\d*)-(\d*)$")
AUTH_REQUIRED = os.getenv("VIDEO_AUTH_REQUIRED", "1").strip() not in {"0", "false", "False", "no", "NO"}
JWT_SECRET = os.getenv("JWT_SECRET")
def _safe_resolve_media_path(rel_path: str) -> Path:
"""
@@ -48,6 +61,37 @@ def _safe_resolve_media_path(rel_path: str) -> Path:
return candidate
def _extract_token(request: Request) -> Optional[str]:
# Query-param token (works for <video> and Safari HLS)
q = request.query_params.get("token")
if q:
return q
# Authorization: Bearer <token> (works for fetch/hls.js)
auth = request.headers.get("authorization") or request.headers.get("Authorization")
if auth and auth.lower().startswith("bearer "):
return auth.split(" ", 1)[1].strip() or None
return None
def _require_auth(request: Request) -> dict:
if not AUTH_REQUIRED:
return {}
token = _extract_token(request)
if not token:
raise HTTPException(status_code=401, detail="Authentication required")
if not JWT_SECRET:
raise HTTPException(status_code=500, detail="Server misconfigured: JWT_SECRET is not set")
try:
return jwt.decode(token, JWT_SECRET, algorithms=["HS256"])
except jwt.ExpiredSignatureError as exc:
raise HTTPException(status_code=401, detail="Invalid or expired token") from exc
except jwt.InvalidTokenError as exc:
raise HTTPException(status_code=401, detail="Invalid or expired token") from exc
def _guess_content_type(path: Path) -> str:
ext = path.suffix.lower()
if ext == ".mkv":
@@ -262,6 +306,33 @@ def _safe_resolve_hls_asset(hls_id: str, asset_path: str) -> Path:
return candidate
def _with_token_query(uri: str, token: str) -> str:
parts = urlsplit(uri)
q = dict(parse_qsl(parts.query, keep_blank_values=True))
if "token" not in q:
q["token"] = token
return urlunsplit((parts.scheme, parts.netloc, parts.path, urlencode(q), parts.fragment))
def _rewrite_m3u8_with_token(content: str, token: str) -> str:
"""
Adds ?token=... to each URI line (segments, key URIs, etc.) in a playlist.
Only touches non-comment, non-empty lines.
"""
out_lines: list[str] = []
for raw_line in content.splitlines(True):
line = raw_line.strip()
if not line or line.startswith("#"):
out_lines.append(raw_line)
continue
# Preserve original newline by rewriting only the URI portion.
uri = line
# If the line already includes leading/trailing whitespace, normalize to original.
rewritten = _with_token_query(uri, token)
out_lines.append(raw_line.replace(uri, rewritten, 1))
return "".join(out_lines)
app = FastAPI(title=APP_NAME)
# Default to permissive CORS because the player may be on another port/host.
@@ -287,7 +358,8 @@ def health() -> dict:
@app.get("/api/media")
def list_media() -> JSONResponse:
def list_media(request: Request) -> JSONResponse:
_require_auth(request)
MEDIA_DIR.mkdir(parents=True, exist_ok=True)
items = []
for p in sorted(MEDIA_DIR.rglob("*")):
@@ -307,12 +379,16 @@ def list_media() -> JSONResponse:
@app.get("/api/hls")
def prepare_hls(request: Request, source: str = Query(..., description="Path relative to ./media (e.g. MyMovie.mkv)")) -> JSONResponse:
def prepare_hls(
request: Request,
source: str = Query(..., description="Path relative to ./media (e.g. MyMovie.mkv)"),
) -> JSONResponse:
"""
Generates (and caches) an HLS VOD playlist for a local media file.
Returns an id and a playlist URL like: /hls/<id>/index.m3u8
"""
_require_auth(request)
media_path = _safe_resolve_media_path(source)
hls_id = _ensure_hls_for_media(media_path)
base = str(request.base_url).rstrip("/")
@@ -328,19 +404,34 @@ def prepare_hls(request: Request, source: str = Query(..., description="Path rel
@app.api_route("/hls/{hls_id}/{asset_path:path}", methods=["GET", "HEAD"])
async def serve_hls_asset(hls_id: str, asset_path: str, request: Request) -> Response:
_require_auth(request)
path = _safe_resolve_hls_asset(hls_id, asset_path)
size = path.stat().st_size
ctype = _hls_content_type(path)
if request.method == "HEAD":
size = path.stat().st_size
headers = {
"Content-Type": ctype,
"Content-Length": str(size),
"Cache-Control": "no-store",
}
if request.method == "HEAD":
return Response(status_code=200, headers=headers)
# For playlists, inject token into segment URIs so the browser can fetch them without headers.
if path.suffix.lower() == ".m3u8":
token = _extract_token(request) or ""
text = path.read_text("utf-8", errors="replace")
if token:
text = _rewrite_m3u8_with_token(text, token)
data = text.encode("utf-8")
return Response(
content=data,
status_code=200,
headers={"Content-Type": ctype, "Content-Length": str(len(data)), "Cache-Control": "no-store"},
)
size = path.stat().st_size
headers = {"Content-Type": ctype, "Content-Length": str(size), "Cache-Control": "no-store"}
return StreamingResponse(
_iter_file(path, 0, size - 1),
status_code=200,
@@ -351,6 +442,7 @@ async def serve_hls_asset(hls_id: str, asset_path: str, request: Request) -> Res
@app.api_route("/media/{file_path:path}", methods=["GET", "HEAD"])
async def serve_media(file_path: str, request: Request) -> Response:
_require_auth(request)
path = _safe_resolve_media_path(file_path)
size = path.stat().st_size
ctype = _guess_content_type(path)