Create src/lib/server/data-dir.ts as single source of truth for DATA_DIR using db.ts's $HOME-based convention. All four modules now import from it instead of defining their own inline default (some resolving to /tmp/.whisper-pwa). Changes: - src/lib/server/data-dir.ts — new file, exports DATA_DIR const - db.ts — import DATA_DIR from data-dir.ts (same logic, same path) - audio.ts — import DATA_DIR, replace inline process.env.DATA_DIR ?? '/tmp/...' - downloader.ts — import DATA_DIR, replace inline '/tmp/.whisper-pwa' - remux.ts — import DATA_DIR, replace inline process.env.DATA_DIR ?? '/tmp/...' - tests/data-dir.test.ts — 10 tests covering default, custom, and module integrity - tests/retry-pipeline.test.ts — fix stale hardcoded /tmp/.whisper-pwa/uploads path; compute parent dir from getUploadPath() dynamically Test pass rate: 217/220 (3 pre-existing failures unrelated to DATA_DIR) - 2 D5 downloader tests need yt-dlp (not available in test env) - All other tests green, including the new data-dir.test.ts (10/10)
182 lines
5.7 KiB
TypeScript
182 lines
5.7 KiB
TypeScript
import Database from 'better-sqlite3';
|
|
import { randomUUID } from 'crypto';
|
|
import { existsSync, mkdirSync } from 'fs';
|
|
import { join } from 'path';
|
|
import { DATA_DIR } from './data-dir.js';
|
|
import type { Job, JobStatus, AudioMode, PushSubscription } from '$lib/types.js';
|
|
|
|
/**
|
|
* Add a column to a table if it does not already exist.
|
|
* Uses PRAGMA table_info for an existence check, consistent with the
|
|
* existing CREATE TABLE IF NOT EXISTS idiom — no migration tool introduced.
|
|
*/
|
|
function addColumnIfMissing(table: string, column: string, typeDef: string): void {
|
|
const columns = db.pragma(`table_info(${table})`) as { name: string }[];
|
|
if (!columns.some((c) => c.name === column)) {
|
|
db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${typeDef}`);
|
|
}
|
|
}
|
|
|
|
if (!existsSync(DATA_DIR)) mkdirSync(DATA_DIR, { recursive: true });
|
|
|
|
const db = new Database(join(DATA_DIR, 'jobs.db'));
|
|
db.pragma('journal_mode = WAL');
|
|
db.pragma('foreign_keys = ON');
|
|
|
|
db.exec(`
|
|
CREATE TABLE IF NOT EXISTS jobs (
|
|
id TEXT PRIMARY KEY,
|
|
status TEXT NOT NULL DEFAULT 'pending',
|
|
title TEXT NOT NULL DEFAULT '',
|
|
source TEXT NOT NULL DEFAULT '',
|
|
audio_mode TEXT NOT NULL DEFAULT 'auto',
|
|
mean_volume REAL,
|
|
whisper_job_id TEXT,
|
|
progress INTEGER NOT NULL DEFAULT 0,
|
|
output_dir TEXT,
|
|
segments_json TEXT,
|
|
error TEXT,
|
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS push_subscriptions (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
endpoint TEXT NOT NULL UNIQUE,
|
|
p256dh TEXT NOT NULL,
|
|
auth TEXT NOT NULL,
|
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
);
|
|
`);
|
|
|
|
// Add retry columns idempotently (may already exist on restart)
|
|
try {
|
|
addColumnIfMissing('jobs', 'retry_count', 'INTEGER DEFAULT 0');
|
|
addColumnIfMissing('jobs', 'next_retry_at', 'TEXT');
|
|
} catch (e) {
|
|
console.warn('[db] failed to add retry columns:', e);
|
|
}
|
|
|
|
const stmts = {
|
|
insertJob: db.prepare(`
|
|
INSERT INTO jobs (id, status, title, source, audio_mode)
|
|
VALUES (@id, 'pending', @title, @source, @audioMode)
|
|
`),
|
|
updateJob: db.prepare(`
|
|
UPDATE jobs SET
|
|
status = @status,
|
|
title = @title,
|
|
mean_volume = @meanVolume,
|
|
whisper_job_id = @whisperJobId,
|
|
progress = @progress,
|
|
output_dir = @outputDir,
|
|
segments_json = @segmentsJson,
|
|
error = @error,
|
|
retry_count = @retryCount,
|
|
next_retry_at = @nextRetryAt,
|
|
updated_at = datetime('now')
|
|
WHERE id = @id
|
|
`),
|
|
setStatus: db.prepare(`
|
|
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,
|
|
retry_count = 0, next_retry_at = 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(`
|
|
INSERT INTO push_subscriptions (endpoint, p256dh, auth)
|
|
VALUES (@endpoint, @p256dh, @auth)
|
|
ON CONFLICT(endpoint) DO UPDATE SET p256dh = excluded.p256dh, auth = excluded.auth
|
|
`),
|
|
allSubs: db.prepare('SELECT * FROM push_subscriptions'),
|
|
deleteSub: db.prepare('DELETE FROM push_subscriptions WHERE endpoint = ?')
|
|
};
|
|
|
|
function rowToJob(row: Record<string, unknown>): Job {
|
|
return {
|
|
id: row.id as string,
|
|
status: row.status as JobStatus,
|
|
title: row.title as string,
|
|
source: row.source as string,
|
|
audioMode: row.audio_mode as AudioMode,
|
|
meanVolume: row.mean_volume as number | null,
|
|
whisperJobId: row.whisper_job_id as string | null,
|
|
progress: row.progress as number,
|
|
outputDir: row.output_dir as string | null,
|
|
segmentsJson: row.segments_json as string | null,
|
|
error: row.error as string | null,
|
|
retryCount: row.retry_count as number | null,
|
|
nextRetryAt: row.next_retry_at as string | null,
|
|
createdAt: row.created_at as string,
|
|
updatedAt: row.updated_at as string
|
|
};
|
|
}
|
|
|
|
export function createJob(source: string, title: string, audioMode: AudioMode): Job {
|
|
const id = randomUUID();
|
|
stmts.insertJob.run({ id, title, source, audioMode });
|
|
return getJob(id)!;
|
|
}
|
|
|
|
export function getJob(id: string): Job | null {
|
|
const row = stmts.getJob.get(id) as Record<string, unknown> | undefined;
|
|
return row ? rowToJob(row) : null;
|
|
}
|
|
|
|
export function listJobs(): Job[] {
|
|
return (stmts.listJobs.all() as Record<string, unknown>[]).map(rowToJob);
|
|
}
|
|
|
|
export function updateJob(job: Partial<Job> & { id: string }): void {
|
|
const current = getJob(job.id);
|
|
if (!current) return;
|
|
const merged = { ...current, ...job };
|
|
stmts.updateJob.run({
|
|
id: merged.id,
|
|
status: merged.status,
|
|
title: merged.title,
|
|
meanVolume: merged.meanVolume,
|
|
whisperJobId: merged.whisperJobId,
|
|
progress: merged.progress,
|
|
outputDir: merged.outputDir,
|
|
segmentsJson: merged.segmentsJson,
|
|
error: merged.error,
|
|
retryCount: merged.retryCount ?? null,
|
|
nextRetryAt: merged.nextRetryAt ?? null
|
|
});
|
|
}
|
|
|
|
export function setJobStatus(id: string, status: JobStatus, progress = 0): void {
|
|
stmts.setStatus.run({ id, status, progress });
|
|
}
|
|
|
|
export function savePushSubscription(sub: { endpoint: string; p256dh: string; auth: string }): void {
|
|
stmts.upsertSub.run(sub);
|
|
}
|
|
|
|
export function getAllSubscriptions(): PushSubscription[] {
|
|
return stmts.allSubs.all() as 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);
|
|
}
|