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

@@ -128,8 +128,8 @@ export class ChartSidebarComponent {
}
onDownloadClicked() {
this.downloadService.addDownload({
versionID: this.selectedVersion.versionID,
this.downloadService.addDownload(
this.selectedVersion.versionID, {
avTagName: this.selectedVersion.avTagName,
artist: this.songResult.artist,
charter: this.selectedVersion.charters,

View File

@@ -1,9 +1,24 @@
<div class="ui cards">
<div *ngFor="let download of downloads; trackBy:trackByVersionID" class="card">
<div *ngFor="let download of downloads; trackBy:trackByVersionID" class="card" [style.background-color]="getBackgroundColor(download)">
<div class="content">
<i class="inside right floated close icon" (click)="cancelDownload(download.versionID)"></i>
<div class="header">{{download.title}}</div>
</div>
<div class="content">
<button
*ngIf="download.type == 'error'"
class="ui right floated labeled icon button"
(click)="retryDownload(download.versionID)">
<i class="redo icon"></i>
Retry
</button>
<button
*ngIf="download.type == 'warning'"
class="ui right floated labeled icon button"
(click)="continueDownload(download.versionID)">
<i class="exclamation triangle icon"></i>
Download Anyway
</button>
<div class="header">{{download.header}}</div>
<div class="description">{{download.description}}</div>
<div appProgressBar [percent]="download.percent" class="ui progress">

View File

@@ -4,4 +4,8 @@
.ui.progress {
margin: 0;
}
i.close.icon {
cursor: pointer;
}

View File

@@ -1,5 +1,5 @@
import { Component, ChangeDetectorRef } from '@angular/core'
import { Download } from '../../../../../electron/shared/interfaces/download.interface'
import { DownloadProgress } from '../../../../../electron/shared/interfaces/download.interface'
import { DownloadService } from '../../../../core/services/download.service'
@Component({
@@ -9,13 +9,15 @@ import { DownloadService } from '../../../../core/services/download.service'
})
export class DownloadsModalComponent {
downloads: Download[] = []
downloads: DownloadProgress[] = []
constructor(downloadService: DownloadService, ref: ChangeDetectorRef) {
constructor(private downloadService: DownloadService, ref: ChangeDetectorRef) {
downloadService.onDownloadUpdated(download => {
const index = this.downloads.findIndex(thisDownload => thisDownload.versionID == download.versionID)
if (index == -1) {
this.downloads.push(download)
} else if (download.type == 'cancel') {
this.downloads.splice(index, 1)
} else {
this.downloads[index] = download
}
@@ -23,7 +25,28 @@ export class DownloadsModalComponent {
})
}
trackByVersionID(_index: number, item: Download) {
trackByVersionID(_index: number, item: DownloadProgress) {
return item.versionID
}
cancelDownload(versionID: number) {
this.downloadService.cancelDownload(versionID)
}
retryDownload(versionID: number) {
this.downloadService.retryDownload(versionID)
}
continueDownload(versionID: number) {
// TODO: test this
this.downloadService.continueDownload(versionID)
}
getBackgroundColor(download: DownloadProgress) {
switch(download.type) {
case 'good': return 'unset'
case 'warning': return 'yellow'
case 'error': return 'indianred'
}
}
}

View File

@@ -1,6 +1,6 @@
import { Injectable, EventEmitter } from '@angular/core'
import { ElectronService } from './electron.service'
import { Download, NewDownload } from '../../../electron/shared/interfaces/download.interface'
import { NewDownload, DownloadProgress } from '../../../electron/shared/interfaces/download.interface'
import * as _ from 'underscore'
@Injectable({
@@ -8,12 +8,24 @@ import * as _ from 'underscore'
})
export class DownloadService {
private downloadUpdatedEmitter = new EventEmitter<Download>()
private downloads: Download[] = []
private downloadUpdatedEmitter = new EventEmitter<DownloadProgress>()
private downloads: DownloadProgress[] = []
constructor(private electronService: ElectronService) { }
addDownload(newDownload: NewDownload) {
get downloadCount() {
return this.downloads.length
}
get totalPercent() {
let total = 0
for (const download of this.downloads) {
total += download.percent
}
return total / this.downloads.length
}
addDownload(versionID: number, newDownload: NewDownload) {
this.electronService.receiveIPC('download-updated', result => {
this.downloadUpdatedEmitter.emit(result)
@@ -25,28 +37,26 @@ export class DownloadService {
this.downloads[thisDownloadIndex] = result
}
})
this.electronService.sendIPC('add-download', newDownload)
this.electronService.sendIPC('download', { action: 'add', versionID, data: newDownload })
}
onDownloadUpdated(callback: (download: Download) => void) {
onDownloadUpdated(callback: (download: DownloadProgress) => void) {
this.downloadUpdatedEmitter.subscribe(_.throttle(callback, 30))
}
get downloadCount() {
return this.downloads.length
}
removeDownload(versionID: number) {
cancelDownload(versionID: number) {
const removedDownload = this.downloads.find(download => download.versionID == versionID)
this.downloads = this.downloads.filter(download => download.versionID != versionID)
removedDownload.type = 'cancel'
this.downloadUpdatedEmitter.emit(removedDownload)
this.electronService.sendIPC('download', { action: 'cancel', versionID })
}
get totalPercent() {
let total = 0
for (const download of this.downloads) {
total += download.percent
}
return total / this.downloads.length
retryDownload(versionID: number) {
this.electronService.sendIPC('download', { action: 'retry', versionID })
}
continueDownload(versionID: number) {
this.electronService.sendIPC('download', { action: 'continue', versionID })
}
}

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