UNPKG

wranglebot

Version:

open source media asset management

394 lines (346 loc) 11.3 kB
import { addAbortSignal, PassThrough, pipeline, Transform } from "stream"; import StreamSpeed from "streamspeed"; import { StringDecoder } from "string_decoder"; import pkg from "xxhash-addon"; const { XXHash128, XXHash3, XXHash32, XXHash64 } = pkg; import diskusage from "diskusage-ng"; import { finder } from "../system/index.js"; import Probe from "./Probe.js"; import fs from "fs"; import Scraper from "./Scraper.js"; export default class CopyTool { key = "12345678"; readStream: fs.ReadStream | null = null; writeStreams: fs.WriteStream[] = []; abortController: AbortController | null = null; paranoid: boolean = false; overwrite: boolean = false; highWaterMark = 1024 * 1024; fileSizeInBytes = -1; streamSpeed: StreamSpeed | null = null; private _source: string = ""; private _destinations: string[] = []; private hash: pkg.XXHash64; private chunkSize: number; /** * Creates an instance of CopyTool. * @param [options] - The options for the CopyTool * @param [options.paranoid] - If true, will check the hash of the file after copying to ensure it matches the source * @param [options.hash] - The hash algorithm to use. Defaults to xxhash64, but can be xxhash128, xxhash32, or xxhash3 * @param [options.overwrite] - If true, will overwrite the destination file if it exists * @param [options.chunkSize] - The size of each chunk to copy in MB. Defaults to 10MB */ constructor(options: { paranoid?: boolean; hash?: "xxhash128" | "xxhash64" | "xxhash32" | "xxhash3"; overwrite?: boolean; chunkSize?: number; key?: string; }) { this.paranoid = options.paranoid || false; this.overwrite = options.overwrite || false; this.chunkSize = options.chunkSize || 10; this.highWaterMark = this.chunkSize * 1024 * 1024; this.key = options.key || "12345678"; switch (options.hash) { case "xxhash128": this.hash = new XXHash128(Buffer.from(this.key)); break; case "xxhash64": this.hash = new XXHash64(Buffer.from(this.key)); break; case "xxhash32": this.hash = new XXHash32(Buffer.from(this.key)); break; case "xxhash3": this.hash = new XXHash3(Buffer.from(this.key)); break; default: this.hash = new XXHash64(Buffer.from(this.key)); } } /** * Adds a source file to copy * @param path - The path to the source file */ source(path): CopyTool { if (!finder.existsSync(path)) { throw new Error("Source does not exist at " + path); } //get size of file const stats = finder.lstatSync(path); this.fileSizeInBytes = stats.size; this._source = path; return this; } /** * Adds a destination paths to copy to * @param paths - The paths to copy to */ destinations(paths: string[]): CopyTool { this._destinations = paths; if (!this.overwrite) { for (let dest of this._destinations) { if (finder.existsSync(dest)) { throw new Error("Destination already exists at " + dest); } } } else { for (let dest of this._destinations) { if (finder.existsSync(dest)) { finder.rmSync(dest); } } } return this; } /** * Checks if there is enough space on the disks to copy the files * @private */ private hasEnoughSpace() { return new Promise((resolve, reject) => { if (this._destinations.length > 0) { //check if there is enough space on the disk to write the file this.calculateRequiredSpace(this._destinations, this.fileSizeInBytes) .then((result) => { if (!result) { reject(new Error("Not enough space on disk")); } else { resolve(true); } }) .catch((e) => { reject(e); }); } else { reject(new Error("No destinations provided")); } }); } /** * hashes the source file and returns the hash */ hashFile( path: string, callback = (progress) => { /* do nothing */ } ) { return new Promise((resolve, reject) => { if (!finder.existsSync(path)) { reject(new Error("File does not exist at " + path)); } //reset the hash this.hash.reset(); let fileSizeInBytes = finder.lstatSync(path).size; const readStream = finder.createReadStream(path, { highWaterMark: this.highWaterMark }); const decoder = new StringDecoder("base64"); /* init abort controller */ this.abortController = new AbortController(); addAbortSignal(this.abortController.signal, readStream); /* end abort controller */ let totalBytesRead = 0; readStream.on("data", (chunk) => { this.hash.update(<Buffer>chunk); totalBytesRead += chunk.length; }); /* init stream speed */ this.streamSpeed = new StreamSpeed(); this.streamSpeed.add(readStream); this.streamSpeed.on("speed", (speed) => { callback({ bytesPerSecond: speed, bytesRead: totalBytesRead, size: fileSizeInBytes, }); }); /* end stream speed */ readStream.on("end", () => { let digest = this.hash.digest(); const hash = decoder.write(digest); resolve(hash); }); readStream.on("error", (err) => { reject(err); }); }); } /** * Copies the file to the destinations * * @example * const callback = (progress) => { * //do something with progress * } * * * copyTool.source("path/to/source").destinations(["path/to/dest1", "path/to/dest2"]).copy(progress, abort).then(() => { * //do something * }).catch((e) => { * //handle error * }); * * @param options * @param callback */ copy( callback = (progress) => { /* do nothing */ } ) { return new Promise((resolve, reject) => { //reset the hash this.hash.reset(); //check if there is enough space on the disk to write the file this.hasEnoughSpace().then((r) => { if (!r) { reject(new Error("Not enough space on disk")); } let totalBytesRead = 0; /* init readStream */ this.readStream = finder.createReadStream(this._source, { highWaterMark: this.highWaterMark }); this.readStream.on("error", (err) => { reject(new Error("Read Process Failed or was Aborted")); }); /* end readStream */ /* init abort controller */ this.abortController = new AbortController(); addAbortSignal(this.abortController.signal, this.readStream); /* end abort controller */ /* init stream speed */ this.streamSpeed = new StreamSpeed(); this.streamSpeed.add(this.readStream); this.streamSpeed.on("speed", (speed) => { callback({ bytesPerSecond: speed, bytesRead: totalBytesRead, size: this.fileSizeInBytes, }); }); /* end stream speed */ /* init writeStreams */ for (let i = 0; i < this._destinations.length; i++) { let writeStream = finder.createWriteStream(this._destinations[i], { highWaterMark: this.highWaterMark }); //mk dir if it doesn't exist finder.mkdirSync(finder.dirname(this._destinations[i]), { recursive: true }); this.writeStreams.push(writeStream); } /* end writeStreams */ /* init pass through */ const passThroughStream = new PassThrough(); this.writeStreams.forEach((writeStream) => { passThroughStream.pipe(writeStream); }); passThroughStream.on("error", (err) => { reject(err); }); /* end pass through */ const transform = new Transform({ transform: (chunk, encoding, callback) => { totalBytesRead += chunk.length; this.hash.update(chunk); callback(null, chunk); }, }); /* start piping */ pipeline(this.readStream, transform, passThroughStream, (err) => { if (err) { reject(err); } //get hash of file let digest = this.hash.digest(); const decoder = new StringDecoder("base64"); const hash = decoder.write(digest); CopyTool.analyseFile(this._source).then((metaData) => { if (!this.paranoid) { resolve({ hash, metaData, size: this.fileSizeInBytes, }); } else { //get destination hashes this.verify(hash).then((result) => { resolve({ hash, metaData, size: this.fileSizeInBytes, }); }); } }); }); }); }); } static async analyseFile(path: string) { try { let metaData = await Probe.analyse(path); return Scraper.parse(metaData); } catch (e) { return {}; } } abort() { if (this.abortController) this.abortController.abort(); return true; throw new Error("No copy process to abort"); } async verify(hash: string): Promise<boolean> { for (let i = 0; i < this._destinations.length; i++) { const result = await this.hashFile(this._destinations[i]); if (result !== hash) { throw new Error(`Hash of ${this._destinations[i]} is not the same as the original file: ${hash} !== ${result}`); } } return true; } getDiskUsage(volumePath) { return new Promise((resolve, reject) => { diskusage(volumePath, function (err, usage) { if (err) reject(err); resolve({ path: volumePath, freeSpace: usage.available }); }); }); } /** * Checks if the size of the file is the same as the size of the file that was read * @param paths * @param fileSize * @return {boolean} */ compareSizes() { for (let i = 0; i < this._destinations.length; i++) { const stats = finder.lstatSync(this._destinations[i]); if (stats.size !== this.fileSizeInBytes) { throw new Error(`File Size of ${this._destinations[i]} is not the same as the original file: ${this.fileSizeInBytes} !== ${stats.size}`); } } return true; } async calculateRequiredSpace(paths, totalJobSizeInBytes) { const volumes: any[] = []; // get list of unique volumes for (const filePath of paths) { const volumePath = finder.getVolumePath(filePath); if (!volumes.some((volume) => volume.path === volumePath)) { volumes.push(await this.getDiskUsage(volumePath)); } } // check if each volume has enough free space for (const volume of volumes) { const requiredSpace = paths.reduce((totalSize, filePath) => { const volumeName = finder.getMountPoint(filePath); if (volumeName === volume.path) { return totalSize + totalJobSizeInBytes; } return totalSize; }, 0); if (volume.freeSpace < requiredSpace) { return false; } } return true; } }