igir
Version:
🕹 A zero-setup ROM collection manager that sorts, filters, extracts or archives, patches, and reports on collections of any size on any OS.
293 lines (292 loc) • 12.3 kB
JavaScript
import fs from "node:fs";
import path from "node:path";
import { parse } from "@gplane/cue";
import Package from "../../globals/package.js";
import Disk from "../../models/dats/disk.js";
import Game from "../../models/dats/game.js";
import Header from "../../models/dats/logiqx/header.js";
import LogiqxDAT from "../../models/dats/logiqx/logiqxDat.js";
import ROM from "../../models/dats/rom.js";
import ArchiveEntry from "../../models/files/archives/archiveEntry.js";
import ChdRaw from "../../models/files/archives/chd/chdRaw.js";
import ArrayUtil from "../../utils/arrayUtil.js";
import IntlUtil from "../../utils/intlUtil.js";
import Module from "../module.js";
class DATGameInferrer extends Module {
static DEFAULT_DAT_NAME = Package.NAME;
options;
constructor(options, progressBar) {
super(progressBar, DATGameInferrer.name);
this.options = options;
}
/**
* Infer {@link Game}s from input files.
*/
async infer(romFiles) {
this.prefixedLogger.trace(
`inferring DATs for ${IntlUtil.toLocaleString(romFiles.length)} ROM${romFiles.length === 1 ? "" : "s"}`
);
const normalizedInputPaths = this.options.getInputPaths().map((inputPath) => path.resolve(inputPath.replace(/([\\/][?*]+)+$/, "")));
const inputPathsToRomFiles = romFiles.reduce((map, file) => {
const filePath = file.getFilePath();
const matchedInputPaths = normalizedInputPaths.filter((inputPath) => filePath.startsWith(inputPath));
const inputPathsToUse = matchedInputPaths.length > 0 ? matchedInputPaths : [DATGameInferrer.DEFAULT_DAT_NAME];
for (const inputPath of inputPathsToUse) {
if (map.has(inputPath)) {
map.get(inputPath)?.push(file);
} else {
map.set(inputPath, [file]);
}
}
return map;
}, /* @__PURE__ */ new Map());
this.prefixedLogger.trace(
`inferred ${IntlUtil.toLocaleString(inputPathsToRomFiles.size)} DAT${inputPathsToRomFiles.size === 1 ? "" : "s"}`
);
const dats = await Promise.all(
Array.from(
inputPathsToRomFiles,
async ([inputPath, datRomFiles]) => await this.createDAT(inputPath, datRomFiles)
)
);
this.prefixedLogger.trace("done inferring DATs");
return dats;
}
async createDAT(inputPath, romFiles) {
let remainingRomFiles = DATGameInferrer.enrichLikeFiles(romFiles);
const gameNamesToRomFiles = [];
const inferFunctions = [
this.inferArchiveEntries.bind(this),
this.inferBinCueFiles.bind(this),
this.inferGdiFiles.bind(this),
this.inferRawFiles.bind(this)
];
for (const inferFunction of inferFunctions) {
const result = await inferFunction.bind(this)(remainingRomFiles);
for (const entry of result) {
gameNamesToRomFiles.push(entry);
}
const consumedFiles = new Set(
result.flatMap(([, resultFiles]) => resultFiles).map((file) => file.toString())
);
remainingRomFiles = remainingRomFiles.filter((file) => !consumedFiles.has(file.toString()));
}
const games = gameNamesToRomFiles.map(([gameName, gameRomFiles]) => {
const roms = gameRomFiles.map((romFile) => {
const romProps = {
name: romFile.getExtractedFilePath(),
size: romFile.getSize(),
crc32: romFile.getCrc32(),
md5: romFile.getMd5(),
sha1: romFile.getSha1(),
sha256: romFile.getSha256()
};
if (romFile instanceof ArchiveEntry && romFile.getArchive() instanceof ChdRaw && romFile.getSize() === 0) {
return new Disk(romProps);
}
return new ROM(romProps);
}).filter(ArrayUtil.filterUniqueMapped((rom) => rom.getName())).toSorted((a, b) => a.getName().localeCompare(b.getName()));
return new Game({
name: gameName,
description: gameName,
disks: roms.filter((rom) => rom instanceof Disk),
roms: roms.filter((rom) => !(rom instanceof Disk)),
dir2datSource: gameRomFiles.map((romFile) => romFile.getFilePath()).reduce(ArrayUtil.reduceUnique(), []).toSorted((a, b) => a.localeCompare(b)).join(", ")
});
}).filter(ArrayUtil.filterUniqueMapped((game) => game.hashCode()));
const datName = path.basename(inputPath);
const header = new Header({
name: datName,
description: datName
});
return new LogiqxDAT({ header, games });
}
/**
* Different types of archives will return different checksums when quick scanning. This will
* result in files that are actually the same having different hash codes.
* Look for files that are the same, combine all known checksums, and enrich files with all
* known checksum information.
*/
static enrichLikeFiles(files) {
const crc32Map = this.combineLikeChecksums(
files,
(file) => file.getCrc32() !== void 0 && file.getSize() > 0 ? `${file.getCrc32()}|${file.getSize()}` : void 0
);
const md5Map = this.combineLikeChecksums(files, (file) => file.getMd5());
const sha1Map = this.combineLikeChecksums(files, (file) => file.getSha1());
const sha256Map = this.combineLikeChecksums(files, (file) => file.getSha256());
return files.map((file) => {
let enrichedFile = file;
for (const checksumProps of [
crc32Map.get(`${file.getCrc32()}|${file.getSize()}`),
md5Map.get(file.getMd5() ?? ""),
sha1Map.get(file.getSha1() ?? ""),
sha256Map.get(file.getSha256() ?? "")
]) {
if (checksumProps === void 0) {
continue;
}
enrichedFile = enrichedFile.withProps(checksumProps);
}
return enrichedFile;
});
}
static combineLikeChecksums(files, keyFunc) {
const crc32Map = files.reduce((map, romFile) => {
const key = keyFunc(romFile);
if (key === void 0) {
return map;
}
if (map.has(key)) {
map.get(key)?.push(romFile);
} else {
map.set(key, [romFile]);
}
return map;
}, /* @__PURE__ */ new Map());
return new Map(
Array.from(crc32Map, ([key, romFiles]) => {
const checksums = {};
for (const romFile of romFiles) {
checksums.crc32 = romFile.getCrc32() ?? checksums.crc32;
checksums.md5 = romFile.getMd5() ?? checksums.md5;
checksums.sha1 = romFile.getSha1() ?? checksums.sha1;
checksums.sha256 = romFile.getSha256() ?? checksums.sha256;
}
return [key, checksums];
})
);
}
static getGameName(file) {
let fileName = file.getExtractedFilePath();
if (file instanceof ArchiveEntry) {
fileName = file.getArchive().getFilePath();
const extIdx = fileName.lastIndexOf(file.getArchive().getExtension());
if (extIdx !== -1) {
return path.basename(fileName.slice(0, extIdx)).trim();
}
}
return path.basename(fileName).replace(/(\.[a-z0-9]+)+$/, "").trim();
}
inferArchiveEntries(romFiles) {
this.prefixedLogger.trace(
`inferring games from archives from ${IntlUtil.toLocaleString(romFiles.length)} file${romFiles.length === 1 ? "" : "s"}`
);
const archivePathsToArchiveEntries = romFiles.filter((file) => file instanceof ArchiveEntry).reduce((map, file) => {
const key = `${file.getFilePath()}|${file.getArchive().constructor.name}`;
if (map.has(key)) {
map.get(key)?.push(file);
} else {
map.set(key, [file]);
}
return map;
}, /* @__PURE__ */ new Map());
const results = Array.from(archivePathsToArchiveEntries.values(), (archiveEntries) => {
const gameName = DATGameInferrer.getGameName(archiveEntries[0]);
return [gameName, archiveEntries];
});
this.prefixedLogger.trace(
`inferred ${IntlUtil.toLocaleString(results.length)} games from archives`
);
return results;
}
async inferBinCueFiles(romFiles) {
const rawFiles = romFiles.filter((file) => !(file instanceof ArchiveEntry));
this.prefixedLogger.trace(
`inferring games from cue files from ${IntlUtil.toLocaleString(rawFiles.length)} non-archive${rawFiles.length === 1 ? "" : "s"}`
);
const rawFilePathsToFiles = rawFiles.reduce((map, file) => {
map.set(file.getFilePath(), file);
return map;
}, /* @__PURE__ */ new Map());
const results = (await Promise.all(
rawFiles.filter((file) => file.getExtractedFilePath().toLowerCase().endsWith(".cue")).map(async (cueFile) => {
try {
const cueData = await fs.promises.readFile(cueFile.getFilePath());
const cueSheet = parse(cueData.toString(), {
fatal: true
}).sheet;
const binFiles = cueSheet.files.map((binFile) => path.join(path.dirname(cueFile.getFilePath()), binFile.name)).map((binFilePath) => rawFilePathsToFiles.get(binFilePath)).filter((file) => file !== void 0);
if (binFiles.length === 0) {
return void 0;
}
const gameName = DATGameInferrer.getGameName(cueFile);
return [gameName, [cueFile, ...binFiles]];
} catch {
return void 0;
}
})
)).filter((result) => result !== void 0);
this.prefixedLogger.trace(
`inferred ${IntlUtil.toLocaleString(results.length)} games from cue files`
);
return results;
}
async inferGdiFiles(romFiles) {
const rawFiles = romFiles.filter((file) => !(file instanceof ArchiveEntry));
this.prefixedLogger.trace(
`inferring games from gdi files from ${IntlUtil.toLocaleString(rawFiles.length)} non-archive${rawFiles.length === 1 ? "" : "s"}`
);
const rawFilePathsToFiles = rawFiles.reduce((map, file) => {
map.set(file.getFilePath(), file);
return map;
}, /* @__PURE__ */ new Map());
const results = (await Promise.all(
rawFiles.filter((file) => file.getExtractedFilePath().toLowerCase().endsWith(".gdi")).map(async (gdiFile) => {
try {
const cueData = await fs.promises.readFile(gdiFile.getFilePath());
const { name: filePrefix } = path.parse(gdiFile.getFilePath());
const gdiContents = `${cueData.toString().split(/\r?\n/).filter((line) => line.length > 0).map((line) => line.replace(filePrefix, "track").replaceAll('"', "")).join("\r\n")}\r
`;
const trackFilePaths = gdiContents.trim().split(/\r?\n/).slice(1).map((line) => line.split(" ", 5)[4]);
const trackFiles = trackFilePaths.map(
(trackFilePath) => path.join(path.dirname(gdiFile.getFilePath()), trackFilePath)
).map((trackFilePath) => rawFilePathsToFiles.get(trackFilePath)).filter((file) => file !== void 0);
if (trackFiles.length === 0) {
return void 0;
}
const gameName = DATGameInferrer.getGameName(gdiFile);
return [gameName, [gdiFile, ...trackFiles]];
} catch {
return void 0;
}
})
)).filter((result) => result !== void 0);
this.prefixedLogger.trace(
`inferred ${IntlUtil.toLocaleString(results.length)} games from cue files`
);
return results;
}
inferRawFiles(romFiles) {
this.prefixedLogger.trace(
`inferring games from raw files from ${IntlUtil.toLocaleString(romFiles.length)} file${romFiles.length === 1 ? "" : "s"}`
);
const dirnamesToFileCounts = romFiles.reduce((map, romFile) => {
const dirname = path.dirname(romFile.getFilePath());
map.set(dirname, (map.get(dirname) ?? 0) + 1);
return map;
}, /* @__PURE__ */ new Map());
const results = romFiles.filter((file) => !(file instanceof ArchiveEntry)).reduce((map, file) => {
let gameName;
if (dirnamesToFileCounts.size > 1 && (dirnamesToFileCounts.get(path.dirname(file.getFilePath())) ?? 0) > 1) {
gameName = path.basename(path.dirname(file.getFilePath()));
} else {
gameName = DATGameInferrer.getGameName(file);
}
if (map.has(gameName)) {
map.get(gameName)?.push(file);
} else {
map.set(gameName, [file]);
}
return map;
}, /* @__PURE__ */ new Map());
this.prefixedLogger.trace(
`inferred ${IntlUtil.toLocaleString(results.size)} games from raw files`
);
return [...results];
}
}
export {
DATGameInferrer as default
};
//# sourceMappingURL=datGameInferrer.js.map