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.
753 lines (752 loc) • 35 kB
JavaScript
import path from "node:path";
import { ValidationResult } from "../../../packages/torrentzip/index.js";
import { ProgressBarSymbol } from "../../console/progressBar.js";
import TokenReplacementException from "../../exceptions/tokenReplacementException.js";
import Disk from "../../models/dats/disk.js";
import MergedDiscGame from "../../models/dats/mergedDiscGame.js";
import ArchiveEntry from "../../models/files/archives/archiveEntry.js";
import ArchiveFile from "../../models/files/archives/archiveFile.js";
import Chd from "../../models/files/archives/chd/chd.js";
import ChdBinCue from "../../models/files/archives/chd/chdBinCue.js";
import Zip from "../../models/files/archives/zip.js";
import File from "../../models/files/file.js";
import ZeroSizeFile from "../../models/files/zeroSizeFile.js";
import { ZipFormat } from "../../models/options.js";
import ROMWithFiles from "../../models/romWithFiles.js";
import WriteCandidate from "../../models/writeCandidate.js";
import OutputFactory from "../../modules/candidates/utils/outputFactory.js";
import ArrayUtil from "../../utils/arrayUtil.js";
import FsUtil from "../../utils/fsUtil.js";
import IntlUtil from "../../utils/intlUtil.js";
import Module from "../module.js";
class CandidateGenerator extends Module {
options;
fileFactory;
readerSemaphore;
constructor(options, progressBar, fileFactory, readerSemaphore) {
super(progressBar, CandidateGenerator.name);
this.options = options;
this.fileFactory = fileFactory;
this.readerSemaphore = readerSemaphore;
}
/**
* Generate the candidates.
*/
async generate(dat, indexedFiles) {
if (indexedFiles.getFiles().length === 0) {
this.prefixedLogger.trace(`${dat.getName()}: no input ROMs to make candidates from`);
return [];
}
this.prefixedLogger.trace(`${dat.getName()}: generating candidates`);
this.progressBar.setSymbol(ProgressBarSymbol.CANDIDATE_GENERATING);
this.progressBar.resetProgress(dat.getGames().length);
const candidates = (await this.readerSemaphore.map(dat.getGames(), async (game) => {
this.progressBar.incrementInProgress();
const childBar = this.progressBar.addChildBar({
name: game.getName()
});
let gameCandidates = [];
try {
gameCandidates = await this.buildCandidatesForGame(dat, game, indexedFiles);
if (gameCandidates.length > 0) {
this.prefixedLogger.trace(
`${dat.getName()}: ${game.getName()}: found candidate: ${gameCandidates[0].getRomsWithFiles().map((rwf) => rwf.getInputFile().toString()).reduce(ArrayUtil.reduceUnique(), []).join(", ")}`
);
}
} catch (error) {
if (!(error instanceof TokenReplacementException)) {
throw error;
}
this.prefixedLogger.debug(
`${dat.getName()}: ${game.getName()}: failed to generate candidate: ${error.message}`
);
} finally {
childBar.delete();
}
this.progressBar.incrementCompleted();
return gameCandidates;
})).flat();
const size = candidates.flatMap((candidate) => candidate.getRomsWithFiles()).reduce((sum, romWithFiles) => sum + romWithFiles.getRom().getSize(), 0);
this.prefixedLogger.trace(
`${dat.getName()}: generated ${FsUtil.sizeReadable(size)} of ${IntlUtil.toLocaleString(candidates.length)} candidate${candidates.length === 1 ? "" : "s"}`
);
this.prefixedLogger.trace(`${dat.getName()}: done generating candidates`);
return candidates;
}
async buildCandidatesForGame(dat, game, indexedFiles) {
const gameRoms = [
...game.getRoms(),
...this.options.getExcludeDisks() ? [] : game.getDisks()
];
const romsAndInputFiles = gameRoms.map((rom) => {
if (!this.options.shouldLink() && rom.getSize() === 0 && (rom.getCrc32() === "00000000" || rom.getMd5() === "d41d8cd98f00b204e9800998ecf8427e" || rom.getSha1() === "da39a3ee5e6b4b0d3255bfef95601890afd80709" || rom.getSha256() === "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855")) {
const foundFiles = indexedFiles.findFiles(rom);
if (foundFiles.length > 0) {
return [rom, foundFiles];
}
return [rom, [ZeroSizeFile.getInstance()]];
}
return [rom, indexedFiles.findFiles(rom)];
});
if (romsAndInputFiles.length > 0 && romsAndInputFiles.reduce((sum, [, inputFiles]) => sum + inputFiles.length, 0) === 0) {
return [];
}
const romsAndLegalInputFiles = this.filterLegalInputFilesForGame(dat, game, romsAndInputFiles);
const romsToOptimalInputFile = this.findOptimalInputFileForGame(
dat,
game,
gameRoms,
romsAndLegalInputFiles,
indexedFiles
);
const romsAndRomsWithFiles = await Promise.all(
gameRoms.map(
async (rom) => await this.buildRomRomWithFilesPair(dat, game, rom, romsToOptimalInputFile)
)
);
const foundRomsWithFiles = romsAndRomsWithFiles.map(([, romWithFiles]) => romWithFiles).filter((romWithFiles) => romWithFiles !== void 0);
if (romsAndRomsWithFiles.length > 0 && foundRomsWithFiles.length === 0) {
this.prefixedLogger.trace(
`${dat.getName()}: ${game.getName()}: could not find any valid input file for any ROM, game cannot be written`
);
return [];
}
const foundRomsWithArchiveFiles = await this.buildRomsWithArchiveEntries(
dat,
game,
foundRomsWithFiles
);
if (foundRomsWithArchiveFiles.length < foundRomsWithFiles.length) {
return [];
}
const missingRoms = romsAndRomsWithFiles.filter(([, romWithFiles]) => !romWithFiles).map(([rom]) => rom);
if (missingRoms.length > 0 && !this.options.getAllowIncompleteSets()) {
if (foundRomsWithArchiveFiles.length > 0) {
this.logMissingRomFiles(dat, game, foundRomsWithArchiveFiles, missingRoms);
}
return [];
}
if (this.hasConflictingOutputFiles(dat, foundRomsWithArchiveFiles)) {
return [];
}
if (!this.options.shouldZip() && !this.options.shouldExtract() && !this.options.getAllowExcessSets() && this.hasExcessFiles(dat, game, foundRomsWithArchiveFiles, indexedFiles)) {
return [];
}
return await this.generateWriteCandidates(dat, game, foundRomsWithArchiveFiles);
}
filterLegalInputFilesForGame(dat, game, romsToInputFiles) {
if (romsToInputFiles.length === 0) {
return romsToInputFiles;
}
return romsToInputFiles.map(([rom, inputFiles]) => {
if (inputFiles.length === 0) {
return [rom, inputFiles];
}
const isAreRawWriting = this.options.shouldWrite() && !this.options.shouldExtractRom(rom) && !this.options.shouldZipRom(rom);
const filteredInputFiles = inputFiles.filter((inputFile) => {
if (!isAreRawWriting && inputFile instanceof ArchiveEntry && !(rom instanceof Disk) && !inputFile.canExtract()) {
this.prefixedLogger.trace(
`${dat.getName()}: ${game.getName()}: ${rom.getName()}: can't use archive because it can't be extracted: ${inputFile.toString()}`
);
return false;
}
if (isAreRawWriting && inputFile instanceof ArchiveEntry && rom.getName().trim() !== "" && inputFile.getArchive().hasMeaningfulEntryPaths()) {
const outputPath = OutputFactory.getPath(this.options, dat, game, rom, inputFile);
if (outputPath.entryPath !== inputFile.getExtractedFilePath()) {
this.prefixedLogger.trace(
`${dat.getName()}: ${game.getName()}: ${rom.getName()}: can't use archive because the entry '${inputFile.getExtractedFilePath()}' doesn't have the correct path '${outputPath.entryPath}'`
);
return false;
}
}
return true;
});
return [rom, filteredInputFiles];
});
}
findOptimalInputFileForGame(dat, game, gameRoms, romsAndInputFiles, indexedFiles) {
if (romsAndInputFiles.length === 0) {
return /* @__PURE__ */ new Map();
}
if (game instanceof MergedDiscGame) {
return this.findOptimalInputFilesForMergedDiscGame(
dat,
game,
romsAndInputFiles,
indexedFiles
);
}
const archiveWithEveryRom = this.findArchiveFileWithEveryRomForGame(
dat,
game,
gameRoms,
romsAndInputFiles,
indexedFiles
);
if (archiveWithEveryRom !== void 0) {
return archiveWithEveryRom;
}
return new Map(
romsAndInputFiles.filter(([, inputFiles]) => inputFiles.length > 0).map(([rom, inputFiles]) => {
return [rom, inputFiles[0]];
})
);
}
/**
* Resolve input files for a {@link MergedDiscGame} one sub-game at a time. Any ROM not resolved
* to a single containing archive falls back to its first matched input file.
*/
findOptimalInputFilesForMergedDiscGame(dat, game, romsAndInputFiles, indexedFiles) {
const entryKey = (rom) => `${rom.getName()}|${rom.hashCode()}`;
const entriesByKey = new Map(
romsAndInputFiles.map((romAndInputFiles) => [
entryKey(romAndInputFiles[0]),
romAndInputFiles
])
);
const filesByKey = /* @__PURE__ */ new Map();
for (const subGame of game.getSubGames()) {
const subGameRoms = subGame.getRoms();
const subGameRomsAndInputFiles = subGameRoms.map((subGameRom) => entriesByKey.get(entryKey(subGameRom))).filter((entry) => entry !== void 0);
if (subGameRomsAndInputFiles.length === 0) {
continue;
}
const archiveWithEveryRom = this.findArchiveFileWithEveryRomForGame(
dat,
subGame,
subGameRoms,
subGameRomsAndInputFiles,
indexedFiles
);
if (archiveWithEveryRom === void 0) {
continue;
}
for (const [rom, inputFile] of archiveWithEveryRom) {
filesByKey.set(entryKey(rom), inputFile);
}
}
const resolved = /* @__PURE__ */ new Map();
for (const [rom, inputFiles] of romsAndInputFiles) {
const inputFile = filesByKey.get(entryKey(rom)) ?? inputFiles.at(0);
if (inputFile !== void 0) {
resolved.set(rom, inputFile);
}
}
return resolved;
}
/**
* Find a single input {@link Archive} that contains every one of a {@link Game}'s {@link ROM}s, and
* return a map from each ROM to its matching entry within that archive. Preferring one archive for
* the whole game avoids output-path conflicts when raw-copying and avoids leaving archives partially
* used when zipping. Returns `undefined` when extracting (the source archive doesn't matter) or when
* no single archive holds every ROM, leaving the caller to fall back to per-ROM matching. ROMs with
* no matching entry are omitted from the returned map, so it is always a `Map<ROM, File>` of only
* resolved ROMs.
*/
findArchiveFileWithEveryRomForGame(dat, game, gameRoms, romsAndInputFiles, indexedFiles) {
if (gameRoms.length === 0) {
return void 0;
}
if (gameRoms.every((rom) => this.options.shouldExtractRom(rom))) {
return void 0;
}
const inputArchivesToRoms = romsAndInputFiles.reduce((map, [rom, files]) => {
for (const file of files) {
if (!(file instanceof ArchiveEntry)) {
continue;
}
const archive = file.getArchive();
if (!map.has(archive)) {
map.set(archive, /* @__PURE__ */ new Set());
}
map.get(archive)?.add(rom);
}
return map;
}, /* @__PURE__ */ new Map());
const archivesWithEveryRom = [...inputArchivesToRoms].filter(([inputArchive, roms]) => {
if (Array.from(roms, (rom) => rom.hashCode()).join(",") === gameRoms.map((rom) => rom.hashCode()).join(",")) {
return true;
}
return inputArchive instanceof ChdBinCue && gameRoms.every(
(rom) => !(this.options.shouldZipRom(rom) || this.options.shouldExtractRom(rom))
) && CandidateGenerator.onlyCueFilesMissingFromChd(game, [...roms]);
}).map(([archive]) => archive);
const filesByPath = indexedFiles.getFilesByFilePath();
const filteredArchivesWithEveryRom = archivesWithEveryRom.filter((archive) => {
const unusedEntries = this.findArchiveUnusedEntryPaths(
archive,
romsAndInputFiles.flatMap(([, inputFiles]) => inputFiles),
indexedFiles
);
if (unusedEntries.length > 0) {
this.prefixedLogger.trace(
`${dat.getName()}: ${game.getName()}: not preferring archive that contains every ROM, plus the excess entries:
${unusedEntries.map((unusedEntry) => ` ${unusedEntry.toString()}`).join("\n")}`
);
return false;
}
return !(this.options.shouldZip() && !(archive instanceof Zip));
}).toSorted((a, b) => {
const aIsOutputFile = filesByPath.get(a.getFilePath())?.some((file) => file.getCanBeCandidateInput()) ? 0 : 1;
const bIsOutputFile = filesByPath.get(b.getFilePath())?.some((file) => file.getCanBeCandidateInput()) ? 0 : 1;
if (aIsOutputFile !== bIsOutputFile) {
return aIsOutputFile - bIsOutputFile;
}
const aEntries = filesByPath.get(a.getFilePath())?.length ?? 0;
const bEntries = filesByPath.get(b.getFilePath())?.length ?? 0;
if (aEntries !== bEntries) {
return aEntries - bEntries;
}
const aChd = a instanceof Chd ? 1 : 0;
const bChd = b instanceof Chd ? 1 : 0;
if (aChd !== bChd) {
return bChd - aChd;
}
const aGameName = path.basename(a.getFilePath()).includes(game.getName()) ? 1 : 0;
const bGameName = path.basename(b.getFilePath()).includes(game.getName()) ? 1 : 0;
return bGameName - aGameName;
});
const archiveWithEveryRom = filteredArchivesWithEveryRom.at(0);
if (archiveWithEveryRom === void 0) {
return void 0;
}
if (filteredArchivesWithEveryRom.length > 1) {
this.prefixedLogger.trace(
`${dat.getName()}: ${game.getName()}: preferring input archive that contains every ROM: '${archiveWithEveryRom.getFilePath()}'; ignoring:
${filteredArchivesWithEveryRom.slice(1).map((archive) => ` ${archive.getFilePath()}`).join("\n")}`
);
} else {
this.prefixedLogger.trace(
`${dat.getName()}: ${game.getName()}: preferring input archive that contains every ROM: '${archiveWithEveryRom.getFilePath()}'`
);
}
return new Map(
romsAndInputFiles.flatMap(([rom, inputFiles]) => {
const archiveEntries = inputFiles.filter(
(inputFile) => inputFile.getFilePath() === archiveWithEveryRom.getFilePath() && inputFile instanceof ArchiveEntry && inputFile.getArchive() === archiveWithEveryRom
);
let archiveEntry = (
// If there are multiple entries in the archive that could be used for this ROM, try to
// pick the best one based on its name. Picking the right entry may let us raw-write this
// archive if it's a zip.
archiveEntries.find(
(archiveEntry2) => archiveEntry2 instanceof ArchiveEntry && archiveEntry2.getEntryPath() === rom.getName()
) ?? // Otherwise, just grab the first one.
archiveEntries.at(0)
);
if (!archiveEntry && rom.getName().toLowerCase().endsWith(".cue") && archiveWithEveryRom instanceof ChdBinCue) {
archiveEntry = filesByPath.get(archiveWithEveryRom.getFilePath())?.find((file) => file.getExtractedFilePath().toLowerCase().endsWith(".cue"));
}
return archiveEntry === void 0 ? [] : [[rom, archiveEntry]];
})
);
}
async buildRomRomWithFilesPair(dat, game, rom, romsToInputFiles) {
let inputFile = romsToInputFiles.get(rom);
if (inputFile === void 0) {
return [rom, void 0];
}
if (!this.options.shouldWrite() && !this.options.shouldTest()) {
return [rom, new ROMWithFiles(rom, inputFile, inputFile)];
}
if (inputFile.getFileHeader() && // ...and we can't rewrite the file
!this.options.shouldWrite()) {
inputFile = inputFile.withoutFileHeader();
}
if (inputFile.getPaddings().length > 0 && // ...and we can't rewrite the file
!this.options.shouldWrite()) {
inputFile = inputFile.withPaddings([]);
}
if (inputFile.getFileHeader() && // ...and we want a headered ROM
(inputFile.getCrc32() !== void 0 && inputFile.getCrc32() === rom.getCrc32() || inputFile.getMd5() !== void 0 && inputFile.getMd5() === rom.getMd5() || inputFile.getSha1() !== void 0 && inputFile.getSha1() === rom.getSha1() || inputFile.getSha256() !== void 0 && inputFile.getSha256() === rom.getSha256()) && // ...and we shouldn't remove the header
!this.options.canRemoveHeader(path.extname(inputFile.getExtractedFilePath()))) {
this.prefixedLogger.trace(
`${dat.getName()}: ${game.getName()}: not removing header, ignoring that one was found for: ${inputFile.toString()}`
);
inputFile = inputFile.withoutFileHeader();
}
if (inputFile.getPaddings().length > 0 && // ...and we want a trimmed ROM
(inputFile.getCrc32() !== void 0 && inputFile.getCrc32() === rom.getCrc32() || inputFile.getMd5() !== void 0 && inputFile.getMd5() === rom.getMd5() || inputFile.getSha1() !== void 0 && inputFile.getSha1() === rom.getSha1() || inputFile.getSha256() !== void 0 && inputFile.getSha256() === rom.getSha256())) {
this.prefixedLogger.trace(
`${dat.getName()}: ${game.getName()}: not adding padding, ignoring that file is trimmed: ${inputFile.toString()}`
);
inputFile = inputFile.withPaddings([]);
}
if (inputFile.getFileHeader() && // ...and we DON'T want a headered ROM
!(inputFile.getCrc32() !== void 0 && inputFile.getCrc32() === rom.getCrc32() || inputFile.getMd5() !== void 0 && inputFile.getMd5() === rom.getMd5() || inputFile.getSha1() !== void 0 && inputFile.getSha1() === rom.getSha1() || inputFile.getSha256() !== void 0 && inputFile.getSha256() === rom.getSha256()) && // ...and we're writing file links
this.options.shouldLink()) {
this.prefixedLogger.trace(
`${dat.getName()}: ${game.getName()}: can't use headered ROM as target for link: ${inputFile.toString()}`
);
return [rom, void 0];
}
if (inputFile.getPaddings().length > 0) {
const desiredPadding = inputFile.getPaddings().find((padding) => {
return padding.getCrc32() !== void 0 && padding.getCrc32() === rom.getCrc32() || padding.getMd5() !== void 0 && padding.getMd5() === rom.getMd5() || padding.getSha1() !== void 0 && padding.getSha1() === rom.getSha1() || padding.getSha256() !== void 0 && padding.getSha256() === rom.getSha256();
});
if (desiredPadding === void 0) {
inputFile = inputFile.withPaddings([]);
} else {
inputFile = inputFile.withPaddings(
this.options.getTrimAddPadding() ? [desiredPadding] : []
);
}
}
try {
const outputFile = await this.getOutputFile(dat, game, rom, inputFile);
if (outputFile === void 0) {
return [rom, void 0];
}
const romWithFiles = new ROMWithFiles(rom, inputFile, outputFile);
return [rom, romWithFiles];
} catch (error) {
this.prefixedLogger.error(`${dat.getName()}: ${game.getName()}: ${error}`);
return [rom, void 0];
}
}
async shouldGenerateArchiveFile(dat, game, romsWithFiles) {
if (this.options.shouldDir2Dat()) {
return "generating a dir2dat";
}
if (this.options.getZipDatName()) {
return "zipping all ROMs from a DAT together later";
}
for (const romWithFiles of romsWithFiles) {
const rom = romWithFiles.getRom();
const inputFile = romWithFiles.getInputFile();
if (!(inputFile instanceof ArchiveEntry)) {
return `input file isn't from an archive`;
}
if (
// If the input file is headered...
inputFile.getFileHeader() && // ...and we want an unheadered ROM
(inputFile.getCrc32WithoutHeader() !== void 0 && inputFile.getCrc32WithoutHeader() !== rom.getCrc32() || inputFile.getMd5WithoutHeader() !== void 0 && inputFile.getMd5WithoutHeader() !== rom.getMd5() || inputFile.getSha1WithoutHeader() !== void 0 && inputFile.getSha1WithoutHeader() !== rom.getSha1() || inputFile.getSha256WithoutHeader() !== void 0 && inputFile.getSha256WithoutHeader() !== rom.getSha256())
) {
return "need to strip an existing ROM header during writing";
}
if (
// If the input file is trimmed...
inputFile.getPaddings().length > 0 && // ...and we want a padded ROM
inputFile.getPaddings().some((padding) => {
return padding.getCrc32() !== void 0 && padding.getCrc32() === rom.getCrc32() || padding.getMd5() !== void 0 && padding.getMd5() === rom.getMd5() || padding.getSha1() !== void 0 && padding.getSha1() === rom.getSha1() || padding.getSha256() !== void 0 && padding.getSha256() === rom.getSha256();
})
) {
return "input file is trimmed and a padded file is expected";
}
if (this.options.shouldExtractRom(rom)) {
return "input archive is being extracted";
}
if (this.options.shouldZipRom(rom) && !(inputFile.getArchive() instanceof Zip)) {
return "input archive is being zipped and it isn't already a zip";
}
if (rom.getName().trim() !== "" && inputFile.getArchive().hasMeaningfulEntryPaths()) {
const outputPath = OutputFactory.getPath(this.options, dat, game, rom, inputFile);
if (outputPath.entryPath !== inputFile.getExtractedFilePath()) {
return `input entry path '${inputFile.getExtractedFilePath()}' doesn't have the correct path '${outputPath.entryPath}'`;
}
}
}
if (romsWithFiles.length > 1 && romsWithFiles.map((romWithFiles) => romWithFiles.getOutputFile().getFilePath()).reduce(ArrayUtil.reduceUnique(), []).length > 1 && game instanceof MergedDiscGame) {
return void 0;
}
if (romsWithFiles.filter(
ArrayUtil.filterUniqueMapped((romWithFiles) => romWithFiles.getInputFile().getFilePath())
).length !== 1) {
return "input files are coming from different archives";
}
const inputArchive = romsWithFiles[0].getInputFile().getArchive();
if (inputArchive instanceof Zip && romsWithFiles.every((romWithFiles) => this.options.shouldZipRom(romWithFiles.getRom()))) {
const tzValidationResult = await this.fileFactory.tzValidationFrom(inputArchive);
if (tzValidationResult === ValidationResult.INVALID) {
return "input zip isn't a valid TorrentZip or RVZSTD archive";
}
if (this.options.getZipFormat() === ZipFormat.TORRENTZIP && tzValidationResult !== ValidationResult.VALID_TORRENTZIP) {
return "input zip isn't a valid TorrentZip archive (it's RVZSTD)";
}
if (this.options.getZipFormat() === ZipFormat.RVZSTD && tzValidationResult !== ValidationResult.VALID_RVZSTD) {
return "input zip isn't a valid RVZSTD archive (it's TorrentZip)";
}
}
return void 0;
}
async buildRomsWithArchiveEntries(dat, game, foundRomsWithFiles) {
if (foundRomsWithFiles.length === 0) {
return foundRomsWithFiles;
}
const shouldGenerateArchiveFile = await this.shouldGenerateArchiveFile(
dat,
game,
foundRomsWithFiles
);
return (await Promise.all(
foundRomsWithFiles.map(async (romWithFiles) => {
if (shouldGenerateArchiveFile !== void 0 && !(romWithFiles.getRom() instanceof Disk)) {
if (romWithFiles.getInputFile() instanceof ArchiveEntry && this.options.shouldWrite() && !this.options.shouldExtractRom(romWithFiles.getRom()) && !this.options.shouldZipRom(romWithFiles.getRom())) {
this.prefixedLogger.trace(
`${dat.getName()}: ${game.getName()}: ${romWithFiles.getRom().getName()}: can't raw-write archive: ${shouldGenerateArchiveFile}`
);
return void 0;
}
return romWithFiles;
}
const oldInputFile = romWithFiles.getInputFile();
if (!(oldInputFile instanceof ArchiveEntry)) {
return romWithFiles;
}
try {
const newInputFile = new ArchiveFile(oldInputFile, {
size: await FsUtil.size(oldInputFile.getFilePath()),
checksumBitmask: oldInputFile.getChecksumBitmask()
});
return romWithFiles.withInputFile(newInputFile);
} catch (error) {
this.prefixedLogger.warn(`${dat.getName()}: ${game.getName()}: ${error}`);
return void 0;
}
})
)).filter((romWithFiles) => romWithFiles !== void 0);
}
logMissingRomFiles(dat, game, foundRomsWithFiles, missingRoms) {
if (foundRomsWithFiles.every(
(romWithFiles) => romWithFiles.getInputFile() instanceof ZeroSizeFile
)) {
return;
}
let message = `${dat.getName()}: ${game.getName()}: found ${IntlUtil.toLocaleString(foundRomsWithFiles.length)} file${foundRomsWithFiles.length === 1 ? "" : "s"}, missing ${IntlUtil.toLocaleString(missingRoms.length)} file${missingRoms.length === 1 ? "" : "s"}:`;
for (const rom of missingRoms) {
message += `
${rom.getName()}`;
}
this.prefixedLogger.trace(message);
if (missingRoms.every((rom) => rom.getName().toLowerCase().endsWith(".cue"))) {
const chdInputFile = foundRomsWithFiles.map((romWithFiles) => romWithFiles.getInputFile()).find(
(inputFile) => inputFile instanceof ArchiveEntry && inputFile.getArchive() instanceof ChdBinCue
);
const missingCueRom = missingRoms.find(
(rom) => this.options.shouldExtractRom(rom) || this.options.shouldZipRom(rom)
);
if (chdInputFile !== void 0 && missingCueRom !== void 0) {
this.prefixedLogger.trace(
`${dat.getName()}: ${game.getName()}: cannot extract .cue sheets from CHDs because Igir doesn't know how to rewrite the track filenames accurately: ${chdInputFile.getArchive().getFilePath()}`
);
}
}
}
hasConflictingOutputFiles(dat, romsWithFiles) {
if (!this.options.shouldWrite()) {
return false;
}
if (romsWithFiles.length < 2) {
return false;
}
const duplicateOutputPaths = romsWithFiles.map((romWithFiles) => romWithFiles.getOutputFile()).filter((outputFile) => !(outputFile instanceof ArchiveEntry)).map((outputFile) => outputFile.getFilePath()).filter((outputPath, idx, outputPaths) => outputPaths.indexOf(outputPath) !== idx).reduce(ArrayUtil.reduceUnique(), []).toSorted((a, b) => a.localeCompare(b));
if (duplicateOutputPaths.length === 0) {
return false;
}
let hasConflict = false;
for (const duplicateOutput of duplicateOutputPaths) {
const conflictedInputFiles = romsWithFiles.filter((romWithFiles) => romWithFiles.getOutputFile().getFilePath() === duplicateOutput).map((romWithFiles) => romWithFiles.getInputFile().toString()).reduce(ArrayUtil.reduceUnique(), []);
if (conflictedInputFiles.length > 1) {
hasConflict = true;
let message = `${dat.getName()}: no single archive contains all necessary files, cannot ${this.options.writeString()} these different input files to: ${duplicateOutput}:`;
for (const conflictedInputFile of conflictedInputFiles) {
message += `
${conflictedInputFile}`;
}
this.prefixedLogger.warn(message);
}
}
return hasConflict;
}
/**
* Given a {@link Game}, return true if all conditions are met:
* - The {@link Game} only has .bin and .cue files
* - Out of the {@link ROM}s that were found in an input directory for the {@link Game}, every
* .bin was found but at least one .cue file is missing
* This is only relevant when we are raw-copying CHD files, where it is difficult to ensure that
* the .cue file is accurate.
*/
static onlyCueFilesMissingFromChd(game, foundRoms) {
if (game.getRoms().length < 2) {
return false;
}
if (foundRoms.length === 0) {
return false;
}
let hasCue = false;
let hasBin = false;
let hasOther = false;
for (const rom of game.getRoms()) {
const romName = rom.getName().toLowerCase();
if (romName.endsWith(".cue")) {
hasCue = true;
} else if (romName.endsWith(".bin")) {
hasBin = true;
} else {
hasOther = true;
}
}
if (!hasCue || !hasBin || hasOther) {
return false;
}
const foundRomNames = new Set(foundRoms.map((rom) => rom.getName()));
return game.getRoms().every(
(rom) => foundRomNames.has(rom.getName()) || rom.getName().toLowerCase().endsWith(".cue")
);
}
hasExcessFiles(dat, game, romsWithFiles, indexedFiles) {
if (romsWithFiles.length === 0) {
return false;
}
const inputArchiveEntries = romsWithFiles.map((romWithFiles) => {
const rom = romWithFiles.getRom();
const inputFile = romWithFiles.getInputFile();
const candidates = indexedFiles.findFiles(rom).filter(
(foundFile) => foundFile.getFilePath() === inputFile.getFilePath() && (inputFile instanceof ArchiveEntry || inputFile instanceof ArchiveFile) && foundFile instanceof ArchiveEntry && inputFile.getArchive() === foundFile.getArchive()
);
return candidates.find(
(foundFile) => foundFile instanceof ArchiveEntry && foundFile.getExtractedFilePath() === rom.getName()
) ?? candidates.at(0);
}).filter((inputFile) => inputFile instanceof ArchiveEntry || inputFile instanceof ArchiveFile);
const inputArchives = inputArchiveEntries.map((archiveEntry) => archiveEntry.getArchive()).filter(ArrayUtil.filterUniqueMapped((archive) => archive.getFilePath()));
if (inputArchives.length === 1 && inputArchives[0] instanceof ChdBinCue && CandidateGenerator.onlyCueFilesMissingFromChd(
game,
romsWithFiles.map((romWithFiles) => romWithFiles.getRom())
)) {
return false;
}
for (const inputArchive of inputArchives) {
const unusedEntries = this.findArchiveUnusedEntryPaths(
inputArchive,
inputArchiveEntries,
indexedFiles
);
if (unusedEntries.length > 0) {
this.prefixedLogger.trace(
`${dat.getName()}: ${game.getName()}: cannot use '${inputArchive.getFilePath()}' as an input file, it has the excess entries:
${unusedEntries.map((unusedEntry) => ` ${unusedEntry.toString()}`).join("\n")}`
);
return true;
}
}
return false;
}
/**
* Given an input {@link archive} and a set of {@link inputFiles} that match to a {@link ROM} from
* a {@link Game}, determine if every entry from the {@link archive} was matched.
*/
findArchiveUnusedEntryPaths(archive, inputFiles, indexedFiles) {
if (this.options.shouldExtract() || this.options.getAllowExcessSets()) {
return [];
}
const usedEntryPaths = new Set(
inputFiles.filter(
(file) => file.getFilePath() === archive.getFilePath() && file instanceof ArchiveEntry && file.getArchive() === archive
).map((entry) => entry.getExtractedFilePath())
);
return (indexedFiles.getFilesByFilePath().get(archive.getFilePath()) ?? []).filter(
(file) => {
if (!(file instanceof ArchiveEntry)) {
return false;
}
if (file.getArchive() !== archive) {
return false;
}
if (archive instanceof ChdBinCue && file.getExtractedFilePath().toLowerCase().endsWith(".cue")) {
return false;
}
return !usedEntryPaths.has(file.getExtractedFilePath());
}
);
}
async getOutputFile(dat, game, rom, inputFile) {
let outputPathParsed;
try {
outputPathParsed = OutputFactory.getPath(this.options, dat, game, rom, inputFile);
} catch (error) {
this.prefixedLogger.trace(`${dat.getName()}: ${game.getName()}: ${error}`);
return void 0;
}
const outputFilePath = outputPathParsed.format();
let outputFileCrc32 = inputFile.getCrc32();
let outputFileMd5 = inputFile.getMd5();
let outputFileSha1 = inputFile.getSha1();
let outputFileSha256 = inputFile.getSha256();
let outputFileSize = inputFile.getSize();
if (inputFile.getFileHeader()) {
outputFileCrc32 = inputFile.getCrc32WithoutHeader();
outputFileMd5 = inputFile.getMd5WithoutHeader();
outputFileSha1 = inputFile.getSha1WithoutHeader();
outputFileSha256 = inputFile.getSha256WithoutHeader();
outputFileSize = inputFile.getSizeWithoutHeader();
}
if (inputFile.getPaddings().length === 1) {
const desiredPadding = inputFile.getPaddings()[0];
outputFileCrc32 = desiredPadding.getCrc32();
outputFileMd5 = desiredPadding.getMd5();
outputFileSha1 = desiredPadding.getSha1();
outputFileSha256 = desiredPadding.getSha256();
outputFileSize = desiredPadding.getPaddedSize();
}
if (this.options.shouldZipRom(rom) && !(inputFile instanceof ArchiveFile) || !this.options.shouldWrite() && inputFile instanceof ArchiveEntry && inputFile.getArchive() instanceof Zip) {
return await ArchiveEntry.entryOf({
archive: new Zip(outputFilePath),
entryPath: outputPathParsed.entryPath,
size: outputFileSize,
crc32: outputFileCrc32,
md5: outputFileMd5,
sha1: outputFileSha1,
sha256: outputFileSha256
});
}
return await File.fileOf({
filePath: outputFilePath,
size: outputFileSize,
crc32: outputFileCrc32,
md5: outputFileMd5,
sha1: outputFileSha1,
sha256: outputFileSha256
});
}
async generateWriteCandidates(dat, game, foundRomsWithFiles) {
const explodedGames = (game.getRegions().length > 0 ? game.getRegions() : [void 0]).flatMap(
(region) => (game.getLanguages().length > 0 ? game.getLanguages() : [void 0]).flatMap(
(language) => (game.getCategories().length > 0 ? game.getCategories() : [void 0]).map(
(category) => game.withProps({
region,
language,
categories: category === void 0 ? [] : [category]
})
)
)
);
const writeCandidates = (await Promise.all(
explodedGames.map(async (explodedGame) => {
const romWithFiles = (await Promise.all(
foundRomsWithFiles.map(async (romWithFiles2) => {
const outputFile = await this.getOutputFile(
dat,
explodedGame,
romWithFiles2.getRom(),
romWithFiles2.getInputFile()
);
if (!outputFile) {
return void 0;
}
return new ROMWithFiles(
romWithFiles2.getRom(),
romWithFiles2.getInputFile(),
outputFile
);
})
)).filter((romWithFiles2) => romWithFiles2 !== void 0);
return new WriteCandidate(explodedGame, romWithFiles);
})
)).filter(ArrayUtil.filterUniqueMapped((candidate) => candidate.hashCode()));
return writeCandidates;
}
}
export {
CandidateGenerator as default
};
//# sourceMappingURL=candidateGenerator.js.map