Files
punktfunk-plugin-rom-manager/plugin/src/domain/scanner.ts
T
enricobuehler d6cf5f3d36 refactor: workspace restructure — contract package + pure domain ported, tests green
- 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>
2026-07-20 18:54:38 +02:00

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");
},
};