Project Files
src / localMedia.ts
import * as fs from "node:fs";
import * as fsp from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import { resolveUnderRoot } from "./paths";
const IMAGE_SUFFIXES = new Set([
".jpg",
".jpeg",
".png",
".webp",
".tif",
".tiff",
".bmp",
".gif",
]);
export function parseLocalMediaRoot(pathStr: string): string {
let raw = pathStr.trim();
if (!raw) throw new Error("local_media_root must be a non-empty path.");
if (raw === "~") raw = os.homedir();
else if (raw.startsWith("~/")) raw = path.join(os.homedir(), raw.slice(2));
const candidate = path.resolve(raw);
if (!path.isAbsolute(candidate)) {
throw new Error(
"local_media_root must be an absolute path (e.g. /media/photos on Linux or C:\\\\Photos on Windows).",
);
}
if (!fs.existsSync(candidate)) {
throw new Error(`local_media_root does not exist: ${candidate}`);
}
const resolved = fs.realpathSync(candidate);
if (!fs.statSync(resolved).isDirectory()) {
throw new Error(`local_media_root is not a directory: ${resolved}`);
}
return resolved;
}
export async function listImageRelativePaths(
rootAbs: string,
relativeDirectory: string,
recursive: boolean,
maxFiles: number,
): Promise<string[]> {
if (maxFiles < 1) throw new Error("max_files must be at least 1");
if (maxFiles > 5000) throw new Error("max_files cannot exceed 5000");
const base = resolveUnderRoot(rootAbs, relativeDirectory);
if (!fs.existsSync(base) || !fs.statSync(base).isDirectory()) {
throw new Error(`Not a directory under local_media_root: ${JSON.stringify(relativeDirectory)}`);
}
const rootResolved = fs.realpathSync(rootAbs);
const out: string[] = [];
async function walk(dir: string) {
const entries = await fsp.readdir(dir, { withFileTypes: true });
const sorted = entries.sort((a, b) => a.name.localeCompare(b.name));
for (const ent of sorted) {
if (out.length >= maxFiles) return;
const full = path.join(dir, ent.name);
if (ent.isDirectory()) {
if (recursive) await walk(full);
continue;
}
if (!ent.isFile()) continue;
const suf = path.extname(ent.name).toLowerCase();
if (!IMAGE_SUFFIXES.has(suf)) continue;
const resolvedFile = fs.realpathSync(full);
const rel = path.relative(rootResolved, resolvedFile);
if (rel.startsWith("..") || path.isAbsolute(rel)) continue;
out.push(rel.split(path.sep).join("/"));
}
}
if (recursive) {
await walk(base);
} else {
const entries = await fsp.readdir(base, { withFileTypes: true });
const sorted = entries.sort((a, b) => a.name.localeCompare(b.name));
for (const ent of sorted) {
if (out.length >= maxFiles) break;
if (!ent.isFile()) continue;
const full = path.join(base, ent.name);
const suf = path.extname(ent.name).toLowerCase();
if (!IMAGE_SUFFIXES.has(suf)) continue;
const resolvedFile = fs.realpathSync(full);
const rel = path.relative(rootResolved, resolvedFile);
if (rel.startsWith("..") || path.isAbsolute(rel)) continue;
out.push(rel.split(path.sep).join("/"));
}
}
return out;
}