From 2935ba73a60fe893ac90b63468291d0fdb3d4313 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 21 Dec 2025 01:03:39 +0000 Subject: [PATCH] feat: Add HLS support for MKV playback Add HLS generation and serving for MKV files to improve browser compatibility. Co-authored-by: mathieumoran1 --- .gitignore | 5 +- README.md | 11 ++ __pycache__/video_server.cpython-312.pyc | Bin 8803 -> 17747 bytes public/js/app_player.js | 53 +++++- video_server.py | 203 ++++++++++++++++++++++- 5 files changed, 267 insertions(+), 5 deletions(-) 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 95df0ed27bb2741199b30dff498661821f698f19..d24902ed056edb6a80db581f4e7ab6f34e00788f 100644 GIT binary patch literal 17747 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 CW~C?q delta 2168 zcmYjS&2JM|5PxsIUfc0U948@8NE#;rVnb}_JJ1FwBz#pxNKpE*Eo>LRXJ?&Vulsfr z5(}E5f>f$XDD0u9URojAChf(4LVKu$#3k)z+lm9KDsFHrr_Ni)BrEUu&CGA+&71k{ z?$($1>3r-_G}?mT`uX%3?T_yDSW*}i*V6-R9b;7;JLrZj!V1kQ257|Px zZu{jSs3A-{5}V0a1?&S8F2BXgiguHp4WV(2@_Bx?v^-3QNzY;$Ar>J$B`l|DfcEi> zG(+~z!hhu$9WGzy;eEZ$r1R(=DhMUGuM8n{6Wu_KbU_P$)zMTO*%zKm(|zUl8awWm zG!9jb&~!5)=_?`f+P9dOu-U)iZ(uQk=nwhU1F2WYD9sfGGH_em*iI>452 zW)67@?5Q2!-W#MLW_~4Ws5UdLMYWbxt5sbqsJ3QS@@B!NcGj{PRgH|Fr67b2I0qyB zVn$?X=X|8M{{YzBcBQmLElVkw6`NLU#a^vaHVU3x{fo#)KD@{Ir}e5d&ci31bMe`v z7Zp*eDzm7<)Jlo6dGOBEzlk5f;V}+8$;Dj#aoZTiQ}=;+N5SCwbXv&-7?8Dz;Hy*4 zk@o$2yreCPW>cmVHJ!2x;8PsC{h{+i?livSJkQ-tdbvHMv_MteUSJ;s>Qm&b=R5IO z{bBwQUdsq>07`?CSZD8Mp4R^(`0b$yKLmJgIvD#aQn*Lk5{d8`VK&ymwNUKja+om+>yGwd>~*t;My z*bNxeUNDK}hIpr75cYx_VDu9hqg{V+_^E(roV#z#taSn04Q$WV4M58XWmYX%F@Y8) zqfpTbKx&b>#^|%}!02nyIkm7rv)sl^-P`loZvcB4Yr@{9m6E-X5g6Zq)wtkqB{l^D zayG4N$ZhdXgq15b`oTuHImQJiPi8I37*{MVJks%bGyXMToX)P~QJAp(+`^CH#tNok zm=$HaRoguFu)tk7w8bY0Q`@;3^9x&)Ez|7*-T}D^t#Bu1dRqR^VNk6msdcz610|9mf7oEi5E;}DJIiOTE$Yh z`I58s=J>#8+$j>RV0%xTM^QHfl`G6#f=Bj_GkT)y{dGXG`&>92?_*09U@JB){yiS` z8uc 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)