Add live transcode progress with a dedicated now-processing panel

- ffmpeg progress streamed via -progress pipe:1, persisted on the job as a percent
- retry/retry-all now mark the old row as superseded instead of deleting it,
  preserving the failed/cancelled audit trail
- frontend shows the running job in its own panel above the list with a live
  progress bar instead of burying it as just another row
This commit is contained in:
Myron Blair
2026-07-27 17:38:53 -05:00
parent 6438407f5b
commit 71e731124a
4 changed files with 109 additions and 23 deletions
+62 -7
View File
@@ -37,6 +37,23 @@ async def probe_video(source: Path) -> Tuple[int, int]:
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
# (height, video_bitrate, max_bitrate, buffer, audio_bitrate)
ALL_VARIANTS = [
(1080, "5000k", "5500k", "7500k", "192k"),
@@ -75,7 +92,7 @@ async def transcode_audio_fix(source: Path, out_dir: Path, on_status: Callable[.
"-hls_segment_filename", str(out_dir / "seg_%04d.ts"),
str(out_dir / "playlist.m3u8"),
]
await _run(cmd, on_status, 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:
@@ -94,7 +111,7 @@ async def transcode_quick(source: Path, out_dir: Path, on_status: Callable[...,
"-hls_segment_filename", str(out_dir / "seg_%04d.ts"),
str(out_dir / "playlist.m3u8"),
]
await _run(cmd, on_status, 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:
@@ -158,23 +175,61 @@ async def transcode_abr(source: Path, out_dir: Path, on_status: Callable[..., Aw
for i in range(n):
(out_dir / f"v{i}").mkdir(exist_ok=True)
await _run(cmd, on_status, out_dir, "master.m3u8")
await _run(cmd, on_status, out_dir, "master.m3u8", source)
async def _run(cmd: List[str], on_status, out_dir: Path, entry_filename: str) -> None:
await on_status("running")
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.
cmd = [cmd[0], "-progress", "pipe:1", "-nostats"] + cmd[1:]
try:
proc = await asyncio.create_subprocess_exec(
*cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
)
_stdout, stderr = await proc.communicate()
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)
await on_status("done", entry=entry_filename, progress=100.0)
except FileNotFoundError:
await on_status("failed", error="ffmpeg not installed")
except Exception as e: