Initial commit: Tonemark PWA
Some checks failed
Build & Push Docker Image / build-and-push (push) Failing after 11s
Some checks failed
Build & Push Docker Image / build-and-push (push) Failing after 11s
Tonemark is a SvelteKit PWA for transcribing YouTube videos, audio and video files, and microphone recordings using a local Whisper backend. Features: - Dark glassmorphic UI with electric-lime accent (5 switchable themes) - Rail nav (desktop) / tab bar (mobile) layout - Drop zone, YouTube URL input, and live audio recording inputs - Audio mode waveform cards (none / standard / aggressive / auto) - Real-time transcription progress with animated waveform - Job queue with SSE streaming updates - Push notifications on job completion - PWA with native SvelteKit service worker - SRT / TXT / MD / JSON transcript downloads Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
45
src/routes/api/jobs/+server.ts
Normal file
45
src/routes/api/jobs/+server.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import { createJob, listJobs } from '$lib/server/db.js';
|
||||
import { startYouTubeJob, startUploadJob } from '$lib/server/pipeline.js';
|
||||
import type { AudioMode } from '$lib/types.js';
|
||||
|
||||
export async function GET() {
|
||||
return json(listJobs());
|
||||
}
|
||||
|
||||
export async function POST({ request }) {
|
||||
const contentType = request.headers.get('content-type') ?? '';
|
||||
|
||||
let url: string | null = null;
|
||||
let audioMode: AudioMode = 'auto';
|
||||
let language: string | undefined;
|
||||
let fileBuffer: Buffer | null = null;
|
||||
let filename = 'upload';
|
||||
|
||||
if (contentType.includes('application/json')) {
|
||||
const body = await request.json();
|
||||
url = body.url ?? null;
|
||||
audioMode = body.audioMode ?? 'auto';
|
||||
language = body.language;
|
||||
} else if (contentType.includes('multipart/form-data')) {
|
||||
const form = await request.formData();
|
||||
url = form.get('url')?.toString() ?? null;
|
||||
audioMode = (form.get('audioMode')?.toString() as AudioMode) ?? 'auto';
|
||||
language = form.get('language')?.toString();
|
||||
const file = form.get('file');
|
||||
if (file instanceof File) {
|
||||
fileBuffer = Buffer.from(await file.arrayBuffer());
|
||||
filename = file.name;
|
||||
}
|
||||
} else {
|
||||
throw error(415, 'Unsupported content type');
|
||||
}
|
||||
|
||||
if (!url && !fileBuffer) throw error(400, 'Provide url or file');
|
||||
|
||||
const jobId = url
|
||||
? await startYouTubeJob(url, audioMode, language)
|
||||
: await startUploadJob(fileBuffer!, filename, audioMode, language);
|
||||
|
||||
return json({ id: jobId }, { status: 201 });
|
||||
}
|
||||
18
src/routes/api/jobs/[id]/+server.ts
Normal file
18
src/routes/api/jobs/[id]/+server.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import { getJob, setJobStatus } from '$lib/server/db.js';
|
||||
|
||||
export async function GET({ params }) {
|
||||
const job = getJob(params.id);
|
||||
if (!job) throw error(404, 'Job not found');
|
||||
return json(job);
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
setJobStatus(params.id, 'cancelled', 0);
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
37
src/routes/api/jobs/[id]/download/[format]/+server.ts
Normal file
37
src/routes/api/jobs/[id]/download/[format]/+server.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import { getJob } from '$lib/server/db.js';
|
||||
import { existsSync } from 'fs';
|
||||
import { readFile } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
|
||||
const MIME: Record<string, string> = {
|
||||
srt: 'text/plain',
|
||||
txt: 'text/plain',
|
||||
md: 'text/markdown',
|
||||
json: 'application/json'
|
||||
};
|
||||
|
||||
export async function GET({ params }) {
|
||||
const { id, format } = params;
|
||||
if (!MIME[format]) throw error(400, `Unknown format: ${format}`);
|
||||
|
||||
const job = getJob(id);
|
||||
if (!job) throw error(404, 'Job not found');
|
||||
if (job.status !== 'done') throw error(409, 'Transcript not ready yet');
|
||||
|
||||
if (!job.outputDir) throw error(500, 'Output directory not set');
|
||||
|
||||
const safeTitle =
|
||||
(job.title ?? id).replace(/[^\w\s-]/g, '').replace(/\s+/g, '_').slice(0, 80) || id;
|
||||
const filePath = join(job.outputDir, `${safeTitle}.${format}`);
|
||||
|
||||
if (!existsSync(filePath)) throw error(404, `${format} file not found`);
|
||||
|
||||
const content = await readFile(filePath);
|
||||
return new Response(content.buffer as ArrayBuffer, {
|
||||
headers: {
|
||||
'Content-Type': MIME[format],
|
||||
'Content-Disposition': `attachment; filename="${safeTitle}.${format}"`
|
||||
}
|
||||
});
|
||||
}
|
||||
34
src/routes/api/jobs/[id]/reprocess/+server.ts
Normal file
34
src/routes/api/jobs/[id]/reprocess/+server.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import { getJob, updateJob } from '$lib/server/db.js';
|
||||
import { deduplicateSegments } from '$lib/server/postprocess.js';
|
||||
import { writeOutputs } from '$lib/server/formatter.js';
|
||||
import type { Segment } from '$lib/types.js';
|
||||
|
||||
/** POST /api/jobs/[id]/reprocess — re-run post-processing and regenerate all output files. */
|
||||
export async function POST({ params }) {
|
||||
const job = getJob(params.id);
|
||||
if (!job) throw error(404, 'Job not found');
|
||||
|
||||
if (!job.segmentsJson) {
|
||||
throw error(422, 'No segments stored for this job — cannot reprocess');
|
||||
}
|
||||
|
||||
try {
|
||||
const rawSegments = JSON.parse(job.segmentsJson) as Segment[];
|
||||
const segments = deduplicateSegments(rawSegments);
|
||||
|
||||
const paths = await writeOutputs(segments, job.title, job.id);
|
||||
const outputDir = paths.srt.replace(/\/[^/]+$/, '');
|
||||
|
||||
updateJob({
|
||||
id: job.id,
|
||||
segmentsJson: JSON.stringify(segments),
|
||||
outputDir
|
||||
});
|
||||
|
||||
return json({ ok: true, paths });
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
throw error(500, `Reprocess failed: ${message}`);
|
||||
}
|
||||
}
|
||||
51
src/routes/api/jobs/[id]/stream/+server.ts
Normal file
51
src/routes/api/jobs/[id]/stream/+server.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import { getJob } from '$lib/server/db.js';
|
||||
import { subscribeProgress } from '$lib/server/pipeline.js';
|
||||
|
||||
export async function GET({ params, request }) {
|
||||
const job = getJob(params.id);
|
||||
if (!job) throw error(404, 'Job not found');
|
||||
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
const enc = new TextEncoder();
|
||||
|
||||
function send(data: string) {
|
||||
controller.enqueue(enc.encode(`data: ${data}\n\n`));
|
||||
}
|
||||
|
||||
// Send current status immediately
|
||||
send(JSON.stringify({ type: 'status', status: job.status, progress: job.progress }));
|
||||
|
||||
if (job.status === 'done' || job.status === 'failed' || job.status === 'cancelled') {
|
||||
send(JSON.stringify({ type: 'done', status: job.status }));
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
|
||||
const unsub = subscribeProgress(params.id, (data) => {
|
||||
send(data);
|
||||
try {
|
||||
const parsed = JSON.parse(data);
|
||||
if (parsed.type === 'done' || parsed.type === 'error') {
|
||||
controller.close();
|
||||
unsub();
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
});
|
||||
|
||||
request.signal.addEventListener('abort', () => {
|
||||
unsub();
|
||||
controller.close();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive'
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user