UNPKG

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
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