Files
tonemark/src/routes/jobs/[id]/+page.svelte
Giancarmine Salucci d1295ce343
All checks were successful
Build & Push Docker Image / build-and-push (push) Successful in 41s
feat: add retry/delete for jobs
- db.ts: add resetJob() and deleteJob() statements + exports
- pipeline.ts: export retryJob() — resets job state and re-runs pipeline
- DELETE /api/jobs/[id]: hard-delete terminal jobs (done/failed/cancelled);
  keep cancel-only behavior for active jobs
- POST /api/jobs/[id]/retry: new endpoint; validates failed/cancelled URL job,
  resets and re-runs via retryJob()
- jobs/[id]/+page.svelte: wire Cancel/Retry/Delete buttons with fetch calls;
  fix hardcoded ACCENT → accent store
- jobs/+page.svelte: per-row Retry+Delete icon buttons (visible on hover);
  fix hardcoded ACCENT → accent store

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-06 17:42:54 +02:00

670 lines
18 KiB
Svelte
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.
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { page } from '$app/stores';
import type { Job, Segment } from '$lib/types.js';
import SourceIcon from '$lib/components/SourceIcon.svelte';
import Waveform from '$lib/components/Waveform.svelte';
import { accent } from '$lib/accent.js';
const ACCENT = $derived($accent.value);
const jobId = $derived($page.params.id);
let job = $state<Job | null>(null);
let segments = $state<Segment[]>([]);
let error = $state('');
let chunkInfo = $state({ chunk: 0, total: 0 });
let eventSource: EventSource | null = null;
const statusLabel: Record<string, string> = {
pending: 'Pending',
downloading: 'Downloading…',
preparing: 'Preparing audio…',
transcribing: 'Transcribing…',
processing: 'Post-processing…',
done: 'Done',
failed: 'Failed',
cancelled: 'Cancelled'
};
// Pipeline stages derived from job status
const pipelineStages = $derived.by(() => {
const status = job?.status ?? 'pending';
const stages = [
{ k: 'fetch', label: 'Fetch source' },
{ k: 'extract', label: 'Extract audio track' },
{ k: 'process', label: `Audio processing · ${job?.audioMode ?? 'auto'}` },
{ k: 'transcribe', label: 'Transcribing' },
{ k: 'finalize', label: 'Format &amp; save' }
];
const order = ['pending', 'downloading', 'preparing', 'transcribing', 'processing', 'done'];
const idx = order.indexOf(status);
return stages.map((s, i) => ({
...s,
done: i < idx - 1 || status === 'done',
active: i === idx - 1 && status !== 'done' && status !== 'failed',
pending: i > idx - 1 && status !== 'done'
}));
});
function jobKind(job: Job): 'youtube' | 'audio' | 'video' | 'file' {
const s = job.source ?? '';
if (s.includes('youtube') || s.includes('youtu.be')) return 'youtube';
if (/\.(mp3|m4a|wav|ogg|flac|aac)$/i.test(s)) return 'audio';
if (/\.(mp4|mov|mkv|webm|avi)$/i.test(s)) return 'video';
return 'file';
}
onMount(async () => {
await loadJob();
if (job && !['done', 'failed', 'cancelled'].includes(job.status)) {
openStream();
}
});
onDestroy(() => eventSource?.close());
async function loadJob() {
const res = await fetch(`/api/jobs/${jobId}`);
if (!res.ok) {
error = 'Job not found';
return;
}
job = await res.json();
if (job?.segmentsJson) {
try {
segments = JSON.parse(job.segmentsJson);
} catch { /* ignore */ }
}
}
function openStream() {
eventSource = new EventSource(`/api/jobs/${jobId}/stream`);
eventSource.onmessage = (e) => {
try {
const data = JSON.parse(e.data);
if (data.type === 'progress') {
chunkInfo = { chunk: data.chunk ?? 0, total: data.total ?? 0 };
if (job) job = { ...job, progress: data.progress ?? job.progress, status: 'transcribing' };
} else if (data.type === 'status') {
if (job) job = { ...job, status: data.status, progress: data.progress ?? job.progress };
} else if (data.type === 'done') {
eventSource?.close();
loadJob();
} else if (data.type === 'error') {
if (job) job = { ...job, status: 'failed', error: data.message };
eventSource?.close();
}
} catch { /* ignore */ }
};
}
function secToTimestamp(sec: number): string {
const h = Math.floor(sec / 3600);
const m = Math.floor((sec % 3600) / 60);
const s = Math.floor(sec % 60);
return h > 0
? `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
: `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
}
const formats = ['srt', 'txt', 'md', 'json'] as const;
const isActive = $derived(!job || !['done', 'failed', 'cancelled'].includes(job.status));
const isTerminal = $derived(job !== null && ['done', 'failed', 'cancelled'].includes(job.status));
const canRetry = $derived(
job !== null &&
['failed', 'cancelled'].includes(job.status) &&
(job.source?.startsWith('http') ?? false)
);
async function cancelJob() {
if (!job) return;
await fetch(`/api/jobs/${job.id}`, { method: 'DELETE' });
await loadJob();
}
async function deleteJob() {
if (!job || !confirm(`Delete job "${job.title || job.id}"?`)) return;
const res = await fetch(`/api/jobs/${job.id}`, { method: 'DELETE' });
if (res.ok) window.location.href = '/';
}
async function retryJobAction() {
if (!job) return;
const res = await fetch(`/api/jobs/${job.id}/retry`, { method: 'POST' });
if (res.ok) {
await loadJob();
openStream();
} else {
const body = await res.json().catch(() => ({}));
alert(body.message ?? 'Retry failed');
}
}
</script>
<svelte:head>
<title>{job?.title ?? 'Job'} — Tonemark</title>
</svelte:head>
<div class="page">
{#if error}
<div class="error-banner" role="alert">{error}</div>
{:else if !job}
<div class="loading" aria-busy="true">
<svg width="20" height="20" viewBox="0 0 20 20" style="animation: spin 1s linear infinite">
<circle cx="10" cy="10" r="8" stroke="var(--text-muted)" stroke-width="2" fill="none" stroke-dasharray="30 14"/>
</svg>
Loading…
</div>
{:else}
<!-- ── Breadcrumb ─────────────────────────────────────── -->
<div class="breadcrumb mono">
<a href="/" class="crumb-link">Home</a>
<span></span>
<span style="color: #fff">{job.id.slice(0, 8)}</span>
</div>
<!-- ── Job header ────────────────────────────────────── -->
<div class="job-header">
<SourceIcon kind={jobKind(job)} size={52} accent={ACCENT} />
<div class="job-header-text">
<h1 class="job-title">{job.title || job.source}</h1>
<div class="job-meta mono">
{job.source?.includes('http') ? job.source : (job.source ?? '')}
{#if job.audioMode}· {job.audioMode}{/if}
{#if job.meanVolume != null}· {job.meanVolume.toFixed(1)} dBFS{/if}
</div>
</div>
<div class="job-actions">
{#if isActive}
<button class="btn-job-action danger" onclick={cancelJob} aria-label="Cancel job">Cancel</button>
{/if}
{#if canRetry}
<button class="btn-job-action accent" onclick={retryJobAction} aria-label="Retry job">↺ Retry</button>
{/if}
{#if isTerminal}
<button class="btn-job-action danger" onclick={deleteJob} aria-label="Delete job">Delete</button>
{/if}
</div>
</div>
<!-- ── Progress block ────────────────────────────────── -->
{#if isActive || job.status === 'done'}
<div class="progress-card glass">
<!-- Waveform coloured by progress -->
<div class="progress-wave">
<Waveform
bars={140}
progress={job.progress}
accent={ACCENT}
height={80}
pattern="default"
/>
</div>
<div class="progress-footer">
<div class="progress-left">
<span class="progress-pct mono">
{job.progress}<span style="color: var(--text-dim); font-weight: 400">%</span>
</span>
<span class="progress-status">{statusLabel[job.status] ?? job.status}</span>
</div>
{#if chunkInfo.total > 0}
<span class="progress-chunks mono">
chunk {chunkInfo.chunk} / {chunkInfo.total}
</span>
{/if}
</div>
<!-- Progress bar -->
<div class="progress-bar-track">
<div
class="progress-bar-fill"
style="width: {job.progress}%; background: {ACCENT}; box-shadow: 0 0 12px {ACCENT}80;"
></div>
</div>
</div>
{/if}
<!-- ── Error ─────────────────────────────────────────── -->
{#if job.error}
<div class="error-banner" role="alert">{job.error}</div>
{/if}
<!-- ── Two-column: pipeline + downloads/transcript ───── -->
<div class="two-col">
<!-- Pipeline stages -->
<div class="glass stage-card">
<div class="label" style="margin-bottom: 16px;">Pipeline</div>
<div class="stages">
{#each pipelineStages as stage}
<div class="stage-row">
<div
class="stage-dot"
style={stage.done
? `background: ${ACCENT};`
: stage.active
? `background: transparent; border: 2px solid ${ACCENT};`
: 'background: rgba(255,255,255,0.05);'}
>
{#if stage.done}
<svg width="10" height="10" viewBox="0 0 10 10" fill="none">
<path d="M2 5l2 2 4-4" stroke="#0c0d10" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
{:else if stage.active}
<div class="stage-dot-inner" style="background: {ACCENT}"></div>
{/if}
</div>
<span
class="stage-label"
style={stage.pending ? 'color: var(--text-dim)' : stage.active ? 'color: #fff; font-weight: 500' : ''}
>
{@html stage.label}
</span>
{#if stage.active}
<span class="mono" style="font-size: 11.5px; color: {ACCENT}">{job.progress}%</span>
{/if}
</div>
{/each}
</div>
</div>
<!-- Downloads or live preview -->
<div class="glass side-card">
{#if job.status === 'done'}
<div class="label" style="margin-bottom: 16px;">Download transcript</div>
<div class="dl-grid">
{#each formats as fmt, i}
<a
href="/api/jobs/{job.id}/download/{fmt}"
download
class="dl-btn mono"
style={i === 0
? `background: color-mix(in oklab, ${ACCENT} 12%, transparent); color: ${ACCENT}; border-color: color-mix(in oklab, ${ACCENT} 30%, transparent);`
: ''}
>
<svg width="11" height="11" viewBox="0 0 11 11" fill="none">
<path d="M5.5 1v7M2 5l3.5 3.5L9 5M1.5 9.5h8" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
{fmt.toUpperCase()}
</a>
{/each}
</div>
{#if job.outputDir}
<div class="output-dir mono">{job.outputDir}</div>
{/if}
{:else if isActive}
<div class="live-header">
<div class="label">Live preview</div>
<div class="streaming-badge" style="color: {ACCENT}">
<div class="stream-dot" style="background: {ACCENT}; animation: pulse 1.4s infinite"></div>
Streaming
</div>
</div>
{#if segments.length > 0}
{@const last = segments[segments.length - 1]}
<div class="live-text">
<span class="mono" style="color: var(--text-dim); margin-right: 8px;">
{secToTimestamp(last.start)}
</span>
{last.text}<span style="color: {ACCENT}; animation: blink 1s infinite; margin-left: 3px;"></span>
</div>
{:else}
<div style="font-size: 13px; color: var(--text-muted); font-style: italic;">
Waiting for segments…
</div>
{/if}
{/if}
</div>
</div>
<!-- ── Transcript viewer ──────────────────────────────── -->
{#if segments.length > 0}
<section class="glass transcript-card">
<div class="transcript-header">
<div class="label">Transcript</div>
<span class="mono" style="font-size: 12px; color: var(--text-muted);">
{segments.length} segments
</span>
</div>
<div class="transcript-body">
{#each segments as seg}
<div class="seg-row">
<span class="seg-ts mono">{secToTimestamp(seg.start)}</span>
<p class="seg-text">{seg.text}</p>
</div>
{/each}
</div>
</section>
{/if}
{/if}
</div>
<style>
.page {
padding: 32px 40px;
display: flex;
flex-direction: column;
gap: 20px;
max-width: 1000px;
}
/* ── Loading / errors ──────────────────────────────────── */
.loading {
display: flex;
align-items: center;
gap: 10px;
color: var(--text-muted);
font-size: 14px;
}
.error-banner {
padding: 12px 16px;
border-radius: 10px;
background: rgba(255, 90, 90, 0.08);
border: 1px solid rgba(255, 90, 90, 0.2);
color: #ff8a8a;
font-size: 13px;
}
/* ── Breadcrumb ─────────────────────────────────────────── */
.breadcrumb {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: var(--text-muted);
}
.crumb-link {
color: var(--text-muted);
text-decoration: none;
}
.crumb-link:hover {
color: var(--text);
}
/* ── Job header ─────────────────────────────────────────── */
.job-header {
display: flex;
align-items: center;
gap: 18px;
}
.job-header-text {
flex: 1;
min-width: 0;
}
.job-title {
margin: 0 0 4px;
font-size: 26px;
font-weight: 600;
letter-spacing: -0.02em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.job-meta {
font-size: 12.5px;
color: var(--text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.job-actions {
display: flex;
gap: 8px;
flex-shrink: 0;
}
.btn-job-action {
padding: 8px 14px;
border-radius: 8px;
font-size: 12.5px;
font-family: inherit;
cursor: pointer;
white-space: nowrap;
transition: background 0.15s;
}
.btn-job-action.danger {
border: 1px solid rgba(255, 90, 90, 0.3);
background: rgba(255, 90, 90, 0.08);
color: #ff8a8a;
}
.btn-job-action.danger:hover {
background: rgba(255, 90, 90, 0.15);
}
.btn-job-action.accent {
border: 1px solid color-mix(in oklab, var(--accent) 40%, transparent);
background: color-mix(in oklab, var(--accent) 10%, transparent);
color: var(--accent);
}
.btn-job-action.accent:hover {
background: color-mix(in oklab, var(--accent) 18%, transparent);
}
/* ── Progress card ──────────────────────────────────────── */
.progress-card {
padding: 28px;
}
.progress-wave {
margin-bottom: 20px;
}
.progress-footer {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: 8px;
}
.progress-left {
display: flex;
align-items: baseline;
gap: 12px;
}
.progress-pct {
font-size: 36px;
font-weight: 600;
letter-spacing: -0.02em;
}
.progress-status {
font-size: 14px;
color: var(--text-muted);
}
.progress-chunks {
font-size: 12.5px;
color: var(--text-muted);
}
.progress-bar-track {
height: 4px;
border-radius: 2px;
background: rgba(255, 255, 255, 0.06);
overflow: hidden;
}
.progress-bar-fill {
height: 100%;
border-radius: 2px;
transition: width 0.5s ease;
}
/* ── Two column ─────────────────────────────────────────── */
.two-col {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
}
.stage-card,
.side-card {
padding: 22px;
}
/* ── Pipeline stages ────────────────────────────────────── */
.stages {
display: flex;
flex-direction: column;
gap: 14px;
}
.stage-row {
display: flex;
align-items: center;
gap: 14px;
}
.stage-dot {
width: 18px;
height: 18px;
border-radius: 9px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.stage-dot-inner {
width: 6px;
height: 6px;
border-radius: 3px;
}
.stage-label {
flex: 1;
font-size: 13.5px;
color: rgba(232, 233, 236, 0.85);
}
/* ── Live preview / downloads ───────────────────────────── */
.live-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 14px;
}
.streaming-badge {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
font-family: var(--font-mono);
}
.stream-dot {
width: 6px;
height: 6px;
border-radius: 3px;
}
.live-text {
font-size: 13.5px;
line-height: 1.7;
color: rgba(232, 233, 236, 0.85);
}
.dl-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px;
}
.dl-btn {
padding: 11px;
border-radius: 9px;
border: 1px solid var(--border);
background: rgba(255, 255, 255, 0.025);
color: rgba(232, 233, 236, 0.85);
font-size: 11.5px;
font-weight: 600;
letter-spacing: 0.04em;
text-decoration: none;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
transition: background 0.15s;
}
.dl-btn:hover {
background: rgba(255, 255, 255, 0.04);
}
.output-dir {
margin-top: 14px;
padding-top: 14px;
border-top: 1px solid var(--border);
font-size: 11px;
color: var(--text-dim);
word-break: break-all;
}
/* ── Transcript ─────────────────────────────────────────── */
.transcript-card {
padding: 22px;
}
.transcript-header {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: 16px;
}
.transcript-body {
max-height: 480px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 16px;
padding-right: 4px;
}
.seg-row {
display: flex;
gap: 14px;
}
.seg-ts {
font-size: 11px;
color: var(--text-dim);
flex-shrink: 0;
margin-top: 3px;
width: 50px;
text-align: right;
}
.seg-text {
margin: 0;
font-size: 14px;
line-height: 1.65;
color: rgba(232, 233, 236, 0.85);
}
/* ── Responsive ─────────────────────────────────────────── */
@media (max-width: 768px) {
.page {
padding: 20px 16px;
}
.job-title {
font-size: 20px;
}
.two-col {
grid-template-columns: 1fr;
}
.dl-grid {
grid-template-columns: repeat(4, 1fr);
}
}
</style>