From 20aba50fa1ab35e11520e4322f80c9955f688348 Mon Sep 17 00:00:00 2001 From: emergent-agent-e1 Date: Wed, 29 Apr 2026 16:21:32 +0000 Subject: [PATCH] auto-commit for cf4f0369-14b7-4d06-b915-b8f7098fd48d --- backend/server.py | 47 +++++++++--- backend/transcode.py | 138 ++++++++++++++++++++++++++++------- frontend/src/lib/api.js | 2 +- frontend/src/pages/Admin.jsx | 31 ++++++-- memory/PRD.md | 8 ++ 5 files changed, 180 insertions(+), 46 deletions(-) diff --git a/backend/server.py b/backend/server.py index b252819..0b21c76 100644 --- a/backend/server.py +++ b/backend/server.py @@ -28,7 +28,7 @@ from auth import hash_password, verify_password, create_token, decode_token from seed import SAMPLE_MOVIES import tmdb as tmdb_client import radarr as radarr_client -from transcode import transcode_to_hls, srt_to_vtt +from transcode import transcode_quick, transcode_abr, srt_to_vtt ROOT_DIR = Path(__file__).parent @@ -469,28 +469,40 @@ async def _set_hls_status(movie_id: str, status: str, path: Optional[str] = None logger.warning(f"HLS {movie_id}: {error}") -async def _run_transcode(movie_id: str, source: Path): +async def _run_transcode(movie_id: str, source: Path, quality: str): out_dir = HLS_DIR / movie_id - async def cb(status, error=None): - if status == "done": - await _set_hls_status(movie_id, "done", path=f"{movie_id}/playlist.m3u8") + # Clear any prior output before re-encoding + import shutil as _sh + _sh.rmtree(out_dir, ignore_errors=True) + + async def cb(status, entry: Optional[str] = None, error: Optional[str] = None): + if status == "done" and entry: + await _set_hls_status(movie_id, "done", path=entry) else: await _set_hls_status(movie_id, status, error=error) - await transcode_to_hls(source, out_dir, cb) + + if quality == "abr": + await transcode_abr(source, out_dir, cb) + else: + await transcode_quick(source, out_dir, cb) @api.post("/movies/{movie_id}/transcode") -async def trigger_transcode(movie_id: str, user: dict = Depends(require_admin)): +async def trigger_transcode(movie_id: str, payload: Optional[dict] = None, user: dict = Depends(require_admin)): + """Body: {"quality": "quick"|"abr"} — default "quick".""" + quality = (payload or {}).get("quality", "quick") + if quality not in ("quick", "abr"): + raise HTTPException(status_code=400, detail="quality must be 'quick' or 'abr'") movie = await db.movies.find_one({"id": movie_id}, {"_id": 0}) if not movie: raise HTTPException(status_code=404, detail="Movie not found") if movie.get("storage_type") not in ("local", "radarr") or not movie.get("storage_path"): raise HTTPException(status_code=400, detail="Only local/radarr movies can be transcoded") - if movie.get("hls_status") == "running": + if movie.get("hls_status") in ("running", "pending"): raise HTTPException(status_code=409, detail="Already transcoding") source = Path(movie["storage_path"]) if movie["storage_type"] == "radarr" else VIDEOS_DIR / movie["storage_path"] await _set_hls_status(movie_id, "pending") - asyncio.create_task(_run_transcode(movie_id, source)) - return {"ok": True, "status": "pending"} + asyncio.create_task(_run_transcode(movie_id, source, quality)) + return {"ok": True, "status": "pending", "quality": quality} @api.get("/movies/{movie_id}/hls/{filename:path}") @@ -513,7 +525,20 @@ async def serve_hls( raise HTTPException(status_code=400, detail="Invalid path") if not target.is_file(): raise HTTPException(status_code=404, detail="HLS file not found") - media_type = "application/vnd.apple.mpegurl" if filename.endswith(".m3u8") else "video/mp2t" + + # Rewrite playlists to propagate ?auth= to relative URLs (variants + segments) + if filename.endswith(".m3u8"): + text = target.read_text(encoding="utf-8", errors="ignore") + out_lines = [] + for line in text.splitlines(): + stripped = line.strip() + if stripped and not stripped.startswith("#") and "://" not in stripped: + sep = "&" if "?" in stripped else "?" + line = f"{stripped}{sep}auth={token}" + out_lines.append(line) + return Response("\n".join(out_lines) + "\n", media_type="application/vnd.apple.mpegurl") + + media_type = "video/mp2t" if filename.endswith(".ts") else "application/octet-stream" return FileResponse(str(target), media_type=media_type) diff --git a/backend/transcode.py b/backend/transcode.py index dae33a6..4d4e91f 100644 --- a/backend/transcode.py +++ b/backend/transcode.py @@ -1,44 +1,133 @@ -"""HLS transcoding via ffmpeg. Background-runs and updates DB.""" +"""HLS transcoding via ffmpeg. + +Two modes: + - quick (stream-copy): instant, single bitrate, no quality loss. Source must be H.264/AAC. + - abr (adaptive): re-encode to multiple bitrates with master playlist for ABR streaming. +""" import asyncio +import json import logging import shutil from pathlib import Path -from typing import Optional +from typing import Optional, List, Tuple, Callable, Awaitable logger = logging.getLogger("kino.transcode") -async def transcode_to_hls( - source: Path, - out_dir: Path, - on_status, -): - """ - Run ffmpeg to produce HLS playlist + segments. - `on_status(status, error=None)` is awaited at start/end with status one of - 'running'|'done'|'failed'. - Uses stream-copy where possible (fast, no re-encode). - """ +async def probe_video(source: Path) -> Tuple[int, int]: + """Return (width, height) using ffprobe; (0, 0) on failure.""" + try: + proc = await asyncio.create_subprocess_exec( + "ffprobe", "-v", "error", "-select_streams", "v:0", + "-show_entries", "stream=width,height", "-of", "json", str(source), + stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, + ) + stdout, _ = await proc.communicate() + if proc.returncode != 0: + return (0, 0) + data = json.loads(stdout.decode("utf-8", errors="ignore") or "{}") + s = (data.get("streams") or [{}])[0] + return (int(s.get("width") or 0), int(s.get("height") or 0)) + except Exception: + return (0, 0) + + +# (height, video_bitrate, max_bitrate, buffer, audio_bitrate) +ALL_VARIANTS = [ + (1080, "5000k", "5500k", "7500k", "192k"), + (720, "2800k", "3000k", "4200k", "128k"), + (480, "1400k", "1500k", "2100k", "96k"), + (360, "800k", "856k", "1200k", "64k"), +] + + +def variants_for_source(src_height: int) -> List[Tuple[int, str, str, str, str]]: + """Pick variants ≤ source height. Always include at least one (smallest).""" + if src_height <= 0: + return [ALL_VARIANTS[2]] # safe default 480p + chosen = [v for v in ALL_VARIANTS if v[0] <= src_height] + if not chosen: + chosen = [ALL_VARIANTS[-1]] + return chosen + + +async def transcode_quick(source: Path, out_dir: Path, on_status: Callable[..., Awaitable]) -> None: + """Stream-copy to single-rate HLS. Output filename: playlist.m3u8.""" if not source.is_file(): await on_status("failed", error=f"Source missing: {source}") return out_dir.mkdir(parents=True, exist_ok=True) - playlist = out_dir / "playlist.m3u8" - cmd = [ - "ffmpeg", "-y", - "-i", str(source), - "-c:v", "copy", - "-c:a", "copy", + "ffmpeg", "-y", "-i", str(source), + "-c:v", "copy", "-c:a", "copy", "-bsf:v", "h264_mp4toannexb", + "-f", "hls", "-hls_time", "6", + "-hls_list_size", "0", "-hls_playlist_type", "vod", + "-hls_segment_filename", str(out_dir / "seg_%04d.ts"), + str(out_dir / "playlist.m3u8"), + ] + await _run(cmd, on_status, out_dir, "playlist.m3u8") + + +async def transcode_abr(source: Path, out_dir: Path, on_status: Callable[..., Awaitable]) -> None: + """Multi-bitrate ABR HLS. Output entry filename: master.m3u8.""" + if not source.is_file(): + await on_status("failed", error=f"Source missing: {source}") + return + out_dir.mkdir(parents=True, exist_ok=True) + + _, height = await probe_video(source) + variants = variants_for_source(height) + n = len(variants) + + # Build filter graph + splits = "".join(f"[v{i}]" for i in range(n)) + fc_parts = [f"[0:v]split={n}{splits}"] + for i, (h, *_rest) in enumerate(variants): + fc_parts.append(f"[v{i}]scale=w=-2:h={h}[v{i}out]") + filter_complex = ";".join(fc_parts) + + cmd: List[str] = ["ffmpeg", "-y", "-i", str(source), "-filter_complex", filter_complex] + + for i, (_h, vb, maxr, buf, _ab) in enumerate(variants): + cmd += [ + "-map", f"[v{i}out]", + f"-c:v:{i}", "libx264", + f"-preset:v:{i}", "veryfast", + f"-profile:v:{i}", "main", + f"-pix_fmt:v:{i}", "yuv420p", + f"-b:v:{i}", vb, + f"-maxrate:v:{i}", maxr, + f"-bufsize:v:{i}", buf, + f"-g", "48", f"-keyint_min", "48", f"-sc_threshold", "0", + ] + + # Audio: same source mapped N times, one per variant + for i in range(n): + cmd += ["-map", "a:0?"] + for i, (*_v, ab) in enumerate(variants): + cmd += [f"-c:a:{i}", "aac", f"-b:a:{i}", ab, f"-ac:a:{i}", "2"] + + var_stream_map = " ".join(f"v:{i},a:{i}" for i in range(n)) + cmd += [ "-f", "hls", "-hls_time", "6", - "-hls_list_size", "0", "-hls_playlist_type", "vod", - "-hls_segment_filename", str(out_dir / "seg_%04d.ts"), - str(playlist), + "-hls_flags", "independent_segments", + "-hls_segment_filename", str(out_dir / "v%v" / "seg_%04d.ts"), + "-master_pl_name", "master.m3u8", + "-var_stream_map", var_stream_map, + str(out_dir / "v%v" / "playlist.m3u8"), ] + # Pre-create variant subdirs (some ffmpeg builds need them) + for i in range(n): + (out_dir / f"v{i}").mkdir(exist_ok=True) + + await _run(cmd, on_status, out_dir, "master.m3u8") + + +async def _run(cmd: List[str], on_status, out_dir: Path, entry_filename: str) -> None: await on_status("running") try: proc = await asyncio.create_subprocess_exec( @@ -49,10 +138,9 @@ async def transcode_to_hls( err = stderr.decode("utf-8", errors="ignore")[-500:] logger.error(f"ffmpeg failed: {err}") await on_status("failed", error=err[:200]) - # cleanup partial files shutil.rmtree(out_dir, ignore_errors=True) return - await on_status("done") + await on_status("done", entry=entry_filename) except FileNotFoundError: await on_status("failed", error="ffmpeg not installed") except Exception as e: @@ -62,11 +150,9 @@ async def transcode_to_hls( def srt_to_vtt(srt_text: str) -> str: - """Convert SRT subtitles to WebVTT format (simple, no styling).""" lines = srt_text.replace("\r\n", "\n").split("\n") out = ["WEBVTT", ""] for line in lines: - # Replace SRT timestamp commas with VTT dots if "-->" in line: out.append(line.replace(",", ".")) else: diff --git a/frontend/src/lib/api.js b/frontend/src/lib/api.js index 90eba7c..27523a9 100644 --- a/frontend/src/lib/api.js +++ b/frontend/src/lib/api.js @@ -23,7 +23,7 @@ export const getStreamUrl = (movie) => { if (!movie) return ""; const token = localStorage.getItem("kino_token") || ""; if (movie.hls_status === "done" && movie.hls_path) { - return `${API}/movies/${movie.id}/hls/playlist.m3u8?auth=${encodeURIComponent(token)}`; + return `${API}/movies/${movie.id}/hls/${movie.hls_path}?auth=${encodeURIComponent(token)}`; } if (movie.storage_type === "local" || movie.storage_type === "radarr") { return `${API}/stream/${movie.id}?auth=${encodeURIComponent(token)}`; diff --git a/frontend/src/pages/Admin.jsx b/frontend/src/pages/Admin.jsx index 9ecc1aa..23d5447 100644 --- a/frontend/src/pages/Admin.jsx +++ b/frontend/src/pages/Admin.jsx @@ -35,11 +35,11 @@ export default function Admin() { load(); }; - const startTranscode = async (m) => { + const startTranscode = async (m, quality = "quick") => { setTranscoding({ ...transcoding, [m.id]: true }); try { - await api.post(`/movies/${m.id}/transcode`); - toast.success("Transcoding started — refresh in a few minutes"); + await api.post(`/movies/${m.id}/transcode`, { quality }); + toast.success(`${quality === "abr" ? "ABR" : "Quick"} transcode started`); load(); } catch (err) { toast.error(err.response?.data?.detail || "Could not start transcode"); @@ -48,6 +48,13 @@ export default function Admin() { } }; + const hlsLabel = (m) => { + if (m.hls_status === "done") { + return m.hls_path === "master.m3u8" ? "ABR" : "Quick"; + } + return null; + }; + const hlsStatusBadge = (m) => { if (!m.hls_status) return null; const colors = { @@ -56,7 +63,8 @@ export default function Admin() { done: "text-[#86efac]", failed: "text-[#fca5a5]", }; - return HLS {m.hls_status}; + const variant = m.hls_status === "done" ? hlsLabel(m) : ""; + return HLS {m.hls_status}{variant ? ` · ${variant}` : ""}; }; return ( @@ -101,10 +109,17 @@ export default function Admin() { {m.storage_type} {hlsStatusBadge(m)}
- {(m.storage_type === "local" || m.storage_type === "radarr") && m.hls_status !== "done" && m.hls_status !== "running" && ( - + {(m.storage_type === "local" || m.storage_type === "radarr") && m.hls_status !== "running" && m.hls_status !== "pending" && ( +
+ + +
)}