feat: Add HLS support for MKV playback
Add HLS generation and serving for MKV files to improve browser compatibility. Co-authored-by: mathieumoran1 <[email protected]>
This commit is contained in:
co-authored by
mathieumoran1
parent
917cd32e77
commit
2935ba73a6
+4
-1
@@ -140,4 +140,7 @@ ayylmao.js
|
|||||||
|
|
||||||
# Local media files (served by python video_server.py)
|
# Local media files (served by python video_server.py)
|
||||||
media/*
|
media/*
|
||||||
!media/.gitkeep
|
!media/.gitkeep
|
||||||
|
|
||||||
|
# HLS cache (generated by video_server.py via ffmpeg)
|
||||||
|
.hls_cache/
|
||||||
@@ -22,6 +22,17 @@ This repo also includes a small Python server that serves local video files with
|
|||||||
- Served as: `http://localhost:8001/media/<filename>`
|
- Served as: `http://localhost:8001/media/<filename>`
|
||||||
- List endpoint: `http://localhost:8001/api/media`
|
- List endpoint: `http://localhost:8001/api/media`
|
||||||
|
|
||||||
|
### 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`.
|
||||||
|
|
||||||
### Install
|
### Install
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
Binary file not shown.
+50
-3
@@ -90,6 +90,36 @@ function looksLikeVideoFile(url) {
|
|||||||
return /\.(mp4|mkv|webm|ogg|ogv|mov|m4v)(\?|#|$)/.test(s);
|
return /\.(mp4|mkv|webm|ogg|ogv|mov|m4v)(\?|#|$)/.test(s);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 res = await fetch(api.toString(), { method: 'GET' });
|
||||||
|
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();
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function toYoutubeEmbed(url) {
|
function toYoutubeEmbed(url) {
|
||||||
try {
|
try {
|
||||||
const u = new URL(url);
|
const u = new URL(url);
|
||||||
@@ -210,14 +240,31 @@ async function playVodLink(vodLink) {
|
|||||||
|
|
||||||
// Direct video file
|
// Direct video file
|
||||||
if (looksLikeVideoFile(raw)) {
|
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');
|
setSourceLabel('Direct video file');
|
||||||
const videoEl = showVideoPlayer();
|
const videoEl = showVideoPlayer();
|
||||||
if (!videoEl) return;
|
if (!videoEl) return;
|
||||||
videoEl.src = raw;
|
videoEl.src = raw;
|
||||||
videoEl.addEventListener('error', () => {
|
videoEl.addEventListener('error', () => {
|
||||||
// If the browser can't play it (or CORS blocks), fall back to iframe.
|
// If the browser can't play it (or CORS blocks), try HLS from the python server, then fall back to iframe.
|
||||||
setSourceLabel('Embed (fallback)');
|
(async () => {
|
||||||
showEmbedPlayer(raw);
|
const playlist = await tryGetGeneratedHlsPlaylist(raw);
|
||||||
|
if (playlist) {
|
||||||
|
setSourceLabel('HLS (generated)');
|
||||||
|
await playVodLink(playlist);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSourceLabel('Embed (fallback)');
|
||||||
|
showEmbedPlayer(raw);
|
||||||
|
})();
|
||||||
}, { once: true });
|
}, { once: true });
|
||||||
try { videoEl.load(); } catch (_) {}
|
try { videoEl.load(); } catch (_) {}
|
||||||
return;
|
return;
|
||||||
|
|||||||
+202
-1
@@ -1,16 +1,21 @@
|
|||||||
import mimetypes
|
import mimetypes
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
import hashlib
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Generator, Iterable, Optional, Tuple
|
from typing import Generator, Iterable, Optional, Tuple
|
||||||
|
|
||||||
from fastapi import FastAPI, HTTPException, Request, Response
|
from fastapi import FastAPI, HTTPException, Query, Request, Response
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.responses import JSONResponse, StreamingResponse
|
from fastapi.responses import JSONResponse, StreamingResponse
|
||||||
|
|
||||||
APP_NAME = "video-file-server"
|
APP_NAME = "video-file-server"
|
||||||
BASE_DIR = Path(__file__).resolve().parent
|
BASE_DIR = Path(__file__).resolve().parent
|
||||||
MEDIA_DIR = BASE_DIR / "media"
|
MEDIA_DIR = BASE_DIR / "media"
|
||||||
|
HLS_CACHE_DIR = Path(os.getenv("HLS_CACHE_DIR", str(BASE_DIR / ".hls_cache"))).resolve()
|
||||||
|
|
||||||
# Allow common video types (what browsers can play is separate from what we serve).
|
# Allow common video types (what browsers can play is separate from what we serve).
|
||||||
ALLOWED_EXTS = {".mp4", ".mkv"}
|
ALLOWED_EXTS = {".mp4", ".mkv"}
|
||||||
@@ -104,6 +109,159 @@ def _iter_file(path: Path, start: int, end: int, chunk_size: int = 1024 * 1024)
|
|||||||
yield 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 _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)
|
||||||
|
|
||||||
|
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 = [
|
||||||
|
"ffmpeg",
|
||||||
|
"-hide_banner",
|
||||||
|
"-loglevel",
|
||||||
|
"error",
|
||||||
|
"-y",
|
||||||
|
"-i",
|
||||||
|
str(media_path),
|
||||||
|
"-map",
|
||||||
|
"0:v:0",
|
||||||
|
"-map",
|
||||||
|
"0:a?",
|
||||||
|
"-c:v",
|
||||||
|
"libx264",
|
||||||
|
"-preset",
|
||||||
|
preset,
|
||||||
|
"-crf",
|
||||||
|
crf,
|
||||||
|
"-c:a",
|
||||||
|
"aac",
|
||||||
|
"-b:a",
|
||||||
|
audio_bitrate,
|
||||||
|
"-f",
|
||||||
|
"hls",
|
||||||
|
"-hls_time",
|
||||||
|
hls_time,
|
||||||
|
"-hls_playlist_type",
|
||||||
|
"vod",
|
||||||
|
"-hls_flags",
|
||||||
|
"independent_segments",
|
||||||
|
"-hls_segment_filename",
|
||||||
|
str(tmp_dir / "seg_%05d.ts"),
|
||||||
|
str(tmp_dir / "index.m3u8"),
|
||||||
|
]
|
||||||
|
|
||||||
|
try:
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(title=APP_NAME)
|
app = FastAPI(title=APP_NAME)
|
||||||
|
|
||||||
# Default to permissive CORS because the player may be on another port/host.
|
# Default to permissive CORS because the player may be on another port/host.
|
||||||
@@ -148,6 +306,49 @@ def list_media() -> JSONResponse:
|
|||||||
return JSONResponse({"items": items})
|
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
|
||||||
|
"""
|
||||||
|
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:
|
||||||
|
path = _safe_resolve_hls_asset(hls_id, asset_path)
|
||||||
|
size = path.stat().st_size
|
||||||
|
ctype = _hls_content_type(path)
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"Content-Type": ctype,
|
||||||
|
"Content-Length": str(size),
|
||||||
|
"Cache-Control": "no-store",
|
||||||
|
}
|
||||||
|
|
||||||
|
if request.method == "HEAD":
|
||||||
|
return Response(status_code=200, headers=headers)
|
||||||
|
|
||||||
|
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"])
|
@app.api_route("/media/{file_path:path}", methods=["GET", "HEAD"])
|
||||||
async def serve_media(file_path: str, request: Request) -> Response:
|
async def serve_media(file_path: str, request: Request) -> Response:
|
||||||
path = _safe_resolve_media_path(file_path)
|
path = _safe_resolve_media_path(file_path)
|
||||||
|
|||||||
Reference in New Issue
Block a user