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
+37 -12
View File
@@ -9,6 +9,7 @@ const STATUS_COLORS = {
done: "text-[#86efac]",
failed: "text-[#fca5a5]",
cancelled: "text-[#8A8A8A]",
superseded: "text-[#8A8A8A]",
};
export default function TranscodeQueue() {
@@ -26,14 +27,19 @@ export default function TranscodeQueue() {
};
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(() => {
const active = jobs.some((j) => j.status === "running" || j.status === "pending");
if (!active) return;
const t = setInterval(load, 4000);
const running = jobs.some((j) => j.status === "running");
const pending = jobs.some((j) => j.status === "pending");
if (!running && !pending) return;
const t = setInterval(load, running ? 2000 : 4000);
return () => clearInterval(t);
}, [jobs]);
const runningJob = jobs.find((j) => j.status === "running");
const listedJobs = jobs.filter((j) => j.status !== "running");
const cancel = async (id) => {
try { await api.delete(`/transcode/queue/${id}`); toast.success("Job cancelled"); load(); }
catch (err) { toast.error(err.response?.data?.detail || "Could not cancel"); }
@@ -116,7 +122,31 @@ export default function TranscodeQueue() {
</button>
</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]">
<span className="col-span-5">Movie</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-1 text-right">Actions</span>
</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>
) : 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 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>
@@ -138,11 +168,6 @@ export default function TranscodeQueue() {
</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">
{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" && (
<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} />