From d1295ce3436fdca40c40ae1dbca66c25614d1452 Mon Sep 17 00:00:00 2001 From: Giancarmine Salucci Date: Wed, 6 May 2026 17:42:54 +0200 Subject: [PATCH] feat: add retry/delete for jobs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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> --- src/lib/server/db.ts | 17 +++++ src/lib/server/pipeline.ts | 12 ++- src/routes/api/jobs/[id]/+server.ts | 16 +++- src/routes/api/jobs/[id]/retry/+server.ts | 16 ++++ src/routes/jobs/+page.svelte | 89 ++++++++++++++++++++++- src/routes/jobs/[id]/+page.svelte | 72 +++++++++++++++--- 6 files changed, 207 insertions(+), 15 deletions(-) create mode 100644 src/routes/api/jobs/[id]/retry/+server.ts diff --git a/src/lib/server/db.ts b/src/lib/server/db.ts index 1115d74..d4df810 100644 --- a/src/lib/server/db.ts +++ b/src/lib/server/db.ts @@ -59,6 +59,15 @@ const stmts = { UPDATE jobs SET status = @status, progress = @progress, updated_at = datetime('now') WHERE id = @id `), + resetJob: db.prepare(` + UPDATE jobs SET + status = 'pending', progress = 0, error = NULL, + mean_volume = NULL, whisper_job_id = NULL, + output_dir = NULL, segments_json = NULL, + updated_at = datetime('now') + WHERE id = @id + `), + deleteJob: db.prepare('DELETE FROM jobs WHERE id = ?'), getJob: db.prepare('SELECT * FROM jobs WHERE id = ?'), listJobs: db.prepare('SELECT * FROM jobs ORDER BY created_at DESC, rowid DESC LIMIT 100'), upsertSub: db.prepare(` @@ -135,3 +144,11 @@ export function getAllSubscriptions(): PushSubscription[] { export function deletePushSubscription(endpoint: string): void { stmts.deleteSub.run(endpoint); } + +export function resetJob(id: string): void { + stmts.resetJob.run({ id }); +} + +export function deleteJob(id: string): void { + stmts.deleteJob.run(id); +} diff --git a/src/lib/server/pipeline.ts b/src/lib/server/pipeline.ts index 7e28b29..6e3581e 100644 --- a/src/lib/server/pipeline.ts +++ b/src/lib/server/pipeline.ts @@ -1,4 +1,4 @@ -import { createJob, updateJob, setJobStatus, getJob } from './db.js'; +import { createJob, updateJob, setJobStatus, getJob, resetJob } from './db.js'; import { downloadYouTube, saveUploadedFile, cleanupJobTmp } from './downloader.js'; import { prepareAudio, cleanup as cleanupFiles } from './audio.js'; import { submitJob, streamJob } from './whisper.js'; @@ -50,6 +50,16 @@ export async function startUploadJob( return job.id; } +/** Retry a failed/cancelled YouTube job by resetting and re-running the pipeline. */ +export async function retryJob(jobId: string): Promise { + const job = getJob(jobId); + if (!job) throw new Error('Job not found'); + resetJob(jobId); + runJob(jobId, { type: 'youtube', url: job.source }, job.audioMode as AudioMode).catch((err) => { + console.error(`[pipeline] retry job ${jobId} failed:`, err); + }); +} + async function runJob( jobId: string, input: { type: 'youtube'; url: string } | { type: 'upload'; buffer: Buffer; filename: string }, diff --git a/src/routes/api/jobs/[id]/+server.ts b/src/routes/api/jobs/[id]/+server.ts index 9201978..21af0cb 100644 --- a/src/routes/api/jobs/[id]/+server.ts +++ b/src/routes/api/jobs/[id]/+server.ts @@ -1,5 +1,6 @@ import { json, error } from '@sveltejs/kit'; -import { getJob, setJobStatus } from '$lib/server/db.js'; +import { getJob, setJobStatus, deleteJob } from '$lib/server/db.js'; +import { rm } from 'fs/promises'; export async function GET({ params }) { const job = getJob(params.id); @@ -7,12 +8,19 @@ export async function GET({ params }) { return json(job); } +const ACTIVE = new Set(['pending', 'downloading', 'preparing', 'transcribing', 'processing']); + export async function DELETE({ params }) { const job = getJob(params.id); if (!job) throw error(404, 'Job not found'); - if (job.status === 'done' || job.status === 'failed') { - throw error(409, 'Job already completed'); + + if (ACTIVE.has(job.status)) { + // Cancel active job (keeps DB record) + setJobStatus(params.id, 'cancelled', 0); + } else { + // Hard-delete terminal job + clean up output files + deleteJob(params.id); + if (job.outputDir) rm(job.outputDir, { recursive: true, force: true }).catch(() => {}); } - setJobStatus(params.id, 'cancelled', 0); return new Response(null, { status: 204 }); } diff --git a/src/routes/api/jobs/[id]/retry/+server.ts b/src/routes/api/jobs/[id]/retry/+server.ts new file mode 100644 index 0000000..80810d2 --- /dev/null +++ b/src/routes/api/jobs/[id]/retry/+server.ts @@ -0,0 +1,16 @@ +import { json, error } from '@sveltejs/kit'; +import { getJob } from '$lib/server/db.js'; +import { retryJob } from '$lib/server/pipeline.js'; + +export async function POST({ params }) { + const job = getJob(params.id); + if (!job) throw error(404, 'Job not found'); + if (!['failed', 'cancelled'].includes(job.status)) { + throw error(409, 'Only failed or cancelled jobs can be retried'); + } + if (!job.source.startsWith('http')) { + throw error(422, 'Cannot retry a file upload — please re-upload the file'); + } + await retryJob(params.id); + return json({ ok: true }); +} diff --git a/src/routes/jobs/+page.svelte b/src/routes/jobs/+page.svelte index 26dd224..1749e83 100644 --- a/src/routes/jobs/+page.svelte +++ b/src/routes/jobs/+page.svelte @@ -3,8 +3,9 @@ import type { Job } 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 = '#cdf24e'; + const ACCENT = $derived($accent.value); let jobs = $state([]); let loading = $state(true); @@ -51,6 +52,30 @@ if (res.ok) jobs = await res.json(); loading = false; }); + + async function deleteJob(e: MouseEvent, job: Job) { + e.preventDefault(); + e.stopPropagation(); + if (!confirm(`Delete "${job.title || job.id}"?`)) return; + const res = await fetch(`/api/jobs/${job.id}`, { method: 'DELETE' }); + if (res.ok) jobs = jobs.filter((j) => j.id !== job.id); + } + + async function retryJob(e: MouseEvent, job: Job) { + e.preventDefault(); + e.stopPropagation(); + const res = await fetch(`/api/jobs/${job.id}/retry`, { method: 'POST' }); + if (res.ok) { + const updated = await fetch(`/api/jobs/${job.id}`); + if (updated.ok) { + const j = await updated.json(); + jobs = jobs.map((x) => (x.id === job.id ? j : x)); + } + } else { + const body = await res.json().catch(() => ({})); + alert(body.message ?? 'Retry failed'); + } + } @@ -106,6 +131,26 @@ {:else}
{job.status}
{/if} + + +
+ {#if ['failed', 'cancelled'].includes(job.status) && job.source?.startsWith('http')} + + {/if} + {#if ['done', 'failed', 'cancelled'].includes(job.status)} + + {/if} +
{/each} @@ -201,6 +246,48 @@ flex-shrink: 0; } + .row-actions { + display: flex; + gap: 4px; + flex-shrink: 0; + opacity: 0; + transition: opacity 0.15s; + } + + .job-row:hover .row-actions { + opacity: 1; + } + + .row-btn { + width: 28px; + height: 28px; + border-radius: 6px; + border: 1px solid transparent; + font-size: 12px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + font-family: inherit; + transition: background 0.15s; + } + .row-btn.danger { + border-color: rgba(255, 90, 90, 0.3); + background: rgba(255, 90, 90, 0.08); + color: #ff8a8a; + } + .row-btn.danger:hover { + background: rgba(255, 90, 90, 0.2); + } + .row-btn.accent { + border-color: color-mix(in oklab, var(--accent) 40%, transparent); + background: color-mix(in oklab, var(--accent) 10%, transparent); + color: var(--accent); + } + .row-btn.accent:hover { + background: color-mix(in oklab, var(--accent) 20%, transparent); + } + @media (max-width: 768px) { .page { padding: 20px 16px; diff --git a/src/routes/jobs/[id]/+page.svelte b/src/routes/jobs/[id]/+page.svelte index 173ae9d..85d31e4 100644 --- a/src/routes/jobs/[id]/+page.svelte +++ b/src/routes/jobs/[id]/+page.svelte @@ -4,8 +4,9 @@ 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 = '#cdf24e'; + const ACCENT = $derived($accent.value); const jobId = $derived($page.params.id); let job = $state(null); @@ -108,6 +109,36 @@ 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'); + } + } @@ -143,11 +174,17 @@ {#if job.meanVolume != null}· {job.meanVolume.toFixed(1)} dBFS{/if} +
{#if isActive} -
- -
+ {/if} + {#if canRetry} + + {/if} + {#if isTerminal} + + {/if} +
@@ -377,20 +414,37 @@ text-overflow: ellipsis; } - .btn-cancel { + .job-actions { + display: flex; + gap: 8px; + flex-shrink: 0; + } + + .btn-job-action { padding: 8px 14px; border-radius: 8px; - border: 1px solid rgba(255, 90, 90, 0.3); - background: rgba(255, 90, 90, 0.08); - color: #ff8a8a; font-size: 12.5px; font-family: inherit; cursor: pointer; white-space: nowrap; + transition: background 0.15s; } - .btn-cancel:hover { + .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 {