Files
Bridge-Multi/src-electron/ipc/download/ChartDownload.ts
2023-12-25 02:49:46 -06:00

306 lines
9.9 KiB
TypeScript

import { randomUUID } from 'crypto'
import EventEmitter from 'events'
import { createWriteStream, WriteStream } from 'fs'
import { access, constants } from 'fs/promises'
import { round, throttle } from 'lodash'
import { mkdirp } from 'mkdirp'
import mv from 'mv'
import { SngStream } from 'parse-sng'
import { join } from 'path'
import { rimraf } from 'rimraf'
import { inspect } from 'util'
import { tempPath } from '../../../src-shared/Paths'
import { sanitizeFilename } from '../../ElectronUtilFunctions'
import { getSettings } from '../SettingsHandler.ipc'
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 destinationName: string
private isSng: 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, private chartName: string) { }
on<T extends keyof ChartDownloadEvents>(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) {
rimraf(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.destinationName = sanitizeFilename(this.isSng ? `${this.chartName}.sng` : this.chartName)
this.showProgress('Checking for any duplicate charts...')
const destinationPath = join(settings.libraryPath, this.destinationName)
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())
try {
this.showProgress('Creating temporary download folder...')
await mkdirp(this.tempPath)
if (this._canceled) { return }
} catch (err) {
throw { header: 'Failed to create temporary download folder', body: inspect(err) }
}
}
private async downloadChart() {
const sngResponse = await fetch(`https://files.enchor.us/${this.md5}.sng`, { mode: 'cors', referrerPolicy: 'no-referrer' })
if (!sngResponse.ok || !sngResponse.body) {
throw { header: 'Failed to download the chart file', body: `Response code ${sngResponse.status}: ${sngResponse.statusText}` }
}
const fileSize = BigInt(sngResponse.headers.get('Content-Length')!)
if (this.isSng) {
const writeStream = createWriteStream(join(this.tempPath, this.destinationName))
const reader = sngResponse.body.getReader()
let downloadedByteCount = BigInt(0)
// eslint-disable-next-line no-constant-condition
while (true) {
let result: ReadableStreamReadResult<Uint8Array>
try {
result = await reader.read()
} catch (err) {
throw { header: 'Failed to download the chart file', body: inspect(err) }
}
if (this._canceled) {
await reader.cancel()
writeStream.end()
return
}
if (result.done) { writeStream.end(); return }
downloadedByteCount += BigInt(result.value.length)
const downloadPercent = round(100 * Number(downloadedByteCount / BigInt(1000)) / Number(fileSize / BigInt(1000)), 1)
this.showProgress(`Downloading... (${downloadPercent}%)`, downloadPercent)
await new Promise<void>((resolve, reject) => {
writeStream.write(result.value, err => {
if (err) {
reject({ header: 'Failed to download the chart file', body: inspect(err) })
} else {
resolve()
}
})
})
if (writeStream.writableNeedDrain) {
await new Promise<void>((resolve, reject) => {
writeStream.once('drain', resolve)
writeStream.once('error', err => reject({ header: 'Failed to download the chart file', body: inspect(err) }))
})
}
}
} else {
const sngStream = new SngStream(() => sngResponse.body!, { generateSongIni: true })
let downloadedByteCount = BigInt(0)
await mkdirp(join(this.tempPath, this.destinationName))
await new Promise<void>((resolve, reject) => {
sngStream.on('file', async (fileName, fileStream) => {
let writeStream: WriteStream
let reader: ReadableStreamDefaultReader<Uint8Array>
try {
writeStream = createWriteStream(join(this.tempPath, this.destinationName, fileName))
writeStream.on('error', () => { /** Surpress unhandled promise rejection */ })
reader = fileStream.getReader()
} catch (err) {
reject(err)
return
}
try {
// eslint-disable-next-line no-constant-condition
while (true) {
let result: ReadableStreamReadResult<Uint8Array>
try {
result = await reader.read()
} catch (err) {
throw { header: 'Failed to download the chart file', body: inspect(err) }
}
if (this._canceled) {
await reader.cancel()
writeStream.end()
resolve()
return
}
if (result.done) { writeStream.end(); return }
downloadedByteCount += BigInt(result.value.length)
const downloadPercent =
round(100 * Number(downloadedByteCount / BigInt(1000)) / Number(fileSize / BigInt(1000)), 1)
this.showProgress(`Downloading "${fileName}"... (${downloadPercent}%)`, downloadPercent)
await new Promise<void>((resolve, reject) => {
writeStream.write(result.value, err => {
if (err) {
reject({ header: 'Failed to download the chart file', body: inspect(err) })
} else {
resolve()
}
})
})
if (writeStream.writableNeedDrain) {
await new Promise<void>((resolve, reject) => {
writeStream.once('drain', resolve)
writeStream.once('error', err => reject({
header: 'Failed to download the chart file',
body: inspect(err),
}))
})
}
}
} catch (err) {
try {
await reader.cancel()
} catch (err) { /** ignore; error already reported */ }
writeStream.end()
reject(err)
}
})
sngStream.on('end', resolve)
sngStream.on('error', err => 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<void>((resolve, reject) => {
if (settings.libraryPath) {
const destinationPath = join(settings.libraryPath, this.destinationName)
mv(join(this.tempPath, this.destinationName), destinationPath, { mkdirp: 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 rimraf(this.tempPath)
} catch (err) {
throw { header: 'Failed to delete temporary folder', body: inspect(err) }
}
const destinationPath = join(settings.libraryPath!, this.destinationName)
this.showProgress.cancel()
this.eventEmitter.emit('end', destinationPath)
}
}