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.

481 lines (480 loc) • 24.9 kB
import os from 'node:os'; import path from 'node:path'; import async from 'async'; import chalk from 'chalk'; import isAdmin from 'is-admin'; import CandidateWriterSemaphore from './async/candidateWriterSemaphore.js'; import DriveSemaphore from './async/driveSemaphore.js'; import MappableSemaphore from './async/mappableSemaphore.js'; import Timer from './async/timer.js'; import MultiBar from './console/multiBar.js'; import { ProgressBarSymbol } from './console/progressBar.js'; import Package from './globals/package.js'; import Temp from './globals/temp.js'; import CandidateArchiveFileHasher from './modules/candidates/candidateArchiveFileHasher.js'; import CandidateCombiner from './modules/candidates/candidateCombiner.js'; import CandidateExtensionCorrector from './modules/candidates/candidateExtensionCorrector.js'; import CandidateGenerator from './modules/candidates/candidateGenerator.js'; import CandidateMergeSplitValidator from './modules/candidates/candidateMergeSplitValidator.js'; import CandidatePatchGenerator from './modules/candidates/candidatePatchGenerator.js'; import CandidatePostProcessor from './modules/candidates/candidatePostProcessor.js'; import CandidateValidator from './modules/candidates/candidateValidator.js'; import CandidateWriter from './modules/candidates/candidateWriter.js'; import DATCombiner from './modules/dats/datCombiner.js'; import DATDiscMerger from './modules/dats/datDiscMerger.js'; import DATFilter from './modules/dats/datFilter.js'; import DATGameInferrer from './modules/dats/datGameInferrer.js'; import DATMergerSplitter from './modules/dats/datMergerSplitter.js'; import DATParentInferrer from './modules/dats/datParentInferrer.js'; import DATPreferer from './modules/dats/datPreferer.js'; import DATScanner from './modules/dats/datScanner.js'; import Dir2DatCreator from './modules/dir2DatCreator.js'; import DirectoryCleaner from './modules/directoryCleaner.js'; import FixdatCreator from './modules/fixdatCreator.js'; import InputSubdirectoriesDeleter from './modules/inputSubdirectoriesDeleter.js'; import MovedROMDeleter from './modules/movedRomDeleter.js'; import PatchScanner from './modules/patchScanner.js'; import PlaylistCreator from './modules/playlistCreator.js'; import ReportGenerator from './modules/reportGenerator.js'; import ROMHeaderProcessor from './modules/roms/romHeaderProcessor.js'; import ROMIndexer from './modules/roms/romIndexer.js'; import ROMScanner from './modules/roms/romScanner.js'; import ROMTrimProcessor from './modules/roms/romTrimProcessor.js'; import StatusGenerator from './modules/statusGenerator.js'; import ArrayPoly from './polyfill/arrayPoly.js'; import FsPoly from './polyfill/fsPoly.js'; import IgirException from './types/exceptions/igirException.js'; import File from './types/files/file.js'; import FileCache from './types/files/fileCache.js'; import { ChecksumBitmask } from './types/files/fileChecksums.js'; import FileFactory from './types/files/fileFactory.js'; import Options, { InputChecksumArchivesMode, LinkMode } from './types/options.js'; import OutputFactory from './types/outputFactory.js'; /** * The main class that coordinates file scanning, processing, and writing. */ export default class Igir { options; logger; constructor(options, logger) { this.options = options; this.logger = logger; } /** * The main method for this application. */ async main() { Temp.setTempDir(this.options.getTempDir()); // Windows 10 may require admin privileges to symlink at all // @see https://github.com/nodejs/node/issues/18518 if (this.options.shouldLink() && this.options.getLinkMode() === LinkMode.SYMLINK && process.platform === 'win32') { this.logger.trace('checking Windows for symlink permissions'); if (!(await FsPoly.canSymlink(Temp.getTempDir()))) { if (!(await isAdmin())) { throw new IgirException(`${Package.NAME} does not have permissions to create symlinks, please try running as administrator`); } throw new IgirException(`${Package.NAME} does not have permissions to create symlinks`); } this.logger.trace('Windows has symlink permissions'); } if (this.options.shouldLink() && this.options.getLinkMode() === LinkMode.HARDLINK) { const outputDirRoot = this.options.getOutputDirRoot(); if (!(await FsPoly.canHardlink(outputDirRoot))) { const outputDisk = FsPoly.diskResolved(outputDirRoot); throw new IgirException(`${outputDisk} does not support hard-linking`); } } // File cache options const fileCache = new FileCache(); if (this.options.getDisableCache()) { this.logger.trace('disabling the file cache'); fileCache.disable(); } else { const cachePath = await this.getCachePath(); if (cachePath !== undefined && process.env.NODE_ENV !== 'test') { this.logger.trace(`loading the file cache at '${cachePath}'`); await fileCache.loadFile(cachePath); } else { this.logger.trace('not using a file for the file cache'); } } const fileFactory = new FileFactory(fileCache, this.logger); // Semaphores const driveSemaphore = new DriveSemaphore(this.options.getReaderThreads()); const readerSemaphore = new MappableSemaphore(this.options.getReaderThreads()); const writerSemaphore = new CandidateWriterSemaphore(this.options.getWriterThreads()); // Scan and process input files let dats = await this.processDATScanner(fileFactory, driveSemaphore); const indexedRoms = await this.processROMScanner(fileFactory, driveSemaphore, this.determineScanningBitmask(dats), this.determineScanningChecksumArchives(dats)); const roms = indexedRoms.getFiles(); const patches = await this.processPatchScanner(fileFactory, driveSemaphore); // Set up progress bar and input for DAT processing const datProcessProgressBar = this.logger.addProgressBar({ name: chalk.underline('Processing DATs'), symbol: ProgressBarSymbol.NONE, total: dats.length, progressBarSizeMultiplier: 2, }); if (dats.length === 0) { dats = await new DATGameInferrer(this.options, datProcessProgressBar).infer(roms); datProcessProgressBar.setTotal(dats.length); } if (dats.length <= 1) { // If there's only one DAT, then it's redundant to show this progress bar datProcessProgressBar.delete(); } const datsToWrittenFiles = new Map(); let romOutputDirs = []; let movedRomsToDelete = []; const datsStatuses = []; // Process every DAT datProcessProgressBar.logTrace(`processing ${dats.length.toLocaleString()} DAT${dats.length === 1 ? '' : 's'}`); await async.eachLimit(dats, this.options.getDatThreads(), async (dat) => { datProcessProgressBar.incrementInProgress(); const progressBar = this.logger.addProgressBar({ name: dat.getDisplayName(), symbol: ProgressBarSymbol.WAITING, total: dat.getParents().length, }); const processedDat = this.processDAT(progressBar, dat); // Generate and filter ROM candidates const candidates = await this.generateCandidates(progressBar, fileFactory, driveSemaphore, readerSemaphore, processedDat, indexedRoms, patches); romOutputDirs = [...romOutputDirs, ...this.getCandidateOutputDirs(processedDat, candidates)]; // Write the output files const writerResults = await new CandidateWriter(this.options, progressBar, writerSemaphore).write(processedDat, candidates); movedRomsToDelete = [...movedRomsToDelete, ...writerResults.moved]; datsToWrittenFiles.set(processedDat, writerResults.wrote); // Write playlists const playlistPaths = await new PlaylistCreator(this.options, progressBar).create(processedDat, candidates); datsToWrittenFiles.set(processedDat, [ ...(datsToWrittenFiles.get(processedDat) ?? []), ...(await readerSemaphore.map(playlistPaths, async (filePath) => File.fileOf({ filePath }))), ]); // Write a dir2dat const dir2DatPath = await new Dir2DatCreator(this.options, progressBar).create(processedDat, candidates); if (dir2DatPath) { datsToWrittenFiles.set(processedDat, [ ...(datsToWrittenFiles.get(processedDat) ?? []), await File.fileOf({ filePath: dir2DatPath }), ]); } // Write a fixdat const fixdatPath = await new FixdatCreator(this.options, progressBar).create(processedDat, candidates); if (fixdatPath) { datsToWrittenFiles.set(processedDat, [ ...(datsToWrittenFiles.get(processedDat) ?? []), await File.fileOf({ filePath: fixdatPath }), ]); } // Write the output report const datStatus = new StatusGenerator(progressBar).generate(processedDat, candidates); datsStatuses.push(datStatus); progressBar.finish([ datStatus.toConsole(this.options), dir2DatPath ? `dir2dat: ${dir2DatPath}` : undefined, fixdatPath ? `Fixdat: ${fixdatPath}` : undefined, ] .filter((line) => line !== undefined && line.length > 0) .join('\n')); // Progress bar cleanup if (candidates.length > 0 || this.options.shouldDir2Dat() || this.options.shouldFixdat()) { progressBar.freeze(); } else { progressBar.delete(); } datProcessProgressBar.incrementCompleted(); }); datProcessProgressBar.logTrace(`done processing ${dats.length.toLocaleString()} DAT${dats.length === 1 ? '' : 's'}`); datProcessProgressBar.finishWithItems(dats.length, 'DAT', 'processed'); datProcessProgressBar.delete(); // Delete moved ROMs await this.deleteMovedRoms(roms, movedRomsToDelete, datsToWrittenFiles); // Clean the output directories const cleanedOutputFiles = await this.processOutputCleaner(romOutputDirs, datsToWrittenFiles); // Generate the report await this.processReportGenerator(roms, cleanedOutputFiles, datsStatuses); MultiBar.stop(); Timer.cancelAll(); } async getCachePath() { const defaultFileName = process.versions.bun ? // As of v1.1.26, Bun uses a different serializer than V8, making cache files incompatible // @see https://bun.sh/docs/runtime/nodejs-apis `${Package.NAME}.bun.cache` : `${Package.NAME}.cache`; // First, try to use the provided path let cachePath = this.options.getCachePath(); if (cachePath !== undefined && (await FsPoly.isDirectory(cachePath))) { cachePath = path.join(cachePath, defaultFileName); this.logger.warn(`A directory was provided for the cache path instead of a file, using '${cachePath}' instead`); } if (cachePath !== undefined) { if (await FsPoly.isWritable(cachePath)) { return cachePath; } this.logger.warn("Provided cache path isn't writable, using the default path"); } const cachePathCandidates = [ path.join(Package.DIRECTORY, defaultFileName), path.join(os.homedir(), defaultFileName), path.join(process.cwd(), defaultFileName), ] .filter((filePath) => filePath.length > 0 && !filePath.startsWith(os.tmpdir())) .reduce(ArrayPoly.reduceUnique(), []); // Next, try to use an already existing path const exists = await Promise.all(cachePathCandidates.map(async (pathCandidate) => FsPoly.exists(pathCandidate))); const existsCachePath = cachePathCandidates.find((_, idx) => exists[idx]); if (existsCachePath !== undefined) { return existsCachePath; } // Next, try to find a writable path const writable = await Promise.all(cachePathCandidates.map(async (pathCandidate) => FsPoly.isWritable(pathCandidate))); const writableCachePath = cachePathCandidates.find((_, idx) => writable[idx]); if (writableCachePath !== undefined) { return writableCachePath; } return undefined; } async processDATScanner(fileFactory, driveSemaphore) { if (this.options.shouldDir2Dat()) { return []; } if (!this.options.usingDats()) { this.logger.warn('No DAT files provided, consider using some for the best results!'); return []; } const progressBar = this.logger.addProgressBar({ name: 'Scanning for DATs', }); let dats = await new DATScanner(this.options, progressBar, fileFactory, driveSemaphore).scan(); if (dats.length === 0) { throw new IgirException('No valid DAT files found!'); } if (dats.length === 1) { [ [this.options.getDirDatName(), '--dir-dat-name'], [this.options.getDirDatDescription(), '--dir-dat-description'], ] .filter(([bool]) => bool) .forEach(([, option]) => { progressBar.logWarn(`${option} is most helpful when processing multiple DATs, only one DAT was found`); }); } if (this.options.getDatCombine()) { progressBar.resetProgress(1); dats = [new DATCombiner(progressBar).combine(dats)]; } progressBar.finishWithItems(dats.length, 'DAT', this.options.getDatCombine() ? 'combined' : 'found'); progressBar.freeze(); return dats; } determineScanningBitmask(dats) { const minimumChecksum = this.options.getInputChecksumMin() ?? ChecksumBitmask.NONE; const maximumChecksum = this.options.getInputChecksumMax() ?? Object.values(ChecksumBitmask).at(-1) ?? minimumChecksum; let matchChecksum = minimumChecksum; if (this.options.getPatchFileCount() > 0) { matchChecksum |= ChecksumBitmask.CRC32; this.logger.trace('using patch files, enabling CRC32 file checksums'); } if (this.options.shouldDir2Dat()) { Object.values(ChecksumBitmask) .filter((bitmask) => // Has not been enabled yet bitmask >= ChecksumBitmask.CRC32 && bitmask <= ChecksumBitmask.SHA1 && !(matchChecksum & bitmask)) .forEach((bitmask) => { matchChecksum |= bitmask; this.logger.trace(`generating a dir2dat, enabling ${bitmask} file checksums`); }); } dats.forEach((dat) => { const datMinimumRomBitmask = dat.getRequiredRomChecksumBitmask(); Object.values(ChecksumBitmask) .filter((bitmask) => // Has not been enabled yet bitmask > minimumChecksum && bitmask <= maximumChecksum && !(matchChecksum & bitmask) && // Should be enabled for this DAT (datMinimumRomBitmask & bitmask) > 0) .forEach((bitmask) => { matchChecksum |= bitmask; this.logger.trace(`${dat.getName()}: needs ${bitmask} file checksums for ROMs, enabling`); }); if (this.options.getExcludeDisks()) { return; } const datMinimumDiskBitmask = dat.getRequiredDiskChecksumBitmask(); Object.values(ChecksumBitmask) .filter((bitmask) => // Has not been enabled yet bitmask > minimumChecksum && bitmask <= maximumChecksum && !(matchChecksum & bitmask) && // Should be enabled for this DAT (datMinimumDiskBitmask & bitmask) > 0) .forEach((bitmask) => { matchChecksum |= bitmask; this.logger.trace(`${dat.getName()}: needs ${bitmask} file checksums for disks, enabling`); }); }); if (matchChecksum === ChecksumBitmask.NONE) { matchChecksum |= ChecksumBitmask.CRC32; this.logger.trace('at least one checksum algorithm is required, enabling CRC32 file checksums'); } return matchChecksum; } determineScanningChecksumArchives(dats) { if (this.options.getInputChecksumArchives() === InputChecksumArchivesMode.NEVER) { return false; } if (this.options.getInputChecksumArchives() === InputChecksumArchivesMode.ALWAYS) { return true; } return dats.some((dat) => dat.getGames().some((game) => game.getRoms().some((rom) => { const isArchive = FileFactory.isExtensionArchive(rom.getName()); if (isArchive) { this.logger.trace(`${dat.getName()}: contains archives, enabling checksum calculation of raw archive contents`); } return isArchive; }))); } async processROMScanner(fileFactory, driveSemaphore, checksumBitmask, checksumArchives) { const romScannerProgressBarName = 'Scanning for ROMs'; const romProgressBar = this.logger.addProgressBar({ name: romScannerProgressBarName, }); const rawRomFiles = await new ROMScanner(this.options, romProgressBar, fileFactory, driveSemaphore).scan(checksumBitmask, checksumArchives); romProgressBar.setName('Detecting ROM headers'); const romFilesWithHeaders = await new ROMHeaderProcessor(this.options, romProgressBar, fileFactory, driveSemaphore).process(rawRomFiles); romProgressBar.setName('Detecting ROM trimming'); const romFilesWithTrimming = await new ROMTrimProcessor(this.options, romProgressBar, fileFactory, driveSemaphore).process(romFilesWithHeaders); romProgressBar.setName('Indexing ROMs'); const indexedRomFiles = new ROMIndexer(this.options, romProgressBar).index(romFilesWithTrimming); romProgressBar.setName(romScannerProgressBarName); // reset romProgressBar.finishWithItems(romFilesWithTrimming.length, 'file', 'found'); romProgressBar.freeze(); return indexedRomFiles; } async processPatchScanner(fileFactory, driveSemaphore) { if (!this.options.getPatchFileCount()) { return []; } const progressBar = this.logger.addProgressBar({ name: 'Scanning for patches', }); const patches = await new PatchScanner(this.options, progressBar, fileFactory, driveSemaphore).scan(); progressBar.finishWithItems(patches.length, 'patch', 'found'); progressBar.freeze(); return patches; } processDAT(progressBar, dat) { return [ (dat) => new DATParentInferrer(this.options, progressBar).infer(dat), (dat) => new DATMergerSplitter(this.options, progressBar).merge(dat), (dat) => new DATDiscMerger(this.options, progressBar).merge(dat), (dat) => new DATFilter(this.options, progressBar).filter(dat), (dat) => new DATPreferer(this.options, progressBar).prefer(dat), ].reduce((processedDat, processor) => { return processor(processedDat); }, dat); } async generateCandidates(progressBar, fileFactory, driveSemaphore, readerSemaphore, dat, indexedRoms, patches) { return [ // Generate the initial set of candidates async () => new CandidateGenerator(this.options, progressBar, readerSemaphore).generate(dat, indexedRoms), // Add patched candidates async (candidates) => new CandidatePatchGenerator(progressBar).generate(dat, candidates, patches), // Correct output filename extensions async (candidates) => new CandidateExtensionCorrector(this.options, progressBar, fileFactory, readerSemaphore).correct(dat, candidates), /** * Delay calculating checksums for {@link ArchiveFile}s until after the above steps for * efficiency */ async (candidates) => new CandidateArchiveFileHasher(this.options, progressBar, fileFactory, driveSemaphore).hash(dat, candidates), // Finalize output file paths (candidates) => new CandidatePostProcessor(this.options, progressBar).process(dat, candidates), // Validate candidates (candidates) => { const invalidCandidates = new CandidateValidator(this.options, progressBar).validate(dat, candidates); if (invalidCandidates.length > 0) { // Return zero candidates if any candidates failed to validate return []; } return candidates; }, // Validate merge/split (candidates) => { new CandidateMergeSplitValidator(this.options, progressBar).validate(dat, candidates); return candidates; }, // Combine candidates into one (candidates) => new CandidateCombiner(this.options, progressBar).combine(dat, candidates), ].reduce(async (candidatesPromise, processor) => { const candidates = await candidatesPromise; return processor(candidates); }, Promise.resolve([])); } /** * Find all ROM output paths for a DAT and its candidates. */ getCandidateOutputDirs(dat, candidates) { return candidates .flatMap((candidate) => candidate.getRomsWithFiles().flatMap((romWithFiles) => OutputFactory.getPath( // Parse the output directory, as supplied by the user, ONLY replacing tokens in the // path and NOT respecting any `--dir-*` options. new Options({ commands: [...this.options.getCommands()], output: this.options.getOutput(), }), dat, candidate.getGame(), romWithFiles.getRom(), romWithFiles.getInputFile()).dir)) .reduce(ArrayPoly.reduceUnique(), []); } async deleteMovedRoms(rawRomFiles, movedRomsToDelete, datsToWrittenFiles) { if (movedRomsToDelete.length === 0) { return; } const progressBarName = 'Deleting moved files'; const progressBar = this.logger.addProgressBar({ name: progressBarName }); const deletedFilePaths = await new MovedROMDeleter(this.options, progressBar).delete(rawRomFiles, movedRomsToDelete, datsToWrittenFiles); progressBar.setName('Deleting empty input subdirectories'); await new InputSubdirectoriesDeleter(this.options, progressBar).delete(movedRomsToDelete); progressBar.setName(progressBarName); progressBar.finishWithItems(deletedFilePaths.length, 'moved file', 'deleted'); if (deletedFilePaths.length > 0) { progressBar.freeze(); } else { progressBar.delete(); } } async processOutputCleaner(dirsToClean, datsToWrittenFiles) { if (!this.options.shouldWrite() || !this.options.shouldClean() || dirsToClean.length === 0) { return []; } const progressBar = this.logger.addProgressBar({ name: 'Cleaning output directory' }); const uniqueDirsToClean = dirsToClean.reduce(ArrayPoly.reduceUnique(), []); const writtenFilesToExclude = [...datsToWrittenFiles.values()].flat(); const filesCleaned = await new DirectoryCleaner(this.options, progressBar).clean(uniqueDirsToClean, writtenFilesToExclude); progressBar.finishWithItems(filesCleaned.length, 'file', 'recycled'); progressBar.freeze(); return filesCleaned; } async processReportGenerator(scannedRomFiles, cleanedOutputFiles, datsStatuses) { if (!this.options.shouldReport()) { return; } const reportProgressBar = this.logger.addProgressBar({ name: 'Generating report', symbol: ProgressBarSymbol.WRITING, }); await new ReportGenerator(this.options, reportProgressBar).generate(scannedRomFiles, cleanedOutputFiles, datsStatuses); } }