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.
778 lines (777 loc) • 33.6 kB
JavaScript
import os from "node:os";
import path from "node:path";
import { ValidationResult } from "../../../packages/torrentzip/index.js";
import { ProgressBarSymbol } from "../../console/progressBar.js";
import { CacheMode } from "../../factories/fileFactory.js";
import ArchiveEntry from "../../models/files/archives/archiveEntry.js";
import ArchiveFile from "../../models/files/archives/archiveFile.js";
import Zip from "../../models/files/archives/zip.js";
import { ChecksumBitmask } from "../../models/files/fileChecksums.js";
import ZeroSizeFile from "../../models/files/zeroSizeFile.js";
import { LinkMode, ZipFormat } from "../../models/options.js";
import ArrayUtil from "../../utils/arrayUtil.js";
import FsUtil, { MoveResult } from "../../utils/fsUtil.js";
import IntlUtil from "../../utils/intlUtil.js";
import Module from "../module.js";
class CandidateWriter extends Module {
// Keep track of written files, to warn on conflicts
static OUTPUT_PATHS_WRITTEN = /* @__PURE__ */ new Map();
options;
fileFactory;
candidateSemaphore;
moveMutex;
filesQueuedForDeletion = [];
constructor(options, progressBar, fileFactory, candidateSemaphore, moveMutex) {
super(progressBar, CandidateWriter.name);
this.options = options;
this.fileFactory = fileFactory;
this.candidateSemaphore = candidateSemaphore;
this.moveMutex = moveMutex;
}
/**
* Write & test candidates.
*/
async write(dat, candidates) {
if (candidates.length === 0) {
return {
wrote: [],
moved: []
};
}
if (!this.options.shouldWrite() && !this.options.shouldTest()) {
return {
wrote: candidates,
moved: []
};
}
const inputToOutputFiles = /* @__PURE__ */ new Set();
const writableCandidates = candidates.filter((candidate) => candidate.canWrite()).map((candidate) => {
const nonDuplicateRomsWithFiles = candidate.getRomsWithFiles().filter((romWithFiles) => {
const inputToOutputFile = `${romWithFiles.getInputFile().toString()} \u2192 ${romWithFiles.getOutputFile().toString()}`;
if (inputToOutputFiles.has(inputToOutputFile)) {
return false;
}
inputToOutputFiles.add(inputToOutputFile);
return true;
});
if (nonDuplicateRomsWithFiles.length === candidate.getRomsWithFiles().length) {
return candidate;
}
return candidate.withRomsWithFiles(nonDuplicateRomsWithFiles);
}).filter((candidate) => candidate.getRomsWithFiles().length > 0);
this.prefixedLogger.trace(
`${dat.getName()}: ${this.options.shouldWrite() ? "writing" : "testing"} ${IntlUtil.toLocaleString(writableCandidates.length)} candidate${writableCandidates.length === 1 ? "" : "s"}`
);
if (this.options.shouldTest() && !this.options.getOverwrite()) {
this.progressBar.setSymbol(ProgressBarSymbol.TESTING);
} else {
this.progressBar.setSymbol(ProgressBarSymbol.WRITING);
}
this.progressBar.resetProgress(writableCandidates.length);
await this.candidateSemaphore.map(writableCandidates, async (candidate) => {
this.progressBar.incrementInProgress();
this.prefixedLogger.trace(
`${dat.getName()}: ${candidate.getName()}: ${this.options.shouldWrite() ? "writing" : "testing"} candidate`
);
if (this.options.shouldLink()) {
await this.writeLink(dat, candidate);
} else {
await this.writeZip(dat, candidate);
await this.writeRaw(dat, candidate);
}
this.prefixedLogger.trace(
`${dat.getName()}: ${candidate.getName()}: done ${this.options.shouldWrite() ? "writing" : "testing"} candidate`
);
this.progressBar.incrementCompleted();
});
this.prefixedLogger.trace(
`${dat.getName()}: done ${this.options.shouldWrite() ? "writing" : "testing"} ${IntlUtil.toLocaleString(writableCandidates.length)} candidate${writableCandidates.length === 1 ? "" : "s"}`
);
const writtenFilePaths = new Set(
writableCandidates.flatMap(
(c) => c.getRomsWithFiles().map((r) => r.getOutputFile().getFilePath())
)
);
const movedCandidates = this.filesQueuedForDeletion.filter(
(writeCandidate) => writeCandidate.getRomsWithFiles().some((romWithFiles) => !writtenFilePaths.has(romWithFiles.getInputFile().getFilePath()))
);
return {
wrote: writableCandidates,
moved: movedCandidates
};
}
static async ensureOutputDirExists(outputFilePath) {
const outputDir = path.dirname(outputFilePath);
if (!await FsUtil.exists(outputDir)) {
await FsUtil.mkdir(outputDir, { recursive: true });
}
}
/**
***********************
*
* Zip Writing *
*
***********************
*/
async writeZip(dat, candidate) {
const inputToOutputZipEntries = candidate.getRomsWithFiles().filter((romWithFiles) => romWithFiles.getOutputFile() instanceof ArchiveEntry).map((romWithFiles) => [
romWithFiles.getInputFile(),
romWithFiles.getOutputFile()
]);
if (inputToOutputZipEntries.length === 0) {
return;
}
const outputZip = inputToOutputZipEntries[0][1].getArchive();
const childBar = this.progressBar.addChildBar({
name: outputZip.getFilePath(),
total: inputToOutputZipEntries.reduce(
(sum, [, outputEntry]) => sum + outputEntry.getSize(),
0
),
progressFormatter: FsUtil.sizeReadable.bind(FsUtil)
});
try {
if (await FsUtil.exists(outputZip.getFilePath())) {
if (this.options.shouldWrite() && !this.options.getOverwrite() && !this.options.getOverwriteInvalid()) {
if (CandidateWriter.OUTPUT_PATHS_WRITTEN.has(outputZip.getFilePath())) {
this.prefixedLogger.warn(
`${dat.getName()}: ${candidate.getName()}: ${outputZip.getFilePath()}: not overwriting existing zip file already written by '${CandidateWriter.OUTPUT_PATHS_WRITTEN.get(outputZip.getFilePath())?.getName()}'`
);
} else {
this.prefixedLogger.debug(
`${dat.getName()}: ${candidate.getName()}: ${outputZip.getFilePath()}: not overwriting existing zip file`
);
}
return;
}
if (!this.options.shouldWrite() || this.options.getOverwriteInvalid()) {
const existingTest = await this.testZipContents(
dat,
candidate,
outputZip.getFilePath(),
inputToOutputZipEntries.map(([, outputEntry]) => outputEntry),
childBar
);
if (this.options.shouldWrite() && !existingTest) {
this.prefixedLogger.debug(
`${dat.getName()}: ${candidate.getName()}: ${outputZip.getFilePath()}: not overwriting existing zip file, the existing zip is correct`
);
for (const [inputRomFile] of inputToOutputZipEntries) {
this.enqueueFileDeletion(candidate, inputRomFile);
}
return;
}
if (!this.options.shouldWrite() && existingTest) {
this.prefixedLogger.error(
`${dat.getName()}: ${candidate.getName()}: ${outputZip.getFilePath()}: ${existingTest}`
);
return;
}
}
if (this.options.shouldWrite() && CandidateWriter.OUTPUT_PATHS_WRITTEN.has(outputZip.getFilePath())) {
this.prefixedLogger.warn(
`${dat.getName()}: ${candidate.getName()}: ${outputZip.getFilePath()}: overwriting existing zip file already written by '${CandidateWriter.OUTPUT_PATHS_WRITTEN.get(outputZip.getFilePath())?.getName()}'`
);
}
}
if (!this.options.shouldWrite()) {
return;
}
CandidateWriter.OUTPUT_PATHS_WRITTEN.set(outputZip.getFilePath(), dat);
this.progressBar.setSymbol(ProgressBarSymbol.WRITING);
let wasWritten = false;
for (let i = 0; i <= this.options.getWriteRetry(); i += 1) {
wasWritten = await this.writeZipFile(
dat,
candidate,
outputZip,
inputToOutputZipEntries,
childBar
);
if (wasWritten && !this.options.shouldTest()) {
break;
}
if (wasWritten && this.options.shouldTest()) {
const writtenTest = await this.testZipContents(
dat,
candidate,
outputZip.getFilePath(),
inputToOutputZipEntries.map((entry) => entry[1]),
childBar
);
if (!writtenTest) {
break;
}
const message = `${dat.getName()}: ${candidate.getName()}: ${outputZip.getFilePath()}: written zip ${writtenTest}`;
if (i < this.options.getWriteRetry()) {
this.prefixedLogger.warn(`${message}, retrying`);
} else {
this.prefixedLogger.error(message);
return;
}
}
}
if (wasWritten) {
for (const [inputRomFile] of inputToOutputZipEntries) {
this.enqueueFileDeletion(candidate, inputRomFile);
}
}
} finally {
childBar.delete();
}
}
async testZipContents(dat, candidate, zipFilePath, expectedArchiveEntries, progressBar) {
this.prefixedLogger.trace(
`${dat.getName()}: ${candidate.getName()}: ${zipFilePath}: testing zip`
);
const zipFile = new Zip(zipFilePath);
const expectedEntriesByPath = expectedArchiveEntries.reduce((map, entry) => {
map.set(entry.getEntryPath(), entry);
return map;
}, /* @__PURE__ */ new Map());
const checksumBitmask = expectedArchiveEntries.reduce(
(bitmask, entry) => bitmask | entry.getChecksumBitmask(),
ChecksumBitmask.CRC32
);
let archiveEntries;
try {
archiveEntries = await this.fileFactory.entriesFromArchive(
zipFile,
checksumBitmask,
CacheMode.IGNORE_CACHED_VALUE,
(progress, total) => {
progressBar.setCompleted(progress);
if (total !== void 0) {
progressBar.setTotal(total);
}
},
true
) ?? [];
} catch (error) {
return `failed to get archive contents: ${error}`;
}
const actualEntriesByPath = archiveEntries.reduce((map, entry) => {
map.set(entry.getEntryPath(), entry);
return map;
}, /* @__PURE__ */ new Map());
if (actualEntriesByPath.size !== expectedEntriesByPath.size) {
return `has ${IntlUtil.toLocaleString(actualEntriesByPath.size)} files, expected ${IntlUtil.toLocaleString(expectedEntriesByPath.size)}`;
}
for (const [entryPath, expectedFile] of expectedEntriesByPath) {
if (!actualEntriesByPath.has(entryPath)) {
return `is missing the file ${entryPath}`;
}
const actualFile = actualEntriesByPath.get(entryPath);
if (actualFile.getSha256() && expectedFile.getSha256() && actualFile.getSha256() !== expectedFile.getSha256()) {
return `entry '${entryPath}' has the SHA256 ${actualFile.getSha256()}, expected ${expectedFile.getSha256()}`;
}
if (actualFile.getSha1() && expectedFile.getSha1() && actualFile.getSha1() !== expectedFile.getSha1()) {
return `entry '${entryPath}' has the SHA1 ${actualFile.getSha1()}, expected ${expectedFile.getSha1()}`;
}
if (actualFile.getMd5() && expectedFile.getMd5() && actualFile.getMd5() !== expectedFile.getMd5()) {
return `entry '${entryPath}' has the MD5 ${actualFile.getMd5()}, expected ${expectedFile.getMd5()}`;
}
if (actualFile.getCrc32() && expectedFile.getCrc32() && actualFile.getCrc32() !== expectedFile.getCrc32()) {
return `entry '${entryPath}' has the CRC32 ${actualFile.getCrc32()}, expected ${expectedFile.getCrc32()}`;
}
if (actualFile.getCrc32() && expectedFile.getCrc32() && actualFile.getSize() !== expectedFile.getSize()) {
return `entry '${entryPath}' has the file ${entryPath} of size ${IntlUtil.toLocaleString(actualFile.getSize())}B, expected ${IntlUtil.toLocaleString(expectedFile.getSize())}B`;
}
}
const tzValidationResult = await this.fileFactory.tzValidationFrom(
zipFile,
CacheMode.IGNORE_CACHED_VALUE
);
if (this.options.getZipFormat() === ZipFormat.TORRENTZIP && tzValidationResult !== ValidationResult.VALID_TORRENTZIP) {
return "is not a valid TorrentZip file";
}
if (this.options.getZipFormat() === ZipFormat.RVZSTD && tzValidationResult !== ValidationResult.VALID_RVZSTD) {
return "is not a valid RVZSTD file";
}
this.prefixedLogger.trace(
`${dat.getName()}: ${candidate.getName()}: ${zipFilePath}: test passed`
);
return void 0;
}
async writeZipFile(dat, candidate, outputZip, inputToOutputZipEntries, progressBar) {
const lockedFilePaths = this.options.shouldMove() ? inputToOutputZipEntries.map(([input]) => input.getFilePath()).reduce(ArrayUtil.reduceUnique(), []) : [];
return await this.moveMutex.runExclusiveForKeys(lockedFilePaths, async () => {
const movedInputToOutputZipEntries = this.options.shouldMove() ? inputToOutputZipEntries.map(([input, output]) => {
const movedFilePath = this.moveMutex.getMovedLocationUnsafe(input.getFilePath());
if (movedFilePath === void 0) {
return [input, output];
}
return [input.withFilePath(movedFilePath), output];
}) : inputToOutputZipEntries;
this.prefixedLogger.info(
[
`${dat.getName()}: ${candidate.getName()}: creating zip archive '${outputZip.getFilePath()}' with the entr${movedInputToOutputZipEntries.length === 1 ? "y" : "ies"}:`,
...movedInputToOutputZipEntries.map(([input, output]) => {
if (input.getFilePath() === output.getFilePath()) {
return ` '${input.getExtractedFilePath()}' (${FsUtil.sizeReadable(input.getSize())}) \u2192 '${output.getExtractedFilePath()}' ${input.getExtractedFilePath() === output.getExtractedFilePath() ? "(rewriting)" : ""}`;
}
return ` '${input.toString()}' (${FsUtil.sizeReadable(input.getSize())}) \u2192 '${output.getExtractedFilePath()}'`;
})
].join("\n")
);
try {
await CandidateWriter.ensureOutputDirExists(outputZip.getFilePath());
const compressorThreads = Math.ceil(
os.availableParallelism() / Math.max(this.candidateSemaphore.openLocks(), 1)
);
await outputZip.createArchive(
movedInputToOutputZipEntries,
this.options.getZipFormat(),
compressorThreads,
(progress, total) => {
progressBar.setCompleted(progress);
progressBar.setTotal(total);
}
);
} catch (error) {
this.prefixedLogger.error(
`${dat.getName()}: ${candidate.getName()}: ${outputZip.getFilePath()}: failed to create zip: ${error}`
);
return false;
}
this.prefixedLogger.trace(
`${dat.getName()}: ${candidate.getName()}: ${outputZip.getFilePath()}: wrote ${IntlUtil.toLocaleString(inputToOutputZipEntries.length)} archive entr${inputToOutputZipEntries.length === 1 ? "y" : "ies"}`
);
return true;
});
}
/**
***********************
*
* Raw Writing *
*
***********************
*/
async writeRaw(dat, candidate) {
const inputToOutputEntries = candidate.getRomsWithFiles().filter((romWithFiles) => !(romWithFiles.getOutputFile() instanceof ArchiveEntry)).map((romWithFiles) => [romWithFiles.getInputFile(), romWithFiles.getOutputFile()]);
if (inputToOutputEntries.length === 0) {
return;
}
const totalBytes = inputToOutputEntries.flatMap(([, outputFile]) => outputFile).reduce((sum, file) => sum + file.getSize(), 0);
this.prefixedLogger.trace(
`${dat.getName()}: ${candidate.getName()}: ${this.options.shouldMove() ? "moving" : "copying"} ${FsUtil.sizeReadable(totalBytes)} of ${IntlUtil.toLocaleString(inputToOutputEntries.length)} ${inputToOutputEntries.some(([input]) => input instanceof ArchiveFile) ? "archive" : "raw"} file${inputToOutputEntries.length === 1 ? "" : "s"}`
);
const uniqueInputToOutputEntriesMap = inputToOutputEntries.reduce(
(map, [inputRomFile, outputRomFile]) => {
const key = inputRomFile.getFilePath();
if (map.has(key)) {
map.get(key)?.push([inputRomFile, outputRomFile]);
} else {
map.set(key, [[inputRomFile, outputRomFile]]);
}
return map;
},
/* @__PURE__ */ new Map()
);
for (const groupedInputToOutput of uniqueInputToOutputEntriesMap.values()) {
await Promise.all(
groupedInputToOutput.map(async ([inputRomFile, outputRomFile]) => {
await this.writeRawSingle(dat, candidate, inputRomFile, outputRomFile);
})
);
}
}
async writeRawSingle(dat, candidate, inputRomFile, outputRomFile) {
if (this.options.shouldWrite() && outputRomFile.equals(inputRomFile)) {
const wasMoved = this.options.shouldMove() && !(inputRomFile instanceof ZeroSizeFile) && await this.moveMutex.moveFile(inputRomFile.getFilePath(), (movedInputPath) => [
// Return if this file was previously moved
movedInputPath !== void 0,
// If this file was previously moved then remember that location, otherwise, mark it as
// moved so that if it's used in another input file it will be copied instead
movedInputPath ?? inputRomFile.getFilePath()
]);
if (!wasMoved) {
this.prefixedLogger.debug(
`${dat.getName()}: ${candidate.getName()}: ${outputRomFile.toString()}: input and output files are the same, skipping`
);
return;
}
}
const outputFilePath = outputRomFile.getFilePath();
const childBar = this.progressBar.addChildBar({
name: outputFilePath,
// Files being patched might not have a known final size, just guess it as the input size
total: outputRomFile.getSize() > 0 ? outputRomFile.getSize() : inputRomFile.getSize(),
progressFormatter: FsUtil.sizeReadable.bind(FsUtil)
});
try {
if (await FsUtil.exists(outputFilePath)) {
if (this.options.shouldWrite() && !this.options.getOverwrite() && !this.options.getOverwriteInvalid()) {
if (CandidateWriter.OUTPUT_PATHS_WRITTEN.has(outputFilePath)) {
this.prefixedLogger.warn(
`${dat.getName()}: ${candidate.getName()}: ${outputFilePath}: not overwriting existing file already written by '${CandidateWriter.OUTPUT_PATHS_WRITTEN.get(outputFilePath)?.getName()}'`
);
} else {
this.prefixedLogger.debug(
`${dat.getName()}: ${candidate.getName()}: ${outputFilePath}: not overwriting existing file`
);
}
return;
}
if (!this.options.shouldWrite() || this.options.getOverwriteInvalid()) {
const existingTest = await this.testWrittenRaw(
dat,
candidate,
outputFilePath,
outputRomFile,
childBar
);
if (this.options.shouldWrite() && !existingTest) {
this.prefixedLogger.debug(
`${dat.getName()}: ${candidate.getName()}: ${outputFilePath}: not overwriting existing file, the existing file is correct`
);
this.enqueueFileDeletion(candidate, inputRomFile);
return;
}
if (!this.options.shouldWrite() && existingTest) {
this.prefixedLogger.error(
`${dat.getName()}: ${candidate.getName()}: ${outputFilePath}: ${existingTest}`
);
return;
}
}
if (this.options.shouldWrite() && CandidateWriter.OUTPUT_PATHS_WRITTEN.has(outputFilePath)) {
this.prefixedLogger.warn(
`${dat.getName()}: ${candidate.getName()}: ${outputFilePath}: overwriting existing file already written by '${CandidateWriter.OUTPUT_PATHS_WRITTEN.get(outputFilePath)?.getName()}'`
);
}
}
if (!this.options.shouldWrite()) {
return;
}
CandidateWriter.OUTPUT_PATHS_WRITTEN.set(outputFilePath, dat);
this.progressBar.setSymbol(ProgressBarSymbol.WRITING);
let written;
for (let i = 0; i <= this.options.getWriteRetry(); i += 1) {
if (this.options.shouldMove()) {
written = await this.moveRawFile(dat, candidate, inputRomFile, outputFilePath, childBar);
} else {
written = await this.copyRawFile(dat, candidate, inputRomFile, outputFilePath, childBar);
}
if (written !== void 0 && !this.options.shouldTest()) {
break;
}
if (written === MoveResult.COPIED && this.options.shouldTest()) {
const writtenTest = await this.testWrittenRaw(
dat,
candidate,
outputFilePath,
outputRomFile,
childBar
);
if (!writtenTest) {
break;
}
const message = `${dat.getName()}: ${candidate.getName()}: ${outputFilePath}: written file ${writtenTest}`;
if (i < this.options.getWriteRetry()) {
this.prefixedLogger.warn(`${message}, retrying`);
} else {
this.prefixedLogger.error(message);
return;
}
}
if (written === MoveResult.RENAMED && this.options.shouldTest()) {
break;
}
}
if (written) {
this.enqueueFileDeletion(candidate, inputRomFile);
}
} finally {
childBar.delete();
}
}
async moveRawFile(dat, candidate, inputRomFile, outputFilePath, progressBar) {
if (inputRomFile instanceof ZeroSizeFile) {
return await this.copyRawFile(dat, candidate, inputRomFile, outputFilePath, progressBar);
}
return await this.moveMutex.moveFile(inputRomFile.getFilePath(), async (movedInputPath) => {
if (movedInputPath) {
if (movedInputPath === outputFilePath) {
return [MoveResult.RENAMED, void 0];
}
const copyResult = await this.copyRawFile(
dat,
candidate,
inputRomFile.withFilePath(movedInputPath),
outputFilePath,
progressBar
);
return [copyResult, void 0];
}
if (inputRomFile instanceof ArchiveEntry || inputRomFile.getFileHeader() !== void 0 || inputRomFile.getPatch() !== void 0) {
const copyResult = await this.copyRawFile(
dat,
candidate,
inputRomFile,
outputFilePath,
progressBar
);
return [copyResult, void 0];
}
this.prefixedLogger.info(
`${dat.getName()}: ${candidate.getName()}: moving file '${inputRomFile.toString()}' (${FsUtil.sizeReadable(inputRomFile.getSize())}) \u2192 '${outputFilePath}'`
);
try {
await CandidateWriter.ensureOutputDirExists(outputFilePath);
const moveResult = await FsUtil.mv(
inputRomFile.getFilePath(),
outputFilePath,
(progress) => {
progressBar.setCompleted(progress);
}
);
return [moveResult, outputFilePath];
} catch (error) {
this.prefixedLogger.error(
`${dat.getName()}: ${candidate.getName()}: failed to move file '${inputRomFile.toString()}' \u2192 '${outputFilePath}': ${error}`
);
if (error.code === "ENOSPC") {
throw error;
}
return [void 0, void 0];
}
});
}
async copyRawFile(dat, candidate, inputRomFile, outputFilePath, progressBar) {
this.prefixedLogger.info(
`${dat.getName()}: ${candidate.getName()}: ${inputRomFile instanceof ArchiveEntry ? "extracting" : "copying"} file '${inputRomFile.toString()}' (${FsUtil.sizeReadable(inputRomFile.getSize())}) \u2192 '${outputFilePath}'`
);
try {
await CandidateWriter.ensureOutputDirExists(outputFilePath);
const tempRawFile = await FsUtil.mktemp(outputFilePath);
await inputRomFile.extractAndTransformToFile(tempRawFile, (progress) => {
progressBar.setCompleted(progress);
});
await FsUtil.mv(tempRawFile, outputFilePath);
return MoveResult.COPIED;
} catch (error) {
this.prefixedLogger.error(
`${dat.getName()}: ${candidate.getName()}: failed to ${inputRomFile instanceof ArchiveEntry ? "extract" : "copy"} file '${inputRomFile.toString()}' \u2192 '${outputFilePath}': ${error}`
);
if (error.code === "ENOSPC") {
throw error;
}
return void 0;
}
}
async testWrittenRaw(dat, candidate, outputFilePath, expectedFile, progressBar) {
this.prefixedLogger.trace(
`${dat.getName()}: ${candidate.getName()}: ${outputFilePath}: testing raw file`
);
let actualFile;
try {
actualFile = await this.fileFactory.fileFrom(
outputFilePath,
expectedFile.getChecksumBitmask(),
(progress) => {
progressBar?.setCompleted(progress);
},
CacheMode.IGNORE_CACHED_VALUE
);
} catch (error) {
return `failed to parse: ${error}`;
}
if (actualFile.getSha256() && expectedFile.getSha256() && actualFile.getSha256() !== expectedFile.getSha256()) {
return `has the SHA256 ${actualFile.getSha256()}, expected ${expectedFile.getSha256()}`;
}
if (actualFile.getSha1() && expectedFile.getSha1() && actualFile.getSha1() !== expectedFile.getSha1()) {
return `has the SHA1 ${actualFile.getSha1()}, expected ${expectedFile.getSha1()}`;
}
if (actualFile.getMd5() && expectedFile.getMd5() && actualFile.getMd5() !== expectedFile.getMd5()) {
return `has the MD5 ${actualFile.getMd5()}, expected ${expectedFile.getMd5()}`;
}
if (actualFile.getCrc32() && expectedFile.getCrc32() && actualFile.getCrc32() !== expectedFile.getCrc32()) {
return `has the CRC32 ${actualFile.getCrc32()}, expected ${expectedFile.getCrc32()}`;
}
if (actualFile.getCrc32() && actualFile.getSize() !== expectedFile.getSize()) {
return `is of size ${IntlUtil.toLocaleString(actualFile.getSize())}B, expected ${IntlUtil.toLocaleString(expectedFile.getSize())}B`;
}
this.prefixedLogger.trace(
`${dat.getName()}: ${candidate.getName()}: ${outputFilePath}: test passed`
);
return void 0;
}
// Input files may be needed for multiple output files, such as an archive with hundreds of ROMs
// in it. That means we need to "move" (delete) files at the very end after all DATs have
// finished writing.
enqueueFileDeletion(candidate, inputRomFile) {
if (!this.options.shouldMove()) {
return;
}
if (inputRomFile instanceof ZeroSizeFile) {
return;
}
this.filesQueuedForDeletion.push(candidate);
}
/**
************************
*
* Link Writing *
*
************************
*/
async writeLink(dat, candidate) {
const inputToOutputEntries = candidate.getRomsWithFiles();
for (const inputToOutputEntry of inputToOutputEntries) {
const inputRomFile = inputToOutputEntry.getInputFile();
const outputRomFile = inputToOutputEntry.getOutputFile();
await this.writeLinkSingle(dat, candidate, inputRomFile, outputRomFile);
}
}
async writeLinkSingle(dat, candidate, inputRomFile, outputRomFile) {
if (outputRomFile.equals(inputRomFile)) {
this.prefixedLogger.debug(
`${dat.getName()}: ${candidate.getName()}: ${outputRomFile.toString()}: input and output files are the same, skipping`
);
return;
}
const linkPath = outputRomFile.getFilePath();
let targetPath = inputRomFile.getFilePath();
if (this.options.getLinkMode() === LinkMode.SYMLINK && this.options.getSymlinkRelative()) {
await CandidateWriter.ensureOutputDirExists(linkPath);
targetPath = await FsUtil.symlinkRelativePath(targetPath, linkPath);
}
if (await FsUtil.exists(linkPath)) {
if (this.options.shouldWrite() && !this.options.getOverwrite() && !this.options.getOverwriteInvalid()) {
if (CandidateWriter.OUTPUT_PATHS_WRITTEN.has(linkPath)) {
this.prefixedLogger.warn(
`${dat.getName()}: ${candidate.getName()}: ${linkPath}: not overwriting existing file already written by '${CandidateWriter.OUTPUT_PATHS_WRITTEN.get(linkPath)?.getName()}'`
);
} else {
this.prefixedLogger.debug(
`${dat.getName()}: ${candidate.getName()}: ${linkPath}: not overwriting existing file`
);
}
return;
}
if (!this.options.shouldWrite() || this.options.getOverwriteInvalid()) {
let existingTest;
if (this.options.getLinkMode() === LinkMode.SYMLINK) {
existingTest = await CandidateWriter.testWrittenSymlink(linkPath, targetPath);
} else if (this.options.getLinkMode() === LinkMode.HARDLINK) {
existingTest = await CandidateWriter.testWrittenHardlink(
linkPath,
inputRomFile.getFilePath()
);
} else {
existingTest = await this.testWrittenRaw(dat, candidate, linkPath, outputRomFile);
}
if (this.options.shouldWrite() && !existingTest) {
this.prefixedLogger.debug(
`${dat.getName()}: ${candidate.getName()}: ${linkPath}: not overwriting existing link, the existing link is correct`
);
return;
}
if (!this.options.shouldWrite() && existingTest) {
this.prefixedLogger.error(
`${dat.getName()}: ${candidate.getName()}: ${linkPath}: ${existingTest}`
);
return;
}
}
if (this.options.shouldWrite() && CandidateWriter.OUTPUT_PATHS_WRITTEN.has(linkPath)) {
this.prefixedLogger.warn(
`${dat.getName()}: ${candidate.getName()}: ${linkPath}: overwriting existing zip file already written by '${CandidateWriter.OUTPUT_PATHS_WRITTEN.get(linkPath)?.getName()}'`
);
}
}
if (!this.options.shouldWrite()) {
return;
}
CandidateWriter.OUTPUT_PATHS_WRITTEN.set(linkPath, dat);
this.progressBar.setSymbol(ProgressBarSymbol.WRITING);
for (let i = 0; i <= this.options.getWriteRetry(); i += 1) {
const wasWritten = await this.writeRawLink(dat, candidate, targetPath, linkPath);
if (wasWritten && !this.options.shouldTest()) {
break;
}
if (wasWritten && this.options.shouldTest()) {
let writtenTest;
if (this.options.getLinkMode() === LinkMode.SYMLINK) {
writtenTest = await CandidateWriter.testWrittenSymlink(linkPath, targetPath);
} else if (this.options.getLinkMode() === LinkMode.HARDLINK) {
writtenTest = await CandidateWriter.testWrittenHardlink(
linkPath,
inputRomFile.getFilePath()
);
} else {
writtenTest = await this.testWrittenRaw(dat, candidate, linkPath, outputRomFile);
}
if (!writtenTest) {
break;
}
const message = `${dat.getName()}: ${candidate.getName()} ${linkPath}: written link ${writtenTest}`;
if (i < this.options.getWriteRetry()) {
this.prefixedLogger.warn(`${message}, retrying`);
} else {
this.prefixedLogger.error(message);
return;
}
}
}
}
async writeRawLink(dat, candidate, targetPath, linkPath) {
try {
await CandidateWriter.ensureOutputDirExists(linkPath);
if (this.options.getLinkMode() === LinkMode.SYMLINK) {
this.prefixedLogger.info(
`${dat.getName()}: ${candidate.getName()}: creating symlink '${targetPath}' \u2192 '${linkPath}'`
);
await FsUtil.symlink(targetPath, linkPath);
} else if (this.options.getLinkMode() === LinkMode.HARDLINK) {
this.prefixedLogger.info(
`${dat.getName()}: ${candidate.getName()}: creating hard link '${targetPath}' \u2192 '${linkPath}'`
);
await FsUtil.hardlink(targetPath, linkPath);
} else {
this.prefixedLogger.info(
`${dat.getName()}: ${candidate.getName()}: creating reflink '${targetPath}' \u2192 '${linkPath}'`
);
await FsUtil.reflink(targetPath, linkPath);
}
return true;
} catch (error) {
this.prefixedLogger.error(
`${dat.getName()}: ${candidate.getName()}: ${linkPath}: failed to link from ${targetPath}: ${error}`
);
return false;
}
}
static async testWrittenSymlink(linkPath, expectedTargetPath) {
if (!await FsUtil.exists(linkPath)) {
return "doesn't exist";
}
if (!await FsUtil.isSymlink(linkPath)) {
return "is not a symlink";
}
const existingSourcePath = await FsUtil.readlink(linkPath);
if (path.normalize(existingSourcePath) !== path.normalize(expectedTargetPath)) {
return `has the target path '${existingSourcePath}', expected '${expectedTargetPath}`;
}
if (!await FsUtil.exists(await FsUtil.readlinkResolved(linkPath))) {
return `has the target path '${existingSourcePath}' which doesn't exist`;
}
return void 0;
}
static async testWrittenHardlink(linkPath, inputRomPath) {
if (!await FsUtil.exists(linkPath)) {
return "doesn't exist";
}
const targetInode = await FsUtil.inode(linkPath);
const sourceInode = await FsUtil.inode(inputRomPath);
if (targetInode !== sourceInode) {
return `references a different file than '${inputRomPath}'`;
}
return void 0;
}
}
export {
CandidateWriter as default
};
//# sourceMappingURL=candidateWriter.js.map