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
+2
View File
@@ -366,6 +366,8 @@ class TranscodeJob(BaseModel):
status: str = "pending" status: str = "pending"
error: str = "" error: str = ""
triggered_by: str = "manual" triggered_by: str = "manual"
progress: float = 0.0
superseded_by: Optional[str] = None
created_at: str = Field(default_factory=_now_iso) created_at: str = Field(default_factory=_now_iso)
started_at: Optional[str] = None started_at: Optional[str] = None
finished_at: Optional[str] = None finished_at: Optional[str] = None
+8 -4
View File
@@ -583,11 +583,13 @@ async def _process_job(job: dict):
last_error = {"msg": ""} last_error = {"msg": ""}
async def cb(status, entry: Optional[str] = None, error: Optional[str] = None): async def cb(status, entry: Optional[str] = None, error: Optional[str] = None, progress: Optional[float] = None):
update = {"hls_status": status} update = {"hls_status": status}
if status == "done" and entry: update["hls_path"] = entry if status == "done" and entry: update["hls_path"] = entry
await coll.update_one({"id": job["movie_id"]}, {"$set": update}) await coll.update_one({"id": job["movie_id"]}, {"$set": update})
if error: last_error["msg"] = error if error: last_error["msg"] = error
if progress is not None:
await db.transcode_queue.update_one({"id": job["id"]}, {"$set": {"progress": progress}})
try: try:
if job["quality"] == "abr": if job["quality"] == "abr":
@@ -684,7 +686,7 @@ async def list_queue(status: Optional[str] = None, user: dict = Depends(require_
@api.get("/transcode/queue/stats") @api.get("/transcode/queue/stats")
async def queue_stats(user: dict = Depends(require_admin)): async def queue_stats(user: dict = Depends(require_admin)):
pipeline = [{"$group": {"_id": "$status", "n": {"$sum": 1}}}] pipeline = [{"$group": {"_id": "$status", "n": {"$sum": 1}}}]
out = {"pending": 0, "running": 0, "done": 0, "failed": 0, "cancelled": 0} out = {"pending": 0, "running": 0, "done": 0, "failed": 0, "cancelled": 0, "superseded": 0}
async for row in db.transcode_queue.aggregate(pipeline): async for row in db.transcode_queue.aggregate(pipeline):
out[row["_id"]] = row["n"] out[row["_id"]] = row["n"]
s = await get_settings() s = await get_settings()
@@ -715,7 +717,9 @@ async def retry_job(job_id: str, user: dict = Depends(require_admin)):
raise HTTPException(status_code=400, detail="Only failed/cancelled jobs can be retried") raise HTTPException(status_code=400, detail="Only failed/cancelled jobs can be retried")
new_job = await _enqueue_transcode(job["movie_id"], job["quality"], triggered_by=job.get("triggered_by", "manual"), content_type=job.get("content_type", "movie")) new_job = await _enqueue_transcode(job["movie_id"], job["quality"], triggered_by=job.get("triggered_by", "manual"), content_type=job.get("content_type", "movie"))
if new_job["id"] != job["id"]: if new_job["id"] != job["id"]:
await db.transcode_queue.delete_one({"id": job["id"]}) # Keep the original row for the audit trail — mark it superseded rather than deleting,
# so cancelled/failed history never silently disappears from the list.
await db.transcode_queue.update_one({"id": job["id"]}, {"$set": {"status": "superseded", "superseded_by": new_job["id"]}})
return {"ok": True, "job_id": new_job["id"]} return {"ok": True, "job_id": new_job["id"]}
@@ -726,7 +730,7 @@ async def retry_all_failed(user: dict = Depends(require_admin)):
for job in jobs: for job in jobs:
new_job = await _enqueue_transcode(job["movie_id"], job["quality"], triggered_by=job.get("triggered_by", "manual"), content_type=job.get("content_type", "movie")) new_job = await _enqueue_transcode(job["movie_id"], job["quality"], triggered_by=job.get("triggered_by", "manual"), content_type=job.get("content_type", "movie"))
if new_job["id"] != job["id"]: if new_job["id"] != job["id"]:
await db.transcode_queue.delete_one({"id": job["id"]}) await db.transcode_queue.update_one({"id": job["id"]}, {"$set": {"status": "superseded", "superseded_by": new_job["id"]}})
retried += 1 retried += 1
return {"ok": True, "retried": retried} return {"ok": True, "retried": retried}
+62 -7
View File
@@ -37,6 +37,23 @@ async def probe_video(source: Path) -> Tuple[int, int]:
return (0, 0) 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) # (height, video_bitrate, max_bitrate, buffer, audio_bitrate)
ALL_VARIANTS = [ ALL_VARIANTS = [
(1080, "5000k", "5500k", "7500k", "192k"), (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"), "-hls_segment_filename", str(out_dir / "seg_%04d.ts"),
str(out_dir / "playlist.m3u8"), 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: 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"), "-hls_segment_filename", str(out_dir / "seg_%04d.ts"),
str(out_dir / "playlist.m3u8"), 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: 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): for i in range(n):
(out_dir / f"v{i}").mkdir(exist_ok=True) (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: def _parse_out_time_seconds(line: str) -> Optional[float]:
await on_status("running") """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: try:
proc = await asyncio.create_subprocess_exec( proc = await asyncio.create_subprocess_exec(
*cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, *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: if proc.returncode != 0:
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])
shutil.rmtree(out_dir, ignore_errors=True) shutil.rmtree(out_dir, ignore_errors=True)
return return
await on_status("done", entry=entry_filename) await on_status("done", entry=entry_filename, progress=100.0)
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:
+37 -12
View File
@@ -9,6 +9,7 @@ const STATUS_COLORS = {
done: "text-[#86efac]", done: "text-[#86efac]",
failed: "text-[#fca5a5]", failed: "text-[#fca5a5]",
cancelled: "text-[#8A8A8A]", cancelled: "text-[#8A8A8A]",
superseded: "text-[#8A8A8A]",
}; };
export default function TranscodeQueue() { export default function TranscodeQueue() {
@@ -26,14 +27,19 @@ export default function TranscodeQueue() {
}; };
useEffect(() => { load(); }, []); useEffect(() => { load(); }, []);
// Poll while jobs are active // Poll while jobs are active faster while something is actually running so the
// progress bar feels live, slower while only pending jobs are waiting.
useEffect(() => { useEffect(() => {
const active = jobs.some((j) => j.status === "running" || j.status === "pending"); const running = jobs.some((j) => j.status === "running");
if (!active) return; const pending = jobs.some((j) => j.status === "pending");
const t = setInterval(load, 4000); if (!running && !pending) return;
const t = setInterval(load, running ? 2000 : 4000);
return () => clearInterval(t); return () => clearInterval(t);
}, [jobs]); }, [jobs]);
const runningJob = jobs.find((j) => j.status === "running");
const listedJobs = jobs.filter((j) => j.status !== "running");
const cancel = async (id) => { const cancel = async (id) => {
try { await api.delete(`/transcode/queue/${id}`); toast.success("Job cancelled"); load(); } try { await api.delete(`/transcode/queue/${id}`); toast.success("Job cancelled"); load(); }
catch (err) { toast.error(err.response?.data?.detail || "Could not cancel"); } catch (err) { toast.error(err.response?.data?.detail || "Could not cancel"); }
@@ -116,7 +122,31 @@ export default function TranscodeQueue() {
</button> </button>
</div> </div>
<div className="mt-10 border border-[#222]"> {runningJob && (
<div className="mt-8 border border-[#D9381E]/40 bg-[#D9381E]/[0.04] p-5" data-testid="now-processing">
<div className="flex items-center justify-between text-[10px] uppercase tracking-[0.3em] text-[#D9381E]">
<span className="flex items-center gap-2">
<span className="w-1.5 h-1.5 rounded-full bg-[#D9381E] animate-pulse" />
Now Processing
</span>
<span>{(runningJob.progress || 0).toFixed(1)}%</span>
</div>
<div className="mt-2 text-white text-lg truncate" title={runningJob.movie_title}>
{runningJob.movie_title || runningJob.movie_id.slice(0, 8)}
</div>
<div className="mt-1 text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">
{runningJob.quality} · {runningJob.triggered_by}
</div>
<div className="mt-3 h-1.5 bg-[#1a1a1a] rounded-full overflow-hidden">
<div
className="h-full bg-[#D9381E] transition-[width] duration-500 ease-linear rounded-full"
style={{ width: `${Math.max(2, runningJob.progress || 0)}%` }}
/>
</div>
</div>
)}
<div className="mt-6 border border-[#222]">
<div className="grid grid-cols-12 px-5 py-3 border-b border-[#222] text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]"> <div className="grid grid-cols-12 px-5 py-3 border-b border-[#222] text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">
<span className="col-span-5">Movie</span> <span className="col-span-5">Movie</span>
<span className="col-span-1">Quality</span> <span className="col-span-1">Quality</span>
@@ -125,9 +155,9 @@ export default function TranscodeQueue() {
<span className="col-span-2">Created</span> <span className="col-span-2">Created</span>
<span className="col-span-1 text-right">Actions</span> <span className="col-span-1 text-right">Actions</span>
</div> </div>
{jobs.length === 0 ? ( {listedJobs.length === 0 ? (
<div className="p-8 text-center text-[#8A8A8A] text-sm" data-testid="queue-empty">No jobs yet.</div> <div className="p-8 text-center text-[#8A8A8A] text-sm" data-testid="queue-empty">No jobs yet.</div>
) : jobs.map((j) => ( ) : listedJobs.map((j) => (
<div key={j.id} className="grid grid-cols-12 items-center px-5 py-3 border-b border-[#222] last:border-b-0 hover:bg-[#0F0F0F] transition-colors" data-testid={`queue-row-${j.id}`}> <div key={j.id} className="grid grid-cols-12 items-center px-5 py-3 border-b border-[#222] last:border-b-0 hover:bg-[#0F0F0F] transition-colors" data-testid={`queue-row-${j.id}`}>
<div className="col-span-5 text-white truncate" title={j.movie_title}>{j.movie_title || j.movie_id.slice(0, 8)}</div> <div className="col-span-5 text-white truncate" title={j.movie_title}>{j.movie_title || j.movie_id.slice(0, 8)}</div>
<span className="col-span-1 text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">{j.quality}</span> <span className="col-span-1 text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">{j.quality}</span>
@@ -138,11 +168,6 @@ export default function TranscodeQueue() {
</span> </span>
<span className="col-span-2 text-xs text-[#8A8A8A]">{new Date(j.created_at).toLocaleString()}</span> <span className="col-span-2 text-xs text-[#8A8A8A]">{new Date(j.created_at).toLocaleString()}</span>
<div className="col-span-1 flex justify-end gap-2"> <div className="col-span-1 flex justify-end gap-2">
{j.status === "running" && (
<button disabled className="text-[#444] cursor-not-allowed" title="Cannot cancel a running job" aria-label="Running">
<X size={14} strokeWidth={1.5} />
</button>
)}
{j.status === "pending" && ( {j.status === "pending" && (
<button onClick={() => cancel(j.id)} className="text-[#8A8A8A] hover:text-[#fca5a5]" data-testid={`cancel-${j.id}`} aria-label="Cancel"> <button onClick={() => cancel(j.id)} className="text-[#8A8A8A] hover:text-[#fca5a5]" data-testid={`cancel-${j.id}`} aria-label="Cancel">
<X size={14} strokeWidth={1.5} /> <X size={14} strokeWidth={1.5} />