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 267 additions and 5 deletions
Showing only changes of commit 2935ba73a6 - Show all commits
+3
View File
@@ -141,3 +141,6 @@ 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/
+11
View File
@@ -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>`
- 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
```bash
Binary file not shown.
+48 -1
View File
@@ -90,6 +90,36 @@ function looksLikeVideoFile(url) {
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) {
try {
const u = new URL(url);
@@ -210,14 +240,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.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.
(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;
+202 -1
View File
@@ -1,16 +1,21 @@
import mimetypes
import os
import re
import shutil
import subprocess
import time
import hashlib
from pathlib import Path
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.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()
# Allow common video types (what browsers can play is separate from what we serve).
ALLOWED_EXTS = {".mp4", ".mkv"}
@@ -104,6 +109,159 @@ def _iter_file(path: Path, start: int, end: int, chunk_size: int = 1024 * 1024)
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)
# 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})
@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"])
async def serve_media(file_path: str, request: Request) -> Response:
path = _safe_resolve_media_path(file_path)