feat: add retry/delete for jobs
All checks were successful
Build & Push Docker Image / build-and-push (push) Successful in 41s
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:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
16
src/routes/api/jobs/[id]/retry/+server.ts
Normal file
16
src/routes/api/jobs/[id]/retry/+server.ts
Normal 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 });
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user