import { createRouter } from '@@/create-router'; import { resolve, dirname, join, sep, parse as parsePath } from 'node:path'; import { readdir, stat, mkdir, rm, rename, readFile, cp } from 'node:fs/promises'; import { existsSync } from 'node:fs'; import { getOwnerHomeDir, DATA_PATH } from '@@/data-path'; import { resolveHomeDir } from '@@/user-home'; import * as errors from '@@/custom-errors'; import { readTtsConfig } from '@@/api/server-settings/tts'; import { readSttConfig } from '@@/api/server-settings/stt'; import { readOcrConfig } from '@@/api/server-settings/ocr'; import { transcribeAudio } from '@@/api/stt/transcribe'; import { getUserSettings } from 'officerdb'; async function getUserTtsVoice(userId: number): Promise { try { const settings = (await getUserSettings(userId)) as { tts?: { voice?: string | null } }; return settings.tts?.voice ?? null; } catch {} return null; } const OLD_CACHE_DIRS = ['ocr', 'tts', 'transcriptions', 'audio', 'video']; async function cleanOldCacheDirs(userDataDir: string) { for (const dir of OLD_CACHE_DIRS) { const target = join(userDataDir, dir); if (existsSync(target)) await rm(target, { recursive: true, force: true }); } } // `seedHomeDir` used to be here, creating Downloads/Documents/Music/Videos/Pictures on the first listing of // any home. Removed 2026-08-11: it invented folders in somebody's home directory as a side effect of LOOKING // at it, which is not a listing's business and not a layout the platform has any standing to choose. export const router = createRouter(); /** * Resolve whose home this request may touch, once, before any handler runs. * * A middleware rather than a change to `getRootDir`'s signature because that function is called from * fifteen places in this file. Making it async would have meant editing fifteen call sites, and the * failure mode of missing one is the worst available: a handler that quietly serves the OWNER'S home to a * member. Resolving here means a handler cannot run without the answer. * * The `user-data` root is untouched by this — it is already keyed on the caller's own email and holds * platform-written data rather than anything executable. */ router.use(async (ctx, next) => { const user = ctx.get('user'); const resolved = await resolveHomeDir(user.id as number); if (!resolved.ok) { throw resolved.needsOsAccount ? errors.FORBIDDEN(`Files are not available for this account: ${resolved.reason}.`) : errors.FORBIDDEN(resolved.reason); } ctx.set('user', { ...user, homeDir: resolved.home }); return next(); }); /** * `homeDir` is put on the context user by `confineToHome` below, so the fifteen-odd call sites of * `getRootDir` keep working unchanged and none of them can forget to resolve it. */ type UserCtx = { email: string; homeDir?: string }; function getUserDataDir(email: string): string { return join(DATA_PATH, email); } export function getRootDir(user: UserCtx, root?: string): string { // `user.homeDir` is set for every request that reached a handler — the middleware refuses the request // otherwise. The fallback exists only for the owner-shaped callers that construct a UserCtx by hand; // it is NOT a "member without an OS account gets the owner's home" path, because such a request never // gets this far. See user-home.ts for why that distinction is the whole point. if (!root || root === 'home') return user.homeDir ?? getOwnerHomeDir(user.email); if (root === 'user-data') return getUserDataDir(user.email); throw errors.BAD_REQUEST(`Invalid root: ${root}`); } // A resolved path counts as contained only when it IS the root or sits beneath it. A bare // startsWith also accepts a sibling whose name merely begins with the root's — `/home/br-backup` // passes a `/home/br` check — which is how `..` segments escaped. const isInside = (root: string, target: string): boolean => target === root || target.startsWith(root + sep); export function resolveUserPath(rootDir: string, relPath: string): string { const resolved = resolve(rootDir, relPath.replace(/^\/+/, '')); if (!isInside(rootDir, resolved)) throw errors.FORBIDDEN('Path outside root directory'); return resolved; } // mkv `title` / mp4 `handler_name` hold a track's name; the generic "…Handler" defaults are ignored. function trackName(tags?: { title?: string; handler_name?: string }): string { const handler = tags?.handler_name ?? ''; return tags?.title || (handler && !/Handler$/.test(handler) ? handler : ''); } // ffprobe bit_rate (bits/s, as a string) → rounded kbps, or null when the container doesn't report it. function kbps(bitRate?: string): number | null { const n = Number(bitRate); return Number.isFinite(n) && n > 0 ? Math.round(n / 1000) : null; } // Serve a video with a chosen audio track selected: fast `-c copy` remux (video untouched, other // audio dropped) cached under the user's data dir, so it streams with byte-range seeking like /raw. // In-flight remuxes are shared so concurrent requests for the same track don't race on the temp file. const audioRemuxInFlight = new Map>(); async function ensureAudioRemux(email: string, absPath: string, relPath: string, track: number): Promise { const parsed = parsePath(relPath); const ext = (parsed.ext.slice(1) || 'mp4').toLowerCase(); const sub = parsed.dir ? `${parsed.dir}/` : ''; // ffmpeg picks the output muxer from the file extension, so both the final and temp names must // keep the real extension (a ".tmp" suffix makes ffmpeg fail with "unable to choose format"). const base = resolve(getUserDataDir(email), `cache/audio/${sub}${parsed.name}.a${track}`); const cacheAbs = `${base}.${ext}`; if (existsSync(cacheAbs)) return cacheAbs; const pending = audioRemuxInFlight.get(cacheAbs); if (pending) return pending; const job = (async () => { await mkdir(dirname(cacheAbs), { recursive: true }); const tmp = `${base}.tmp.${ext}`; const movflags = ext === 'mp4' || ext === 'mov' || ext === 'm4v' ? ['-movflags', '+faststart'] : []; const proc = Bun.spawn( [ 'ffmpeg', '-v', 'error', '-i', absPath, '-map', '0:v', '-map', `0:a:${track}`, '-c', 'copy', '-dn', '-sn', ...movflags, '-y', tmp, ], { stdout: 'ignore', stderr: 'pipe' }, ); const code = await proc.exited; if (code !== 0) { const err = await new Response(proc.stderr).text(); await rm(tmp, { force: true }).catch(() => {}); throw errors.BAD_REQUEST(err.trim() || 'Audio track remux failed'); } await rename(tmp, cacheAbs); return cacheAbs; })(); audioRemuxInFlight.set(cacheAbs, job); try { return await job; } finally { audioRemuxInFlight.delete(cacheAbs); } } // List directory entries router.get('/ls', async (ctx) => { const user = ctx.get('user'); const rootDir = getRootDir(user, ctx.req.query('root') ?? undefined); const relPath = (ctx.req.query('path') || '/').replace(/^\/+/, ''); const absPath = resolveUserPath(rootDir, relPath); // Create the home root itself if it is missing, and nothing else. A listing that invents its own contents // is a listing you cannot trust — the folder set it used to seed is gone. // // Non-fatal: a member's home is theirs, so this can raise EPERM, and `readdir` below is the real test of // whether the directory can be used. if (!ctx.req.query('root') || ctx.req.query('root') === 'home') { try { await mkdir(absPath, { recursive: true }); } catch { // Either it exists, or it is not ours to create. } } // Remove old top-level cache dirs (migrated to cache/ prefix) if (ctx.req.query('root') === 'user-data') { await cleanOldCacheDirs(rootDir); } let names: string[]; try { names = await readdir(absPath); } catch (ex) { // A missing directory resets the browser to the root, which is the right answer for a stale path. // // A PERMISSION failure is not that, and conflating them cost an afternoon: a member's home is 700 and // theirs, so before the ACL grant in os-user.ts the platform's readdir raised EACCES here and this // returned an empty listing — the UI said "This folder is empty" over five directories that existed. // An empty result is data; it should never be how a refusal looks. if ((ex as { code?: string }).code === 'EACCES' || (ex as { code?: string }).code === 'EPERM') { throw errors.FORBIDDEN( `Officer cannot read ${relPath || 'this folder'}. If this is a member's home, its access control ` + `lists are missing — reprovision the Linux account from Settings → User management.`, ); } return ctx.json({ path: '/', entries: [], reset: true }); } const entries = await Promise.all( names.map(async (name) => { const fullPath = resolve(absPath, name); // Skip entries that escape the home dir (shouldn't happen but be safe) if (!isInside(rootDir, fullPath)) return null; const s = await stat(fullPath).catch(() => null); if (!s) return null; return { name, type: s.isDirectory() ? 'directory' : 'file', size: s.size, modifiedAt: s.mtimeMs, }; }), ); const path = '/' + absPath.slice(rootDir.length).replace(/^\/+/, ''); return ctx.json({ path, rootDir, entries: entries.filter(Boolean) }); }); // Read file contents router.get('/read', async (ctx) => { const user = ctx.get('user'); const rootDir = getRootDir(user, ctx.req.query('root') ?? undefined); const relPath = (ctx.req.query('path') || '').replace(/^\/+/, ''); if (!relPath) throw errors.BAD_REQUEST('path is required'); const absPath = resolveUserPath(rootDir, relPath); const s = await stat(absPath); if (s.isDirectory()) throw errors.BAD_REQUEST('Cannot read a directory'); const MAX_TEXT_SIZE = 5 * 1024 * 1024; // 5 MB if (s.size > MAX_TEXT_SIZE) throw errors.BAD_REQUEST('File too large to read (max 5 MB)'); const content = await readFile(absPath, 'utf-8'); return ctx.json({ content, size: s.size }); }); // Write file contents router.post('/write', async (ctx) => { const user = ctx.get('user'); const rootDir = getRootDir(user, ctx.req.query('root') ?? undefined); const { path, content } = ctx.get('body') as { path: string; content: string }; if (!path) throw errors.BAD_REQUEST('path is required'); if (typeof content !== 'string') throw errors.BAD_REQUEST('content must be a string'); const MAX_SIZE = 5 * 1024 * 1024; // 5 MB if (new TextEncoder().encode(content).length > MAX_SIZE) throw errors.BAD_REQUEST('Content too large (max 5 MB)'); const absPath = resolveUserPath(rootDir, path); await mkdir(dirname(absPath), { recursive: true }); await Bun.write(absPath, content); return ctx.json({ ok: true }); }); // Create directory router.post('/mkdir', async (ctx) => { const user = ctx.get('user'); const rootDir = getRootDir(user, ctx.req.query('root') ?? undefined); const { path } = ctx.get('body') as { path: string }; if (!path) throw errors.BAD_REQUEST('path is required'); const absPath = resolveUserPath(rootDir, path); await mkdir(absPath, { recursive: true }); return ctx.json({ ok: true }); }); // Upload files router.post('/upload', async (ctx) => { const user = ctx.get('user'); const rootDir = getRootDir(user, ctx.req.query('root') ?? undefined); const relPath = (ctx.req.query('path') || '/').replace(/^\/+/, ''); const targetDir = resolveUserPath(rootDir, relPath); await mkdir(targetDir, { recursive: true }); const body = ctx.get('body') as Record; const raw = body['file']; const files = Array.isArray(raw) ? raw : raw ? [raw] : []; for (const file of files) { if (!(file instanceof File)) continue; const filePath = resolve(targetDir, file.name); if (!isInside(rootDir, filePath)) continue; await mkdir(dirname(filePath), { recursive: true }); await Bun.write(filePath, file); } return ctx.json({ ok: true }); }); // Rename file or directory router.post('/rename', async (ctx) => { const user = ctx.get('user'); const rootDir = getRootDir(user, ctx.req.query('root') ?? undefined); const { path: filePath, newName } = ctx.get('body') as { path: string; newName: string }; if (!filePath || !newName) throw errors.BAD_REQUEST('path and newName are required'); if (newName.includes('/')) throw errors.BAD_REQUEST('newName must not contain /'); const absPath = resolveUserPath(rootDir, filePath); if (absPath === rootDir) throw errors.FORBIDDEN('Cannot rename home directory'); const newPath = resolve(dirname(absPath), newName); if (!isInside(rootDir, newPath)) throw errors.FORBIDDEN('Path outside home directory'); await rename(absPath, newPath); return ctx.json({ ok: true }); }); // Serve raw file (binary-safe, for audio/video/images/download) // Supports Range requests for audio/video seeking router.get('/raw', async (ctx) => { const user = ctx.get('user'); const rootDir = getRootDir(user, ctx.req.query('root') ?? undefined); const relPath = (ctx.req.query('path') || '').replace(/^\/+/, ''); if (!relPath) throw errors.BAD_REQUEST('path is required'); const absPath = resolveUserPath(rootDir, relPath); const s = await stat(absPath); if (s.isDirectory()) throw errors.BAD_REQUEST('Cannot serve a directory'); // Optional: serve with a specific audio track selected (track 0 is the default → serve raw). let fileAbs = absPath; const audioParam = ctx.req.query('audio'); if (audioParam) { const track = parseInt(audioParam, 10); if (Number.isInteger(track) && track > 0) fileAbs = await ensureAudioRemux(user.email, absPath, relPath, track); } const file = Bun.file(fileAbs); const contentType = file.type || 'application/octet-stream'; const total = file.size; const rangeHeader = ctx.req.header('range'); if (rangeHeader) { const match = rangeHeader.match(/bytes=(\d*)-(\d*)/); if (match) { const start = match[1] ? parseInt(match[1], 10) : 0; const end = match[2] ? parseInt(match[2], 10) : total - 1; const chunkSize = end - start + 1; const slice = file.slice(start, end + 1); return new Response(slice, { status: 206, headers: { 'Content-Type': contentType, 'Content-Range': `bytes ${start}-${end}/${total}`, 'Content-Length': String(chunkSize), 'Accept-Ranges': 'bytes', }, }); } } return new Response(file, { headers: { 'Content-Type': contentType, 'Content-Length': String(total), 'Accept-Ranges': 'bytes', }, }); }); // List a video's text-based subtitle tracks (for the in-browser player's selector) const TEXT_SUBTITLE_CODECS = new Set([ 'subrip', 'srt', 'ass', 'ssa', 'mov_text', 'webvtt', 'text', 'subviewer', 'microdvd', ]); router.get('/subtitles', async (ctx) => { const user = ctx.get('user'); const rootDir = getRootDir(user, ctx.req.query('root') ?? undefined); const relPath = (ctx.req.query('path') || '').replace(/^\/+/, ''); if (!relPath) throw errors.BAD_REQUEST('path is required'); const absPath = resolveUserPath(rootDir, relPath); const proc = Bun.spawn( [ 'ffprobe', '-v', 'error', '-select_streams', 's', '-show_entries', 'stream=codec_name:stream_tags=language,title,handler_name', '-of', 'json', absPath, ], { stdout: 'pipe', stderr: 'ignore' }, ); const out = await new Response(proc.stdout).text(); await proc.exited; type ProbeStream = { codec_name?: string; tags?: { language?: string; title?: string; handler_name?: string } }; let streams: ProbeStream[] = []; try { streams = (JSON.parse(out).streams as ProbeStream[]) ?? []; } catch { streams = []; } // mkv stores the track name in `title`; mp4/mov stores it in `handler_name` (default names like // "SubtitleHandler" are generic and ignored). const trackName = (tags: ProbeStream['tags']) => { const handler = tags?.handler_name ?? ''; return tags?.title || (handler && !/Handler$/.test(handler) ? handler : ''); }; // `id` is the subtitle-relative index among ALL subtitle streams (what `-map 0:s:id` expects), // so it is assigned before filtering out image-based tracks that can't become WebVTT. const tracks = streams .map((s, id) => ({ id, s })) .filter(({ s }) => TEXT_SUBTITLE_CODECS.has((s.codec_name ?? '').toLowerCase())) .map(({ id, s }) => ({ id, codec: s.codec_name ?? '', lang: s.tags?.language ?? '', title: trackName(s.tags) })); return ctx.json(tracks); }); // Extract one subtitle track as WebVTT for a element router.get('/subtitle-vtt', async (ctx) => { const user = ctx.get('user'); const rootDir = getRootDir(user, ctx.req.query('root') ?? undefined); const relPath = (ctx.req.query('path') || '').replace(/^\/+/, ''); if (!relPath) throw errors.BAD_REQUEST('path is required'); const track = parseInt(ctx.req.query('track') ?? '', 10); if (!Number.isInteger(track) || track < 0) throw errors.BAD_REQUEST('valid track is required'); const absPath = resolveUserPath(rootDir, relPath); const proc = Bun.spawn(['ffmpeg', '-v', 'error', '-i', absPath, '-map', `0:s:${track}`, '-f', 'webvtt', 'pipe:1'], { stdout: 'pipe', stderr: 'ignore', }); return new Response(proc.stdout as ReadableStream, { headers: { 'Content-Type': 'text/vtt; charset=utf-8' }, }); }); // List a video's audio tracks (for the external audio-track selector; served via raw?audio=N) router.get('/audio-tracks', async (ctx) => { const user = ctx.get('user'); const rootDir = getRootDir(user, ctx.req.query('root') ?? undefined); const relPath = (ctx.req.query('path') || '').replace(/^\/+/, ''); if (!relPath) throw errors.BAD_REQUEST('path is required'); const absPath = resolveUserPath(rootDir, relPath); const proc = Bun.spawn( [ 'ffprobe', '-v', 'error', '-select_streams', 'a', '-show_entries', 'stream=channels,codec_name,bit_rate:stream_tags=language,title,handler_name', '-of', 'json', absPath, ], { stdout: 'pipe', stderr: 'ignore' }, ); const out = await new Response(proc.stdout).text(); await proc.exited; type ProbeAudio = { channels?: number; codec_name?: string; bit_rate?: string; tags?: { language?: string; title?: string; handler_name?: string }; }; let streams: ProbeAudio[] = []; try { streams = (JSON.parse(out).streams as ProbeAudio[]) ?? []; } catch { streams = []; } const tracks = streams.map((s, id) => ({ id, codec: s.codec_name ?? '', channels: s.channels ?? 0, bitrate: kbps(s.bit_rate), lang: s.tags?.language ?? '', title: trackName(s.tags), })); return ctx.json(tracks); }); // Read-only audio metadata for the Get Lyrics run-task panel: title/artist/duration + whether the file // already has embedded lyrics. Tolerant of missing tags / probe failures (defaults to empty/0/false). router.get('/audio-meta', async (ctx) => { const user = ctx.get('user'); const rootDir = getRootDir(user, ctx.req.query('root') ?? undefined); const relPath = (ctx.req.query('path') || '').replace(/^\/+/, ''); if (!relPath) throw errors.BAD_REQUEST('path is required'); const absPath = resolveUserPath(rootDir, relPath); // Format tags + duration. const fmtProc = Bun.spawn( [ 'ffprobe', '-v', 'error', '-show_entries', 'format=duration:format_tags=title,artist,album,TITLE,ARTIST', '-of', 'json', absPath, ], { stdout: 'pipe', stderr: 'ignore' }, ); const fmtOut = await new Response(fmtProc.stdout).text(); await fmtProc.exited; let format: { duration?: string; tags?: Record } = {}; try { format = (JSON.parse(fmtOut) as { format?: typeof format }).format ?? {}; } catch { format = {}; } const tags: Record = {}; for (const [k, val] of Object.entries(format.tags ?? {})) tags[k.toLowerCase()] = val; // ID3 case varies const durNum = format.duration ? parseFloat(format.duration) : 0; // ID3 lyrics frames (USLT/SYLT) don't reliably surface in format_tags — probe stream+format tags and // flag lyrics if any key matches USLT/SYLT/lyrics (case-insensitive; some muxers emit `lyrics-XXX`). const lyrProc = Bun.spawn( ['ffprobe', '-v', 'error', '-show_entries', 'stream_tags:format_tags', '-of', 'json', absPath], { stdout: 'pipe', stderr: 'ignore' }, ); const lyrOut = await new Response(lyrProc.stdout).text(); await lyrProc.exited; let hasLyrics = false; try { const data = JSON.parse(lyrOut) as { format?: { tags?: Record }; streams?: Array<{ tags?: Record }>; }; const keys = [ ...Object.keys(data.format?.tags ?? {}), ...(data.streams ?? []).flatMap((s) => Object.keys(s.tags ?? {})), ]; hasLyrics = keys.some((k) => /uslt|sylt|lyrics/i.test(k)); } catch { hasLyrics = false; } return ctx.json({ title: tags.title ?? '', artist: tags.artist ?? '', duration: Number.isFinite(durNum) ? durNum : 0, hasLyrics, }); }); // Recursively probe a folder's videos and group them by track layout, so the task runner can offer // one set of audio/subtitle pickers for a whole season when every episode matches — and flag the // odd files out when they don't. Two files "match" when their audio (language + channel count) and // subtitle (language) streams line up in order; per-episode titles are ignored (they always differ). const VIDEO_EXTENSIONS = new Set([ 'mp4', 'mkv', 'webm', 'mov', 'avi', 'wmv', 'flv', 'm4v', 'mpg', 'mpeg', 'ts', 'm2ts', 'mts', '3gp', 'ogv', 'vob', 'divx', 'asf', 'f4v', 'rm', 'rmvb', ]); type FolderAudioTrack = { id: number; codec: string; channels: number; bitrate: number | null; lang: string; title: string; }; type FolderSubtitleTrack = { id: number; codec: string; lang: string; title: string }; type ProbedTracks = { audio: FolderAudioTrack[]; subtitle: FolderSubtitleTrack[] }; async function probeVideoTracks(absPath: string): Promise { const proc = Bun.spawn( [ 'ffprobe', '-v', 'error', '-show_entries', 'stream=codec_type,codec_name,channels,bit_rate:stream_tags=language,title,handler_name', '-of', 'json', absPath, ], { stdout: 'pipe', stderr: 'ignore' }, ); const out = await new Response(proc.stdout).text(); await proc.exited; type ProbeStream = { codec_type?: string; codec_name?: string; channels?: number; bit_rate?: string; tags?: { language?: string; title?: string; handler_name?: string }; }; let streams: ProbeStream[] = []; try { streams = (JSON.parse(out).streams as ProbeStream[]) ?? []; } catch { streams = []; } const audio = streams .filter((s) => s.codec_type === 'audio') .map((s, id) => ({ id, codec: s.codec_name ?? '', channels: s.channels ?? 0, bitrate: kbps(s.bit_rate), lang: s.tags?.language ?? '', title: trackName(s.tags), })); // subtitle `id` is the index among ALL subtitle streams (what `-map 0:s:id` expects), assigned // before filtering out image-based tracks that can't become soft subs. const subtitle = streams .filter((s) => s.codec_type === 'subtitle') .map((s, id) => ({ id, codec: s.codec_name ?? '', lang: s.tags?.language ?? '', title: trackName(s.tags) })) .filter((t) => TEXT_SUBTITLE_CODECS.has(t.codec.toLowerCase())); return { audio, subtitle }; } const layoutSignature = (t: ProbedTracks) => `A:${t.audio.map((a) => `${a.lang || 'und'}:${a.channels}`).join(',')}|S:${t.subtitle.map((s) => s.lang || 'und').join(',')}`; router.get('/probe-folder', async (ctx) => { const user = ctx.get('user'); const rootDir = getRootDir(user, ctx.req.query('root') ?? undefined); const relPath = (ctx.req.query('path') || '').replace(/^\/+/, ''); if (!relPath) throw errors.BAD_REQUEST('path is required'); const absPath = resolveUserPath(rootDir, relPath); let entries: string[] = []; try { entries = await readdir(absPath, { recursive: true }); } catch { entries = []; } const files = entries .filter((p) => VIDEO_EXTENSIONS.has(p.split('.').pop()?.toLowerCase() ?? '')) .sort((a, b) => a.localeCompare(b)); // Probe in small batches so a big season doesn't spawn dozens of ffprobes at once. const CONCURRENCY = 8; const probed: { file: string; tracks: ProbedTracks }[] = []; for (let i = 0; i < files.length; i += CONCURRENCY) { const batch = files.slice(i, i + CONCURRENCY); const results = await Promise.all( batch.map(async (file) => ({ file, tracks: await probeVideoTracks(resolveUserPath(rootDir, join(relPath, file))), })), ); probed.push(...results); } type Group = { signature: string; files: string[]; audioTracks: FolderAudioTrack[]; subtitleTracks: FolderSubtitleTrack[]; }; const groupsMap = new Map(); for (const { file, tracks } of probed) { const sig = layoutSignature(tracks); let group = groupsMap.get(sig); if (!group) { group = { signature: sig, files: [], audioTracks: tracks.audio, subtitleTracks: tracks.subtitle }; groupsMap.set(sig, group); } group.files.push(file); } const groups = [...groupsMap.values()] .map((g) => ({ ...g, count: g.files.length })) .sort((a, b) => b.count - a.count || (a.files[0] ?? '').localeCompare(b.files[0] ?? '')); return ctx.json({ fileCount: files.length, groups }); }); // Save a cached result (ocr/tts/transcriptions/audio) next to the original file const CACHE_PREFIXES = ['cache/tts/', 'cache/audio/']; router.post('/save-result', async (ctx) => { const user = ctx.get('user'); const { path: cachedPath } = ctx.get('body') as { path: string }; if (!cachedPath) throw errors.BAD_REQUEST('path is required'); const prefix = CACHE_PREFIXES.find((p) => cachedPath.startsWith(p)); if (!prefix) throw errors.BAD_REQUEST('Not a cached result path'); let relativePath = cachedPath.slice(prefix.length); // TTS cache includes a voice subdirectory (e.g. am_liam/) — strip it if (prefix === 'cache/tts/') { const slashIdx = relativePath.indexOf('/'); if (slashIdx !== -1) relativePath = relativePath.slice(slashIdx + 1); } // Strip any nested cache prefixes (e.g. tts/ocr/photos/file.mp3 → photos/file.mp3) let nested: string | undefined; while ((nested = CACHE_PREFIXES.find((p) => relativePath.startsWith(p)))) { relativePath = relativePath.slice(nested.length); } const userDataDir = getUserDataDir(user.email); const srcAbs = resolve(userDataDir, cachedPath); if (!existsSync(srcAbs)) throw errors.BAD_REQUEST('Cached file not found'); const homeDir = getRootDir(user, 'home'); const destAbs = resolve(homeDir, relativePath); if (!isInside(homeDir, destAbs)) throw errors.FORBIDDEN('Path outside home directory'); await mkdir(dirname(destAbs), { recursive: true }); await cp(srcAbs, destAbs); const destPath = '/' + destAbs.slice(homeDir.length).replace(/^\/+/, ''); return ctx.json({ savedPath: destPath }); }); // Text-to-speech with caching router.post('/tts', async (ctx) => { const user = ctx.get('user'); const { path: filePath, root, saveNextTo } = ctx.get('body') as { path: string; root?: string; saveNextTo?: boolean }; if (!filePath) throw errors.BAD_REQUEST('path is required'); const rootDir = getRootDir(user, root); const absPath = resolveUserPath(rootDir, filePath); const s = await stat(absPath); if (s.isDirectory()) throw errors.BAD_REQUEST('Cannot read aloud a directory'); const ttsConfig = await readTtsConfig(); if (!ttsConfig) throw errors.BAD_REQUEST('TTS not configured — set it up in Settings → Text to Speech'); const userVoice = await getUserTtsVoice(user.id); const voice = userVoice ?? ttsConfig.voice; const userDataDir = getUserDataDir(user.email); const { dir, name } = parsePath(filePath.replace(/^\/+/, '')); const voicePrefix = `${voice}/`; const cacheRel = dir ? `cache/tts/${voicePrefix}${dir}/${name}.mp3` : `cache/tts/${voicePrefix}${name}.mp3`; const cacheAbs = resolve(userDataDir, cacheRel); if (saveNextTo) { const siblingRel = dir ? `${dir}/${name}.mp3` : `${name}.mp3`; const siblingAbs = resolveUserPath(rootDir, siblingRel); if (existsSync(siblingAbs)) { return ctx.json({ audioPath: `/${siblingRel}`, audioRoot: root ?? 'home', cached: true }); } } if (existsSync(cacheAbs)) { if (saveNextTo) { const siblingRel = dir ? `${dir}/${name}.mp3` : `${name}.mp3`; const siblingAbs = resolveUserPath(rootDir, siblingRel); const cached = await readFile(cacheAbs); await Bun.write(siblingAbs, cached); return ctx.json({ audioPath: `/${siblingRel}`, audioRoot: root ?? 'home', cached: true }); } return ctx.json({ audioPath: cacheRel, audioRoot: 'user-data' }); } const content = await readFile(absPath, 'utf-8'); const headers: Record = { 'Content-Type': 'application/json' }; let res: Response; if (ttsConfig.provider === 'elevenlabs') { if (!ttsConfig.apiKey) throw errors.BAD_REQUEST('ElevenLabs API key not configured'); res = await fetch(`https://api.elevenlabs.io/v1/text-to-speech/${encodeURIComponent(voice)}`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'xi-api-key': ttsConfig.apiKey }, body: JSON.stringify({ text: content, model_id: ttsConfig.model }), }); } else { if (ttsConfig.apiKey) headers['Authorization'] = `Bearer ${ttsConfig.apiKey}`; res = await fetch(`${ttsConfig.url.replace(/\/+$/, '')}/v1/audio/speech`, { method: 'POST', headers, body: JSON.stringify({ model: ttsConfig.model, input: content, voice, response_format: 'mp3' }), }); } if (!res.ok) throw errors.BAD_REQUEST('TTS request failed'); await mkdir(dirname(cacheAbs), { recursive: true }); const buffer = await res.arrayBuffer(); await Bun.write(cacheAbs, buffer); if (saveNextTo) { const siblingRel = dir ? `${dir}/${name}.mp3` : `${name}.mp3`; const siblingAbs = resolveUserPath(rootDir, siblingRel); await Bun.write(siblingAbs, buffer); return ctx.json({ audioPath: `/${siblingRel}`, audioRoot: root ?? 'home', cached: false }); } return ctx.json({ audioPath: cacheRel, audioRoot: 'user-data' }); }); // Text-to-speech from raw text with caching router.post('/tts-text', async (ctx) => { const user = ctx.get('user'); const { text, id } = ctx.get('body') as { text: string; id: string }; if (!text) throw errors.BAD_REQUEST('text is required'); if (!id) throw errors.BAD_REQUEST('id is required'); const ttsConfig = await readTtsConfig(); if (!ttsConfig) throw errors.BAD_REQUEST('TTS not configured — set it up in Settings → Text to Speech'); const userVoice = await getUserTtsVoice(user.id); const voice = userVoice ?? ttsConfig.voice; const userDataDir = getUserDataDir(user.email); const cacheRel = `cache/tts/chat/${voice}/${id}.mp3`; const cacheAbs = resolve(userDataDir, cacheRel); if (existsSync(cacheAbs)) { return ctx.json({ audioPath: cacheRel, audioRoot: 'user-data' }); } const headers: Record = { 'Content-Type': 'application/json' }; let res: Response; if (ttsConfig.provider === 'elevenlabs') { if (!ttsConfig.apiKey) throw errors.BAD_REQUEST('ElevenLabs API key not configured'); res = await fetch(`https://api.elevenlabs.io/v1/text-to-speech/${encodeURIComponent(voice)}`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'xi-api-key': ttsConfig.apiKey }, body: JSON.stringify({ text, model_id: ttsConfig.model }), }); } else { if (ttsConfig.apiKey) headers['Authorization'] = `Bearer ${ttsConfig.apiKey}`; res = await fetch(`${ttsConfig.url.replace(/\/+$/, '')}/v1/audio/speech`, { method: 'POST', headers, body: JSON.stringify({ model: ttsConfig.model, input: text, voice, response_format: 'mp3' }), }); } if (!res.ok) throw errors.BAD_REQUEST('TTS request failed'); await mkdir(dirname(cacheAbs), { recursive: true }); const buffer = await res.arrayBuffer(); await Bun.write(cacheAbs, buffer); return ctx.json({ audioPath: cacheRel, audioRoot: 'user-data' }); }); // OCR image via vision model with caching router.post('/ocr', async (ctx) => { const user = ctx.get('user'); const { path: filePath, root, saveNextTo } = ctx.get('body') as { path: string; root?: string; saveNextTo?: boolean }; if (!filePath) throw errors.BAD_REQUEST('path is required'); const rootDir = getRootDir(user, root); const absPath = resolveUserPath(rootDir, filePath); const s = await stat(absPath); if (s.isDirectory()) throw errors.BAD_REQUEST('Cannot OCR a directory'); // No caching: OCR is always run fresh and overwrites any previous result. const { dir, name } = parsePath(filePath.replace(/^\/+/, '')); const ocrConfig = await readOcrConfig(); if (!ocrConfig) throw errors.BAD_REQUEST('OCR not configured — set it up in Settings → OCR'); const imageBytes = await Bun.file(absPath).arrayBuffer(); const base64 = Buffer.from(imageBytes).toString('base64'); const ext = absPath.split('.').pop()?.toLowerCase() ?? 'png'; const mime = ext === 'jpg' || ext === 'jpeg' ? 'image/jpeg' : ext === 'webp' ? 'image/webp' : `image/${ext}`; const res = await fetch(`${ocrConfig.url.replace(/\/+$/, '')}/v1/chat/completions`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ model: ocrConfig.model, messages: [ { role: 'system', content: [ 'You are an OCR assistant. Extract meaningful text content from images.', 'Rules:', '- Output ONLY the extracted text, no commentary or explanations.', '- For documents, articles, books: preserve the original text, paragraphs, and structure as markdown.', '- For tables: use markdown table format.', '- For code/terminal screenshots: use fenced code blocks.', '- For social media posts/threads: extract as clean conversation. Format as:', ' **username** says: "their text"', ' **replier** replies: "their text"', ' Strip all UI chrome (follow buttons, timestamps, like counts, avatars, "Everybody can reply", etc).', ' Keep only usernames and what they actually wrote.', '- For memes/image macros: describe the image briefly, then extract any text.', '- For handwriting: transcribe as accurately as possible.', '- For receipts/invoices: extract as structured text with line items.', '- Strip all UI elements, navigation, ads, watermarks, and other noise.', '- Preserve the original language of the text.', ].join('\n'), }, { role: 'user', content: [ { type: 'image_url', image_url: { url: `data:${mime};base64,${base64}` } }, { type: 'text', text: 'Extract the text from this image.' }, ], }, ], }), }); if (!res.ok) throw errors.BAD_REQUEST('OCR request failed'); const json = (await res.json()) as { choices: { message: { content: string } }[] }; const text = json.choices[0]?.message?.content ?? ''; if (saveNextTo) { const siblingRel = dir ? `${dir}/${name}.md` : `${name}.md`; const siblingAbs = resolveUserPath(rootDir, siblingRel); await Bun.write(siblingAbs, text); return ctx.json({ ocrPath: `/${siblingRel}`, ocrRoot: root ?? 'home', cached: false }); } return ctx.json({ text, cached: false }); }); // Extract audio from video via ffmpeg with caching router.post('/extract-audio', async (ctx) => { const user = ctx.get('user'); const { path: filePath, root } = ctx.get('body') as { path: string; root?: string }; if (!filePath) throw errors.BAD_REQUEST('path is required'); const rootDir = getRootDir(user, root); const absPath = resolveUserPath(rootDir, filePath); const s = await stat(absPath); if (s.isDirectory()) throw errors.BAD_REQUEST('Cannot extract audio from a directory'); // No caching: the extraction is always redone and overwrites the previous result. The output still // lives under the user's data dir because this endpoint backs the file viewer's Extract Audio // button, which plays it rather than saving it beside the video — the Extract Audio task is what // writes into the user's folders. const userDataDir = getUserDataDir(user.email); const { dir, name } = parsePath(filePath.replace(/^\/+/, '')); const outRel = dir ? `cache/audio/${dir}/${name}.mp3` : `cache/audio/${name}.mp3`; const cacheAbs = resolve(userDataDir, outRel); await mkdir(dirname(cacheAbs), { recursive: true }); const proc = Bun.spawn(['ffmpeg', '-i', absPath, '-vn', '-codec:a', 'libmp3lame', '-q:a', '2', '-y', cacheAbs], { stdout: 'ignore', stderr: 'pipe', }); const exitCode = await proc.exited; if (exitCode !== 0) { const stderr = await new Response(proc.stderr).text(); throw errors.BAD_REQUEST(stderr.trim() || 'Audio extraction failed'); } return ctx.json({ audioPath: outRel, audioRoot: 'user-data', cached: false }); }); // Extract archive (zip, tar, 7z, rar) into a sibling folder router.post('/extract', async (ctx) => { const user = ctx.get('user'); const { path: filePath, root } = ctx.get('body') as { path: string; root?: string }; if (!filePath) throw errors.BAD_REQUEST('path is required'); const rootDir = getRootDir(user, root); const absPath = resolveUserPath(rootDir, filePath); const s = await stat(absPath); if (s.isDirectory()) throw errors.BAD_REQUEST('Cannot extract a directory'); const fileName = absPath.split('/').pop()!; const lower = fileName.toLowerCase(); // Determine archive type and build command type ArchiveType = 'tar' | 'zip' | '7z' | 'rar'; let archiveType: ArchiveType; if ( lower.endsWith('.tar') || lower.endsWith('.tar.gz') || lower.endsWith('.tgz') || lower.endsWith('.tar.bz2') || lower.endsWith('.tbz2') || lower.endsWith('.tar.xz') || lower.endsWith('.txz') || lower.endsWith('.tar.zst') || lower.endsWith('.gz') || lower.endsWith('.bz2') || lower.endsWith('.xz') || lower.endsWith('.zst') ) { archiveType = 'tar'; } else if (lower.endsWith('.zip')) { archiveType = 'zip'; } else if (lower.endsWith('.7z')) { archiveType = '7z'; } else if (lower.endsWith('.rar')) { archiveType = 'rar'; } else { throw errors.BAD_REQUEST('Unsupported archive format'); } // Compute destination folder name (strip archive extension) const stripArchiveExt = (name: string): string => { const l = name.toLowerCase(); for (const compound of ['.tar.gz', '.tar.bz2', '.tar.xz', '.tar.zst']) { if (l.endsWith(compound)) return name.slice(0, -compound.length); } const dotIdx = name.lastIndexOf('.'); return dotIdx > 0 ? name.slice(0, dotIdx) : name; }; const baseName = stripArchiveExt(fileName); const destPath = resolve(dirname(absPath), baseName); const finalDest = await resolveCollision(destPath); await mkdir(finalDest, { recursive: true }); let cmd: string[]; switch (archiveType) { case 'tar': cmd = ['tar', 'xf', absPath, '-C', finalDest]; break; case 'zip': cmd = ['unzip', '-q', absPath, '-d', finalDest]; break; case '7z': cmd = ['7z', 'x', absPath, `-o${finalDest}`, '-y']; break; case 'rar': cmd = ['unrar', 'x', '-o+', absPath, `${finalDest}/`]; break; } const proc = Bun.spawn(cmd, { stdout: 'ignore', stderr: 'pipe' }); const exitCode = await proc.exited; if (exitCode !== 0) { const stderr = await new Response(proc.stderr).text(); // Clean up the empty directory on failure await rm(finalDest, { recursive: true, force: true }).catch(() => {}); throw errors.BAD_REQUEST(stderr.trim() || 'Archive extraction failed'); } const extractedPath = '/' + finalDest.slice(rootDir.length).replace(/^\/+/, ''); return ctx.json({ extractedPath }); }); // Transcribe audio via Whisper with caching // Workflow: detect language → check user's spoken languages → translate if needed → transcribe router.post('/transcribe', async (ctx) => { const user = ctx.get('user'); const { path: filePath, root, saveNextTo } = ctx.get('body') as { path: string; root?: string; saveNextTo?: boolean }; if (!filePath) throw errors.BAD_REQUEST('path is required'); const rootDir = getRootDir(user, root); const absPath = resolveUserPath(rootDir, filePath); const s = await stat(absPath); if (s.isDirectory()) throw errors.BAD_REQUEST('Cannot transcribe a directory'); // No caching: a transcription is always produced fresh and overwrites any previous one. const { dir, name } = parsePath(filePath.replace(/^\/+/, '')); const sttConfig = await readSttConfig(); const whisperUrl = sttConfig?.url; if (!whisperUrl) throw errors.BAD_REQUEST('Whisper not configured — set it up in Settings → Speech to Text'); const settings = await getUserSettings(user.id); const spokenLanguages = (settings.languages as { spoken?: string[] })?.spoken ?? []; let text: string; try { const result = await transcribeAudio({ file: Bun.file(absPath), whisperUrl, spokenLanguages }); text = result.text; } catch (err) { throw errors.BAD_REQUEST(err instanceof Error ? err.message : 'Transcription failed'); } if (saveNextTo) { const siblingRel = dir ? `${dir}/${name}.md` : `${name}.md`; const siblingAbs = resolveUserPath(rootDir, siblingRel); await Bun.write(siblingAbs, text); return ctx.json({ transcriptionPath: `/${siblingRel}`, transcriptionRoot: root ?? 'home', cached: false }); } return ctx.json({ text, cached: false }); }); // Search files by name router.get('/search', async (ctx) => { const user = ctx.get('user'); const rootDir = getRootDir(user, ctx.req.query('root') ?? undefined); const query = (ctx.req.query('q') || '').trim().toLowerCase(); if (!query) throw errors.BAD_REQUEST('q is required'); const searchPath = ctx.req.query('path') || ''; const startDir = searchPath ? resolveUserPath(rootDir, searchPath) : rootDir; const MAX_RESULTS = 50; const results: { path: string; name: string; type: string; size: number; modifiedAt: number }[] = []; async function walk(dir: string) { if (results.length >= MAX_RESULTS) return; const names = await readdir(dir).catch(() => [] as string[]); for (const name of names) { if (results.length >= MAX_RESULTS) break; const fullPath = resolve(dir, name); if (!isInside(rootDir, fullPath)) continue; const s = await stat(fullPath).catch(() => null); if (!s) continue; if (name.toLowerCase().includes(query)) { const relPath = '/' + fullPath.slice(rootDir.length).replace(/^\/+/, ''); results.push({ path: relPath, name, type: s.isDirectory() ? 'directory' : 'file', size: s.size, modifiedAt: s.mtimeMs, }); } if (s.isDirectory()) await walk(fullPath); } } await walk(startDir); return ctx.json({ results }); }); // Resolve a destination path, appending " (copy)", " (copy 2)", etc. if it already exists async function resolveCollision(destPath: string): Promise { try { await stat(destPath); } catch { return destPath; } const dir = dirname(destPath); const base = destPath.split('/').pop()!; const dotIdx = base.lastIndexOf('.'); const name = dotIdx > 0 ? base.slice(0, dotIdx) : base; const ext = dotIdx > 0 ? base.slice(dotIdx) : ''; let n = 1; while (true) { const suffix = n === 1 ? ' (copy)' : ` (copy ${n})`; const candidate = resolve(dir, `${name}${suffix}${ext}`); try { await stat(candidate); n++; } catch { return candidate; } } } type CopyMoveItem = { source: string; destination: string }; type CopyMoveBody = CopyMoveItem | { items: CopyMoveItem[] }; function parseCopyMoveBody(body: CopyMoveBody): CopyMoveItem[] { if ('items' in body && Array.isArray(body.items)) return body.items; return [body as CopyMoveItem]; } // Copy file or directory router.post('/copy', async (ctx) => { const user = ctx.get('user'); const rootDir = getRootDir(user, ctx.req.query('root') ?? undefined); const body = ctx.get('body') as CopyMoveBody; const items = parseCopyMoveBody(body); if (items.length === 0) throw errors.BAD_REQUEST('No items provided'); const results: { source: string; destination: string; error?: string }[] = []; for (const item of items) { if (!item.source || !item.destination) { results.push({ source: item.source, destination: item.destination, error: 'source and destination are required', }); continue; } try { const srcAbs = resolveUserPath(rootDir, item.source); const destAbs = resolveUserPath(rootDir, item.destination); await mkdir(dirname(destAbs), { recursive: true }); const finalDest = await resolveCollision(destAbs); await cp(srcAbs, finalDest, { recursive: true }); const relDest = '/' + finalDest.slice(rootDir.length).replace(/^\/+/, ''); results.push({ source: item.source, destination: relDest }); } catch (err: unknown) { const msg = err instanceof Error ? err.message : 'Unknown error'; results.push({ source: item.source, destination: item.destination, error: msg }); } } return ctx.json({ ok: true, results }); }); // Move file or directory router.post('/move', async (ctx) => { const user = ctx.get('user'); const rootDir = getRootDir(user, ctx.req.query('root') ?? undefined); const body = ctx.get('body') as CopyMoveBody; const items = parseCopyMoveBody(body); if (items.length === 0) throw errors.BAD_REQUEST('No items provided'); const results: { source: string; destination: string; error?: string }[] = []; for (const item of items) { if (!item.source || !item.destination) { results.push({ source: item.source, destination: item.destination, error: 'source and destination are required', }); continue; } try { const srcAbs = resolveUserPath(rootDir, item.source); const destAbs = resolveUserPath(rootDir, item.destination); // Prevent moving a directory into itself if (destAbs.startsWith(srcAbs + '/')) { throw new Error('Cannot move a directory into itself'); } await mkdir(dirname(destAbs), { recursive: true }); const finalDest = await resolveCollision(destAbs); await rename(srcAbs, finalDest); const relDest = '/' + finalDest.slice(rootDir.length).replace(/^\/+/, ''); results.push({ source: item.source, destination: relDest }); } catch (err: unknown) { const msg = err instanceof Error ? err.message : 'Unknown error'; results.push({ source: item.source, destination: item.destination, error: msg }); } } return ctx.json({ ok: true, results }); }); // Download video by delegating to the ReClip service (its own yt-dlp). It runs as a background job so a // large download never holds one long HTTP request open (a reverse proxy would 504 on that). POST // returns a jobId immediately; the client polls GET /download-video/:jobId until done/error. const RECLIP_BASE = process.env.RECLIP_URL ?? 'http://localhost:8899'; type DownloadJob = { status: 'downloading' | 'transferring' | 'done' | 'error'; error?: string; filename?: string; at: number; }; const downloadJobs = new Map(); async function runReclipDownload(jobId: string, url: string, absPath: string, audioOnly: boolean) { const set = (patch: Partial) => downloadJobs.set(jobId, { ...downloadJobs.get(jobId)!, ...patch, at: Date.now() }); try { // Best-effort metadata for a nice title-based filename (ReClip names by job id otherwise). let title = ''; try { const infoRes = await fetch(`${RECLIP_BASE}/api/info`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url }), signal: AbortSignal.timeout(60_000), }); if (infoRes.ok) title = ((await infoRes.json()) as { title?: string }).title ?? ''; } catch { /* metadata is optional */ } const dlRes = await fetch(`${RECLIP_BASE}/api/download`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url, format: audioOnly ? 'audio' : 'video', title }), signal: AbortSignal.timeout(30_000), }).catch(() => { throw new Error(`Could not reach ReClip at ${RECLIP_BASE}`); }); if (!dlRes.ok) throw new Error('ReClip rejected the download request'); const reclipJob = ((await dlRes.json()) as { job_id?: string }).job_id; if (!reclipJob) throw new Error('ReClip did not return a job id'); // Poll ReClip until done/error (generous deadline; ReClip enforces its own per-download cap). const deadline = Date.now() + 60 * 60_000; let filename = ''; for (;;) { if (Date.now() > deadline) throw new Error('Download timed out'); await new Promise((r) => setTimeout(r, 2000)); const stRes = await fetch(`${RECLIP_BASE}/api/status/${reclipJob}`, { signal: AbortSignal.timeout(15_000), }).catch(() => null); if (!stRes?.ok) continue; const st = (await stRes.json()) as { status: string; error?: string | null; filename?: string | null }; if (st.status === 'error') throw new Error(st.error || 'ReClip download failed'); if (st.status === 'done') { filename = st.filename || `${reclipJob}.${audioOnly ? 'mp3' : 'mp4'}`; break; } } set({ status: 'transferring', filename }); // Stream the finished file into the user's folder (filename is title-sanitized, no path parts). const fileRes = await fetch(`${RECLIP_BASE}/api/file/${reclipJob}`, { signal: AbortSignal.timeout(600_000) }); const body = fileRes.body; if (!fileRes.ok || !body) throw new Error('Failed to fetch the downloaded file from ReClip'); // Pump the reader manually — Bun.write(path, Response) can deadlock on a streaming fetch body. const sink = Bun.file(join(absPath, filename)).writer(); const reader = body.getReader(); for (;;) { const { done, value } = await reader.read(); if (done) break; sink.write(value); } await sink.end(); set({ status: 'done', filename }); } catch (err) { set({ status: 'error', error: err instanceof Error ? err.message : String(err) }); } } router.post('/download-video', async (ctx) => { const user = ctx.get('user'); const rootDir = getRootDir(user, ctx.req.query('root') ?? undefined); const { url, path, audioOnly } = ctx.get('body') as { url: string; path: string; audioOnly?: boolean }; if (!url) throw errors.BAD_REQUEST('url is required'); if (!path) throw errors.BAD_REQUEST('path is required'); const absPath = resolveUserPath(rootDir, path); await mkdir(absPath, { recursive: true }); // Evict finished jobs older than an hour so the map doesn't grow unbounded. const cutoff = Date.now() - 60 * 60_000; for (const [id, job] of downloadJobs) if (job.at < cutoff) downloadJobs.delete(id); const jobId = crypto.randomUUID(); downloadJobs.set(jobId, { status: 'downloading', at: Date.now() }); void runReclipDownload(jobId, url, absPath, !!audioOnly); return ctx.json({ jobId }); }); router.get('/download-video/:jobId', (ctx) => { const job = downloadJobs.get(ctx.req.param('jobId')); if (!job) return ctx.json({ status: 'error', error: 'unknown or expired job' }); return ctx.json({ status: job.status, error: job.error, filename: job.filename }); }); // Prefetch a single video's metadata (title / thumbnail / duration / uploader) — proxied straight to // ReClip's /api/info so the download dialog can show a preview card before committing. Errors (private // video, timeout, …) come back as { error } with a 200 so the client can render them inline. // GET, not POST: it reads and writes nothing. The method is not cosmetic — the permission model reads // it to decide whether a non-owner may call this, and a read filed as a write is a read they lose. router.get('/video-info', async (ctx) => { const url = ctx.req.query('url'); if (!url) throw errors.BAD_REQUEST('url is required'); const res = await fetch(`${RECLIP_BASE}/api/info`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url }), signal: AbortSignal.timeout(90_000), }).catch(() => null); if (!res) return ctx.json({ error: `Could not reach ReClip at ${RECLIP_BASE}` }); const data = (await res.json().catch(() => ({}))) as Record; return ctx.json(data); }); // Expand a playlist URL into its individual video URLs (ReClip's /api/playlist → { urls }). The client // then prefetches /video-info per url to build the per-entry cards. // GET for the same reason as /video-info above. router.get('/video-playlist', async (ctx) => { const url = ctx.req.query('url'); if (!url) throw errors.BAD_REQUEST('url is required'); const res = await fetch(`${RECLIP_BASE}/api/playlist`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url }), signal: AbortSignal.timeout(120_000), }).catch(() => null); if (!res) return ctx.json({ error: `Could not reach ReClip at ${RECLIP_BASE}` }); const data = (await res.json().catch(() => ({}))) as Record; return ctx.json(data); }); // Git clone a repository into a directory router.post('/git-clone', async (ctx) => { const user = ctx.get('user'); const rootDir = getRootDir(user, ctx.req.query('root') ?? undefined); const { url, path } = ctx.get('body') as { url: string; path: string }; if (!url) throw errors.BAD_REQUEST('url is required'); if (!path) throw errors.BAD_REQUEST('path is required'); const absPath = resolveUserPath(rootDir, path); await mkdir(absPath, { recursive: true }); const proc = Bun.spawn(['git', 'clone', url], { cwd: absPath, stdout: 'ignore', stderr: 'pipe' }); const stderrText = await new Response(proc.stderr).text(); const exitCode = await proc.exited; if (exitCode !== 0) { throw errors.BAD_REQUEST(stderrText.trim() || 'git clone failed'); } return ctx.json({ ok: true }); }); // Download file or directory (directories are zipped on-the-fly) router.get('/download', async (ctx) => { const user = ctx.get('user'); const rootDir = getRootDir(user, ctx.req.query('root') ?? undefined); const relPath = (ctx.req.query('path') || '').replace(/^\/+/, ''); if (!relPath) throw errors.BAD_REQUEST('path is required'); const absPath = resolveUserPath(rootDir, relPath); const s = await stat(absPath); const name = absPath.split('/').pop()!; if (s.isDirectory()) { const proc = Bun.spawn(['zip', '-r', '-', name], { cwd: dirname(absPath), stdout: 'pipe', stderr: 'ignore', }); return new Response(proc.stdout as ReadableStream, { headers: { 'Content-Type': 'application/zip', 'Content-Disposition': `attachment; filename="${name}.zip"`, }, }); } const file = Bun.file(absPath); return new Response(file, { headers: { 'Content-Type': file.type || 'application/octet-stream', 'Content-Disposition': `attachment; filename="${name}"`, 'Content-Length': String(s.size), }, }); }); // Download multiple items as a single zip router.post('/download', async (ctx) => { const user = ctx.get('user'); const rootDir = getRootDir(user, ctx.req.query('root') ?? undefined); const { paths } = ctx.get('body') as { paths: string[] }; if (!Array.isArray(paths) || paths.length === 0) throw errors.BAD_REQUEST('paths is required'); const items: string[] = []; for (const p of paths) { const relPath = p.replace(/^\/+/, ''); const absPath = resolveUserPath(rootDir, relPath); await stat(absPath); // throws if not found items.push(relPath); } const proc = Bun.spawn(['zip', '-r', '-', ...items], { cwd: rootDir, stdout: 'pipe', stderr: 'ignore', }); return new Response(proc.stdout as ReadableStream, { headers: { 'Content-Type': 'application/zip', 'Content-Disposition': 'attachment; filename="download.zip"', }, }); }); // Delete file or directory router.delete('/rm', async (ctx) => { const user = ctx.get('user'); const rootDir = getRootDir(user, ctx.req.query('root') ?? undefined); const body = await ctx.req.json<{ path: string }>(); const { path } = body; if (!path) throw errors.BAD_REQUEST('path is required'); const absPath = resolveUserPath(rootDir, path); // Prevent deleting the home dir itself if (absPath === rootDir) throw errors.FORBIDDEN('Cannot delete home directory'); await rm(absPath, { recursive: true, force: true }); return ctx.json({ ok: true }); });