Files
kino-app/backend/transcode.py
T
root b28a49e8fa Codec-aware transcode routing, two-stage queue, request candidate picker, admin reorg
- Requests approval now shows a Radarr/Sonarr candidate picker instead of grabbing the first search result (fixes wrong-title/wrong-year matches)
- approve_request reuses an existing Radarr/Sonarr entry instead of erroring when content is already in the library
- New codec-check queue (step 1) probes source codecs and skips transcoding entirely for already browser-native files; only non-native content reaches the transcode queue (step 2)
- Fixes ffmpeg hard-failing on non-H.264 sources via the h264_mp4toannexb bitstream filter (root cause of the fix-audio-all failures)
- Added Codec Check Worker / Transcode Worker to the services health panel with restart
- Reorganized the admin page into Pipeline/Utilities dropdowns; added the missing Sonarr Import link
- Bumped VERSION 1.0.0 -> 1.1.0
2026-07-30 20:41:11 -05:00

303 lines
12 KiB
Python

"""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, List, Tuple, Callable, Awaitable
logger = logging.getLogger("kino.transcode")
# Moderate priority (was 19, the absolute lowest) and a thread cap so an ABR encode can't
# starve the rest of the stack (Mongo, *arr apps, the API itself) on this 8-core host.
TRANSCODE_NICE = 10
TRANSCODE_THREADS = 6
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)
async def probe_duration(source: Path) -> float:
"""Return source duration in seconds using ffprobe; 0 on failure."""
try:
proc = await asyncio.create_subprocess_exec(
"ffprobe", "-v", "error", "-show_entries", "format=duration",
"-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 "{}")
return float((data.get("format") or {}).get("duration") or 0.0)
except Exception:
return 0.0
async def probe_codecs(source: Path) -> Tuple[str, str]:
"""Return (video_codec, audio_codec), lowercased, via ffprobe; ('', '') on failure."""
try:
proc = await asyncio.create_subprocess_exec(
"ffprobe", "-v", "error", "-show_entries", "stream=codec_type,codec_name",
"-of", "json", str(source),
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
)
stdout, _ = await proc.communicate()
if proc.returncode != 0:
return ("", "")
data = json.loads(stdout.decode("utf-8", errors="ignore") or "{}")
video_codec = audio_codec = ""
for s in data.get("streams") or []:
if s.get("codec_type") == "video" and not video_codec:
video_codec = (s.get("codec_name") or "").lower()
elif s.get("codec_type") == "audio" and not audio_codec:
audio_codec = (s.get("codec_name") or "").lower()
return (video_codec, audio_codec)
except Exception:
return ("", "")
BROWSER_NATIVE_AUDIO = {"aac", "mp3"}
async def classify_source(source: Path) -> str:
"""Decide the cheapest correct transcode path for a source file:
- 'native': H.264 video + AAC/MP3 audio — browsers already play this file as-is (the
raw-file stream endpoint serves it directly), so no transcode is needed at all.
- 'audio': H.264 video but non-browser audio (AC3/DTS/EAC3/etc) — cheap audio-only
re-encode, video stream-copied untouched.
- 'abr': anything else (HEVC/VP9/etc, or codec probing failed) — needs a real video
re-encode. Also the safe fallback on probe failure: it's the only mode that doesn't
assume the source is already H.264 — 'audio'/'quick' apply the h264_mp4toannexb
bitstream filter unconditionally, which makes ffmpeg hard-fail on non-H.264 input.
"""
if not source.is_file():
return "abr"
video_codec, audio_codec = await probe_codecs(source)
if not video_codec or video_codec != "h264":
return "abr"
if audio_codec in BROWSER_NATIVE_AUDIO:
return "native"
return "audio"
# (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 up to 2 variants ≤ source height (the two highest applicable renditions —
ALL_VARIANTS is ordered highest-to-lowest, so slicing keeps the best pair). Capped at 2
instead of the full ladder to roughly halve ABR encode time on this host's aging CPU, while
still giving a real high/low adaptive-bitrate pair for most connections."""
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[:2]
async def transcode_audio_fix(source: Path, out_dir: Path, on_status: Callable[..., Awaitable]) -> None:
"""Video stream-copied untouched, audio re-encoded to AAC — fixes browsers' inability to
play AC3/DTS/EAC3 (common on BDRips) without paying for a full video re-encode. Same
H.264-source assumption as transcode_quick; HEVC/x265 sources still need the full ABR path
since the video itself isn't browser-playable either in that case."""
if not source.is_file():
await on_status("failed", error=f"Source missing: {source}")
return
out_dir.mkdir(parents=True, exist_ok=True)
cmd = [
"nice", "-n", str(TRANSCODE_NICE),
"ffmpeg", "-y", "-i", str(source),
"-c:v", "copy", "-c:a", "aac", "-b:a", "192k", "-ac", "2",
"-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", source)
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)
cmd = [
"nice", "-n", str(TRANSCODE_NICE),
"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", source)
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)
# Per-variant thread count, not the full cap each — x264 threads are set per encoder
# instance, so giving every one of the (up to 2) variants the full TRANSCODE_THREADS
# budget lets them all run near-full-tilt simultaneously and still saturate the host.
per_variant_threads = max(1, TRANSCODE_THREADS // n)
cmd: List[str] = ["nice", "-n", str(TRANSCODE_NICE), "ffmpeg", "-y", "-threads", str(per_variant_threads), "-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}", "superfast",
f"-threads:v:{i}", str(per_variant_threads),
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_playlist_type", "vod",
"-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", source)
def _parse_out_time_seconds(line: str) -> Optional[float]:
"""Parse an `out_time=HH:MM:SS.microseconds` line from ffmpeg's -progress output."""
_, _, value = line.partition("=")
value = value.strip()
if not value or value == "N/A":
return None
try:
h, m, s = value.split(":")
return int(h) * 3600 + int(m) * 60 + float(s)
except ValueError:
return None
async def _run(cmd: List[str], on_status, out_dir: Path, entry_filename: str, source: Optional[Path] = None) -> None:
duration = await probe_duration(source) if source else 0.0
await on_status("running", progress=0.0)
# Machine-readable progress on stdout, separate from ffmpeg's normal stderr logging.
# Insert right after the "ffmpeg" token, not cmd[0] — cmd is prefixed with `nice -n N`,
# and these flags belong to ffmpeg, not to nice.
ffmpeg_idx = cmd.index("ffmpeg")
cmd = cmd[:ffmpeg_idx + 1] + ["-progress", "pipe:1", "-nostats"] + cmd[ffmpeg_idx + 1:]
try:
proc = await asyncio.create_subprocess_exec(
*cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
)
stderr_chunks: List[bytes] = []
async def _drain_stderr():
async for line in proc.stderr:
stderr_chunks.append(line)
stderr_task = asyncio.create_task(_drain_stderr())
last_reported = -1.0
async for raw in proc.stdout:
line = raw.decode("utf-8", errors="ignore").strip()
if line.startswith("out_time=") and duration > 0:
seconds = _parse_out_time_seconds(line)
if seconds is not None:
pct = round(min(99.0, seconds / duration * 100), 1)
if pct != last_reported:
last_reported = pct
await on_status("running", progress=pct)
await proc.wait()
await stderr_task
stderr = b"".join(stderr_chunks)
if proc.returncode != 0:
err = stderr.decode("utf-8", errors="ignore")[-500:]
logger.error(f"ffmpeg failed: {err}")
await on_status("failed", error=err[:200])
shutil.rmtree(out_dir, ignore_errors=True)
return
await on_status("done", entry=entry_filename, progress=100.0)
except FileNotFoundError:
await on_status("failed", error="ffmpeg not installed")
except Exception as e:
logger.exception("transcode crashed")
await on_status("failed", error=str(e))
shutil.rmtree(out_dir, ignore_errors=True)
def srt_to_vtt(srt_text: str) -> str:
lines = srt_text.replace("\r\n", "\n").split("\n")
out = ["WEBVTT", ""]
for line in lines:
if "-->" in line:
out.append(line.replace(",", "."))
else:
out.append(line)
return "\n".join(out)