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
178 lines
6.9 KiB
TypeScript
178 lines
6.9 KiB
TypeScript
import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import path from "node:path";
|
|
import type { MediaSourceItem } from "@rakazo/adapter-kit";
|
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
import { ffmpegAvailable } from "./media-emulator-clips.js";
|
|
import { FolderMediaSourceProvider } from "./media-folder-source.js";
|
|
import { parseIso6709, runMediaTool } from "./media-folder-source-probe.js";
|
|
|
|
let root: string;
|
|
const provider = new FolderMediaSourceProvider();
|
|
|
|
async function touch(relative: string, content = "") {
|
|
const file = path.join(root, relative);
|
|
await mkdir(path.dirname(file), { recursive: true });
|
|
await writeFile(file, content);
|
|
}
|
|
|
|
async function listAll(limit: number, cursor: string | null = null) {
|
|
const pages: MediaSourceItem[][] = [];
|
|
let next = cursor;
|
|
do {
|
|
const page = await provider.list({ config: { path: root }, cursor: next, limit });
|
|
expect(page.items.length).toBeLessThanOrEqual(limit);
|
|
pages.push(page.items);
|
|
next = page.nextCursor;
|
|
} while (next);
|
|
return pages.flat().map((item) => item.externalId);
|
|
}
|
|
|
|
beforeEach(async () => {
|
|
root = await mkdtemp(path.join(tmpdir(), "media-folder-"));
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await rm(root, { recursive: true, force: true });
|
|
});
|
|
|
|
describe("FolderMediaSourceProvider", () => {
|
|
it("lists media in stable code-unit order across pages", async () => {
|
|
for (const name of ["b/c.mp4", "b-d.jpg", "a.MOV", "b/a/z.webm", "c.png", "notes.txt"])
|
|
await touch(name);
|
|
const ids = await listAll(2);
|
|
expect(ids).toEqual(["a.MOV", "b-d.jpg", "b/a/z.webm", "b/c.mp4", "c.png"]);
|
|
});
|
|
|
|
it("keeps the cursor stable when files are added mid-sync", async () => {
|
|
for (const name of ["a.mp4", "c.mp4", "e.mp4", "g.mp4"]) await touch(name);
|
|
const first = await provider.list({ config: { path: root }, cursor: null, limit: 2 });
|
|
expect(first.items.map((item) => item.externalId)).toEqual(["a.mp4", "c.mp4"]);
|
|
await touch("b.mp4"); // before the cursor: picked up by the next full sync
|
|
await touch("d.mp4"); // after the cursor: part of this sync
|
|
const rest = await listAll(2, first.nextCursor);
|
|
expect(rest).toEqual(["d.mp4", "e.mp4", "g.mp4"]);
|
|
expect(await listAll(10)).toEqual(["a.mp4", "b.mp4", "c.mp4", "d.mp4", "e.mp4", "g.mp4"]);
|
|
});
|
|
|
|
it("skips hidden entries and symlinks without following them", async () => {
|
|
await touch("keep/clip.mp4");
|
|
await touch(".hidden.mp4");
|
|
await touch(".cache/inside.mp4");
|
|
await touch("keep/.DS_Store.jpg");
|
|
const outside = await mkdtemp(path.join(tmpdir(), "media-folder-outside-"));
|
|
try {
|
|
await writeFile(path.join(outside, "secret.mp4"), "");
|
|
await symlink(path.join(outside, "secret.mp4"), path.join(root, "link.mp4"));
|
|
await symlink(outside, path.join(root, "linked-dir"));
|
|
await symlink(path.join(root, "keep"), path.join(root, "keep-alias"));
|
|
expect(await listAll(10)).toEqual(["keep/clip.mp4"]);
|
|
await expect(provider.resolve({ path: root }, "link.mp4")).rejects.toThrow();
|
|
await expect(provider.resolve({ path: root }, "linked-dir/secret.mp4")).rejects.toThrow();
|
|
await expect(provider.resolve({ path: root }, "keep-alias/clip.mp4")).rejects.toThrow();
|
|
} finally {
|
|
await rm(outside, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("resolves inside the root and rejects traversal", async () => {
|
|
await touch("keep/clip.mp4");
|
|
await touch(".hidden.mp4");
|
|
await expect(provider.resolve({ path: root }, "keep/clip.mp4")).resolves.toBe(
|
|
path.join(root, "keep/clip.mp4"),
|
|
);
|
|
for (const bad of [
|
|
"../x",
|
|
"../x.mp4",
|
|
"keep/../../x.mp4",
|
|
"/etc/passwd",
|
|
".hidden.mp4",
|
|
"keep",
|
|
])
|
|
await expect(provider.resolve({ path: root }, bad), bad).rejects.toThrow();
|
|
});
|
|
|
|
it("rejects relative roots and forged cursors", async () => {
|
|
await expect(
|
|
provider.list({ config: { path: "media" }, cursor: null, limit: 5 }),
|
|
).rejects.toThrow();
|
|
await expect(
|
|
provider.list({ config: { path: root }, cursor: "not base64url!", limit: 5 }),
|
|
).rejects.toThrow();
|
|
});
|
|
|
|
it("pages a 1,000-file folder without duplicates or gaps", async () => {
|
|
const expected: string[] = [];
|
|
for (let dir = 0; dir < 10; dir++) {
|
|
for (let file = 0; file < 100; file++) {
|
|
const relative = `d${dir}/f${String(file).padStart(3, "0")}.${file % 3 === 0 ? "jpg" : "mp4"}`;
|
|
// Unreadable media must not abort a page; most files are empty and skip probing.
|
|
await touch(relative, file % 50 === 7 ? "garbage, not a video" : "");
|
|
expected.push(relative);
|
|
}
|
|
}
|
|
await touch("d3/ignore.txt");
|
|
const pages: MediaSourceItem[][] = [];
|
|
let cursor: string | null = null;
|
|
do {
|
|
const page = await provider.list({ config: { path: root }, cursor, limit: 137 });
|
|
pages.push(page.items);
|
|
cursor = page.nextCursor;
|
|
} while (cursor);
|
|
const items = pages.flat();
|
|
expect(pages.length).toBe(Math.ceil(1000 / 137));
|
|
expect(items.map((item) => item.externalId)).toEqual(expected.sort());
|
|
expect(new Set(items.map((item) => item.externalId)).size).toBe(1000);
|
|
const garbage = items.find((item) => item.externalId === "d0/f007.mp4");
|
|
expect(garbage).toMatchObject({ durationMs: null, width: null, sizeBytes: 20, kind: "video" });
|
|
expect(garbage?.capturedAt).toBeInstanceOf(Date);
|
|
expect(garbage?.revision).toMatch(/^\d+-20$/);
|
|
expect(items.find((item) => item.externalId === "d0/f000.jpg")?.kind).toBe("photo");
|
|
});
|
|
|
|
it.skipIf(!ffmpegAvailable())(
|
|
"reads duration, size, capture time and GPS with ffprobe",
|
|
async () => {
|
|
await mkdir(path.join(root, "trip"), { recursive: true });
|
|
await runMediaTool("ffmpeg", [
|
|
"-v",
|
|
"error",
|
|
"-f",
|
|
"lavfi",
|
|
"-i",
|
|
"testsrc2=s=320x180:r=25:d=1",
|
|
"-c:v",
|
|
"libx264",
|
|
"-preset",
|
|
"ultrafast",
|
|
"-metadata",
|
|
"creation_time=2026-05-02T08:00:00Z",
|
|
"-metadata",
|
|
"location=+52.5200+013.4050/",
|
|
path.join(root, "trip/clip.mp4"),
|
|
]);
|
|
const [item] = (await provider.list({ config: { path: root }, cursor: null, limit: 5 }))
|
|
.items;
|
|
expect(item).toMatchObject({
|
|
externalId: "trip/clip.mp4",
|
|
kind: "video",
|
|
durationMs: 1000,
|
|
width: 320,
|
|
height: 180,
|
|
fps: 25,
|
|
gpsLat: 52.52,
|
|
gpsLon: 13.405,
|
|
albumNames: ["trip"],
|
|
});
|
|
expect(item?.capturedAt?.toISOString()).toBe("2026-05-02T08:00:00.000Z");
|
|
},
|
|
);
|
|
|
|
it("parses ISO 6709 locations", () => {
|
|
expect(parseIso6709("+52.5200+013.4050+034.000/")).toEqual({ lat: 52.52, lon: 13.405 });
|
|
expect(parseIso6709("-33.8688+151.2093/")).toEqual({ lat: -33.8688, lon: 151.2093 });
|
|
expect(parseIso6709("+95.0+013.0/")).toBeNull();
|
|
expect(parseIso6709("nowhere")).toBeNull();
|
|
});
|
|
});
|