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
222 lines
7.7 KiB
TypeScript
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}`,
|
|
};
|
|
}
|
|
}
|