Video file serving app #17
@@ -47,6 +47,30 @@ Example:
|
|||||||
- Source: `http://localhost:8001/media/MyMovie.mkv`
|
- 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`.
|
- 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
|
### Install
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
Binary file not shown.
+145
-15
@@ -7,7 +7,8 @@ import time
|
|||||||
import hashlib
|
import hashlib
|
||||||
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
|
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
|
||||||
from pathlib import Path
|
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
|
import jwt
|
||||||
from fastapi import FastAPI, HTTPException, Query, Request, Response
|
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"}
|
AUTH_REQUIRED = os.getenv("VIDEO_AUTH_REQUIRED", "1").strip() not in {"0", "false", "False", "no", "NO"}
|
||||||
JWT_SECRET = os.getenv("JWT_SECRET")
|
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:
|
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()
|
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:
|
def _acquire_lock(lock_path: Path, timeout_s: int = 600) -> None:
|
||||||
"""
|
"""
|
||||||
Simple file lock to avoid concurrent transcodes for the same id.
|
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)
|
shutil.rmtree(tmp_dir, ignore_errors=True)
|
||||||
tmp_dir.mkdir(parents=True, exist_ok=True)
|
tmp_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Multi-rendition adaptive HLS (master + variants).
|
||||||
hls_time = os.getenv("HLS_TIME", "6").strip()
|
hls_time = os.getenv("HLS_TIME", "6").strip()
|
||||||
crf = os.getenv("HLS_CRF", "23").strip()
|
crf = os.getenv("HLS_CRF", "23").strip()
|
||||||
preset = os.getenv("HLS_PRESET", "veryfast").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",
|
"ffmpeg",
|
||||||
"-hide_banner",
|
"-hide_banner",
|
||||||
"-loglevel",
|
"-loglevel",
|
||||||
@@ -234,20 +329,43 @@ def _ensure_hls_for_media(media_path: Path) -> str:
|
|||||||
"-y",
|
"-y",
|
||||||
"-i",
|
"-i",
|
||||||
str(media_path),
|
str(media_path),
|
||||||
"-map",
|
"-filter_complex",
|
||||||
"0:v:0",
|
filter_complex,
|
||||||
"-map",
|
]
|
||||||
"0:a?",
|
|
||||||
"-c:v",
|
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",
|
"libx264",
|
||||||
"-preset",
|
"-preset",
|
||||||
preset,
|
preset,
|
||||||
"-crf",
|
"-crf",
|
||||||
crf,
|
crf,
|
||||||
"-c:a",
|
f"-b:v:{i}",
|
||||||
"aac",
|
f"{b_kbps}k",
|
||||||
"-b:a",
|
f"-maxrate:v:{i}",
|
||||||
audio_bitrate,
|
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",
|
"-f",
|
||||||
"hls",
|
"hls",
|
||||||
"-hls_time",
|
"-hls_time",
|
||||||
@@ -257,11 +375,19 @@ def _ensure_hls_for_media(media_path: Path) -> str:
|
|||||||
"-hls_flags",
|
"-hls_flags",
|
||||||
"independent_segments",
|
"independent_segments",
|
||||||
"-hls_segment_filename",
|
"-hls_segment_filename",
|
||||||
str(tmp_dir / "seg_%05d.ts"),
|
str(tmp_dir / "seg_%v_%05d.ts"),
|
||||||
str(tmp_dir / "index.m3u8"),
|
"-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:
|
try:
|
||||||
|
with _HLS_JOB_SEM:
|
||||||
subprocess.run(cmd, check=True, capture_output=True)
|
subprocess.run(cmd, check=True, capture_output=True)
|
||||||
except FileNotFoundError as exc:
|
except FileNotFoundError as exc:
|
||||||
raise HTTPException(status_code=500, detail="ffmpeg not installed") from 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")
|
host = os.getenv("VIDEO_SERVER_HOST", "0.0.0.0")
|
||||||
port = int(os.getenv("VIDEO_SERVER_PORT", "8001"))
|
port = int(os.getenv("VIDEO_SERVER_PORT", "8001"))
|
||||||
reload = os.getenv("VIDEO_SERVER_RELOAD", "1").strip() not in {"0", "false", "False", "no", "NO"}
|
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)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user