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 from seed import SAMPLE_MOVIES
import tmdb as tmdb_client import tmdb as tmdb_client
import radarr as radarr_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 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}") 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 out_dir = HLS_DIR / movie_id
async def cb(status, error=None): # Clear any prior output before re-encoding
if status == "done": import shutil as _sh
await _set_hls_status(movie_id, "done", path=f"{movie_id}/playlist.m3u8") _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: else:
await _set_hls_status(movie_id, status, error=error) 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") @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}) movie = await db.movies.find_one({"id": movie_id}, {"_id": 0})
if not movie: raise HTTPException(status_code=404, detail="Movie not found") 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"): 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") 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") raise HTTPException(status_code=409, detail="Already transcoding")
source = Path(movie["storage_path"]) if movie["storage_type"] == "radarr" else VIDEOS_DIR / movie["storage_path"] source = Path(movie["storage_path"]) if movie["storage_type"] == "radarr" else VIDEOS_DIR / movie["storage_path"]
await _set_hls_status(movie_id, "pending") await _set_hls_status(movie_id, "pending")
asyncio.create_task(_run_transcode(movie_id, source)) asyncio.create_task(_run_transcode(movie_id, source, quality))
return {"ok": True, "status": "pending"} return {"ok": True, "status": "pending", "quality": quality}
@api.get("/movies/{movie_id}/hls/{filename:path}") @api.get("/movies/{movie_id}/hls/{filename:path}")
@@ -513,7 +525,20 @@ async def serve_hls(
raise HTTPException(status_code=400, detail="Invalid path") raise HTTPException(status_code=400, detail="Invalid path")
if not target.is_file(): if not target.is_file():
raise HTTPException(status_code=404, detail="HLS file not found") 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) 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 asyncio
import json
import logging import logging
import shutil import shutil
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional, List, Tuple, Callable, Awaitable
logger = logging.getLogger("kino.transcode") logger = logging.getLogger("kino.transcode")
async def transcode_to_hls( async def probe_video(source: Path) -> Tuple[int, int]:
source: Path, """Return (width, height) using ffprobe; (0, 0) on failure."""
out_dir: Path, try:
on_status, proc = await asyncio.create_subprocess_exec(
): "ffprobe", "-v", "error", "-select_streams", "v:0",
""" "-show_entries", "stream=width,height", "-of", "json", str(source),
Run ffmpeg to produce HLS playlist + segments. stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
`on_status(status, error=None)` is awaited at start/end with status one of )
'running'|'done'|'failed'. stdout, _ = await proc.communicate()
Uses stream-copy where possible (fast, no re-encode). 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(): if not source.is_file():
await on_status("failed", error=f"Source missing: {source}") await on_status("failed", error=f"Source missing: {source}")
return return
out_dir.mkdir(parents=True, exist_ok=True) out_dir.mkdir(parents=True, exist_ok=True)
playlist = out_dir / "playlist.m3u8"
cmd = [ cmd = [
"ffmpeg", "-y", "ffmpeg", "-y", "-i", str(source),
"-i", str(source), "-c:v", "copy", "-c:a", "copy",
"-c:v", "copy",
"-c:a", "copy",
"-bsf:v", "h264_mp4toannexb", "-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", "-f", "hls",
"-hls_time", "6", "-hls_time", "6",
"-hls_list_size", "0",
"-hls_playlist_type", "vod", "-hls_playlist_type", "vod",
"-hls_segment_filename", str(out_dir / "seg_%04d.ts"), "-hls_flags", "independent_segments",
str(playlist), "-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") await on_status("running")
try: try:
proc = await asyncio.create_subprocess_exec( proc = await asyncio.create_subprocess_exec(
@@ -49,10 +138,9 @@ async def transcode_to_hls(
err = stderr.decode("utf-8", errors="ignore")[-500:] err = stderr.decode("utf-8", errors="ignore")[-500:]
logger.error(f"ffmpeg failed: {err}") logger.error(f"ffmpeg failed: {err}")
await on_status("failed", error=err[:200]) await on_status("failed", error=err[:200])
# cleanup partial files
shutil.rmtree(out_dir, ignore_errors=True) shutil.rmtree(out_dir, ignore_errors=True)
return return
await on_status("done") await on_status("done", entry=entry_filename)
except FileNotFoundError: except FileNotFoundError:
await on_status("failed", error="ffmpeg not installed") await on_status("failed", error="ffmpeg not installed")
except Exception as e: except Exception as e:
@@ -62,11 +150,9 @@ async def transcode_to_hls(
def srt_to_vtt(srt_text: str) -> str: 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") lines = srt_text.replace("\r\n", "\n").split("\n")
out = ["WEBVTT", ""] out = ["WEBVTT", ""]
for line in lines: for line in lines:
# Replace SRT timestamp commas with VTT dots
if "-->" in line: if "-->" in line:
out.append(line.replace(",", ".")) out.append(line.replace(",", "."))
else: else:
+1 -1
View File
@@ -23,7 +23,7 @@ export const getStreamUrl = (movie) => {
if (!movie) return ""; if (!movie) return "";
const token = localStorage.getItem("kino_token") || ""; const token = localStorage.getItem("kino_token") || "";
if (movie.hls_status === "done" && movie.hls_path) { 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") { if (movie.storage_type === "local" || movie.storage_type === "radarr") {
return `${API}/stream/${movie.id}?auth=${encodeURIComponent(token)}`; return `${API}/stream/${movie.id}?auth=${encodeURIComponent(token)}`;
+23 -8
View File
@@ -35,11 +35,11 @@ export default function Admin() {
load(); load();
}; };
const startTranscode = async (m) => { const startTranscode = async (m, quality = "quick") => {
setTranscoding({ ...transcoding, [m.id]: true }); setTranscoding({ ...transcoding, [m.id]: true });
try { try {
await api.post(`/movies/${m.id}/transcode`); await api.post(`/movies/${m.id}/transcode`, { quality });
toast.success("Transcoding started — refresh in a few minutes"); toast.success(`${quality === "abr" ? "ABR" : "Quick"} transcode started`);
load(); load();
} catch (err) { } catch (err) {
toast.error(err.response?.data?.detail || "Could not start transcode"); 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) => { const hlsStatusBadge = (m) => {
if (!m.hls_status) return null; if (!m.hls_status) return null;
const colors = { const colors = {
@@ -56,7 +63,8 @@ export default function Admin() {
done: "text-[#86efac]", done: "text-[#86efac]",
failed: "text-[#fca5a5]", failed: "text-[#fca5a5]",
}; };
return <span className={`text-[9px] uppercase tracking-[0.2em] ${colors[m.hls_status] || "text-[#8A8A8A]"}`}>HLS {m.hls_status}</span>; const variant = m.hls_status === "done" ? hlsLabel(m) : "";
return <span className={`text-[9px] uppercase tracking-[0.2em] ${colors[m.hls_status] || "text-[#8A8A8A]"}`}>HLS {m.hls_status}{variant ? ` · ${variant}` : ""}</span>;
}; };
return ( return (
@@ -101,10 +109,17 @@ export default function Admin() {
<span className="col-span-2 text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">{m.storage_type}</span> <span className="col-span-2 text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">{m.storage_type}</span>
<span className="col-span-2">{hlsStatusBadge(m)}</span> <span className="col-span-2">{hlsStatusBadge(m)}</span>
<div className="col-span-2 flex justify-end gap-2"> <div className="col-span-2 flex justify-end gap-2">
{(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" && (
<button onClick={() => startTranscode(m)} disabled={transcoding[m.id]} <div className="flex gap-1">
className="text-[10px] uppercase tracking-[0.2em] border border-[#222] hover:border-[#D9381E] hover:text-[#D9381E] text-[#8A8A8A] px-3 py-1 disabled:opacity-50" <button onClick={() => startTranscode(m, "quick")} disabled={transcoding[m.id]}
data-testid={`transcode-${m.id}`}>HLS</button> title="Quick transcode (stream-copy, single bitrate)"
className="text-[10px] uppercase tracking-[0.2em] border border-[#222] hover:border-[#D9381E] hover:text-[#D9381E] text-[#8A8A8A] px-2 py-1 disabled:opacity-50"
data-testid={`transcode-quick-${m.id}`}>Quick</button>
<button onClick={() => startTranscode(m, "abr")} disabled={transcoding[m.id]}
title="Adaptive bitrate (re-encodes to 360/480/720/1080p — slow)"
className="text-[10px] uppercase tracking-[0.2em] border border-[#222] hover:border-[#D9381E] hover:text-[#D9381E] text-[#8A8A8A] px-2 py-1 disabled:opacity-50"
data-testid={`transcode-abr-${m.id}`}>ABR</button>
</div>
)} )}
<button onClick={() => toggleFeatured(m)} className={`${m.featured ? "text-[#D9381E]" : "text-[#8A8A8A]"} hover:text-[#ED4B32] transition-colors`} aria-label="Feature" data-testid={`feature-${m.id}`}> <button onClick={() => toggleFeatured(m)} className={`${m.featured ? "text-[#D9381E]" : "text-[#8A8A8A]"} hover:text-[#ED4B32] transition-colors`} aria-label="Feature" data-testid={`feature-${m.id}`}>
<Star size={16} strokeWidth={1.5} fill={m.featured ? "#D9381E" : "none"} /> <Star size={16} strokeWidth={1.5} fill={m.featured ? "#D9381E" : "none"} />
+8
View File
@@ -43,6 +43,14 @@
- **HLS transcoding** via ffmpeg: admin-triggered, background task, status tracking, `<video>` auto-uses HLS when ready (via hls.js fallback for non-Safari) - **HLS transcoding** via ffmpeg: admin-triggered, background task, status tracking, `<video>` auto-uses HLS when ready (via hls.js fallback for non-Safari)
- **Admin Settings page** for TMDB + Radarr config with masked inputs + connection test - **Admin Settings page** for TMDB + Radarr config with masked inputs + connection test
### Phase 3 (2026-04-29)
- **Adaptive bitrate (ABR) HLS**: admin can choose Quick (stream-copy, single-rate, instant) OR ABR (re-encode to 360p/480p/720p/1080p with master playlist)
- **Smart variant selection**: ABR ladder auto-trims to source resolution (no upscaling)
- **Auth-aware playlist serving**: master.m3u8 and per-variant playlists are rewritten on serve to inject `?auth=token` into all relative URLs, so hls.js segment requests stay authenticated
- **Quality badge** in admin row shows whether HLS is `Quick` or `ABR`
- **Defensive `-pix_fmt yuv420p`** ensures encoder works across all source chroma formats
- Verified end-to-end: 720p source → 3 variants (720p/480p/360p) generated in seconds with proper master playlist
## Backlog (P1) ## Backlog (P1)
- Sonarr (TV shows): requires episodes/seasons data model — significant addition - Sonarr (TV shows): requires episodes/seasons data model — significant addition
- Subtitle search via OpenSubtitles API - Subtitle search via OpenSubtitles API