evOo-local/packages/adapters/src/media-folder-source.ts
Claude 270ea8068a
feat(media): inference emulator, sidecar client, folder source and factory
Add the Media Studio adapters behind the frozen provider contracts: a
deterministic offline inference emulator driven by file-name markers that
renders real redactions with ffmpeg, a synthetic emulator library, the
bearer-auth HTTP client for the local media sidecar with validated
responses, typed errors, busy retries and timeouts, a lazily paginated
folder source that never follows symlinks, and the configuration factory.
A shared conformance suite runs against the emulator and, in the Docker
integration job, against the sidecar.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138YqDj3ttfayRwuMKKrqVj
2026-10-03 17:52:17 +00:00

222 lines
7.7 KiB
TypeScript

import type { Dirent } from "node:fs";
import { lstat, readdir } from "node:fs/promises";
import path from "node:path";
import type {
MediaSourceItem,
MediaSourceListRequest,
MediaSourceListResult,
MediaSourceProvider,
} from "@rakazo/adapter-kit";
import type { MediaAssetKind } from "@rakazo/contracts";
import { PathOutsideRootError, resolveInside } from "@rakazo/media/node";
import { z } from "zod";
import { probeMedia } from "./media-folder-source-probe.js";
/*
* Server-side folder source. The deployment owner picks an absolute root; the
* walk lists media files below it in a stable order (relative POSIX paths,
* code-unit sorted) so the opaque cursor (the last relative path) stays valid
* while files are added. Hidden entries and symlinks are skipped and never
* followed. Only the directories needed for one page are read.
*/
export const FOLDER_MEDIA_EXTENSIONS: Readonly<Record<string, MediaAssetKind>> = {
mp4: "video",
mov: "video",
m4v: "video",
mkv: "video",
webm: "video",
avi: "video",
jpg: "photo",
jpeg: "photo",
png: "photo",
heic: "photo",
heif: "photo",
webp: "photo",
};
const FolderConfigSchema = z.object({
path: z.string().min(1).refine(path.isAbsolute, "folder path must be absolute"),
});
const MAX_PAGE = 1000;
const PROBE_CONCURRENCY = 4;
export interface FolderMediaSourceOptions {
ffprobePath?: string;
}
export function encodeFolderCursor(relativePath: string): string {
return Buffer.from(relativePath, "utf8").toString("base64url");
}
export function decodeFolderCursor(cursor: string | null): string | null {
if (!cursor) return null;
const decoded = Buffer.from(cursor, "base64url").toString("utf8");
if (!decoded || decoded.includes("\0") || encodeFolderCursor(decoded) !== cursor)
throw new Error("invalid folder cursor");
return decoded;
}
function mediaKindOf(name: string): MediaAssetKind | null {
const ext = path.extname(name).slice(1).toLowerCase();
return FOLDER_MEDIA_EXTENSIONS[ext] ?? null;
}
/** Sort key that makes a depth-first walk yield relative paths in code-unit order. */
function entryKey(entry: Dirent): string {
return entry.isDirectory() ? `${entry.name}/` : entry.name;
}
function compareKeys(a: string, b: string): number {
return a < b ? -1 : a > b ? 1 : 0;
}
/**
* Yield relative paths of media files strictly after `after`, in order.
* Directories whose whole subtree sorts before the cursor are not read.
*/
async function* walkAfter(
root: string,
relativeDir: string,
after: string | null,
signal?: AbortSignal,
): AsyncGenerator<string> {
signal?.throwIfAborted();
let entries: Dirent[];
try {
entries = await readdir(path.join(root, relativeDir), { withFileTypes: true });
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
// A directory removed or locked mid-walk is skipped; the root itself must exist.
if (relativeDir && (code === "ENOENT" || code === "EACCES" || code === "ENOTDIR")) return;
throw error;
}
const visible = entries
.filter((entry) => !entry.name.startsWith(".") && !entry.isSymbolicLink())
.filter((entry) => entry.isDirectory() || (entry.isFile() && mediaKindOf(entry.name)))
.map((entry) => ({ entry, key: entryKey(entry) }))
.sort((a, b) => compareKeys(a.key, b.key));
for (const { entry, key } of visible) {
const relative = relativeDir ? `${relativeDir}/${key}` : key;
if (entry.isDirectory()) {
// Subtree entirely before the cursor: every path in it starts with `relative`.
if (after !== null && relative < after && !after.startsWith(relative)) continue;
yield* walkAfter(root, relative.slice(0, -1), after, signal);
} else if (after === null || relative > after) {
yield relative;
}
}
}
async function mapLimited<T, R>(
items: T[],
limit: number,
fn: (item: T) => Promise<R>,
): Promise<R[]> {
const out = new Array<R>(items.length);
let next = 0;
const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
while (next < items.length) {
const index = next++;
out[index] = await fn(items[index] as T);
}
});
await Promise.all(workers);
return out;
}
export class FolderMediaSourceProvider implements MediaSourceProvider {
readonly kind = "folder" as const;
constructor(private readonly options: FolderMediaSourceOptions = {}) {}
async list(request: MediaSourceListRequest): Promise<MediaSourceListResult> {
const { path: root } = FolderConfigSchema.parse(request.config);
const limit = Math.max(1, Math.min(MAX_PAGE, Math.floor(request.limit)));
const after = decodeFolderCursor(request.cursor);
const relatives: string[] = [];
let more = false;
for await (const relative of walkAfter(root, "", after, request.signal)) {
if (relatives.length === limit) {
more = true;
break;
}
relatives.push(relative);
}
const items = await mapLimited(relatives, PROBE_CONCURRENCY, (relative) =>
this.describe(root, relative, request.signal),
);
const present = items.filter((item): item is MediaSourceItem => item !== null);
const last = relatives.at(-1);
return { items: present, nextCursor: more && last ? encodeFolderCursor(last) : null };
}
async resolve(config: Record<string, unknown>, externalId: string): Promise<string> {
const { path: root } = FolderConfigSchema.parse(config);
const segments = externalId.split("/");
if (segments.some((segment) => segment.startsWith(".")) || !mediaKindOf(externalId))
throw new PathOutsideRootError(externalId);
const absolute = await resolveInside(root, externalId);
// Symlinks are never followed, at any depth below the root.
let current = await resolveInside(root, ".");
for (const segment of segments) {
current = path.join(current, segment);
if ((await lstat(current)).isSymbolicLink()) throw new PathOutsideRootError(externalId);
}
if (!(await lstat(absolute)).isFile()) throw new PathOutsideRootError(externalId);
return absolute;
}
/**
* Stat and probe one file. Probe failures yield null metadata, never a failed
* page. The parent folder name doubles as an album for rule-based labels.
*/
private async describe(
root: string,
relative: string,
signal?: AbortSignal,
): Promise<MediaSourceItem | null> {
const absolute = path.join(root, relative);
let stats: Awaited<ReturnType<typeof lstat>>;
try {
stats = await lstat(absolute);
} catch {
return null; // Removed between walk and stat.
}
if (!stats.isFile()) return null;
const kind = mediaKindOf(relative) ?? "video";
let probed: Awaited<ReturnType<typeof probeMedia>> | null = null;
if (stats.size > 0) {
try {
probed = await probeMedia(absolute, {
ffprobePath: this.options.ffprobePath,
signal,
frameTags: kind === "photo",
});
} catch {
// Unreadable, unsupported, or no ffprobe on this host: keep the file with null metadata.
signal?.throwIfAborted();
probed = null;
}
}
return {
externalId: relative,
kind,
capturedAt: probed?.capturedAt ?? new Date(Math.trunc(stats.mtimeMs)),
durationMs: kind === "video" ? (probed?.durationMs ?? null) : null,
width: probed?.width ?? null,
height: probed?.height ?? null,
fps: kind === "video" ? (probed?.fps ?? null) : null,
gpsLat: probed?.gpsLat ?? null,
gpsLon: probed?.gpsLon ?? null,
deviceModel: probed?.deviceModel ?? null,
albumNames: relative.includes("/") ? [path.posix.basename(path.posix.dirname(relative))] : [],
isFavorite: false,
fileName: path.posix.basename(relative),
sizeBytes: stats.size,
revision: `${Math.trunc(stats.mtimeMs)}-${stats.size}`,
};
}
}