diff --git a/src/browser.ts b/src/browser.ts index d634068..d81a030 100644 --- a/src/browser.ts +++ b/src/browser.ts @@ -14,6 +14,7 @@ import HttpError from "./download/download-engine/streams/download-engine-fetch- import BaseDownloadEngine from "./download/download-engine/engine/base-download-engine.js"; import {InvalidOptionError} from "./download/download-engine/engine/error/InvalidOptionError.js"; import {DownloadFlags, DownloadStatus} from "./download/download-engine/download-file/progress-status-file.js"; +import {NoDownloadEngineProvidedError} from "./download/download-engine/engine/error/no-download-engine-provided-error.js"; export { DownloadFlags, @@ -28,7 +29,8 @@ export { FetchStreamError, IpullError, EngineError, - InvalidOptionError + InvalidOptionError, + NoDownloadEngineProvidedError }; export type { diff --git a/src/download/browser-download.ts b/src/download/browser-download.ts index b194d72..b4f4ebf 100644 --- a/src/download/browser-download.ts +++ b/src/download/browser-download.ts @@ -1,5 +1,6 @@ import DownloadEngineBrowser, {DownloadEngineOptionsBrowser} from "./download-engine/engine/download-engine-browser.js"; import DownloadEngineMultiDownload from "./download-engine/engine/download-engine-multi-download.js"; +import {NoDownloadEngineProvidedError} from "./download-engine/engine/error/no-download-engine-provided-error.js"; const DEFAULT_PARALLEL_STREAMS_FOR_BROWSER = 3; @@ -25,5 +26,9 @@ export async function downloadFileBrowser(options: DownloadFileBrowserOptions) { * Download multiple files in the browser environment. */ export async function downloadSequenceBrowser(...downloads: (DownloadEngineBrowser | Promise)[]) { + if (downloads.length === 0) { + throw new NoDownloadEngineProvidedError(); + } + return await DownloadEngineMultiDownload.fromEngines(downloads); } diff --git a/src/download/download-engine/download-file/progress-status-file.ts b/src/download/download-engine/download-file/progress-status-file.ts index eec9011..a2d7e42 100644 --- a/src/download/download-engine/download-file/progress-status-file.ts +++ b/src/download/download-engine/download-file/progress-status-file.ts @@ -13,6 +13,7 @@ export type ProgressStatus = { }; export enum DownloadStatus { + Loading = "Loading", Active = "Active", Paused = "Paused", NotStarted = "NotStarted", @@ -33,7 +34,7 @@ export default class ProgressStatusFile { public readonly downloadPart: number; public readonly transferredBytes: number; public readonly transferAction: string; - public readonly downloadStatus: DownloadStatus = DownloadStatus.Active; + public downloadStatus: DownloadStatus = DownloadStatus.Active; public downloadFlags: DownloadFlags[] = []; public totalBytes: number = 0; public startTime: number = 0; diff --git a/src/download/download-engine/engine/download-engine-multi-download.ts b/src/download/download-engine/engine/download-engine-multi-download.ts index f3b42df..ebdbadf 100644 --- a/src/download/download-engine/engine/download-engine-multi-download.ts +++ b/src/download/download-engine/engine/download-engine-multi-download.ts @@ -5,6 +5,7 @@ import BaseDownloadEngine, {BaseDownloadEngineEvents} from "./base-download-engi import DownloadAlreadyStartedError from "./error/download-already-started-error.js"; import {concurrency} from "./utils/concurrency.js"; import {DownloadFlags, DownloadStatus} from "../download-file/progress-status-file.js"; +import {NoDownloadEngineProvidedError} from "./error/no-download-engine-provided-error.js"; const DEFAULT_PARALLEL_DOWNLOADS = 1; @@ -27,7 +28,8 @@ export default class DownloadEngineMultiDownload Promise)[] = []; - + protected _lastStatus?: ProgressStatusWithIndex; + protected _loadingDownloads = 0; protected constructor(engines: (DownloadEngineMultiAllowedEngines | DownloadEngineMultiDownload)[], options: DownloadEngineMultiDownloadOptions) { super(); @@ -40,30 +42,68 @@ export default class DownloadEngineMultiDownload acc + engine.downloadSize, 0); } protected _init() { this._progressStatisticsBuilder.downloadStatus = DownloadStatus.NotStarted; - - this._changeEngineFinishDownload(); - for (const [index, engine] of Object.entries(this.downloads)) { - const numberIndex = Number(index); - this._downloadStatues[numberIndex] = engine.status; - engine.on("progress", (progress) => { - this._downloadStatues[numberIndex] = progress; - }); - } - - this._progressStatisticsBuilder.add(...this.downloads); this._progressStatisticsBuilder.on("progress", progress => { progress = { ...progress, downloadFlags: progress.downloadFlags.concat([DownloadFlags.DownloadSequence]) }; + this._lastStatus = progress; this.emit("progress", progress); }); + + let index = 0; + for (const engine of this.downloads) { + this._addEngine(engine, index++); + } + + // Prevent multiple progress events on adding engines + this._progressStatisticsBuilder.add(...this.downloads); + } + + private _addEngine(engine: Engine, index: number) { + this._downloadStatues[index] = engine.status; + engine.on("progress", (progress) => { + this._downloadStatues[index] = progress; + }); + + this._changeEngineFinishDownload(engine); + } + + public async addDownload(engine: Engine | DownloadEngineMultiDownload | Promise>) { + const index = this.downloads.length + this._loadingDownloads; + this._downloadStatues[index] = ProgressStatisticsBuilder.loadingStatusEmptyStatistics(); + + this._loadingDownloads++; + this._progressStatisticsBuilder._totalDownloadParts++; + const awaitEngine = engine instanceof Promise ? await engine : engine; + this._progressStatisticsBuilder._totalDownloadParts--; + this._loadingDownloads--; + + if (awaitEngine instanceof DownloadEngineMultiDownload) { + let countEngines = 0; + for (const subEngine of awaitEngine.downloads) { + this._addEngine(subEngine, index + countEngines++); + this.downloads.push(subEngine); + } + this._progressStatisticsBuilder.add(...awaitEngine.downloads); + } else { + this._addEngine(awaitEngine, index); + this.downloads.push(awaitEngine); + this._progressStatisticsBuilder.add(awaitEngine); + } } public async download(): Promise { @@ -93,20 +133,18 @@ export default class DownloadEngineMultiDownload { - await onFinishAsync?.(); - await options.writeStream.close(); - await onCloseAsync?.(); - }); - } + private _changeEngineFinishDownload(engine: Engine) { + const options = engine._fileEngineOptions; + const onFinishAsync = options.onFinishAsync; + const onCloseAsync = options.onCloseAsync; + + options.onFinishAsync = undefined; + options.onCloseAsync = undefined; + this._closeFiles.push(async () => { + await onFinishAsync?.(); + await options.writeStream.close(); + await onCloseAsync?.(); + }); } private async _finishEnginesDownload() { diff --git a/src/download/download-engine/engine/error/no-download-engine-provided-error.ts b/src/download/download-engine/engine/error/no-download-engine-provided-error.ts new file mode 100644 index 0000000..e4c771a --- /dev/null +++ b/src/download/download-engine/engine/error/no-download-engine-provided-error.ts @@ -0,0 +1,7 @@ +import EngineError from "./engine-error.js"; + +export class NoDownloadEngineProvidedError extends EngineError { + constructor(error = "No download engine provided for download sequence") { + super(error); + } +} diff --git a/src/download/node-download.ts b/src/download/node-download.ts index 73c4f4a..69647fe 100644 --- a/src/download/node-download.ts +++ b/src/download/node-download.ts @@ -3,6 +3,7 @@ import BaseDownloadEngine from "./download-engine/engine/base-download-engine.js import DownloadEngineMultiDownload, {DownloadEngineMultiDownloadOptions} from "./download-engine/engine/download-engine-multi-download.js"; import CliAnimationWrapper, {CliProgressDownloadEngineOptions} from "./transfer-visualize/transfer-cli/cli-animation-wrapper.js"; import {CLI_LEVEL} from "./transfer-visualize/transfer-cli/transfer-cli.js"; +import {NoDownloadEngineProvidedError} from "./download-engine/engine/error/no-download-engine-provided-error.js"; const DEFAULT_PARALLEL_STREAMS_FOR_NODEJS = 3; export type DownloadFileOptions = DownloadEngineOptionsNodejs & CliProgressDownloadEngineOptions & { @@ -45,6 +46,10 @@ export async function downloadSequence(options?: DownloadSequenceOptions | Downl downloadOptions = options; } + if (downloads.length === 0) { + throw new NoDownloadEngineProvidedError(); + } + downloadOptions.cliLevel = CLI_LEVEL.HIGH; const downloader = DownloadEngineMultiDownload.fromEngines(downloads, downloadOptions); const wrapper = new CliAnimationWrapper(downloader, downloadOptions); diff --git a/src/download/transfer-visualize/progress-statistics-builder.ts b/src/download/transfer-visualize/progress-statistics-builder.ts index d2f2891..db10ada 100644 --- a/src/download/transfer-visualize/progress-statistics-builder.ts +++ b/src/download/transfer-visualize/progress-statistics-builder.ts @@ -3,7 +3,8 @@ import {EventEmitter} from "eventemitter3"; import TransferStatistics from "./transfer-statistics.js"; import {createFormattedStatus, FormattedStatus} from "./format-transfer-status.js"; import DownloadEngineFile from "../download-engine/download-file/download-engine-file.js"; -import {DownloadStatus, ProgressStatus} from "../download-engine/download-file/progress-status-file.js"; +import ProgressStatusFile, {DownloadStatus, ProgressStatus} from "../download-engine/download-file/progress-status-file.js"; +import DownloadEngineMultiDownload from "../download-engine/engine/download-engine-multi-download.js"; export type ProgressStatusWithIndex = FormattedStatus & { index: number, @@ -13,35 +14,47 @@ interface CliProgressBuilderEvents { progress: (progress: ProgressStatusWithIndex) => void; } -export type AnyEngine = DownloadEngineFile | BaseDownloadEngine; +export type AnyEngine = DownloadEngineFile | BaseDownloadEngine | DownloadEngineMultiDownload; export default class ProgressStatisticsBuilder extends EventEmitter { private _engines: AnyEngine[] = []; private _activeTransfers: { [index: number]: number } = {}; private _totalBytes = 0; private _transferredBytes = 0; - private _totalDownloadParts = 0; + /** + * @internal + */ + _totalDownloadParts = 0; private _activeDownloadPart = 0; private _startTime = 0; - private statistics = new TransferStatistics(); + private _statistics = new TransferStatistics(); + private _lastStatus?: ProgressStatusWithIndex; public downloadStatus: DownloadStatus = null!; public get totalBytes() { return this._totalBytes; } - public get transferredBytesWithActiveTransfers() { return this._transferredBytes + Object.values(this._activeTransfers) .reduce((acc, bytes) => acc + bytes, 0); } + public get status() { + return this._lastStatus; + } + + /** + * Add engines to the progress statistics builder, will only add engines once + */ public add(...engines: AnyEngine[]) { for (const engine of engines) { - this._initEvents(engine); + if (!this._engines.includes(engine)) { + this._initEvents(engine, engines.at(-1) === engine); + } } } - private _initEvents(engine: AnyEngine) { + private _initEvents(engine: AnyEngine, sendProgress = false) { this._engines.push(engine); this._totalBytes += engine.downloadSize; const index = this._engines.length - 1; @@ -56,6 +69,10 @@ export default class ProgressStatisticsBuilder extends EventEmitter status.downloadStatus === DownloadStatus.Active); - const remaining = statuses.filter(status => status.downloadStatus === DownloadStatus.Paused || status.downloadStatus === DownloadStatus.NotStarted); + const remaining = statuses.filter(status => [DownloadStatus.Paused, DownloadStatus.NotStarted].includes(status.downloadStatus)); + const loading = statuses.filter(status => status.downloadStatus === DownloadStatus.Loading); const finishedTasks = statuses.filter(status => status.downloadStatus === DownloadStatus.Finished) .sort((a, b) => b.endTime - a.endTime); - const showTotalTasks = activeTasks.concat(remaining); + const showTotalTasks = activeTasks.concat(remaining) + .concat(loading); const showTotalTasksWithFinished = showTotalTasks.concat(finishedTasks); return { notFinished: showTotalTasks.length > 0, - remaining: remaining.length, + remaining: remaining.length + loading.length, allStatusesSorted: showTotalTasksWithFinished }; } diff --git a/src/download/transfer-visualize/transfer-cli/progress-bars/base-transfer-cli-progress-bar.ts b/src/download/transfer-visualize/transfer-cli/progress-bars/base-transfer-cli-progress-bar.ts index 21e323f..c8f356f 100644 --- a/src/download/transfer-visualize/transfer-cli/progress-bars/base-transfer-cli-progress-bar.ts +++ b/src/download/transfer-visualize/transfer-cli/progress-bars/base-transfer-cli-progress-bar.ts @@ -6,10 +6,12 @@ import {DownloadStatus} from "../../../download-engine/download-file/progress-st import {BaseMultiProgressBar} from "../multiProgressBars/BaseMultiProgressBar.js"; import {STATUS_ICONS} from "../../utils/progressBarIcons.js"; import {DataLine, DataPart, renderDataLine} from "../../utils/data-line.js"; +import cliSpinners, {Spinner} from "cli-spinners"; const SKIP_ETA_START_TIME = 1000 * 2; const MIN_NAME_LENGTH = 20; const MIN_COMMENT_LENGTH = 15; +const DEFAULT_SPINNER_UPDATE_INTERVAL_MS = 10; export type CliFormattedStatus = FormattedStatus & { transferAction: string @@ -17,6 +19,7 @@ export type CliFormattedStatus = FormattedStatus & { export type BaseCliOptions = { truncateName?: boolean | number + loadingSpinner?: cliSpinners.SpinnerName }; export interface TransferCliProgressBar { @@ -30,6 +33,12 @@ export interface TransferCliProgressBar { */ export default class BaseTransferCliProgressBar implements TransferCliProgressBar { public multiProgressBar = BaseMultiProgressBar; + public downloadLoadingSpinner: Spinner; + private _spinnerState = { + step: 0, + lastChanged: 0 + }; + protected status: CliFormattedStatus = null!; protected options: BaseCliOptions; protected minNameLength = MIN_NAME_LENGTH; @@ -37,6 +46,7 @@ export default class BaseTransferCliProgressBar implements TransferCliProgressBa public constructor(options: BaseCliOptions) { this.options = options; + this.downloadLoadingSpinner = cliSpinners[options.loadingSpinner ?? "dots"]; } switchTransferToShortText() { @@ -54,9 +64,7 @@ export default class BaseTransferCliProgressBar implements TransferCliProgressBa return this.status.startTime < Date.now() - SKIP_ETA_START_TIME; } - protected get nameSize() { - const {fileName} = this.status; - + protected getNameSize(fileName = this.status.fileName) { return this.options.truncateName === false ? fileName.length : typeof this.options.truncateName === "number" @@ -64,6 +72,20 @@ export default class BaseTransferCliProgressBar implements TransferCliProgressBa : Math.min(fileName.length, this.minNameLength); } + protected getSpinnerText() { + const spinner = this.downloadLoadingSpinner.frames[this._spinnerState.step]; + + if (this._spinnerState.lastChanged + DEFAULT_SPINNER_UPDATE_INTERVAL_MS < Date.now()) { + this._spinnerState.step++; + if (this._spinnerState.step >= this.downloadLoadingSpinner.frames.length) { + this._spinnerState.step = 0; + } + this._spinnerState.lastChanged = Date.now(); + } + + return spinner; + } + protected getNameAndCommentDataParts(): DataPart[] { const {fileName, comment, downloadStatus} = this.status; @@ -79,7 +101,7 @@ export default class BaseTransferCliProgressBar implements TransferCliProgressBa return [{ type: "name", fullText: fileName, - size: this.nameSize, + size: this.getNameSize(), flex: typeof this.options.truncateName === "number" ? undefined : 1, @@ -111,7 +133,6 @@ export default class BaseTransferCliProgressBar implements TransferCliProgressBa )]; } - protected getETA(spacer = " | ", formatter: (text: string, size: number, type: "spacer" | "time") => string = text => text): DataLine { const formatedTimeLeft = this.status.timeLeft < 1_000 ? "0s" : this.status.formatTimeLeft; const timeLeft = `${formatedTimeLeft.padStart("10s".length)} left`; @@ -174,7 +195,7 @@ export default class BaseTransferCliProgressBar implements TransferCliProgressBa }, { type: "progressBar", - size: this.nameSize, + size: this.getNameSize(), fullText: "", flex: 4, addEndPadding: 4, @@ -260,6 +281,36 @@ export default class BaseTransferCliProgressBar implements TransferCliProgressBa ]); } + protected renderLoadingLine() { + const spinner = this.getSpinnerText(); + const showText = "Gathering information"; + + return renderDataLine([ + { + type: "status", + fullText: spinner, + size: spinner.length, + formatter: (text) => chalk.cyan(text) + }, + { + type: "spacer", + fullText: " ", + size: " ".length + }, + { + type: "name", + fullText: showText, + size: this.getNameSize(showText), + flex: typeof this.options.truncateName === "number" + ? undefined + : 1, + maxSize: showText.length, + cropper: truncateText, + formatter: (text) => chalk.bold(text) + } + ]); + } + public createStatusLine(status: CliFormattedStatus): string { this.status = status; @@ -271,6 +322,10 @@ export default class BaseTransferCliProgressBar implements TransferCliProgressBa return this.renderPendingLine(); } + if (this.status.downloadStatus === DownloadStatus.Loading) { + return this.renderLoadingLine(); + } + return this.renderProgressLine(); } } diff --git a/src/download/transfer-visualize/transfer-cli/progress-bars/summary-transfer-cli-progress-bar.ts b/src/download/transfer-visualize/transfer-cli/progress-bars/summary-transfer-cli-progress-bar.ts index 56d0a77..acdf3e5 100644 --- a/src/download/transfer-visualize/transfer-cli/progress-bars/summary-transfer-cli-progress-bar.ts +++ b/src/download/transfer-visualize/transfer-cli/progress-bars/summary-transfer-cli-progress-bar.ts @@ -20,6 +20,10 @@ export default class SummaryTransferCliProgressBar extends FancyTransferCliProgr return this.status.transferAction; } + override getSpinnerText() { + return STATUS_ICONS.pending; + } + override renderProgressLine(): string { if (this.status.downloadFlags.includes(DownloadFlags.DownloadSequence)) { return this.renderDownloadSequence(); diff --git a/src/download/transfer-visualize/transfer-cli/progress-bars/switch-cli-progress-style.ts b/src/download/transfer-visualize/transfer-cli/progress-bars/switch-cli-progress-style.ts index 4e4eb99..a9ba807 100644 --- a/src/download/transfer-visualize/transfer-cli/progress-bars/switch-cli-progress-style.ts +++ b/src/download/transfer-visualize/transfer-cli/progress-bars/switch-cli-progress-style.ts @@ -1,4 +1,4 @@ -import BaseTransferCliProgressBar from "./base-transfer-cli-progress-bar.js"; +import BaseTransferCliProgressBar, {BaseCliOptions} from "./base-transfer-cli-progress-bar.js"; import FancyTransferCliProgressBar from "./fancy-transfer-cli-progress-bar.js"; import SummaryTransferCliProgressBar from "./summary-transfer-cli-progress-bar.js"; import ci from "ci-info"; @@ -6,7 +6,7 @@ import CiTransferCliProgressBar from "./ci-transfer-cli-progress-bar.js"; export type AvailableCLIProgressStyle = "basic" | "fancy" | "ci" | "summary" | "auto"; -export default function switchCliProgressStyle(cliStyle: AvailableCLIProgressStyle, options: { truncateName?: boolean | number }) { +export default function switchCliProgressStyle(cliStyle: AvailableCLIProgressStyle, options: BaseCliOptions) { switch (cliStyle) { case "basic": return new BaseTransferCliProgressBar(options); diff --git a/src/download/transfer-visualize/transfer-cli/transfer-cli.ts b/src/download/transfer-visualize/transfer-cli/transfer-cli.ts index 45c8810..9e64af8 100644 --- a/src/download/transfer-visualize/transfer-cli/transfer-cli.ts +++ b/src/download/transfer-visualize/transfer-cli/transfer-cli.ts @@ -24,7 +24,7 @@ export const DEFAULT_TRANSFER_CLI_OPTIONS: TransferCliOptions = { maxViewDownloads: 10, truncateName: true, debounceWait: 20, - maxDebounceWait: 100, + maxDebounceWait: process.platform === "win32" ? 500 : 100, createProgressBar: switchCliProgressStyle("auto", {truncateName: true}), loadingAnimation: "dots", loadingText: "Gathering information", diff --git a/src/index.ts b/src/index.ts index f29512c..dbc4183 100644 --- a/src/index.ts +++ b/src/index.ts @@ -16,7 +16,7 @@ import BaseDownloadEngine from "./download/download-engine/engine/base-download- import {InvalidOptionError} from "./download/download-engine/engine/error/InvalidOptionError.js"; import {BaseMultiProgressBar, MultiProgressBarOptions} from "./download/transfer-visualize/transfer-cli/multiProgressBars/BaseMultiProgressBar.js"; import {DownloadFlags, DownloadStatus} from "./download/download-engine/download-file/progress-status-file.js"; - +import {NoDownloadEngineProvidedError} from "./download/download-engine/engine/error/no-download-engine-provided-error.js"; export { DownloadFlags, @@ -33,7 +33,8 @@ export { FetchStreamError, IpullError, EngineError, - InvalidOptionError + InvalidOptionError, + NoDownloadEngineProvidedError };