diff --git a/.gitignore b/.gitignore index d6b7901..7647573 100644 --- a/.gitignore +++ b/.gitignore @@ -140,4 +140,7 @@ ayylmao.js # Local media files (served by python video_server.py) media/* -!media/.gitkeep \ No newline at end of file +!media/.gitkeep + +# HLS cache (generated by video_server.py via ffmpeg) +.hls_cache/ \ No newline at end of file diff --git a/README.md b/README.md index 341d83a..e90f967 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,17 @@ This repo also includes a small Python server that serves local video files with - Served as: `http://localhost:8001/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=` +- Playlist served from: `http://localhost:8001/hls//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 diff --git a/__pycache__/video_server.cpython-312.pyc b/__pycache__/video_server.cpython-312.pyc index 95df0ed..d24902e 100644 Binary files a/__pycache__/video_server.cpython-312.pyc and b/__pycache__/video_server.cpython-312.pyc differ diff --git a/public/js/app_player.js b/public/js/app_player.js index 8c87c09..cd89673 100644 --- a/public/js/app_player.js +++ b/public/js/app_player.js @@ -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/ 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. - 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; diff --git a/video_server.py b/video_server.py index 53b6aca..99bcaf0 100644 --- a/video_server.py +++ b/video_server.py @@ -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//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)