// On-disk library: one directory per video, each with a video.json. // // /library.json { order: [videoId, ...] } // /config.json app settings (incl. Deepgram key), mode 0600 // //video.json // //thumbnail.jpg // //takes//{screen,camera}.webm, peaks.json, transcript.json // /.trash/ deleted videos are moved here import fs from 'node:fs/promises'; import path from 'node:path'; import type { AppConfig, Video, VideoSummary } from '../shared/types.ts'; import { exportSignature } from '../shared/types.ts'; import { randomId, timelineDuration } from '../shared/timeline.ts'; export class HttpError extends Error { status: number; constructor(status: number, message: string) { super(message); this.status = status; } } let root = ''; export async function initStore(dir: string) { root = dir; await fs.mkdir(root, { recursive: true }); } export const libraryDir = () => root; export const videoDir = (id: string) => path.join(root, checkId(id)); export const takeDir = (id: string, takeId: string) => path.join(videoDir(id), 'takes', checkId(takeId)); const ID_RE = /^[a-z0-9][a-z0-9-]{0,79}$/; export function checkId(id: string): string { if (typeof id !== 'string' || !ID_RE.test(id)) throw new HttpError(400, `Invalid id: ${id}`); return id; } export async function exists(p: string) { try { await fs.access(p); return true; } catch { return false; } } async function readJson(file: string, fallback: T): Promise { try { return JSON.parse(await fs.readFile(file, 'utf8')) as T; } catch (e) { if ((e as NodeJS.ErrnoException).code === 'ENOENT') return fallback; throw e; } } export async function writeJsonAtomic(file: string, data: unknown, mode = 0o644) { const tmp = `${file}.${randomId(6)}.tmp`; await fs.writeFile(tmp, JSON.stringify(data, null, 2), { mode }); await fs.rename(tmp, file); } // Serialize read-modify-write cycles per key. const locks = new Map>(); function withLock(key: string, fn: () => Promise): Promise { const prev = locks.get(key) ?? Promise.resolve(); const next = prev.then(fn, fn); const settled = next.catch(() => {}); locks.set(key, settled); settled.then(() => { if (locks.get(key) === settled) locks.delete(key); }); return next; } // ---- config ---- const DEFAULT_CONFIG: AppConfig & { deepgramApiKey: string } = { deepgramApiKey: '', deepgramModel: 'nova-3', autoTranscribe: true, defaultCountdown: 3, defaultCameraHeight: 720, defaultOutputHeight: 720, matteHost: '', matteSetup: '', }; export type FullConfig = typeof DEFAULT_CONFIG; export async function getConfig(): Promise { return { ...DEFAULT_CONFIG, ...(await readJson>(path.join(root, 'config.json'), {})) }; } export function updateConfig(patch: Partial): Promise { return withLock('__config__', async () => { const cfg = await getConfig(); for (const k of Object.keys(DEFAULT_CONFIG) as (keyof FullConfig)[]) { if (patch[k] !== undefined) (cfg as unknown as Record)[k] = patch[k]; } await writeJsonAtomic(path.join(root, 'config.json'), cfg, 0o600); return cfg; }); } // ---- videos ---- export async function getVideo(id: string): Promise