mirror of
https://github.com/Myxelium/Bridge-Multi.git
synced 2026-04-11 14:19:38 +00:00
Cancel, Retry, and Warning download UI
This commit is contained in:
@@ -4,7 +4,7 @@ import { createHash, randomBytes as _randomBytes } from 'crypto'
|
||||
import { tempPath } from '../../shared/Paths'
|
||||
import { promisify } from 'util'
|
||||
import { join } from 'path'
|
||||
import { Download, NewDownload } from '../../shared/interfaces/download.interface'
|
||||
import { Download, NewDownload, DownloadProgress } from '../../shared/interfaces/download.interface'
|
||||
import { emitIPCEvent } from '../../main'
|
||||
import { mkdir as _mkdir } from 'fs'
|
||||
import { FileExtractor } from './FileExtractor'
|
||||
@@ -13,16 +13,29 @@ import { sanitizeFilename, interpolate } from '../../shared/UtilFunctions'
|
||||
const randomBytes = promisify(_randomBytes)
|
||||
const mkdir = promisify(_mkdir)
|
||||
|
||||
export class AddDownloadHandler implements IPCEmitHandler<'add-download'> {
|
||||
event: 'add-download' = 'add-download'
|
||||
export class DownloadHandler implements IPCEmitHandler<'download'> {
|
||||
event: 'download' = 'download'
|
||||
|
||||
async handler(data: NewDownload) {
|
||||
const download: Download = {
|
||||
// TODO: replace needle with got (for cancel() method) (if before-headers event is possible?)
|
||||
|
||||
downloadCallbacks: { [versionID: number]: { cancel: () => void, retry: () => void, continue: () => void } } = {}
|
||||
|
||||
async handler(data: Download) {
|
||||
switch (data.action) {
|
||||
case 'cancel': this.downloadCallbacks[data.versionID].cancel(); return
|
||||
case 'retry': this.downloadCallbacks[data.versionID].retry(); return
|
||||
case 'continue': this.downloadCallbacks[data.versionID].continue(); return
|
||||
case 'add': this.downloadCallbacks[data.versionID] = { cancel: () => { }, retry: () => { }, continue: () => { } }
|
||||
}
|
||||
|
||||
// data.action == add; data.data should be defined
|
||||
const download: DownloadProgress = {
|
||||
versionID: data.versionID,
|
||||
title: `${data.avTagName} - ${data.artist}`,
|
||||
title: `${data.data.avTagName} - ${data.data.artist}`,
|
||||
header: '',
|
||||
description: '',
|
||||
percent: 0
|
||||
percent: 0,
|
||||
type: 'good'
|
||||
}
|
||||
const randomString = (await randomBytes(5)).toString('hex')
|
||||
const chartPath = join(tempPath, `chart_${randomString}`)
|
||||
@@ -30,17 +43,19 @@ export class AddDownloadHandler implements IPCEmitHandler<'add-download'> {
|
||||
|
||||
let allFilesProgress = 0
|
||||
// Only iterate over the keys in data.links that have link values (not hashes)
|
||||
const fileKeys = Object.keys(data.links).filter(link => data.links[link].includes('.'))
|
||||
const fileKeys = Object.keys(data.data.links).filter(link => data.data.links[link].includes('.'))
|
||||
const individualFileProgressPortion = 80 / fileKeys.length
|
||||
for (let i = 0; i < fileKeys.length; i++) {
|
||||
const typeHash = createHash('md5').update(data.links[fileKeys[i]]).digest('hex')
|
||||
const downloader = new FileDownloader(data.links[fileKeys[i]], chartPath, data.links[typeHash])
|
||||
const typeHash = createHash('md5').update(data.data.links[fileKeys[i]]).digest('hex')
|
||||
const downloader = new FileDownloader(data.data.links[fileKeys[i]], chartPath, data.data.links[typeHash])
|
||||
this.downloadCallbacks[data.versionID].cancel = () => downloader.cancelDownload() // Make cancel button cancel this download
|
||||
let fileProgress = 0
|
||||
|
||||
let waitTime: number
|
||||
downloader.on('wait', (_waitTime) => {
|
||||
download.header = `[${fileKeys[i]}] (file ${i + 1}/${fileKeys.length})`
|
||||
download.description = `Waiting for Google rate limit... (${_waitTime}s)`
|
||||
download.type = 'good'
|
||||
waitTime = _waitTime
|
||||
})
|
||||
|
||||
@@ -48,6 +63,7 @@ export class AddDownloadHandler implements IPCEmitHandler<'add-download'> {
|
||||
download.description = `Waiting for Google rate limit... (${secondsRemaining}s)`
|
||||
fileProgress = interpolate(secondsRemaining, waitTime, 0, 0, individualFileProgressPortion / 2)
|
||||
download.percent = allFilesProgress + fileProgress
|
||||
download.type = 'good'
|
||||
emitIPCEvent('download-updated', download)
|
||||
})
|
||||
|
||||
@@ -55,13 +71,15 @@ export class AddDownloadHandler implements IPCEmitHandler<'add-download'> {
|
||||
download.description = `Sending request...`
|
||||
fileProgress = individualFileProgressPortion / 2
|
||||
download.percent = allFilesProgress + fileProgress
|
||||
download.type = 'good'
|
||||
emitIPCEvent('download-updated', download)
|
||||
})
|
||||
|
||||
downloader.on('warning', (continueAnyway) => {
|
||||
download.description = 'WARNING'
|
||||
this.downloadCallbacks[data.versionID].continue = continueAnyway
|
||||
download.type = 'warning'
|
||||
emitIPCEvent('download-updated', download)
|
||||
//TODO: continue anyway
|
||||
})
|
||||
|
||||
let filesize = -1
|
||||
@@ -73,6 +91,7 @@ export class AddDownloadHandler implements IPCEmitHandler<'add-download'> {
|
||||
} else {
|
||||
download.description = `Downloading... (0 MB)`
|
||||
}
|
||||
download.type = 'good'
|
||||
emitIPCEvent('download-updated', download)
|
||||
})
|
||||
|
||||
@@ -85,14 +104,16 @@ export class AddDownloadHandler implements IPCEmitHandler<'add-download'> {
|
||||
download.description = `Downloading... (${Math.round(bytesDownloaded / 1e+5) / 10} MB)`
|
||||
download.percent = allFilesProgress + fileProgress
|
||||
}
|
||||
download.type = 'good'
|
||||
emitIPCEvent('download-updated', download)
|
||||
})
|
||||
|
||||
downloader.on('error', (error, retry) => {
|
||||
download.header = error.header
|
||||
download.description = error.body
|
||||
download.type = 'error'
|
||||
this.downloadCallbacks[data.versionID].retry = retry
|
||||
emitIPCEvent('download-updated', download)
|
||||
// TODO: retry
|
||||
})
|
||||
|
||||
// Wait for the 'complete' event before moving on to another file download
|
||||
@@ -107,14 +128,16 @@ export class AddDownloadHandler implements IPCEmitHandler<'add-download'> {
|
||||
})
|
||||
}
|
||||
|
||||
const destinationFolderName = sanitizeFilename(`${data.artist} - ${data.avTagName} (${data.charter})`)
|
||||
const destinationFolderName = sanitizeFilename(`${data.data.artist} - ${data.data.avTagName} (${data.data.charter})`)
|
||||
const extractor = new FileExtractor(chartPath, fileKeys.includes('archive'), destinationFolderName)
|
||||
this.downloadCallbacks[data.versionID].cancel = () => extractor.cancelExtract()
|
||||
|
||||
let archive = ''
|
||||
extractor.on('extract', (filename) => {
|
||||
archive = filename
|
||||
download.header = `[${archive}]`
|
||||
download.description = `Extracting...`
|
||||
download.type = 'good'
|
||||
emitIPCEvent('download-updated', download)
|
||||
})
|
||||
|
||||
@@ -122,6 +145,7 @@ export class AddDownloadHandler implements IPCEmitHandler<'add-download'> {
|
||||
download.header = `[${archive}] (${filecount} file${filecount == 1 ? '' : 's'} extracted)`
|
||||
download.description = `Extracting... (${percent}%)`
|
||||
download.percent = interpolate(percent, 0, 100, 80, 95)
|
||||
download.type = 'good'
|
||||
emitIPCEvent('download-updated', download)
|
||||
})
|
||||
|
||||
@@ -129,6 +153,7 @@ export class AddDownloadHandler implements IPCEmitHandler<'add-download'> {
|
||||
download.header = `Moving files to library folder...`
|
||||
download.description = filepath
|
||||
download.percent = 95
|
||||
download.type = 'good'
|
||||
emitIPCEvent('download-updated', download)
|
||||
})
|
||||
|
||||
@@ -136,14 +161,16 @@ export class AddDownloadHandler implements IPCEmitHandler<'add-download'> {
|
||||
download.header = `Download complete.`
|
||||
download.description = filepath
|
||||
download.percent = 100
|
||||
download.type = 'good'
|
||||
emitIPCEvent('download-updated', download)
|
||||
})
|
||||
|
||||
extractor.on('error', (error, retry) => {
|
||||
download.header = error.header
|
||||
download.description = error.body
|
||||
download.type = 'error'
|
||||
this.downloadCallbacks[data.versionID].retry = retry
|
||||
emitIPCEvent('download-updated', download)
|
||||
// TODO: retry
|
||||
})
|
||||
|
||||
extractor.beginExtract()
|
||||
@@ -19,12 +19,13 @@ export type DownloadError = { header: string, body: string }
|
||||
|
||||
export class FileDownloader {
|
||||
private RATE_LIMIT_DELAY: number
|
||||
private readonly RETRY_MAX = 3
|
||||
private readonly RETRY_MAX = 2
|
||||
private static waitTime = 0
|
||||
private static clock: NodeJS.Timer
|
||||
|
||||
private callbacks = {} as Callbacks
|
||||
private retryCount: number
|
||||
private wasCanceled = false
|
||||
|
||||
constructor(private url: string, private destinationFolder: string, private expectedHash?: string) {
|
||||
if (FileDownloader.clock == undefined) {
|
||||
@@ -60,6 +61,7 @@ export class FileDownloader {
|
||||
}
|
||||
this.callbacks.wait(waitTime)
|
||||
const clock = setInterval(() => {
|
||||
if (this.wasCanceled) { clearInterval(clock); return } // CANCEL POINT
|
||||
waitTime--
|
||||
this.callbacks.waitProgress(waitTime)
|
||||
if (waitTime <= 0) {
|
||||
@@ -75,10 +77,12 @@ export class FileDownloader {
|
||||
* @param cookieHeader the "cookie=" header to include this request.
|
||||
*/
|
||||
private requestDownload(cookieHeader?: string) {
|
||||
if (this.wasCanceled) { return } // CANCEL POINT
|
||||
this.callbacks.request()
|
||||
let uuid = generateUUID()
|
||||
const req = needle.get(this.url, {
|
||||
follow_max: 10,
|
||||
open_timeout: 5000,
|
||||
headers: Object.assign({
|
||||
'User-Agent': 'PostmanRuntime/7.22.0',
|
||||
'Referer': this.url,
|
||||
@@ -99,12 +103,12 @@ export class FileDownloader {
|
||||
}
|
||||
})
|
||||
|
||||
req.on('err', (err) => {
|
||||
// TODO: this is called on timeout; if there are other cases where this can fail, they should be printed correctly
|
||||
// this.callbacks.error({ header: 'Error', description: `${err}` }, () => this.beginDownload())
|
||||
req.on('err', (err: Error) => {
|
||||
this.callbacks.error({ header: 'Connection Error', body: `${err.name}: ${err.message}` }, () => this.beginDownload())
|
||||
})
|
||||
|
||||
req.on('header', (statusCode, headers: Headers) => {
|
||||
if (this.wasCanceled) { return } // CANCEL POINT
|
||||
if (statusCode != 200) {
|
||||
this.callbacks.error({ header: 'Connection failed', body: `Server returned status code: ${statusCode}` }, () => this.beginDownload())
|
||||
return
|
||||
@@ -117,10 +121,10 @@ export class FileDownloader {
|
||||
const fileName = this.getDownloadFileName(headers)
|
||||
const downloadHash = this.getDownloadHash(headers)
|
||||
if (this.expectedHash !== undefined && downloadHash !== this.expectedHash) {
|
||||
req.pause()
|
||||
this.callbacks.warning(() => {
|
||||
//TODO: check if this will actually work (or will the data get lost in the time before the button is clicked?)
|
||||
// Maybe show the message at the end, and ask if they want to keep it.
|
||||
this.handleDownloadResponse(req, fileName, headers['content-length'])
|
||||
req.resume()
|
||||
})
|
||||
} else {
|
||||
this.handleDownloadResponse(req, fileName, headers['content-length'])
|
||||
@@ -220,4 +224,8 @@ export class FileDownloader {
|
||||
return headers['x-goog-hash']
|
||||
}
|
||||
}
|
||||
|
||||
cancelDownload() {
|
||||
this.wasCanceled = true
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,7 @@ export class FileExtractor {
|
||||
|
||||
private callbacks = {} as Callbacks
|
||||
private libraryFolder: string
|
||||
private wasCanceled = false
|
||||
constructor(private sourceFolder: string, private isArchive: boolean, private destinationFolderName: string) { }
|
||||
|
||||
/**
|
||||
@@ -58,6 +59,7 @@ export class FileExtractor {
|
||||
* @param filename The name of the archive file.
|
||||
*/
|
||||
private extract(filename: string) {
|
||||
if (this.wasCanceled) { return } // CANCEL POINT
|
||||
this.callbacks.extract(filename)
|
||||
const source = join(this.sourceFolder, filename)
|
||||
|
||||
@@ -93,6 +95,7 @@ export class FileExtractor {
|
||||
* Deletes the archive at <archiveFilepath>, then transfers the extracted chart to <this.libraryFolder>.
|
||||
*/
|
||||
private async transfer(archiveFilepath?: string) {
|
||||
if (this.wasCanceled) { return } // CANCEL POINT
|
||||
try {
|
||||
|
||||
// Create destiniation folder if it doesn't exist
|
||||
@@ -117,6 +120,8 @@ export class FileExtractor {
|
||||
files = await readdir(this.sourceFolder)
|
||||
}
|
||||
|
||||
if (this.wasCanceled) { return } // CANCEL POINT
|
||||
|
||||
// Copy the files from the temporary directory to the destination
|
||||
for (const file of files) {
|
||||
await copyFile(join(this.sourceFolder, file), join(destinationFolder, file))
|
||||
@@ -133,4 +138,8 @@ export class FileExtractor {
|
||||
this.callbacks.error({ header: 'Transfer Failed', body: `Unable to transfer downloaded files to the library folder: ${e.name}` }, undefined)
|
||||
}
|
||||
}
|
||||
|
||||
cancelExtract() {
|
||||
this.wasCanceled = true
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,7 @@ export default class Database {
|
||||
}
|
||||
})
|
||||
|
||||
// TODO: make this error message more user-friendly (retry option?)
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
this.conn.connect(err => {
|
||||
if (err) {
|
||||
|
||||
@@ -3,8 +3,8 @@ import { VersionResult, AlbumArtResult } from './interfaces/songDetails.interfac
|
||||
import SearchHandler from '../ipc/SearchHandler.ipc'
|
||||
import SongDetailsHandler from '../ipc/SongDetailsHandler.ipc'
|
||||
import AlbumArtHandler from '../ipc/AlbumArtHandler.ipc'
|
||||
import { Download, NewDownload } from './interfaces/download.interface'
|
||||
import { AddDownloadHandler } from '../ipc/download/AddDownloadHandler'
|
||||
import { Download, NewDownload, DownloadProgress } from './interfaces/download.interface'
|
||||
import { DownloadHandler } from '../ipc/download/DownloadHandler'
|
||||
import { Settings } from './Settings'
|
||||
import InitSettingsHandler from '../ipc/InitSettingsHandler.ipc'
|
||||
|
||||
@@ -51,13 +51,13 @@ export interface IPCInvokeHandler<E extends keyof IPCInvokeEvents> {
|
||||
|
||||
export function getIPCEmitHandlers(): IPCEmitHandler<keyof IPCEmitEvents>[]{
|
||||
return [
|
||||
new AddDownloadHandler()
|
||||
new DownloadHandler()
|
||||
]
|
||||
}
|
||||
|
||||
export type IPCEmitEvents = {
|
||||
'add-download': NewDownload
|
||||
'download-updated': Download
|
||||
'download': Download
|
||||
'download-updated': DownloadProgress
|
||||
}
|
||||
|
||||
export interface IPCEmitHandler<E extends keyof IPCEmitEvents> {
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
export interface Download {
|
||||
action: 'add' | 'retry' | 'continue' | 'cancel'
|
||||
versionID: number
|
||||
data ?: NewDownload
|
||||
}
|
||||
|
||||
/**
|
||||
* Contains the data required to start downloading a single chart
|
||||
*/
|
||||
export interface NewDownload {
|
||||
versionID: number
|
||||
avTagName: string
|
||||
artist: string
|
||||
charter: string
|
||||
@@ -12,10 +17,11 @@ export interface NewDownload {
|
||||
/**
|
||||
* Represents the download progress of a single chart
|
||||
*/
|
||||
export interface Download {
|
||||
export interface DownloadProgress {
|
||||
versionID: number
|
||||
title: string
|
||||
header: string
|
||||
description: string
|
||||
percent: number
|
||||
type: 'good' | 'warning' | 'error' | 'cancel'
|
||||
}
|
||||
Reference in New Issue
Block a user