import path from 'path'
import fs from 'fs-extra'
import {download, fileToMd5, unzip} from '../utils/files'
/**
* Process checksum entries with limited concurrency to avoid spiking
* CPU / mem usage too hard for large files. Returns array of checksums
* with md5 hash of local file and a boolean for validity.
* @param items
* @param concurrencyLimit
* @returns {Promise<object[]>}
*/
export const validateItems = async (items, concurrencyLimit = 5) => {
const results = [];
// Wrapper to process each file and resolve with the result
async function processFile(item) {
const file = item.absolutePath
const exists = await fs.pathExists(file)
if (!exists) {
return {
...item,
valid: false,
}
} else {
const md5 = await fileToMd5(file)
return {
...item,
valid: md5 === item.md5,
}
}
}
const executing = []
for (const item of items) {
const executor = processFile(item).then(result => {
// Remove resolved promise from executing array
executing.splice(executing.indexOf(executor), 1)
// Push the result to results array
results.push(result)
});
executing.push(executor)
if (executing.length >= concurrencyLimit) {
// Wait for one of the promises to resolve before adding more
await Promise.race(executing)
}
}
// Wait for all promises to resolve
await Promise.all(executing)
return results
}
class ChecksumList {
constructor ({checksums, filePath, workingDir, downloadsDir}) {
this._checksums = checksums
this._localChecksums = []
this._filePath = filePath
this._workingDir = workingDir
this._downloadsDir = downloadsDir
}
/**
* Read the json checksum array from the file (if any).
* @returns {Promise<object[]>}
*/
readFile = async () => {
if (!this._checksums && this._filePath) {
this._checksums = await fs.readJson(this._filePath)
}
return this._checksums
}
/**
* Validate that the checksum files exist on disk and have a valid checksum.
* @returns {Promise<object[]>}
*/
validateFilesOnDisk = async () => {
await this.readFile()
this._localChecksums = await validateItems(this._checksums.map(item => {
return {
...item,
absolutePath: path.resolve(path.join(this._workingDir, item.relativePath))
}
}))
return this._localChecksums
}
/**
* Download any invalidated files.
* @param {function} onProgress
* @param {AbortSignal} abortSignal
* @returns {Promise<void>}
*/
downloadAndExtractInvalidatedFiles = async (onProgress = () => {}, abortSignal) => {
if (!this._localChecksums.length) {
await this.validateFilesOnDisk()
}
if (abortSignal?.aborted) {
throw new Error('Downloads aborted before starting')
}
const invalidated = this._localChecksums.filter((item, index) => {
return !item.valid && this._localChecksums.findIndex(i => i.downloadUrl === item.downloadUrl) === index
})
for (let item of invalidated) {
if (abortSignal?.aborted) {
throw new Error('Downloads aborted mid-download')
}
const downloadPath = path.join(this._downloadsDir, item.downloadUrl.split('/').pop())
await download(item.downloadUrl, downloadPath, (props) => onProgress({...props, item}), abortSignal)
if (abortSignal?.aborted) {
throw new Error('Downloads aborted mid-download')
}
await unzip(downloadPath, this._workingDir)
}
}
}
export default ChecksumList