images and videos get a thumbnail in grid and list. one <Thumbnail>, two sizes — two implementations would have drifted the first time one learned something the other did not, and the fallback is the part that would have drifted. FFMPEG FOR BOTH, and no new dependency. a video needs a frame extracted, which only ffmpeg does; an image only needs resizing, which sharp does faster and with better format coverage. sharp is nevertheless not used: it sits in node_modules as somebody else's transitive dependency and is absent from package.json, so importing it is an undeclared dependency — the thing the frozen lockfile exists to prevent — and declaring it is a deliberate --no-frozen-lockfile that belongs to whoever owns the dependency list. ffmpeg is already a hard requirement of this router, so this adds nothing to what the machine must have. the cost is HEIC and a few exotics, which fall back to the icon. THE CACHE KEY CONTAINS MTIME AND SIZE. a path alone would be wrong: replace a photo under the same name and every screen keeps the old picture with nothing to clear it but somebody who knows the cache exists. keyed on content, a changed file is a different key — the old entry is orphaned rather than stale, and the response can be `immutable` honestly rather than as a bet. FAILURES ARE REMEMBERED. the browser retries a broken <img> every time it re-enters the viewport, so without this one corrupt video runs ffmpeg on every scroll, forever. in memory, so a restart gives it another chance after somebody installs a codec. `ls` DECIDES what can be thumbnailed, not the client. the browser's own NATIVE_VIDEO_EXTS is deliberately only what a <video> tag can play, which is a different question from what ffmpeg can take a frame out of — every .mkv would have gone without a picture for that reason alone. the entry has just been stat'd, so the answer is a lookup on its name. FOUND WHILE TESTING, and the reason there is a test folder: ffmpeg EXITS 0 HAVING WRITTEN A ZERO-BYTE FILE when the seek lands past the end of the input. a 0.4s clip gave exit 0 and 0 bytes with `-ss 1`, and exit 0 and a real JPEG without it. the retry keyed on the exit code and so never fired, reporting every short video as having no thumbnail. it keys on the output file now — there is one question worth asking an image encoder and it is whether there is an image. four ffmpeg at a time. the grid is virtualised so only what is on screen is asked for, but scrolling fast through a folder of video queues far more than is visible, and unbounded decoding turned a 1.9GB box into a swap storm once already. size is snapped to 64px steps so a panel resizing by a pixel cannot mint a hundred entries of one image. verified: 240 and 256 share a cache entry, 200 gets its own. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
1621 lines
62 KiB
TypeScript
1621 lines
62 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';
|
|
|
|
async function getUserTtsVoice(userId: number): Promise<string | null> {
|
|
try {
|
|
const settings = (await getUserSettings(userId)) as { tts?: { voice?: string | null } };
|
|
return settings.tts?.voice ?? null;
|
|
} catch {}
|
|
return null;
|
|
}
|
|
|
|
const OLD_CACHE_DIRS = ['ocr', 'tts', 'transcriptions', 'audio', 'video'];
|
|
|
|
async function cleanOldCacheDirs(userDataDir: string) {
|
|
for (const dir of OLD_CACHE_DIRS) {
|
|
const target = join(userDataDir, dir);
|
|
if (existsSync(target)) await rm(target, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
// `seedHomeDir` used to be here, creating Downloads/Documents/Music/Videos/Pictures on the first listing of
|
|
// any home. Removed 2026-08-11: it invented folders in somebody's home directory as a side effect of LOOKING
|
|
// at it, which is not a listing's business and not a layout the platform has any standing to choose.
|
|
|
|
export const router = createRouter();
|
|
|
|
/**
|
|
* Resolve whose home this request may touch, once, before any handler runs.
|
|
*
|
|
* A middleware rather than a change to `getRootDir`'s signature because that function is called from
|
|
* fifteen places in this file. Making it async would have meant editing fifteen call sites, and the
|
|
* failure mode of missing one is the worst available: a handler that quietly serves the OWNER'S home to a
|
|
* member. Resolving here means a handler cannot run without the answer.
|
|
*
|
|
* The `user-data` root is untouched by this — it is already keyed on the caller's own email and holds
|
|
* platform-written data rather than anything executable.
|
|
*/
|
|
router.use(async (ctx, next) => {
|
|
const user = ctx.get('user');
|
|
const resolved = await resolveHomeDir(user.id as number);
|
|
if (!resolved.ok) {
|
|
throw resolved.needsOsAccount
|
|
? errors.FORBIDDEN(`Files are not available for this account: ${resolved.reason}.`)
|
|
: errors.FORBIDDEN(resolved.reason);
|
|
}
|
|
ctx.set('user', { ...user, homeDir: resolved.home });
|
|
return next();
|
|
});
|
|
|
|
/**
|
|
* `homeDir` is put on the context user by `confineToHome` below, so the fifteen-odd call sites of
|
|
* `getRootDir` keep working unchanged and none of them can forget to resolve it.
|
|
*/
|
|
type UserCtx = { email: string; homeDir?: string };
|
|
|
|
function getUserDataDir(email: string): string {
|
|
return join(DATA_PATH, email);
|
|
}
|
|
|
|
export function getRootDir(user: UserCtx, root?: string): string {
|
|
// `user.homeDir` is set for every request that reached a handler — the middleware refuses the request
|
|
// otherwise. The fallback exists only for the owner-shaped callers that construct a UserCtx by hand;
|
|
// it is NOT a "member without an OS account gets the owner's home" path, because such a request never
|
|
// gets this far. See user-home.ts for why that distinction is the whole point.
|
|
if (!root || root === 'home') return user.homeDir ?? getOwnerHomeDir(user.email);
|
|
if (root === 'user-data') return getUserDataDir(user.email);
|
|
throw errors.BAD_REQUEST(`Invalid root: ${root}`);
|
|
}
|
|
|
|
// A resolved path counts as contained only when it IS the root or sits beneath it. A bare
|
|
// startsWith also accepts a sibling whose name merely begins with the root's — `/home/br-backup`
|
|
// passes a `/home/br` check — which is how `..` segments escaped.
|
|
const isInside = (root: string, target: string): boolean => target === root || target.startsWith(root + sep);
|
|
|
|
export function resolveUserPath(rootDir: string, relPath: string): string {
|
|
const resolved = resolve(rootDir, relPath.replace(/^\/+/, ''));
|
|
if (!isInside(rootDir, resolved)) throw errors.FORBIDDEN('Path outside root directory');
|
|
return resolved;
|
|
}
|
|
|
|
// mkv `title` / mp4 `handler_name` hold a track's name; the generic "…Handler" defaults are ignored.
|
|
function trackName(tags?: { title?: string; handler_name?: string }): string {
|
|
const handler = tags?.handler_name ?? '';
|
|
return tags?.title || (handler && !/Handler$/.test(handler) ? handler : '');
|
|
}
|
|
|
|
// ffprobe bit_rate (bits/s, as a string) → rounded kbps, or null when the container doesn't report it.
|
|
function kbps(bitRate?: string): number | null {
|
|
const n = Number(bitRate);
|
|
return Number.isFinite(n) && n > 0 ? Math.round(n / 1000) : null;
|
|
}
|
|
|
|
// Serve a video with a chosen audio track selected: fast `-c copy` remux (video untouched, other
|
|
// audio dropped) cached under the user's data dir, so it streams with byte-range seeking like /raw.
|
|
// In-flight remuxes are shared so concurrent requests for the same track don't race on the temp file.
|
|
const audioRemuxInFlight = new Map<string, Promise<string>>();
|
|
|
|
async function ensureAudioRemux(email: string, absPath: string, relPath: string, track: number): Promise<string> {
|
|
const parsed = parsePath(relPath);
|
|
const ext = (parsed.ext.slice(1) || 'mp4').toLowerCase();
|
|
const sub = parsed.dir ? `${parsed.dir}/` : '';
|
|
// ffmpeg picks the output muxer from the file extension, so both the final and temp names must
|
|
// keep the real extension (a ".tmp" suffix makes ffmpeg fail with "unable to choose format").
|
|
const base = resolve(getUserDataDir(email), `cache/audio/${sub}${parsed.name}.a${track}`);
|
|
const cacheAbs = `${base}.${ext}`;
|
|
if (existsSync(cacheAbs)) return cacheAbs;
|
|
|
|
const pending = audioRemuxInFlight.get(cacheAbs);
|
|
if (pending) return pending;
|
|
|
|
const job = (async () => {
|
|
await mkdir(dirname(cacheAbs), { recursive: true });
|
|
const tmp = `${base}.tmp.${ext}`;
|
|
const movflags = ext === 'mp4' || ext === 'mov' || ext === 'm4v' ? ['-movflags', '+faststart'] : [];
|
|
const proc = Bun.spawn(
|
|
[
|
|
'ffmpeg',
|
|
'-v',
|
|
'error',
|
|
'-i',
|
|
absPath,
|
|
'-map',
|
|
'0:v',
|
|
'-map',
|
|
`0:a:${track}`,
|
|
'-c',
|
|
'copy',
|
|
'-dn',
|
|
'-sn',
|
|
...movflags,
|
|
'-y',
|
|
tmp,
|
|
],
|
|
{ stdout: 'ignore', stderr: 'pipe' },
|
|
);
|
|
const code = await proc.exited;
|
|
if (code !== 0) {
|
|
const err = await new Response(proc.stderr).text();
|
|
await rm(tmp, { force: true }).catch(() => {});
|
|
throw errors.BAD_REQUEST(err.trim() || 'Audio track remux failed');
|
|
}
|
|
await rename(tmp, cacheAbs);
|
|
return cacheAbs;
|
|
})();
|
|
|
|
audioRemuxInFlight.set(cacheAbs, job);
|
|
try {
|
|
return await job;
|
|
} finally {
|
|
audioRemuxInFlight.delete(cacheAbs);
|
|
}
|
|
}
|
|
|
|
// List directory entries
|
|
router.get('/ls', async (ctx) => {
|
|
const user = ctx.get('user');
|
|
const rootDir = getRootDir(user, ctx.req.query('root') ?? undefined);
|
|
const relPath = (ctx.req.query('path') || '/').replace(/^\/+/, '');
|
|
const absPath = resolveUserPath(rootDir, relPath);
|
|
|
|
// Create the home root itself if it is missing, and nothing else. A listing that invents its own contents
|
|
// is a listing you cannot trust — the folder set it used to seed is gone.
|
|
//
|
|
// Non-fatal: a member's home is theirs, so this can raise EPERM, and `readdir` below is the real test of
|
|
// whether the directory can be used.
|
|
if (!ctx.req.query('root') || ctx.req.query('root') === 'home') {
|
|
try {
|
|
await mkdir(absPath, { recursive: true });
|
|
} catch {
|
|
// Either it exists, or it is not ours to create.
|
|
}
|
|
}
|
|
|
|
// Remove old top-level cache dirs (migrated to cache/ prefix)
|
|
if (ctx.req.query('root') === 'user-data') {
|
|
await cleanOldCacheDirs(rootDir);
|
|
}
|
|
|
|
let names: string[];
|
|
try {
|
|
names = await readdir(absPath);
|
|
} catch (ex) {
|
|
// A missing directory resets the browser to the root, which is the right answer for a stale path.
|
|
//
|
|
// A PERMISSION failure is not that, and conflating them cost an afternoon: a member's home is 700 and
|
|
// theirs, so before the ACL grant in os-user.ts the platform's readdir raised EACCES here and this
|
|
// returned an empty listing — the UI said "This folder is empty" over five directories that existed.
|
|
// An empty result is data; it should never be how a refusal looks.
|
|
if ((ex as { code?: string }).code === 'EACCES' || (ex as { code?: string }).code === 'EPERM') {
|
|
throw errors.FORBIDDEN(
|
|
`Officer cannot read ${relPath || 'this folder'}. If this is a member's home, its access control ` +
|
|
`lists are missing — reprovision the Linux account from Settings → User management.`,
|
|
);
|
|
}
|
|
return ctx.json({ path: '/', entries: [], reset: true });
|
|
}
|
|
const entries = await Promise.all(
|
|
names.map(async (name) => {
|
|
const fullPath = resolve(absPath, name);
|
|
// Skip entries that escape the home dir (shouldn't happen but be safe)
|
|
if (!isInside(rootDir, fullPath)) return null;
|
|
const s = await stat(fullPath).catch(() => null);
|
|
if (!s) return null;
|
|
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.
|
|
*/
|
|
thumb: isDirectory ? null : thumbnailKind(name),
|
|
};
|
|
}),
|
|
);
|
|
|
|
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.
|
|
*/
|
|
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);
|
|
}
|
|
|
|
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 kind = thumbnailKind(relPath);
|
|
// 415 rather than 404: the file is there, it is simply not something a frame can be taken from. The
|
|
// client does not ask for these, so this is the answer to a stale tab rather than to normal use.
|
|
if (!kind) return ctx.text('no thumbnail for this file type', 415);
|
|
|
|
const result = await buildThumbnail(absPath, getUserDataDir(user.email), kind, clampSize(ctx.req.query('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',
|
|
},
|
|
});
|
|
});
|
|
|
|
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');
|
|
}
|
|
|
|
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 });
|
|
});
|