diff --git a/README.md b/README.md index 4b0f8a8..1d4af17 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,7 @@ these are the default values method: 'GET', // Request Method Verb headers: {}, // Custom HTTP Header ex: Authorization, User-Agent timeout: -1, // Request timeout in milliseconds (-1 use default), is the equivalent of 'httpRequestOptions: { timeout: value }' (also applied to https) + resumeIfFileExists: false, // it will resume if a file already exists and is not completed, you might want to set removeOnStop and removeOnFail to false. If you used pipe for compression it will produce corrupted files fileName: string|cb(fileName, filePath, contentType)|{name, ext}, // Custom filename when saved retry: false, // { maxRetries: number, delay: number in ms } or false to disable (default) forceResume: false, // If the server does not return the "accept-ranges" header, can be force if it does support it @@ -120,7 +121,26 @@ for `httpsRequestOptions` the available options are detailed in here https://nod | getTotalSize | gets the total file size from the server | | getDownloadPath | gets the full path where the file will be downloaded (available after the start phase) | | isResumable | return true/false if the download can be resumable (available after the start phase) | +| getResumeState | Get the state required to resume the download after restart. This state can be passed back to `resumeFromFile()` to resume a download | +| resumeFromFile | `resumeFromFile(filePath?: string, state?: IResumeState)` Resume the download from a previous file path, if the state is not provided it will try to fetch from the information the headers and filePath, @see `resumeIfFileExists` option | +usage of `resumeFromFile` + +```javascript +const downloadDir = 'D:/TEMP'; +const { DownloaderHelper } = require('node-downloader-helper'); +const dl = new DownloaderHelper('https://proof.ovh.net/files/1Gb.dat', downloadDir); +dl.on('end', () => console.log('Download Completed')); +dl.on('error', (err) => console.log('Download Failed', err)); + +// option 1 +const prevFilePath = `${downloadDir}/1Gb.dat`; +dl.resumeFromFile(prevFilePath).catch(err => console.error(err)); + +// option 2 +const prevState = dl.getResumeState(); // this should be stored in a file, localStorage, db, etc in a previous process for example on 'stop' +dl.resumeFromFile(prevState.filePath, prevState).catch(err => console.error(err)); +``` ## Events diff --git a/package.json b/package.json index 44f92e7..50889d1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "node-downloader-helper", - "version": "2.0.1", + "version": "2.1.0", "description": "A simple http file downloader for node.js", "main": "./dist/index.js", "types": "./types/index.d.ts", diff --git a/src/index.js b/src/index.js index 3710343..731cc23 100644 --- a/src/index.js +++ b/src/index.js @@ -49,7 +49,8 @@ export class DownloaderHelper extends EventEmitter { removeOnFail: true, progressThrottle: 1000, httpRequestOptions: {}, - httpsRequestOptions: {} + httpsRequestOptions: {}, + resumeIfFileExists: false, }; this.__opts = Object.assign({}, this.__defaultOpts); this.__pipes = []; @@ -83,10 +84,29 @@ export class DownloaderHelper extends EventEmitter { * @memberof DownloaderHelper */ start() { - return new Promise((resolve, reject) => { + const startPromise = () => new Promise((resolve, reject) => { this.__promise = { resolve, reject }; this.__start(); }); + + // this will determine the file path from the headers + // and attempt to get the file size and resume if possible + if (this.__opts.resumeIfFileExists && this.state !== this.__states.RESUMED) { + return this.getTotalSize().then(({ name, total }) => { + const override = this.__opts.override; + this.__opts.override = true; + this.__filePath = this.__getFilePath(name); + this.__opts.override = override; + if (this.__filePath && fs.existsSync(this.__filePath)) { + const fileSize = this.__getFilesizeInBytes(this.__filePath); + return fileSize !== total + ? this.resumeFromFile(this.__filePath, { total, fileName: name }) + : startPromise(); + } + return startPromise(); + }); + } + return startPromise(); } /** @@ -132,7 +152,7 @@ export class DownloaderHelper extends EventEmitter { this.__setState(this.__states.RESUMED); if (this.__isResumable) { this.__isResumed = true; - this.__options['headers']['range'] = 'bytes=' + this.__downloaded + '-'; + this.__options['headers']['range'] = `bytes=${this.__downloaded}-`; } this.emit('resume', this.__isResumed); return this.__start(); @@ -287,14 +307,18 @@ export class DownloaderHelper extends EventEmitter { * @memberof DownloaderHelper */ getTotalSize() { - const options = this.__getOptions('HEAD', this.url, this.__headers); + const headers = Object.assign({}, this.__headers); + if (headers.hasOwnProperty('range')) { + delete headers['range']; + } + const options = this.__getOptions('HEAD', this.url, headers); return new Promise((resolve, reject) => { const request = this.__protocol.request(options, response => { if (this.__isRequireRedirect(response)) { const redirectedURL = /^https?:\/\//.test(response.headers.location) ? response.headers.location : new URL(response.headers.location, this.url).href; - const options = this.__getOptions('HEAD', redirectedURL, this.__headers); + const options = this.__getOptions('HEAD', redirectedURL, headers); const request = this.__protocol.request(options, response => { if (response.statusCode !== 200) { reject(new Error(`Response status was ${response.statusCode}`)); @@ -319,6 +343,53 @@ export class DownloaderHelper extends EventEmitter { }); } + /** + * Get the state required to resume the download after restart. This state + * can be passed back to `resumeFromFile()` to resume a download + * + * @returns {Object} Returns the state required to resume + * @memberof DownloaderHelper + */ + getResumeState() { + return { + downloaded: this.__downloaded, + filePath: this.__filePath, + fileName: this.__fileName, + total: this.__total, + }; + } + + /** + * Resume the download from a previous file path + * + * @param {string} filePath - The path to the file to resume from ex: C:\Users\{user}\Downloads\file.txt + * @param {Object} state - (optionl) resume download state, if not provided it will try to fetch from the headers and filePath + * + * @returns {Promise} - Returns the same result as `start()` + * @memberof DownloaderHelper + */ + resumeFromFile(filePath, state = {}) { + this.__opts.override = true; + this.__filePath = filePath; + return ((state.total && state.fileName) + ? Promise.resolve({ name: state.fileName, total: state.total }) + : this.getTotalSize()) + .then(({ name, total }) => { + this.__total = state.total || total; + this.__fileName = state.fileName || name; + this.__downloaded = state.downloaded || this.__getFilesizeInBytes(this.__filePath); + this.__options['headers']['range'] = `bytes=${this.__downloaded}-`; + this.__isResumed = true; + this.__isResumable = true; + this.__setState(this.__states.RESUMED); + this.emit('resume', this.__isResumed); + return new Promise((resolve, reject) => { + this.__promise = { resolve, reject }; + this.__start(); + }); + }); + } + __start() { if (!this.__isRedirected && this.state !== this.__states.RESUMED) { diff --git a/types/index.d.ts b/types/index.d.ts index 4440c21..44774f5 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -56,6 +56,13 @@ interface FileRenamedStats { prevFileName: string; } +interface IResumeState { + downloaded?: number; + filePath?: string; + fileName?: string; + total?: number; +} + interface ErrorStats { /** Error message */ message: string; @@ -124,6 +131,10 @@ interface DownloaderHelperOptions { /** Custom filename when saved */ fileName?: string | FilenameCallback | FilenameDefinition; retry?: boolean | RetryOptions; + /* Request timeout in milliseconds (-1 use default), is the equivalent of 'httpRequestOptions: { timeout: value }' (also applied to https) */ + timeout?: number; + /** it will resume if a file already exists and is not completed, you might want to set removeOnStop and removeOnFail to false. If you used pipe for compression it will produce corrupted files */ + resumeIfFileExists?: boolean; /** If the server does not return the "accept-ranges" header, can be force if it does support it */ forceResume?: boolean; /** remove the file when is stopped (default:true) */ @@ -247,4 +258,23 @@ export class DownloaderHelper extends EventEmitter { * @memberof EventEmitter */ on(event: E, callback: DownloadEvents[E]): any; + + /** + * Get the state required to resume the download after restart. This state + * can be passed back to `resumeFromFile()` to resume a download + * + * @returns {IResumeState} Returns the state required to resume + * @memberof DownloaderHelper + */ + getResumeState(): IResumeState; + + /** + * + * @param {string} filePath - The path to the file to resume from ex: C:\Users\{user}\Downloads\file.txt + * @param {IResumeState} state - (optionl) resume download state, if not provided it will try to fetch from the headers and filePath + * + * @returns {Promise} - Returns the same result as `start()` + * @memberof DownloaderHelper + */ + resumeFromFile(filePath: string, state?: IResumeState): Promise; }