Files
platform/src/servers/api/file-browser/router.ts
T
pastilhasandClaude Opus 5 000df7fe45 platform events, and thumbnails warmed the moment files arrive
a closed, named list of events — `files:uploaded`, `archive:extracted`,
`files:placed` — each carrying absolute paths and an owner. NOT an event bus in the
open sense: `emit(anyString, anything)` becomes an API nobody can change, because
the moment a plugin listens for a string that string is a contract with no list
saying so. a union means adding one is a deliberate edit and removing one fails to
compile.

TWO GUARANTEES, AND THEY ARE THE WHOLE DESIGN. emit never blocks: it schedules and
returns, so an upload does not wait for a subscriber thumbnailing a HEIC. and a
subscriber cannot fail the operation that triggered it — the upload is written and
answered before anybody is told, so a plugin that is down, slow or throwing gets a
line in the log. tested both ways, sync and async, plus that one failing listener
does not stop the next.

PLUGINS SUBSCRIBE THROUGH THE MANIFEST, same shape as `fileTypes`: `hooks: {
'files:uploaded': '/v1/on-upload' }`. resolved at EMIT time rather than at boot,
because plugins are installed, updated and disabled while the server runs and a
registration made at startup describes a machine that has since changed. nothing
here names a plugin.

CORE IS THE FIRST SUBSCRIBER, and that is deliberate rather than a shortcut. the
thumbnail cache belongs to the platform — its key, its route — so a plugin doing
this work would produce files the browser never looks at. the warmer calls the same
`buildThumbnail` the request path calls, which means HEIC still warms through
av-club via the fileTypes claim, without this file knowing that plugin exists.

AND A SINGLE-FLIGHT MAP, which the warmer made necessary. two callers now want the
same thumbnail as a matter of course: the warmer starts on upload and the browser
asks a moment later. measured before the fix — four concurrent requests plus the
warmer each decoded the same 4032x3024 HEIC and took a slot from a limit of four,
which is a warmer competing with the person waiting rather than helping them. after:
all five share one build, 5.5s once, then 130ms.

verified end to end: upload returns in 56ms, four concurrent requests all 200 in a
single 5.5s build, cached 130ms. 1679 tests, docs:check green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-29 08:20:35 +01:00

1738 lines
68 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 { writeAtomic } from '@@/write-atomic';
import * as errors from '@@/custom-errors';
import { readTtsConfig } from '@@/api/server-settings/tts';
import { readOcrConfig } from '@@/api/server-settings/ocr';
import { getUserSettings } from 'officerdb';
import { buildThumbnail, clampSize, thumbnailKind } from './thumbnails';
import { pluginFileTypes, sidecarFetch } from './plugin-file-types';
import { emit } from '../../events/bus';
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 });
}
/*
What a plugin can show that we cannot. Resolved ONCE per listing rather than per entry — it reads the
database, and a folder of two hundred files would otherwise ask two hundred times for one answer.
*/
const claimed = await pluginFileTypes();
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;
const isDirectory = s.isDirectory();
return {
name,
type: isDirectory ? 'directory' : 'file',
size: s.size,
modifiedAt: s.mtimeMs,
/*
Whether `/thumb` will produce something, decided HERE so there is one list of extensions
rather than one on each side of the wire.
The client had no way to know without its own copy, and the copies would have drifted — the
browser's `NATIVE_VIDEO_EXTS` is deliberately only the formats a <video> tag can play, which
is a different question from what ffmpeg can take a frame out of, and every .mkv in the
folder would have gone without a picture for that reason alone.
Costs nothing: this entry has just been stat'd and the answer is a lookup on its name.
*/
/*
Core's own answer first, then a plugin's claim.
A file only says `thumb` when something can actually produce one: core for the formats sharp
and ffmpeg read, a plugin for what it declared in `fileTypes`. Without that second half, `.heic`
promised a thumbnail nothing would make and cost a 422 on every scroll — which is why core
stopped claiming it.
*/
thumb: isDirectory
? null
: (thumbnailKind(name) ??
(claimed.has(name.slice(name.lastIndexOf('.') + 1).toLowerCase()) ? 'image' : null)),
};
}),
);
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 writeAtomic(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] : [];
/*
WRITE ASIDE, THEN RENAME. Never straight to the final path.
`Bun.write(filePath, file)` streams to the name the file will end up with, so for as long as the
upload takes, that name exists holding a partial file — and anything watching the folder cannot
tell. The failure is silent rather than loud: a truncated m4a decodes fine, it is simply short. A
watcher transcribing voice memos as they land would produce a clean transcript of the first few
seconds of a two-minute recording and report success.
A rename within one directory is atomic, so afterwards the final name either does not exist or is
a complete file. Every consumer gets that for free — the phone's own `uploadVerified`, a watcher,
an agent reading the folder — instead of each inventing a heuristic like "the size stopped
changing", which cannot tell a finished upload from a stalled one.
`.part` rather than a dotfile, deliberately: a leftover should be VISIBLE, named for what it was
going to be, next to where it belongs. Nothing sweeps them up — a job deleting files it did not
create is a worse trade than a fragment somebody can see.
*/
const written: string[] = [];
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 writeAtomic(filePath, file);
written.push(filePath);
}
/*
Announced AFTER the writes and BEFORE the response, which is the only ordering that is correct.
After, because a subscriber reads these from disk and they have to be there. Before returning is
fine because `emit` schedules and returns — the upload does not wait for anybody. What it must never
become is an await: a subscriber thumbnailing a HEIC would add five seconds to an upload that has
already finished.
*/
emit('files:uploaded', { paths: written, email: user.email });
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
/**
* A small JPEG of an image or a video, for the browser's grid and list.
*
* ── Why not just point <img> at `/raw` ──
*
* Because a directory of photographs is then a directory of full-size downloads. Thirty 8MB images to
* draw thirty 80px squares is a quarter of a gigabyte over the wire to show something the size of a
* postage stamp, and it is worse on a phone, which is where it matters. Video cannot be done that way
* at all — there is no frame until something decodes one.
*
* ── Authenticated by `?token=`, like `/raw` ──
*
* An `<img>` cannot send an Authorization header. `user-middleware` already accepts the token in the
* query for exactly this reason, so this route needs no special handling — and it stays behind the same
* gate as everything else here rather than becoming a public URL that happens to be hard to guess.
*
* ── `immutable`, honestly ──
*
* The cache key contains the file's mtime and size, so this URL's answer genuinely cannot change: a
* different file is a different URL. That is what makes a year-long cache correct rather than a bet.
*/
router.get('/thumb', 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 size = clampSize(ctx.req.query('size'));
const kind = thumbnailKind(relPath);
/*
A type core cannot read, but an installed plugin can.
Forwarded to the plugin's own sidecar and cached HERE, under the same key everything else uses —
so a HEIC thumbnail is produced once and then served from disk like any other, and the plugin is
not asked again on every scroll.
`buildThumbnail` is given the fetch rather than a path: the caching, the concurrency limit and the
remembered failures are all in that one function, and a second copy of them here for this one case
is how the two would come to behave differently.
*/
if (!kind) {
const claimed = (await pluginFileTypes()).get(relPath.slice(relPath.lastIndexOf('.') + 1).toLowerCase());
if (claimed?.thumbnail) {
const fetched = await buildThumbnail(absPath, getUserDataDir(user.email), 'image', size, async () => {
const res = await sidecarFetch(
`${claimed.thumbnail}?path=${encodeURIComponent(absPath)}&size=${size}`,
user.email,
);
return res.ok ? Buffer.from(await res.arrayBuffer()) : null;
});
if (!fetched.ok) return ctx.text(fetched.reason, 422);
return new Response(Bun.file(fetched.path), {
headers: { 'Content-Type': 'image/jpeg', 'Cache-Control': 'private, max-age=31536000, immutable' },
});
}
// 415 rather than 404: the file is there, it is simply not something anything here can read. The
// client does not ask for these, so this answers a stale tab rather than normal use.
return ctx.text('no thumbnail for this file type', 415);
}
const result = await buildThumbnail(absPath, getUserDataDir(user.email), kind, size);
// 422, and the client falls back to the file icon. Not 500: a file ffmpeg cannot read is a fact about
// the file, and logging it as a server error would bury the ones that are.
if (!result.ok) return ctx.text(result.reason, 422);
return new Response(Bun.file(result.path), {
headers: {
'Content-Type': 'image/jpeg',
'Cache-Control': 'private, max-age=31536000, immutable',
},
});
});
/**
* A displayable version of a file the browser cannot show itself.
*
* Only for types an installed plugin CLAIMS — everything a browser can already decode keeps going
* through `/raw`, which streams bytes, supports ranges and lets the browser render progressively. This
* route exists for exactly one situation: the file is an image, and no browser can display it.
*
* Not cached on disk like `/thumb`. A full-size render is large, viewed once, and keyed on a width the
* viewer chooses — caching it would fill the disk to make the second look at one photograph faster. The
* short `max-age` lets the browser hold it while somebody zooms.
*/
router.get('/render', 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 claimed = (await pluginFileTypes()).get(relPath.slice(relPath.lastIndexOf('.') + 1).toLowerCase());
// 415 and not 404: the file is there. Nothing installed can show it, which is a different answer and
// the one the viewer needs in order to say so rather than spin.
if (!claimed?.render) return ctx.text('nothing installed can display this file type', 415);
const asked = Number(ctx.req.query('width'));
const width = Number.isFinite(asked) && asked > 0 ? Math.min(Math.max(asked, 256), 4096) : 2048;
const res = await sidecarFetch(`${claimed.render}?path=${encodeURIComponent(absPath)}&width=${width}`, user.email);
if (!res.ok) return ctx.text(`${claimed.plugin} could not read this file`, 422);
return new Response(await res.arrayBuffer(), {
headers: {
'Content-Type': res.headers.get('content-type') ?? 'image/webp',
'Cache-Control': 'private, max-age=300',
},
});
});
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 writeAtomic(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 writeAtomic(cacheAbs, buffer);
if (saveNextTo) {
const siblingRel = dir ? `${dir}/${name}.mp3` : `${name}.mp3`;
const siblingAbs = resolveUserPath(rootDir, siblingRel);
await writeAtomic(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 writeAtomic(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 writeAtomic(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');
}
/*
What actually came out, walked once rather than guessed from the archive listing.
Bounded, because an archive can hold a hundred thousand files and this runs on the request's way
out. The warmer has its own cap too; this one keeps the WALK from being the expensive part.
*/
const unpacked: string[] = [];
const collect = async (dir: string, depth = 0): Promise<void> => {
if (depth > 8 || unpacked.length >= 500) return;
for (const entry of await readdir(dir, { withFileTypes: true }).catch(() => [])) {
if (unpacked.length >= 500) return;
const full = join(dir, entry.name);
if (entry.isDirectory()) await collect(full, depth + 1);
else unpacked.push(full);
}
};
await collect(finalDest);
emit('archive:extracted', { paths: unpacked, email: user.email });
const extractedPath = '/' + finalDest.slice(rootDir.length).replace(/^\/+/, '');
return ctx.json({ extractedPath });
});
// 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);
}
// Zip from the items' own folder, not from the home root.
//
// `cwd: rootDir` with paths like `Tests/test_folder` put the whole chain from home inside the archive —
// unzip it and you got `Tests/test_folder/...` rather than `test_folder/...`. Nobody selecting three
// files in a folder is asking for the path to that folder to be part of what they downloaded.
//
// A mixed selection cannot collapse that way without colliding (two `notes.txt` from different folders
// would overwrite), so it keeps the root-relative form, where the paths are what keeps them apart. The
// UI only ever selects within one folder, so the second branch is the safety net rather than the case.
const parents = new Set(items.map((i) => dirname(resolveUserPath(rootDir, i))));
const sameFolder = parents.size === 1;
const cwd = sameFolder ? [...parents][0]! : rootDir;
const args = sameFolder ? items.map((i) => i.split('/').pop()!) : items;
const proc = Bun.spawn(['zip', '-r', '-', ...args], {
cwd,
stdout: 'pipe',
stderr: 'ignore',
});
return new Response(proc.stdout as ReadableStream, {
headers: {
'Content-Type': 'application/zip',
'Content-Disposition': 'attachment; filename="download.zip"',
},
});
});
/**
* Compress items into a zip ON DISK, beside them.
*
* Distinct from GET/POST /download, which also shell out to `zip` but stream the result to the browser
* and leave nothing behind. There was no way to make an archive and keep it — the file browser could
* unpack an archive (`/extract`) but not create one, which is a strange half of a pair.
*
* The name comes from the selection: one item gives `<name>.zip`, several give `archive.zip`, and
* `resolveCollision` handles the rest — so compressing twice yields `archive (copy).zip` rather than
* silently overwriting the first one.
*
* `-r` because a directory has to go in whole, and paths are relative to `rootDir` so the archive holds
* `photos/a.jpg` rather than the absolute path of somebody's home directory.
*/
router.post('/compress', 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[] = [];
let onlyIsDir = false;
for (const p of paths) {
const relPath = p.replace(/^\/+/, '');
const absPath = resolveUserPath(rootDir, relPath);
const st = await stat(absPath); // throws if not found
onlyIsDir = st.isDirectory();
items.push(relPath);
}
// Everything must sit in one directory for the archive to land beside it, which the UI guarantees —
// a selection is always within the folder being viewed. Checked rather than assumed.
const parents = new Set(items.map((i) => dirname(resolveUserPath(rootDir, i))));
if (parents.size !== 1) throw errors.BAD_REQUEST('all items must be in the same folder');
const parent = [...parents][0]!;
// Strip the extension only for a FILE: `report.pdf` → `report.zip`, but a directory called
// `my.photos` must stay `my.photos.zip` rather than becoming `my.zip`.
const only = items.length === 1 ? (items[0]!.split('/').pop() ?? 'archive') : null;
const base = only === null ? 'archive' : onlyIsDir ? only : only.replace(/\.[^.]+$/, '');
const target = await resolveCollision(resolve(parent, `${base}.zip`));
const proc = Bun.spawn(['zip', '-r', '-q', target, ...items.map((i) => i.split('/').pop()!)], {
cwd: parent,
stdout: 'ignore',
stderr: 'pipe',
});
const err = await new Response(proc.stderr).text();
if ((await proc.exited) !== 0) throw errors.BAD_REQUEST(`zip failed: ${err.trim().slice(-200)}`);
return ctx.json({ ok: true, path: target.slice(rootDir.length), name: target.split('/').pop() });
});
// 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 });
});