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(); }); });