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
1738 lines
68 KiB
TypeScript
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 });
|
|
});
|