All checks were successful
Build & Push Docker Image / build-and-push (push) Successful in 41s
- 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>
670 lines
18 KiB
Svelte
670 lines
18 KiB
Svelte
<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 & 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>
|
||
|