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
7 changed files with 806 additions and 9 deletions
+7
View File
@@ -137,3 +137,10 @@ dist
# ayylmao
ayylmao.js
# Local media files (served by python video_server.py)
media/*
!media/.gitkeep
# HLS cache (generated by video_server.py via ffmpeg)
.hls_cache/
+82
View File
@@ -14,3 +14,85 @@ Config those in your .env file:
**OMDB_API=**`1234567`     (for movie info)
run with **node .\index.js**
## Python video file server (mp4/mkv)
This repo also includes a small Python server that serves local video files with **HTTP Range** support (required for HTML5 `<video>` seeking).
- Put your files in: `./media/` (repo root)
- 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).
- Prepare endpoint: `http://localhost:8001/api/hls?source=<path relative to media>`
- Playlist served from: `http://localhost:8001/hls/<id>/index.m3u8`
- Output is cached under: `./.hls_cache/` (ignored by git)
Example:
- Source: `http://localhost:8001/media/MyMovie.mkv`
- The player will request: `http://localhost:8001/api/hls?source=MyMovie.mkv` and then play the returned `.m3u8`.
### Multi-stream / adaptive bitrate
HLS is generated as **multi-rendition adaptive bitrate** by default (a master playlist `index.m3u8` pointing to multiple variant streams).
You can configure variants with:
```bash
export HLS_VARIANTS="1080:5500,720:3000,480:1500,360:900"
```
### Concurrency (many viewers at once)
- **Serving** multiple viewers is handled by Uvicorn/FastAPI; for heavy traffic run with more workers:
```bash
export VIDEO_SERVER_RELOAD=0
export VIDEO_SERVER_WORKERS=4
python3 video_server.py
```
- **Transcoding** is CPU-heavy; it is throttled with:
```bash
export HLS_MAX_JOBS=2
```
### Install
```bash
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
```
### Run
```bash
python3 video_server.py
```
### Use in the web player
Set the movie's `video_src` (or `vod_link`) to something like:
- `http://localhost:8001/media/MyMovie.mp4`
- `http://localhost:8001/media/MyMovie.mkv`
If the Node app is on a different origin/port, the video server enables CORS by default. You can lock it down with:
```bash
export CORS_ORIGINS="http://localhost:5000"
```
Binary file not shown.
+1
View File
@@ -0,0 +1 @@
+80 -8
View File
@@ -87,7 +87,61 @@ function looksLikeHls(url) {
function looksLikeVideoFile(url) {
const s = String(url || '').toLowerCase();
// common direct file extensions
return /\.(mp4|webm|ogg|ogv|mov|m4v)(\?|#|$)/.test(s);
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);
return u.pathname.toLowerCase().endsWith('.mkv');
} catch (_) {
return /\.mkv(\?|#|$)/i.test(String(url || ''));
}
}
async function tryGetGeneratedHlsPlaylist(rawUrl) {
// Only supports the python server's /media/<path> URLs.
try {
const u = new URL(rawUrl, window.location.origin);
if (!u.pathname.startsWith('/media/')) return null;
const rel = decodeURIComponent(u.pathname.slice('/media/'.length));
if (!rel) return null;
const api = new URL('/api/hls', u.origin);
api.searchParams.set('source', rel);
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;
const abs = playlist.startsWith('http') ? playlist : new URL(playlist, u.origin).toString();
return withAuthTokenQuery(abs);
} catch (_) {
return null;
}
}
function toYoutubeEmbed(url) {
@@ -191,7 +245,7 @@ async function playVodLink(vodLink) {
}
// "Open original" button
setOpenOriginal(raw);
setOpenOriginal(withAuthTokenQuery(raw));
// Prefer known embed providers
const yt = toYoutubeEmbed(raw);
@@ -210,14 +264,31 @@ async function playVodLink(vodLink) {
// Direct video file
if (looksLikeVideoFile(raw)) {
// MKV is commonly not playable in browsers; try HLS generation first if it's from our python server.
if (isMkv(raw)) {
const playlist = await tryGetGeneratedHlsPlaylist(raw);
if (playlist) {
setSourceLabel('HLS (generated)');
return await playVodLink(playlist);
}
}
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), fall back to iframe.
setSourceLabel('Embed (fallback)');
showEmbedPlayer(raw);
// If the browser can't play it (or CORS blocks), try HLS from the python server, then fall back to iframe.
(async () => {
const playlist = await tryGetGeneratedHlsPlaylist(raw);
if (playlist) {
setSourceLabel('HLS (generated)');
await playVodLink(playlist);
return;
}
setSourceLabel('Embed (fallback)');
showEmbedPlayer(raw);
})();
}, { once: true });
try { videoEl.load(); } catch (_) {}
return;
@@ -228,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;
}
@@ -241,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) {
+4
View File
@@ -0,0 +1,4 @@
fastapi==0.115.6
uvicorn[standard]==0.34.0
PyJWT==2.10.1
python-dotenv==1.2.1
+631
View File
@@ -0,0 +1,631 @@
import mimetypes
import os
import re
import shutil
import subprocess
import time
import hashlib
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
from pathlib import Path
from typing import Any, Dict, Generator, Iterable, List, Optional, Tuple
import threading
import jwt
from fastapi import FastAPI, HTTPException, Query, Request, Response
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse, StreamingResponse
APP_NAME = "video-file-server"
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")
# Limit concurrent ffmpeg transcodes (HLS generation) to avoid CPU overload.
HLS_MAX_JOBS = max(1, int(os.getenv("HLS_MAX_JOBS", "2").strip() or "2"))
_HLS_JOB_SEM = threading.Semaphore(HLS_MAX_JOBS)
def _safe_resolve_media_path(rel_path: str) -> Path:
"""
Resolve a user-provided path against MEDIA_DIR, preventing path traversal.
"""
# Normalize separators and strip leading slashes.
rel_path = rel_path.replace("\\", "/").lstrip("/")
if not rel_path:
raise HTTPException(status_code=404, detail="Missing file path")
candidate = (MEDIA_DIR / rel_path).resolve()
media_root = MEDIA_DIR.resolve()
try:
candidate.relative_to(media_root)
except ValueError as exc:
raise HTTPException(status_code=400, detail="Invalid path") from exc
if not candidate.is_file():
raise HTTPException(status_code=404, detail="File not found")
if candidate.suffix.lower() not in ALLOWED_EXTS:
raise HTTPException(status_code=415, detail="Unsupported file type")
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":
return "video/x-matroska"
# Let mimetypes handle mp4 and others.
ctype, _ = mimetypes.guess_type(str(path))
return ctype or "application/octet-stream"
def _parse_range(range_header: str, size: int) -> Optional[Tuple[int, int]]:
"""
Parse a single HTTP Range header of form: bytes=start-end
Returns (start, end) inclusive, or None if header is missing/empty.
Raises HTTPException for invalid ranges.
"""
if not range_header:
return None
m = RANGE_RE.match(range_header.strip())
if not m:
raise HTTPException(status_code=416, detail="Invalid Range header")
start_s, end_s = m.group(1), m.group(2)
if start_s == "" and end_s == "":
raise HTTPException(status_code=416, detail="Invalid Range header")
if start_s == "":
# suffix range: last N bytes
suffix_len = int(end_s)
if suffix_len <= 0:
raise HTTPException(status_code=416, detail="Invalid Range header")
if suffix_len > size:
return (0, size - 1)
return (size - suffix_len, size - 1)
start = int(start_s)
end = int(end_s) if end_s != "" else size - 1
if start >= size or start < 0:
raise HTTPException(status_code=416, detail="Range not satisfiable")
if end < start:
raise HTTPException(status_code=416, detail="Range not satisfiable")
end = min(end, size - 1)
return (start, end)
def _iter_file(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:
chunk = f.read(min(chunk_size, remaining))
if not chunk:
break
remaining -= len(chunk)
yield chunk
def _hls_content_type(path: Path) -> str:
ext = path.suffix.lower()
if ext == ".m3u8":
return "application/vnd.apple.mpegurl"
if ext == ".ts":
return "video/mp2t"
ctype, _ = mimetypes.guess_type(str(path))
return ctype or "application/octet-stream"
def _hls_id_for_media(path: Path) -> str:
rel = path.relative_to(MEDIA_DIR).as_posix()
st = path.stat()
key = f"{rel}|{st.st_size}|{st.st_mtime_ns}".encode("utf-8")
return hashlib.sha1(key).hexdigest()
def _probe_streams(path: Path) -> Dict[str, Any]:
"""
Returns basic info about the input using ffprobe: {width,height,has_audio}.
"""
cmd = [
"ffprobe",
"-v",
"error",
"-select_streams",
"v:0",
"-show_entries",
"stream=width,height",
"-of",
"csv=p=0:s=x",
str(path),
]
try:
p = subprocess.run(cmd, check=True, capture_output=True, text=True)
wh = (p.stdout or "").strip()
except FileNotFoundError as exc:
raise HTTPException(status_code=500, detail="ffprobe not installed") from exc
except subprocess.CalledProcessError as exc:
stderr = (exc.stderr or "").strip()
raise HTTPException(status_code=500, detail=f"ffprobe failed: {stderr or 'ffprobe error'}") from exc
width = height = None
if "x" in wh:
try:
w_s, h_s = wh.split("x", 1)
width, height = int(w_s), int(h_s)
except Exception:
width = height = None
cmd_a = [
"ffprobe",
"-v",
"error",
"-select_streams",
"a:0",
"-show_entries",
"stream=index",
"-of",
"csv=p=0",
str(path),
]
try:
pa = subprocess.run(cmd_a, check=False, capture_output=True, text=True)
has_audio = bool((pa.stdout or "").strip())
except FileNotFoundError as exc:
raise HTTPException(status_code=500, detail="ffprobe not installed") from exc
if not width or not height:
raise HTTPException(status_code=415, detail="Unsupported media: missing video stream")
return {"width": width, "height": height, "has_audio": has_audio}
def _acquire_lock(lock_path: Path, timeout_s: int = 600) -> None:
"""
Simple file lock to avoid concurrent transcodes for the same id.
"""
started = time.time()
while True:
try:
fd = os.open(str(lock_path), os.O_CREAT | os.O_EXCL | os.O_WRONLY)
try:
os.write(fd, str(os.getpid()).encode("utf-8"))
finally:
os.close(fd)
return
except FileExistsError:
if time.time() - started > timeout_s:
raise HTTPException(status_code=409, detail="HLS generation already running")
time.sleep(0.25)
def _release_lock(lock_path: Path) -> None:
try:
lock_path.unlink(missing_ok=True)
except Exception:
# Best-effort; lock will be overwritten on next run if needed.
pass
def _ensure_hls_for_media(media_path: Path) -> str:
"""
Generates an HLS VOD playlist for a given media file in HLS_CACHE_DIR.
Returns the HLS id.
"""
hls_id = _hls_id_for_media(media_path)
out_dir = HLS_CACHE_DIR / hls_id
index_path = out_dir / "index.m3u8"
if index_path.exists():
return hls_id
HLS_CACHE_DIR.mkdir(parents=True, exist_ok=True)
lock_path = HLS_CACHE_DIR / f"{hls_id}.lock"
_acquire_lock(lock_path)
try:
# Another process might have finished while we waited.
if index_path.exists():
return hls_id
tmp_dir = HLS_CACHE_DIR / f"{hls_id}.tmp"
if tmp_dir.exists():
shutil.rmtree(tmp_dir, ignore_errors=True)
tmp_dir.mkdir(parents=True, exist_ok=True)
# Multi-rendition adaptive HLS (master + variants).
hls_time = os.getenv("HLS_TIME", "6").strip()
crf = os.getenv("HLS_CRF", "23").strip()
preset = os.getenv("HLS_PRESET", "veryfast").strip()
info = _probe_streams(media_path)
src_h = int(info["height"])
has_audio = bool(info["has_audio"])
# Variant syntax: "height:video_kbps,height:video_kbps,..."
default_variants = "1080:5500,720:3000,480:1500,360:900"
variants_env = os.getenv("HLS_VARIANTS", default_variants).strip()
variants: List[Tuple[int, int]] = []
for part in [p.strip() for p in variants_env.split(",") if p.strip()]:
if ":" not in part:
continue
h_s, b_s = part.split(":", 1)
try:
h = int(h_s)
b = int(b_s)
except ValueError:
continue
if h <= 0 or b <= 0:
continue
if h <= src_h:
variants.append((h, b))
if not variants:
variants = [(min(720, src_h), 3000)]
variants = sorted(list({v for v in variants}), key=lambda x: x[0], reverse=True)
n = len(variants)
# Build filter_complex split+scale for N renditions
split_tags = "".join([f"[v{i}]" for i in range(n)])
scale_filters = [f"[v{i}]scale=-2:{variants[i][0]}[v{i}o]" for i in range(n)]
filter_complex = f"[0:v]split={n}{split_tags};" + ";".join(scale_filters)
cmd: List[str] = [
"ffmpeg",
"-hide_banner",
"-loglevel",
"error",
"-y",
"-i",
str(media_path),
"-filter_complex",
filter_complex,
]
for i in range(n):
cmd += ["-map", f"[v{i}o]"]
if has_audio:
cmd += ["-map", "0:a?"]
cmd += ["-pix_fmt", "yuv420p"]
audio_bitrate = os.getenv("HLS_AUDIO_BITRATE", "160k").strip()
for i, (_h, b_kbps) in enumerate(variants):
maxrate = int(b_kbps * 1.07)
bufsize = int(b_kbps * 1.5)
cmd += [
f"-c:v:{i}",
"libx264",
"-preset",
preset,
"-crf",
crf,
f"-b:v:{i}",
f"{b_kbps}k",
f"-maxrate:v:{i}",
f"{maxrate}k",
f"-bufsize:v:{i}",
f"{bufsize}k",
"-sc_threshold",
"0",
]
if has_audio:
cmd += [f"-c:a:{i}", "aac", f"-b:a:{i}", audio_bitrate]
cmd += ["-force_key_frames", f"expr:gte(t,n_forced*{hls_time})"]
cmd += [
"-f",
"hls",
"-hls_time",
hls_time,
"-hls_playlist_type",
"vod",
"-hls_flags",
"independent_segments",
"-hls_segment_filename",
str(tmp_dir / "seg_%v_%05d.ts"),
"-master_pl_name",
"index.m3u8",
]
if has_audio:
var_map = " ".join([f"v:{i},a:{i}" for i in range(n)])
else:
var_map = " ".join([f"v:{i}" for i in range(n)])
cmd += ["-var_stream_map", var_map, str(tmp_dir / "v%v.m3u8")]
try:
with _HLS_JOB_SEM:
subprocess.run(cmd, check=True, capture_output=True)
except FileNotFoundError as exc:
raise HTTPException(status_code=500, detail="ffmpeg not installed") from exc
except subprocess.CalledProcessError as exc:
stderr = (exc.stderr or b"").decode("utf-8", errors="replace").strip()
raise HTTPException(status_code=500, detail=f"HLS generation failed: {stderr or 'ffmpeg error'}") from exc
# Move into place.
if out_dir.exists():
# Race: someone else created it; keep theirs.
shutil.rmtree(tmp_dir, ignore_errors=True)
return hls_id
os.replace(str(tmp_dir), str(out_dir))
return hls_id
finally:
_release_lock(lock_path)
def _safe_resolve_hls_asset(hls_id: str, asset_path: str) -> Path:
if not re.fullmatch(r"[a-f0-9]{40}", hls_id):
raise HTTPException(status_code=400, detail="Invalid HLS id")
asset_path = asset_path.replace("\\", "/").lstrip("/")
if not asset_path:
raise HTTPException(status_code=404, detail="Missing asset path")
root = (HLS_CACHE_DIR / hls_id).resolve()
candidate = (root / asset_path).resolve()
try:
candidate.relative_to(root)
except ValueError as exc:
raise HTTPException(status_code=400, detail="Invalid asset path") from exc
if not candidate.is_file():
raise HTTPException(status_code=404, detail="Asset not found")
# Restrict to typical HLS outputs.
if candidate.suffix.lower() not in {".m3u8", ".ts"}:
raise HTTPException(status_code=415, detail="Unsupported asset type")
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.
# Configure with env: CORS_ORIGINS="http://localhost:5000,http://127.0.0.1:5000"
cors_origins_env = os.getenv("CORS_ORIGINS", "*").strip()
if cors_origins_env == "*":
allow_origins: Iterable[str] = ["*"]
else:
allow_origins = [o.strip() for o in cors_origins_env.split(",") if o.strip()]
app.add_middleware(
CORSMiddleware,
allow_origins=list(allow_origins),
allow_credentials=False,
allow_methods=["GET", "HEAD", "OPTIONS"],
allow_headers=["*"],
)
@app.get("/health")
def health() -> dict:
return {"ok": True, "app": APP_NAME}
@app.get("/api/media")
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("*")):
if not p.is_file():
continue
if p.suffix.lower() not in ALLOWED_EXTS:
continue
rel = p.relative_to(MEDIA_DIR).as_posix()
items.append(
{
"name": rel,
"size": p.stat().st_size,
"url": f"/media/{rel}",
}
)
return JSONResponse({"items": items})
@app.get("/api/hls")
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("/")
playlist_path = f"/hls/{hls_id}/index.m3u8"
return JSONResponse(
{
"hls_id": hls_id,
"playlist_url": playlist_path,
"playlist_abs_url": f"{base}{playlist_path}",
}
)
@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)
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",
}
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,
headers=headers,
media_type=ctype,
)
@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)
# Support byte ranges for HTML5 video seeking.
range_header = request.headers.get("range", "")
try:
parsed = _parse_range(range_header, size) if range_header else None
except HTTPException as exc:
# Required by RFC: include */size
headers = {"Content-Range": f"bytes */{size}", "Accept-Ranges": "bytes"}
return Response(status_code=exc.status_code, content=exc.detail, headers=headers)
common_headers = {
"Accept-Ranges": "bytes",
"Content-Type": ctype,
# Helps some players / proxies behave better with range requests.
"Cache-Control": "no-store",
}
if parsed is None:
if request.method == "HEAD":
return Response(status_code=200, headers={**common_headers, "Content-Length": str(size)})
return StreamingResponse(
_iter_file(path, 0, size - 1),
status_code=200,
headers={**common_headers, "Content-Length": str(size)},
media_type=ctype,
)
start, end = parsed
length = end - start + 1
headers = {
**common_headers,
"Content-Length": str(length),
"Content-Range": f"bytes {start}-{end}/{size}",
}
if request.method == "HEAD":
return Response(status_code=206, headers=headers)
return StreamingResponse(
_iter_file(path, start, end),
status_code=206,
headers=headers,
media_type=ctype,
)
if __name__ == "__main__":
import uvicorn
host = os.getenv("VIDEO_SERVER_HOST", "0.0.0.0")
port = int(os.getenv("VIDEO_SERVER_PORT", "8001"))
reload = os.getenv("VIDEO_SERVER_RELOAD", "1").strip() not in {"0", "false", "False", "no", "NO"}
workers = int(os.getenv("VIDEO_SERVER_WORKERS", "1").strip() or "1")
# Uvicorn ignores/limits workers when reload=True.
if reload:
workers = 1
uvicorn.run("video_server:app", host=host, port=port, reload=reload, workers=workers)