Files
netfelix-audio-fix/src/features/pipeline/ProcessingColumn.tsx
T
felixfoertsch 90fd87be61
Build and Push Docker Image / build (push) Successful in 1m49s
pipeline cards: click the title to open the audio details view
across review, processing, and done columns the movie/episode name is
now a link to /review/audio/\$id — matches the usual web pattern and
removes an extra click through the now-redundant Details button on
pipeline cards. jellyfin's deep link moves to a small ↗ affordance
next to the title so the 'open in jellyfin' path is still one click
away without hijacking the primary click.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 18:24:00 +02:00

108 lines
4.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Link } from "@tanstack/react-router";
import { useEffect, useState } from "react";
import { Badge } from "~/shared/components/ui/badge";
import { api } from "~/shared/lib/api";
import type { PipelineJobItem } from "~/shared/lib/types";
import { ColumnShell } from "./ColumnShell";
interface ProcessingColumnProps {
items: PipelineJobItem[];
progress?: { id: number; seconds: number; total: number } | null;
queueStatus?: { status: string; until?: string; seconds?: number } | null;
onMutate: () => void;
}
export function ProcessingColumn({ items, progress, queueStatus, onMutate }: ProcessingColumnProps) {
const job = items[0]; // at most one running job
// Wall-clock elapsed since the job started — re-renders every second.
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
if (!job) return;
const t = setInterval(() => setNow(Date.now()), 1000);
return () => clearInterval(t);
}, [job]);
// Only trust progress if it belongs to the current job — stale events from
// a previous job would otherwise show wrong numbers until the new job emits.
const liveProgress = job && progress && progress.id === job.id ? progress : null;
const startedAt = job?.started_at ? new Date(`${job.started_at}Z`).getTime() : null;
const elapsedMs = startedAt ? Math.max(0, now - startedAt) : 0;
const percent = liveProgress && liveProgress.total > 0 ? (liveProgress.seconds / liveProgress.total) * 100 : null;
// ETA = elapsed × (1 - p) / p — only meaningful once we have a few percent
// of progress and the rate has stabilised.
const etaMs = percent != null && percent >= 1 && elapsedMs > 0 ? (elapsedMs * (100 - percent)) / percent : null;
const formatDuration = (ms: number) => {
const total = Math.floor(ms / 1000);
const h = Math.floor(total / 3600);
const m = Math.floor((total % 3600) / 60);
const s = total % 60;
if (h > 0) return `${h}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
return `${m}:${String(s).padStart(2, "0")}`;
};
const stop = async () => {
if (!confirm(`Stop the running job "${job?.name}"? It will be marked as error.`)) return;
await api.post("/api/execute/stop");
onMutate();
};
return (
<ColumnShell
title="Processing"
count={job ? 1 : 0}
actions={job ? [{ label: "Stop", onClick: stop, danger: true }] : undefined}
>
{queueStatus && queueStatus.status !== "running" && (
<div className="mb-2 text-xs text-gray-500 bg-white rounded border p-2">
{queueStatus.status === "paused" && <>Paused until {queueStatus.until}</>}
{queueStatus.status === "sleeping" && <>Sleeping {queueStatus.seconds}s between jobs</>}
{queueStatus.status === "idle" && <>Idle</>}
</div>
)}
{job ? (
<div className="rounded border bg-white p-3">
<div className="flex items-start justify-between gap-2">
<Link
to="/review/audio/$id"
params={{ id: String(job.item_id) }}
className="text-sm font-medium truncate flex-1 hover:text-blue-600 hover:underline"
>
{job.name}
</Link>
<button
type="button"
onClick={stop}
className="text-xs px-2 py-0.5 rounded border border-red-200 text-red-700 hover:bg-red-50 shrink-0"
>
Stop
</button>
</div>
<div className="flex items-center gap-2 mt-1">
<Badge variant="running">running</Badge>
<Badge variant={job.job_type === "transcode" ? "manual" : "noop"}>{job.job_type}</Badge>
</div>
<div className="mt-3">
<div className="flex justify-between text-xs text-gray-500 mb-1 tabular-nums">
<span>elapsed {formatDuration(elapsedMs)}</span>
<span>{percent != null ? `${Math.round(percent)}%` : "starting…"}</span>
<span>{etaMs != null ? `~${formatDuration(etaMs)} left` : "—"}</span>
</div>
<div className="h-2 bg-gray-200 rounded-full overflow-hidden">
<div
className="h-full bg-blue-500 rounded-full transition-all"
style={{ width: `${percent != null ? Math.min(100, percent) : 0}%` }}
/>
</div>
</div>
</div>
) : (
<p className="text-sm text-gray-400 text-center py-8">No active job</p>
)}
</ColumnShell>
);
}