diff --git a/src/utils/download_manager.test.ts b/src/utils/download_manager.test.ts new file mode 100644 index 00000000..f81e9896 --- /dev/null +++ b/src/utils/download_manager.test.ts @@ -0,0 +1,60 @@ +import { expect } from 'chai'; +import { DownloadManager, EntityToDownload, DownloadResult } from './download_manager'; + +const downloadedFolderResult: DownloadResult = { + status: 'success', + localPath: '/path/to/my/downloaded/folder' +}; + +const downloadedFileResult: DownloadResult = { + status: 'success', + localPath: '/path/to/my/downloaded/file' +}; + +const folderToDownload: EntityToDownload = { + entityType: 'folder', + download: () => Promise.resolve(downloadedFolderResult) +}; + +const fileToDownload: EntityToDownload = { + entityType: 'file', + download: () => Promise.resolve(downloadedFileResult) +}; + +const slowFileToDownload: EntityToDownload = { + entityType: 'file', + download: () => new Promise((resolve) => setTimeout(() => resolve(downloadedFileResult), 10)) +}; + +describe('DownloadManager class', () => { + it('instantly resolves when an empty array of items is passed', () => { + const downloader = new DownloadManager([], 5); + return downloader.start(); + }); + + it('resolves in an array of the same length than the one passed to the constructor', async () => { + const downloader = new DownloadManager([folderToDownload, fileToDownload], 5); + const results = await downloader.start(); + expect(results).to.deep.equal([downloadedFolderResult, downloadedFileResult]); + }); + + it('the downloads-in-progress count equals the max when the length of items to download is bigger than the max', async () => { + const downloader = new DownloadManager([folderToDownload, slowFileToDownload, fileToDownload], 1); + const downloadPromise = downloader.start(); + expect(downloader.downloadsInProgressAmount).to.equal(1); + await downloadPromise; + }); + + it('start method will resolve after ALL of the pending downloads were handled', async () => { + const downloader = new DownloadManager([folderToDownload, slowFileToDownload, fileToDownload], 2); + const downloadPromise = downloader.start(); + await downloadPromise; + expect(downloader.downloadsInProgressAmount).to.equal(0); + }); + + it('throws when an invalid maxDownloadsInParallel is passed', () => { + expect(() => new DownloadManager([], -1)).to.throw(); + expect(() => new DownloadManager([], 0)).to.throw(); + expect(() => new DownloadManager([], 0.5)).to.throw(); + }); +}); diff --git a/src/utils/download_manager.ts b/src/utils/download_manager.ts new file mode 100644 index 00000000..5f6f00b3 --- /dev/null +++ b/src/utils/download_manager.ts @@ -0,0 +1,58 @@ +export interface EntityToDownload { + readonly entityType: 'file' | 'folder'; + readonly download: (progressCallback?: ProgressCallback) => Promise; +} + +export interface DownloadResult { + readonly status: 'success' | 'failed'; + readonly error?: Error; + readonly localPath: string; +} + +export type ProgressCallback = (pctTotal: number, pctFile: number, curFileName: string, curFilePath: string) => void; + +export class DownloadManager { + private readonly downloadsInProgress: EntityToDownload[] = []; + private readonly pendingDownloads: EntityToDownload[] = []; + private readonly downloadResults: DownloadResult[] = []; + + constructor(entitiesToDownload: EntityToDownload[], private readonly maxDownloadsInParallel: number) { + if (!Number.isInteger(maxDownloadsInParallel) || maxDownloadsInParallel <= 0) { + throw new Error('The max downloads in parallel must be an integer number greater than zero'); + } + this.pendingDownloads = entitiesToDownload.slice(); + } + + get downloadsInProgressAmount(): number { + return this.downloadsInProgress.length; + } + + private flush = async (): Promise => { + const itemsToDownloadAmount = this.maxDownloadsInParallel - this.downloadsInProgressAmount; + const itemsToDownload = this.pendingDownloads.splice(0, itemsToDownloadAmount); + itemsToDownload.forEach(async (entityToDownload) => { + entityToDownload.download().then((result) => { + this.downloadResults.push(result); + const inProgressIndex = this.downloadsInProgress.findIndex((download) => download === entityToDownload); + if (inProgressIndex !== -1) { + this.downloadsInProgress.splice(inProgressIndex, 1); + } + }); + this.downloadsInProgress.push(entityToDownload); + await entityToDownload.download(); + }); + debugger; + if (this.downloadsInProgressAmount) { + const downloadsInProgressRace = Promise.race(this.downloadsInProgress); + await downloadsInProgressRace; + } + if (this.pendingDownloads.length || this.downloadsInProgressAmount) { + await this.flush(); + } + }; + + public async start(): Promise { + await this.flush(); + return this.downloadResults; + } +}