mirror of
https://github.com/Myxelium/Bridge-Multi.git
synced 2026-04-09 05:09:39 +00:00
- Add Chart Preview - Add Drum Type dropdown when the "drums" instrument is selected - Add Min/Max Year to advanced search - Add Track Hash to advanced search - Add "Download Video Backgrounds" setting - Updated and improved detected chart issues
275 lines
9.3 KiB
TypeScript
275 lines
9.3 KiB
TypeScript
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<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) {
|
|
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<void>((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<void>((resolve, reject) => {
|
|
sngStream.on('file', async (fileName, fileStream, nextFile) => {
|
|
const nodeFileStream = Readable.fromWeb(fileStream as ReadableStream<Uint8Array>, { highWaterMark: 2e+9 })
|
|
nodeFileStream.pipe(createWriteStream(join(this.tempPath, fileName), { highWaterMark: 2e+9 }))
|
|
|
|
await new Promise<void>((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<void>(resolve => setTimeout(resolve, 200)) // Delay for OS file processing
|
|
await new Promise<void>((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 })
|
|
})
|
|
})
|
|
}
|