auto-commit for cf4f0369-14b7-4d06-b915-b8f7098fd48d

This commit is contained in:
emergent-agent-e1
2026-04-29 16:21:32 +00:00
parent 00b0d5426c
commit 20aba50fa1
5 changed files with 180 additions and 46 deletions
+36 -11
View File
@@ -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)
+112 -26
View File
@@ -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: