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> = { 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 { 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( items: T[], limit: number, fn: (item: T) => Promise, ): Promise { const out = new Array(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 { 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, externalId: string): Promise { 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 { const absolute = path.join(root, relative); let stats: Awaited>; 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> | 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}`, }; } }