d6cf5f3d36
- bun workspaces: contract/ (Schemas = source of truth: config raw↔resolved,
domain DTOs, HttpApi contract, API errors) + plugin/ + ui/
- pure domain moved src/{engine,art}→plugin/src/{domain,art}, types now
imported from @rom-manager/contract and @punktfunk/plugin-kit/wire (the
hand-copied wire.ts dies), loggers injected instead of module-global
- ui.* and devEntry dropped from the config shape (standalone server is
gone; stale keys tolerated on decode, dropped by the next save)
- all 48 domain tests ported and green BEFORE any Effect wiring
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
271 lines
8.0 KiB
TypeScript
271 lines
8.0 KiB
TypeScript
// The ROM scanner (design §5): walk a ROM root, apply excludes, and reduce the file tree to launchable
|
|
// candidates — folding multi-disc `.m3u` playlists and `.cue`/`.gdi` sheets so a disc set is ONE entry,
|
|
// not one per track. The filesystem is injected (`ScanFs`) so the whole thing is unit-testable against
|
|
// a synthetic tree with zero real I/O.
|
|
|
|
import * as fs from "node:fs";
|
|
import * as nodePath from "node:path";
|
|
import type { Platform } from "./platforms.js";
|
|
|
|
/** A directory entry the walker sees. */
|
|
export interface DirEnt {
|
|
name: string;
|
|
isDir: boolean;
|
|
isFile: boolean;
|
|
}
|
|
|
|
/** The filesystem surface the scanner needs — injected for tests. */
|
|
export interface ScanFs {
|
|
readDir(dir: string): DirEnt[];
|
|
readText(file: string): string;
|
|
}
|
|
|
|
/** One launchable candidate produced by the scan. */
|
|
export interface RomCandidate {
|
|
platform: string;
|
|
/** The root directory this file was found under. */
|
|
rootDir: string;
|
|
/** Absolute path to the launchable file (the `.m3u`/`.cue`/`.gdi`, or the ROM itself). */
|
|
absPath: string;
|
|
/** POSIX path of `absPath` relative to `rootDir` — the `external_id` suffix. */
|
|
relPath: string;
|
|
/** Lowercase extension of `absPath` (e.g. `.sfc`). */
|
|
ext: string;
|
|
/** Basename of `absPath` without extension — fed to the title parser. */
|
|
stem: string;
|
|
/** True for `.zip`/`.7z` — gated on emulator archive support at reconcile time. */
|
|
isArchive: boolean;
|
|
}
|
|
|
|
const ARCHIVE_EXT = new Set([".zip", ".7z"]);
|
|
const SHEET_EXT = new Set([".cue", ".gdi", ".ccd", ".mds"]);
|
|
const TRACK_EXT = new Set([".bin", ".img", ".sub", ".raw"]);
|
|
|
|
const lc = (s: string): string => s.toLowerCase();
|
|
const extOf = (name: string): string => lc(nodePath.extname(name));
|
|
const stemOf = (name: string): string =>
|
|
nodePath.basename(name, nodePath.extname(name));
|
|
const toPosix = (p: string): string => p.split(nodePath.sep).join("/");
|
|
|
|
// ---------------------------------------------------------------- glob excludes
|
|
|
|
/** Convert a gitignore-ish glob to an anchored regex (`**` = any depth, `*` = within a segment, `?`). */
|
|
const globToRegex = (glob: string): RegExp => {
|
|
let re = "";
|
|
for (let i = 0; i < glob.length; i++) {
|
|
const c = glob[i];
|
|
if (c === "*") {
|
|
if (glob[i + 1] === "*") {
|
|
re += ".*";
|
|
i++;
|
|
if (glob[i + 1] === "/") i++; // `**/` swallows the slash
|
|
} else {
|
|
re += "[^/]*";
|
|
}
|
|
} else if (c === "?") {
|
|
re += "[^/]";
|
|
} else if (c && "\\^$.|+()[]{}".includes(c)) {
|
|
re += `\\${c}`;
|
|
} else {
|
|
re += c;
|
|
}
|
|
}
|
|
return new RegExp(`^${re}$`, "i");
|
|
};
|
|
|
|
/**
|
|
* gitignore-ish evaluation: patterns are excludes; a `!`-prefixed pattern re-includes. Last match
|
|
* wins. A bare pattern with no `/` matches the basename at any depth (like gitignore).
|
|
*/
|
|
export const makeExcluder = (
|
|
patterns: string[] | undefined,
|
|
): ((relPath: string) => boolean) => {
|
|
if (!patterns || patterns.length === 0) return () => false;
|
|
const compiled = patterns.map((p) => {
|
|
const negate = p.startsWith("!");
|
|
const body = negate ? p.slice(1) : p;
|
|
const anchored = body.includes("/");
|
|
return {
|
|
negate,
|
|
re: globToRegex(anchored ? body : `**/${body}`),
|
|
bare: body,
|
|
};
|
|
});
|
|
return (relPath: string): boolean => {
|
|
let excluded = false;
|
|
for (const { negate, re } of compiled) {
|
|
if (re.test(relPath)) excluded = !negate;
|
|
}
|
|
return excluded;
|
|
};
|
|
};
|
|
|
|
// ---------------------------------------------------------------- walk
|
|
|
|
interface FileRec {
|
|
absPath: string;
|
|
relPath: string; // posix, relative to root
|
|
dir: string; // absolute dir
|
|
name: string;
|
|
ext: string;
|
|
}
|
|
|
|
/** Recursively enumerate files under `rootDir`, applying excludes. Symlinked dirs are not followed. */
|
|
const walk = (
|
|
fsx: ScanFs,
|
|
rootDir: string,
|
|
exclude: (rel: string) => boolean,
|
|
): FileRec[] => {
|
|
const out: FileRec[] = [];
|
|
const recurse = (dir: string): void => {
|
|
let ents: DirEnt[];
|
|
try {
|
|
ents = fsx.readDir(dir);
|
|
} catch {
|
|
return;
|
|
}
|
|
for (const ent of ents) {
|
|
const abs = nodePath.join(dir, ent.name);
|
|
const rel = toPosix(nodePath.relative(rootDir, abs));
|
|
if (ent.isDir) {
|
|
if (exclude(`${rel}/`)) continue;
|
|
recurse(abs);
|
|
} else if (ent.isFile) {
|
|
if (exclude(rel)) continue;
|
|
out.push({
|
|
absPath: abs,
|
|
relPath: rel,
|
|
dir,
|
|
name: ent.name,
|
|
ext: extOf(ent.name),
|
|
});
|
|
}
|
|
}
|
|
};
|
|
recurse(rootDir);
|
|
return out;
|
|
};
|
|
|
|
// ---------------------------------------------------------------- disc folding
|
|
|
|
/** Parse the disc/track paths referenced by an `.m3u` (non-comment lines) or a `.cue`/`.gdi` sheet. */
|
|
const parseReferences = (fsx: ScanFs, file: FileRec): string[] => {
|
|
let text: string;
|
|
try {
|
|
text = fsx.readText(file.absPath);
|
|
} catch {
|
|
return [];
|
|
}
|
|
const refs: string[] = [];
|
|
if (file.ext === ".m3u") {
|
|
for (const line of text.split(/\r?\n/)) {
|
|
const t = line.trim();
|
|
if (t && !t.startsWith("#")) refs.push(t);
|
|
}
|
|
} else if (
|
|
file.ext === ".cue" ||
|
|
file.ext === ".ccd" ||
|
|
file.ext === ".mds"
|
|
) {
|
|
for (const m of text.matchAll(/FILE\s+"([^"]+)"/gi)) {
|
|
if (m[1]) refs.push(m[1]);
|
|
}
|
|
} else if (file.ext === ".gdi") {
|
|
// GDI: `<track> <lba> <type> <sectorSize> <filename> <offset>` — filename may be quoted.
|
|
for (const line of text.split(/\r?\n/)) {
|
|
const m = /(?:"([^"]+)"|(\S+\.(?:bin|raw|iso)))/i.exec(line.trim());
|
|
const f = m?.[1] ?? m?.[2];
|
|
if (f) refs.push(f);
|
|
}
|
|
}
|
|
// Resolve each reference relative to the sheet's directory, as a posix path key.
|
|
return refs.map((r) => nodePath.join(file.dir, r));
|
|
};
|
|
|
|
/**
|
|
* Fold disc images: `.m3u` playlists become one entry (their discs hidden); `.cue`/`.gdi` sheets become
|
|
* one entry (their tracks hidden); sheets referenced by a playlist are hidden too. Everything else that
|
|
* matches the platform's extensions is a loose candidate.
|
|
*/
|
|
const foldDiscs = (
|
|
fsx: ScanFs,
|
|
files: FileRec[],
|
|
platform: Platform,
|
|
): FileRec[] => {
|
|
const referencedByM3u = new Set<string>();
|
|
const referencedBySheet = new Set<string>();
|
|
for (const f of files) {
|
|
if (f.ext === ".m3u") {
|
|
for (const r of parseReferences(fsx, f)) referencedByM3u.add(r);
|
|
}
|
|
}
|
|
// Parse EVERY sheet for its tracks (even a sheet that a playlist references) so a `.bin` behind a
|
|
// cue is always hidden — whether the cue stands alone or lives inside an `.m3u`.
|
|
for (const f of files) {
|
|
if (SHEET_EXT.has(f.ext)) {
|
|
for (const r of parseReferences(fsx, f)) referencedBySheet.add(r);
|
|
}
|
|
}
|
|
const extSet = new Set(platform.extensions.map(lc));
|
|
const out: FileRec[] = [];
|
|
for (const f of files) {
|
|
if (referencedByM3u.has(f.absPath)) continue; // disc within a playlist
|
|
if (referencedBySheet.has(f.absPath)) continue; // track behind a sheet
|
|
if (f.ext === ".m3u") {
|
|
out.push(f);
|
|
continue;
|
|
}
|
|
if (SHEET_EXT.has(f.ext)) {
|
|
out.push(f); // a standalone sheet (its tracks are hidden above)
|
|
continue;
|
|
}
|
|
if (TRACK_EXT.has(f.ext) && !extSet.has(f.ext)) continue; // orphan track, not a platform ROM ext
|
|
if (ARCHIVE_EXT.has(f.ext) || extSet.has(f.ext)) out.push(f);
|
|
}
|
|
return out;
|
|
};
|
|
|
|
// ---------------------------------------------------------------- scan
|
|
|
|
/** Scan one ROM root into candidates. `platform` is the resolved platform for `rootDir`. */
|
|
export const scanRoot = (
|
|
fsx: ScanFs,
|
|
rootDir: string,
|
|
platform: Platform,
|
|
excludes: string[] | undefined,
|
|
): RomCandidate[] => {
|
|
const exclude = makeExcluder(excludes);
|
|
const files = walk(fsx, rootDir, exclude);
|
|
const extSet = new Set(platform.extensions.map(lc));
|
|
|
|
const chosen = platform.disc
|
|
? foldDiscs(fsx, files, platform)
|
|
: files.filter((f) => ARCHIVE_EXT.has(f.ext) || extSet.has(f.ext));
|
|
|
|
return chosen.map((f) => ({
|
|
platform: platform.id,
|
|
rootDir,
|
|
absPath: f.absPath,
|
|
relPath: f.relPath,
|
|
ext: f.ext,
|
|
stem: stemOf(f.name),
|
|
isArchive: ARCHIVE_EXT.has(f.ext),
|
|
}));
|
|
};
|
|
|
|
// ---------------------------------------------------------------- real ScanFs
|
|
|
|
/** The production filesystem, backed by node fs. Directory reads are cheap and non-recursive here. */
|
|
export const realScanFs: ScanFs = {
|
|
readDir(dir) {
|
|
return fs.readdirSync(dir, { withFileTypes: true }).map((d) => ({
|
|
name: d.name,
|
|
isDir: d.isDirectory(),
|
|
isFile: d.isFile(),
|
|
}));
|
|
},
|
|
readText(file) {
|
|
return fs.readFileSync(file, "utf8");
|
|
},
|
|
};
|