1// On-disk library: one directory per video, each with a video.json.
2//
3// <root>/library.json { order: [videoId, ...] }
4// <root>/config.json app settings (incl. Deepgram key), mode 0600
5// <root>/<videoId>/video.json
6// <root>/<videoId>/thumbnail.jpg
7// <root>/<videoId>/takes/<takeId>/{screen,camera}.webm, peaks.json, transcript.json
8// <root>/.trash/ deleted videos are moved here
10import fs from 'node:fs/promises';
11import path from 'node:path';
12import type { AppConfig, Video, VideoSummary } from '../shared/types.ts';
13import { exportSignature } from '../shared/types.ts';
14import { randomId, timelineDuration } from '../shared/timeline.ts';
16export class HttpError extends Error {
17 status: number;
18 constructor(status: number, message: string) {
19 super(message);
20 this.status = status;
21 }
22}
24let root = '';
26export async function initStore(dir: string) {
27 root = dir;
28 await fs.mkdir(root, { recursive: true });
29}
31export const libraryDir = () => root;
32export const videoDir = (id: string) => path.join(root, checkId(id));
33export const takeDir = (id: string, takeId: string) => path.join(videoDir(id), 'takes', checkId(takeId));
35const ID_RE = /^[a-z0-9][a-z0-9-]{0,79}$/;
37export function checkId(id: string): string {
38 if (typeof id !== 'string' || !ID_RE.test(id)) throw new HttpError(400, `Invalid id: ${id}`);
39 return id;
40}
42export async function exists(p: string) {
43 try {
44 await fs.access(p);
45 return true;
46 } catch {
47 return false;
48 }
49}
51async function readJson<T>(file: string, fallback: T): Promise<T> {
52 try {
53 return JSON.parse(await fs.readFile(file, 'utf8')) as T;
54 } catch (e) {
55 if ((e as NodeJS.ErrnoException).code === 'ENOENT') return fallback;
56 throw e;
57 }
58}
60export async function writeJsonAtomic(file: string, data: unknown, mode = 0o644) {
61 const tmp = `${file}.${randomId(6)}.tmp`;
62 await fs.writeFile(tmp, JSON.stringify(data, null, 2), { mode });
63 await fs.rename(tmp, file);
64}
66// Serialize read-modify-write cycles per key.
67const locks = new Map<string, Promise<unknown>>();
68function withLock<T>(key: string, fn: () => Promise<T>): Promise<T> {
69 const prev = locks.get(key) ?? Promise.resolve();
70 const next = prev.then(fn, fn);
71 const settled = next.catch(() => {});
72 locks.set(key, settled);
73 settled.then(() => {
74 if (locks.get(key) === settled) locks.delete(key);
75 });
76 return next;
77}
79// ---- config ----
81const DEFAULT_CONFIG: AppConfig & { deepgramApiKey: string } = {
82 deepgramApiKey: '',
83 deepgramModel: 'nova-3',
84 autoTranscribe: true,
85 defaultCountdown: 3,
86 defaultCameraHeight: 720,
87 defaultOutputHeight: 720,
88 matteHost: '',
89 matteSetup: '',
90};
92export type FullConfig = typeof DEFAULT_CONFIG;
94export async function getConfig(): Promise<FullConfig> {
95 return { ...DEFAULT_CONFIG, ...(await readJson<Partial<FullConfig>>(path.join(root, 'config.json'), {})) };
96}
98export function updateConfig(patch: Partial<FullConfig>): Promise<FullConfig> {
99 return withLock('__config__', async () => {
100 const cfg = await getConfig();
101 for (const k of Object.keys(DEFAULT_CONFIG) as (keyof FullConfig)[]) {
102 if (patch[k] !== undefined) (cfg as unknown as Record<string, unknown>)[k] = patch[k];
103 }
104 await writeJsonAtomic(path.join(root, 'config.json'), cfg, 0o600);
105 return cfg;
106 });
107}
109// ---- videos ----
111export async function getVideo(id: string): Promise<Video> {
112 const v = await readJson<Video | null>(path.join(videoDir(id), 'video.json'), null);
113 if (!v) throw new HttpError(404, `Video not found: ${id}`);
114 return v;
115}
117// `touch: false` is for bookkeeping that is not an edit (the library shows updatedAt).
118export function updateVideo(id: string, fn: (v: Video) => void | Promise<void>, { touch = true } = {}): Promise<Video> {
119 return withLock(id, async () => {
120 const v = await getVideo(id);
121 await fn(v);
122 if (touch) v.updatedAt = new Date().toISOString();
123 await writeJsonAtomic(path.join(videoDir(id), 'video.json'), v);
124 return v;
125 });
126}
128function newVideoId(title: string) {
129 const d = new Date();
130 const pad = (n: number) => String(n).padStart(2, '0');
131 const stamp = `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}`;
132 const slug = slugify(title).slice(0, 40);
133 return [stamp, slug, randomId(4)].filter(Boolean).join('-');
134}
136export function slugify(s: string) {
137 return s
138 .toLowerCase()
139 .normalize('NFKD')
140 .replace(/[^a-z0-9]+/g, '-')
141 .replace(/^-+|-+$/g, '');
142}
144export async function createVideo(input: { title?: string; description?: string; notes?: string }): Promise<Video> {
145 const cfg = await getConfig();
146 const now = new Date().toISOString();
147 const title = (input.title ?? '').trim() || 'Untitled video';
148 const v: Video = {
149 id: newVideoId(title),
150 title,
151 description: input.description ?? '',
152 notes: input.notes ?? '',
153 createdAt: now,
154 updatedAt: now,
155 takes: [],
156 timeline: [],
157 layout: { mode: 'pip', corner: 'br', size: 0.22, shape: 'rounded' },
158 output: { height: cfg.defaultOutputHeight, normalizeAudio: true },
159 thumbnail: null,
160 export: null,
161 };
162 await fs.mkdir(path.join(videoDir(v.id), 'takes'), { recursive: true });
163 await writeJsonAtomic(path.join(videoDir(v.id), 'video.json'), v);
164 await withLock('__library__', async () => {
165 const lib = await readLibrary();
166 lib.order.push(v.id);
167 await writeJsonAtomic(path.join(root, 'library.json'), lib);
168 });
169 return v;
170}
172async function readLibrary() {
173 return readJson<{ order: string[] }>(path.join(root, 'library.json'), { order: [] });
174}
176export function summarize(v: Video): VideoSummary {
177 const ready = v.takes.filter((t) => t.status === 'ready');
178 const used = new Set(v.timeline.map((s) => s.takeId));
179 const usedTakes = ready.filter((t) => used.has(t.id));
180 return {
181 id: v.id,
182 title: v.title,
183 description: v.description,
184 createdAt: v.createdAt,
185 updatedAt: v.updatedAt,
186 duration: timelineDuration(v.timeline),
187 takeCount: ready.length,
188 hasScreen: usedTakes.some((t) => t.tracks.screen),
189 hasCamera: usedTakes.some((t) => t.tracks.camera),
190 thumbnailUrl: v.thumbnail ? `/media/${v.id}/${v.thumbnail.file}?v=${encodeURIComponent(v.thumbnail.updatedAt)}` : null,
191 exportState: !v.export ? 'none' : v.export.signature === exportSignature(v) ? 'current' : 'stale',
192 };
193}
195export async function listVideos(): Promise<VideoSummary[]> {
196 const entries = await fs.readdir(root, { withFileTypes: true });
197 const videos = new Map<string, Video>();
198 for (const e of entries) {
199 if (!e.isDirectory() || !ID_RE.test(e.name)) continue;
200 try {
201 videos.set(e.name, await getVideo(e.name));
202 } catch {
203 // not a video directory
204 }
205 }
206 const lib = await readLibrary();
207 const ordered = lib.order.filter((id) => videos.has(id));
208 const rest = [...videos.keys()].filter((id) => !ordered.includes(id)).sort((a, b) => videos.get(a)!.createdAt.localeCompare(videos.get(b)!.createdAt));
209 return [...ordered, ...rest].map((id) => summarize(videos.get(id)!));
210}
212export function setOrder(order: string[]) {
213 order.forEach(checkId);
214 return withLock('__library__', async () => {
215 await writeJsonAtomic(path.join(root, 'library.json'), { order });
216 });
217}
219export async function trashVideo(id: string) {
220 const src = videoDir(id);
221 if (!(await exists(path.join(src, 'video.json')))) throw new HttpError(404, `Video not found: ${id}`);
222 const trash = path.join(root, '.trash');
223 await fs.mkdir(trash, { recursive: true });
224 await withLock(id, () => fs.rename(src, path.join(trash, `${id}--deleted-${Date.now()}`)));
225 await withLock('__library__', async () => {
226 const lib = await readLibrary();
227 lib.order = lib.order.filter((x) => x !== id);
228 await writeJsonAtomic(path.join(root, 'library.json'), lib);
229 });
230}