Cancel, Retry, and Warning download UI

This commit is contained in:
Geomitron
2020-02-11 20:50:51 -05:00
parent a98b03dcd4
commit db083d573a
13 changed files with 291 additions and 179 deletions

View File

@@ -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()

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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) {

View File

@@ -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> {

View File

@@ -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'
}