Files
platform/src/servers/api/file-browser/router.ts
T
pastilhasandClaude Opus 5 b4f88ec161 routes refuse at the route, and a new home is empty
Three things, from a member sitting on /music with no music capability on a server with
no music sidecar: an empty library, and 403s in the console.

PERMISSIONS AT THE ROUTE. `canVisit` filtered the dock and nothing else, so the tile was
hidden and the route was wide open — typing the path, following an old link or restoring
a tab rendered the screen anyway. RouteGate now wraps every screen in one place, inside
the error boundary.

It does not redirect. Sending someone to `/` erases what they asked for and reads as a
bug: they clicked Music and landed on Home. It says why instead, and the URL stays put so
a reload after installing the thing just works.

And it says which of the two reasons applies, because they need different screens and send
the reader to different places. `not-installed` is a fact about the SERVER — the owner gets
a link to the app store. `not-granted` is a fact about the ACCOUNT, and only the owner can
change it. Presenting either as the other sends you looking in the wrong place.

ROUTES FOLLOW THE SIDECAR. Free, once the above exists: `deniedRoutes` already covers
"held but its sidecar is not installed", so an uninstalled feature has no tile AND no
screen. The dock, the Permissions list and the routes now agree because they read one
answer.

NO MORE SEEDING. Downloads/Documents/Music/Videos/Pictures are gone from both places that
made them — the member's provisioning and, older and worse, `/ls`, which created folders in
somebody's home as a side effect of LOOKING at it. A listing that invents its own contents
is a listing you cannot trust, and the platform has no standing to choose a person's folder
layout. A new home is empty.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 18:42:39 +00:00

1516 lines
56 KiB
TypeScript

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<string | null> {
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<string, Promise<string>>();
async function ensureAudioRemux(email: string, absPath: string, relPath: string, track: number): Promise<string> {
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<string, unknown>;
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 <track> 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<string, string> } = {};
try {
format = (JSON.parse(fmtOut) as { format?: typeof format }).format ?? {};
} catch {
format = {};
}
const tags: Record<string, string> = {};
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<string, string> };
streams?: Array<{ tags?: Record<string, string> }>;
};
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<ProbedTracks> {
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<string, Group>();
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<string, string> = { '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<string, string> = { '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<string> {
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<string, DownloadJob>();
async function runReclipDownload(jobId: string, url: string, absPath: string, audioOnly: boolean) {
const set = (patch: Partial<DownloadJob>) =>
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<string, unknown>;
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<string, unknown>;
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 });
});