mirror of
https://github.com/myronblair/kino-app
synced 2026-06-30 17:50:16 -05:00
auto-commit for cf4f0369-14b7-4d06-b915-b8f7098fd48d
This commit is contained in:
+36
-11
@@ -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
@@ -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:
|
||||
|
||||
@@ -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)}`;
|
||||
|
||||
@@ -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 <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 (
|
||||
@@ -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">{hlsStatusBadge(m)}</span>
|
||||
<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" && (
|
||||
<button onClick={() => startTranscode(m)} disabled={transcoding[m.id]}
|
||||
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"
|
||||
data-testid={`transcode-${m.id}`}>HLS</button>
|
||||
{(m.storage_type === "local" || m.storage_type === "radarr") && m.hls_status !== "running" && m.hls_status !== "pending" && (
|
||||
<div className="flex gap-1">
|
||||
<button onClick={() => startTranscode(m, "quick")} disabled={transcoding[m.id]}
|
||||
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}`}>
|
||||
<Star size={16} strokeWidth={1.5} fill={m.featured ? "#D9381E" : "none"} />
|
||||
|
||||
@@ -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)
|
||||
- **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)
|
||||
- Sonarr (TV shows): requires episodes/seasons data model — significant addition
|
||||
- Subtitle search via OpenSubtitles API
|
||||
|
||||
Reference in New Issue
Block a user