feat: add retry/delete for jobs
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>
This commit is contained in:
Giancarmine Salucci
2026-05-06 17:42:54 +02:00
parent 37175ec791
commit d1295ce343
6 changed files with 207 additions and 15 deletions

View File

@@ -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);
}

View File

@@ -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<void> {
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 },

View File

@@ -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 });
}

View File

@@ -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 });
}

View File

@@ -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<Job[]>([]);
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');
}
}
</script>
<svelte:head>
@@ -106,6 +131,26 @@
{:else}
<div class="job-pct mono" style="color: {statusColor[job.status]}">{job.status}</div>
{/if}
<!-- Row actions -->
<div class="row-actions">
{#if ['failed', 'cancelled'].includes(job.status) && job.source?.startsWith('http')}
<button
class="row-btn accent"
onclick={(e) => retryJob(e, job)}
aria-label="Retry"
title="Retry"
></button>
{/if}
{#if ['done', 'failed', 'cancelled'].includes(job.status)}
<button
class="row-btn danger"
onclick={(e) => deleteJob(e, job)}
aria-label="Delete"
title="Delete"
></button>
{/if}
</div>
</a>
{/each}
</div>
@@ -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;

View File

@@ -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<Job | null>(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');
}
}
</script>
<svelte:head>
@@ -143,11 +174,17 @@
{#if job.meanVolume != null}· {job.meanVolume.toFixed(1)} dBFS{/if}
</div>
</div>
<div class="job-actions">
{#if isActive}
<form method="POST" action="/api/jobs/{job.id}?_method=DELETE">
<button type="button" class="btn-cancel" aria-label="Cancel job">Cancel</button>
</form>
<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 ────────────────────────────────── -->
@@ -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 {