import { randomUUID } from 'crypto' import EventEmitter from 'events' import { createWriteStream } from 'fs' import { ensureDir, move, remove } from 'fs-extra' import { access, constants } from 'fs/promises' import { IncomingMessage } from 'http' import https from 'https' import _ from 'lodash' import { SngStream } from 'parse-sng' import { join } from 'path' import { Readable } from 'stream' import { ReadableStream } from 'stream/web' import { inspect } from 'util' import { tempPath } from '../../../src-shared/Paths.js' import { resolveChartFolderName } from '../../../src-shared/UtilFunctions.js' import { getSettings } from '../SettingsHandler.ipc.js' export interface DownloadMessage { header: string body: string isPath?: boolean } interface ChartDownloadEvents { 'progress': (message: DownloadMessage, percent: number | null) => void 'error': (err: DownloadMessage) => void 'end': (destinationPath: string) => void } // eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging export declare interface ChartDownload { /** * Registers `listener` to be called when download progress has occured. * `percent` is a number between 0 and 100, or `null` if the progress is indeterminate. * Progress events are throttled to avoid performance issues with rapid updates. */ on(event: 'progress', listener: (message: DownloadMessage, percent: number | null) => void): void /** * Registers `listener` to be called if the download process threw an exception. If this is called, the "end" event won't happen. */ on(event: 'error', listener: (err: DownloadMessage) => void): void /** * Registers `listener` to be called when the chart has been fully downloaded. If this is called, the "error" event won't happen. */ on(event: 'end', listener: (destinationPath: string) => void): void } // eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging export class ChartDownload { private _canceled = false private eventEmitter = new EventEmitter() private stepCompletedCount = 0 private tempPath: string private chartFolderPath: string private isSng: boolean private downloadVideos: boolean private showProgress = _.throttle((description: string, percent: number | null = null) => { this.eventEmitter.emit('progress', { header: description, body: '' }, percent) }, 10, { leading: true, trailing: true }) constructor( public readonly md5: string, public readonly hasVideoBackground: boolean, private chart: { name: string; artist: string; album: string; genre: string; year: string; charter: string }, ) { } on(event: T, listener: ChartDownloadEvents[T]) { this.eventEmitter.on(event, listener) } /** * Checks the target directory to determine if it is accessible. * * Checks the target directory if the chart already exists. * * Downloads the chart to a temporary directory. * * Moves the chart to the target directory. */ async startOrRetry() { try { switch (this.stepCompletedCount) { case 0: await this.checkFilesystem(); this.stepCompletedCount++; if (this._canceled) { return } // break omitted case 1: await this.downloadChart(); this.stepCompletedCount++; if (this._canceled) { return } // break omitted case 2: await this.transferChart(); this.stepCompletedCount++; if (this._canceled) { return } // break omitted } } catch (err) { this.showProgress.cancel() if (err.header && (err.body || err.body === '')) { this.eventEmitter.emit('error', err) } else { this.eventEmitter.emit('error', { header: 'Unknown Error', body: inspect(err) }) } } } /** * Cancels the download if it is running. */ cancel() { this.showProgress.cancel() this._canceled = true if (this.tempPath) { remove(this.tempPath).catch(() => { /** Do nothing */ }) // Delete temp folder } } private async checkFilesystem() { this.showProgress('Loading settings...') const settings = await getSettings() if (this._canceled) { return } if (!settings.libraryPath) { throw { header: 'Library folder not specified', body: 'Please go to the settings to set your library folder.' } } try { this.showProgress('Checking library path...') await access(settings.libraryPath, constants.W_OK) if (this._canceled) { return } } catch (err) { throw { header: 'Failed to access library folder', body: inspect(err) } } this.isSng = settings.isSng this.downloadVideos = settings.downloadVideos this.chartFolderPath = resolveChartFolderName(settings.chartFolderName, this.chart) + (this.isSng ? '.sng' : '') this.showProgress('Checking for any duplicate charts...') const destinationPath = join(settings.libraryPath, this.chartFolderPath) const isDuplicate = await access(destinationPath, constants.F_OK).then(() => true).catch(() => false) if (this._canceled) { return } if (isDuplicate) { throw { header: 'This chart already exists in your library folder', body: destinationPath, isPath: true } } this.tempPath = join(tempPath, randomUUID()) + (this.isSng ? '.sng' : '') } private async downloadChart() { const downloadNovideoVersion = this.hasVideoBackground && !this.downloadVideos const { response, abortController } = await getDownloadStream(this.md5, downloadNovideoVersion) const fileSize = BigInt(response.headers['content-length']!) if (this.isSng) { response.pipe(createWriteStream(this.tempPath, { highWaterMark: 2e+9 })) await new Promise((resolve, reject) => { let downloadedByteCount = BigInt(0) response.on('end', resolve) response.on('error', err => reject(err)) response.on('data', data => { downloadedByteCount += BigInt(data.length) const downloadPercent = _.round(100 * Number(downloadedByteCount / BigInt(1000)) / Number(fileSize / BigInt(1000)), 1) this.showProgress(`Downloading... (${downloadPercent}%)`, downloadPercent) if (this._canceled) { abortController.abort() } }) }) } else { // eslint-disable-next-line @typescript-eslint/no-explicit-any const sngStream = new SngStream(Readable.toWeb(response) as any, { generateSongIni: true }) let downloadedByteCount = BigInt(0) await ensureDir(this.tempPath) await new Promise((resolve, reject) => { sngStream.on('file', async (fileName, fileStream, nextFile) => { const nodeFileStream = Readable.fromWeb(fileStream as ReadableStream, { highWaterMark: 2e+9 }) nodeFileStream.pipe(createWriteStream(join(this.tempPath, fileName), { highWaterMark: 2e+9 })) await new Promise((resolve, reject) => { nodeFileStream.on('end', resolve) nodeFileStream.on('error', err => reject(err)) nodeFileStream.on('data', data => { if (this._canceled) { abortController.abort() } else { downloadedByteCount += BigInt(data.length) const downloadPercent = _.round(100 * Number(downloadedByteCount / BigInt(1000)) / Number(fileSize / BigInt(1000)), 1) this.showProgress(`Downloading "${fileName}"... (${downloadPercent}%)`, downloadPercent) } }) }) if (nextFile) { nextFile() } else { resolve() } }) sngStream.on('error', err => { if (err instanceof Error && err.message === 'aborted') { // Errors from cancelling downloads are intentional resolve() } else { reject(err) } }) sngStream.start() }) } } private async transferChart() { const settings = await getSettings() if (this._canceled) { return } this.showProgress('Moving chart to library folder...', 100) await new Promise(resolve => setTimeout(resolve, 200)) // Delay for OS file processing await new Promise((resolve, reject) => { if (settings.libraryPath) { const destinationPath = join(settings.libraryPath, this.chartFolderPath) move(this.tempPath, destinationPath, { overwrite: true }, err => { if (err) { reject({ header: 'Failed to move chart to library folder', body: inspect(err) }) } else { resolve() } }) } else { reject({ header: 'Library folder not specified', body: 'Please go to the settings to set your library folder.' }) } }) this.showProgress('Deleting temporary folder...') try { await remove(this.tempPath) } catch (err) { throw { header: 'Failed to delete temporary folder', body: inspect(err) } } this.showProgress.cancel() this.eventEmitter.emit('end', join(settings.libraryPath!, this.chartFolderPath)) } } function getDownloadStream(md5: string, downloadNovideoVersion: boolean): Promise<{ response: IncomingMessage; abortController: AbortController }> { const abortController = new AbortController() return new Promise((resolve, reject) => { const request = https.get(`https://files.enchor.us/${md5 + (downloadNovideoVersion ? '_novideo' : '')}.sng`, { agent: new https.Agent({ timeout: 30000 }), headers: { 'mode': 'cors', // eslint-disable-next-line @typescript-eslint/naming-convention 'referrer-policy': 'no-referrer', }, timeout: 30000, signal: abortController.signal, }) request.on('response', response => { if (response.statusCode !== 200 || !response.headers['content-length']) { reject({ header: 'Failed to download the chart file', body: `Response code ${response.statusCode}: ${response.statusMessage}`, }) return } resolve({ response, abortController }) }) }) }