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.

753 lines (752 loc) • 35 kB
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 generateArchiveFilePreventionReason = await this.shouldGenerateArchiveFile( dat, game, foundRomsWithFiles ); return (await Promise.all( foundRomsWithFiles.map(async (romWithFiles) => { if (generateArchiveFilePreventionReason !== 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: ${generateArchiveFilePreventionReason}` ); 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