feat: Add adaptive bitrate HLS and concurrency controls

Co-authored-by: mathieumoran1 <[email protected]>
This commit is contained in:
Cursor Agent
2025-12-21 01:10:37 +00:00
co-authored by mathieumoran1
parent c1f6b139e7
commit 65cc7ba4d7
3 changed files with 175 additions and 21 deletions
+24
View File
@@ -47,6 +47,30 @@ 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
Binary file not shown.
+145 -15
View File
@@ -7,7 +7,8 @@ import time
import hashlib
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
from pathlib import Path
from typing import Generator, Iterable, Optional, Tuple
from typing import Any, Dict, Generator, Iterable, List, Optional, Tuple
import threading
import jwt
from fastapi import FastAPI, HTTPException, Query, Request, Response
@@ -35,6 +36,10 @@ 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:
"""
@@ -170,6 +175,63 @@ def _hls_id_for_media(path: Path) -> str:
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.
@@ -221,12 +283,45 @@ def _ensure_hls_for_media(media_path: Path) -> str:
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()
audio_bitrate = os.getenv("HLS_AUDIO_BITRATE", "160k").strip()
cmd = [
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",
@@ -234,20 +329,43 @@ def _ensure_hls_for_media(media_path: Path) -> str:
"-y",
"-i",
str(media_path),
"-map",
"0:v:0",
"-map",
"0:a?",
"-c:v",
"-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,
"-c:a",
"aac",
"-b:a",
audio_bitrate,
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",
@@ -257,11 +375,19 @@ def _ensure_hls_for_media(media_path: Path) -> str:
"-hls_flags",
"independent_segments",
"-hls_segment_filename",
str(tmp_dir / "seg_%05d.ts"),
str(tmp_dir / "index.m3u8"),
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
@@ -497,5 +623,9 @@ if __name__ == "__main__":
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"}
uvicorn.run("video_server:app", host=host, port=port, reload=reload)
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)