From db02710b37dbfe7d36eaf220de4214d82aa61551 Mon Sep 17 00:00:00 2001 From: Geomitron <22552797+Geomitron@users.noreply.github.com> Date: Wed, 4 Mar 2020 18:17:54 -0500 Subject: [PATCH] Fixed Downloads --- src/app/core/services/download.service.ts | 20 +- src/app/core/services/settings.service.ts | 4 +- src/electron/ipc/AlbumArtHandler.ipc.ts | 6 +- .../ipc/BatchSongDetailsHandler.ipc.ts | 6 +- src/electron/ipc/SearchHandler.ipc.ts | 6 +- src/electron/ipc/SettingsHandler.ipc.ts | 15 +- src/electron/ipc/SongDetailsHandler.ipc.ts | 6 +- src/electron/ipc/download/ChartDownload.ts | 272 ++++++++++++++++++ src/electron/ipc/download/DownloadHandler.ts | 243 +++------------- src/electron/ipc/download/FileDownloader.ts | 159 +++------- src/electron/ipc/download/FileExtractor.ts | 45 +-- src/electron/ipc/download/GoogleTimer.ts | 50 ++++ src/electron/main.ts | 4 +- src/electron/shared/ElectronUtilFunctions.ts | 4 +- src/electron/shared/IPCHandler.ts | 26 +- .../shared/interfaces/download.interface.ts | 10 +- 16 files changed, 480 insertions(+), 396 deletions(-) create mode 100644 src/electron/ipc/download/ChartDownload.ts create mode 100644 src/electron/ipc/download/GoogleTimer.ts diff --git a/src/app/core/services/download.service.ts b/src/app/core/services/download.service.ts index bdc65fc..6339a45 100644 --- a/src/app/core/services/download.service.ts +++ b/src/app/core/services/download.service.ts @@ -11,7 +11,9 @@ export class DownloadService { private downloadUpdatedEmitter = new EventEmitter() private downloads: DownloadProgress[] = [] - constructor(private electronService: ElectronService) { } + constructor(private electronService: ElectronService) { + process.setMaxListeners(100) + } get downloadCount() { return this.downloads.length @@ -26,17 +28,18 @@ export class DownloadService { } addDownload(versionID: number, newDownload: NewDownload) { - if (this.downloads.findIndex(download => download.versionID == versionID) == -1) { // Don't download something twice + if (!this.downloads.find(download => download.versionID == versionID)) { // Don't download something twice this.electronService.receiveIPC('download-updated', result => { - this.downloadUpdatedEmitter.emit(result) - // Update with result const thisDownloadIndex = this.downloads.findIndex(download => download.versionID == result.versionID) if (thisDownloadIndex == -1) { this.downloads.push(result) + // TODO: this.downloads.sort(downloadSorter) } else { this.downloads[thisDownloadIndex] = result } + + this.downloadUpdatedEmitter.emit(result) }) this.electronService.sendIPC('download', { action: 'add', versionID, data: newDownload }) } @@ -45,13 +48,10 @@ export class DownloadService { onDownloadUpdated(callback: (download: DownloadProgress) => void) { const debouncedCallback = _.throttle(callback, 30) this.downloadUpdatedEmitter.subscribe((download: DownloadProgress) => { - if (this.downloads.findIndex(oldDownload => oldDownload.versionID == download.versionID) == -1) { - // If this is a new download item, don't call debouncedCallback; it may miss adding new versions to the list - callback(download) - } else if (download.type == 'wait') { - callback(download) // Many wait events can be recieved at once - } else { + if (download.type == 'fastUpdate') { // 'good' updates can happen so frequently that the UI doesn't update correctly debouncedCallback(download) + } else { + callback(download) } }) } diff --git a/src/app/core/services/settings.service.ts b/src/app/core/services/settings.service.ts index eefeb66..00cbd2d 100644 --- a/src/app/core/services/settings.service.ts +++ b/src/app/core/services/settings.service.ts @@ -12,8 +12,7 @@ export class SettingsService { private currentThemeLink: HTMLLinkElement constructor(private electronService: ElectronService) { - this.getSettings() // Should resolve immediately because GetSettingsHandler returns a value, not a promise - console.log(`QUICKLY RESOLVED SETTINGS: ${this.settings}`) + this.getSettings() } async getSettings() { @@ -51,7 +50,6 @@ export class SettingsService { } // Individual getters/setters - // TODO: remove the undefined checks if the constructor gets the settings every time get libraryDirectory() { return this.settings == undefined ? '' : this.settings.libraryPath } diff --git a/src/electron/ipc/AlbumArtHandler.ipc.ts b/src/electron/ipc/AlbumArtHandler.ipc.ts index a15846b..181275f 100644 --- a/src/electron/ipc/AlbumArtHandler.ipc.ts +++ b/src/electron/ipc/AlbumArtHandler.ipc.ts @@ -5,7 +5,7 @@ import { AlbumArtResult } from '../shared/interfaces/songDetails.interface' /** * Handles the 'album-art' event. */ -export default class AlbumArtHandler implements IPCInvokeHandler<'album-art'> { +class AlbumArtHandler implements IPCInvokeHandler<'album-art'> { event: 'album-art' = 'album-art' /** @@ -27,4 +27,6 @@ export default class AlbumArtHandler implements IPCInvokeHandler<'album-art'> { WHERE songID = ${songID}; ` } -} \ No newline at end of file +} + +export const albumArtHandler = new AlbumArtHandler() \ No newline at end of file diff --git a/src/electron/ipc/BatchSongDetailsHandler.ipc.ts b/src/electron/ipc/BatchSongDetailsHandler.ipc.ts index fd32fc9..f8cd1f7 100644 --- a/src/electron/ipc/BatchSongDetailsHandler.ipc.ts +++ b/src/electron/ipc/BatchSongDetailsHandler.ipc.ts @@ -5,7 +5,7 @@ import { VersionResult } from '../shared/interfaces/songDetails.interface' /** * Handles the 'batch-song-details' event. */ -export default class BatchSongDetailsHandler implements IPCInvokeHandler<'batch-song-details'> { +class BatchSongDetailsHandler implements IPCInvokeHandler<'batch-song-details'> { event: 'batch-song-details' = 'batch-song-details' /** @@ -27,4 +27,6 @@ export default class BatchSongDetailsHandler implements IPCInvokeHandler<'batch- WHERE songID IN (${songIDs.join(',')}); ` } -} \ No newline at end of file +} + +export const batchSongDetailsHandler = new BatchSongDetailsHandler() \ No newline at end of file diff --git a/src/electron/ipc/SearchHandler.ipc.ts b/src/electron/ipc/SearchHandler.ipc.ts index 84a7e9b..bf3c08b 100644 --- a/src/electron/ipc/SearchHandler.ipc.ts +++ b/src/electron/ipc/SearchHandler.ipc.ts @@ -6,7 +6,7 @@ import { escape } from 'mysql' /** * Handles the 'song-search' event. */ -export default class SearchHandler implements IPCInvokeHandler<'song-search'> { +class SearchHandler implements IPCInvokeHandler<'song-search'> { event: 'song-search' = 'song-search' /** @@ -39,4 +39,6 @@ export default class SearchHandler implements IPCInvokeHandler<'song-search'> { LIMIT ${20} OFFSET ${0}; ` // TODO: add parameters for the limit and offset } -} \ No newline at end of file +} + +export const searchHandler = new SearchHandler() \ No newline at end of file diff --git a/src/electron/ipc/SettingsHandler.ipc.ts b/src/electron/ipc/SettingsHandler.ipc.ts index e92ff97..dbd3194 100644 --- a/src/electron/ipc/SettingsHandler.ipc.ts +++ b/src/electron/ipc/SettingsHandler.ipc.ts @@ -14,20 +14,20 @@ let settings: Settings /** * Handles the 'get-settings' event. */ -export class GetSettingsHandler implements IPCInvokeHandler<'get-settings'> { +class GetSettingsHandler implements IPCInvokeHandler<'get-settings'> { event: 'get-settings' = 'get-settings' /** * @returns the current settings oject, or default settings if they couldn't be loaded. */ handler() { - return GetSettingsHandler.getSettings() + return this.getSettings() } /** * @returns the current settings oject, or default settings if they couldn't be loaded. */ - static getSettings() { + getSettings() { if (settings == undefined) { return defaultSettings } else { @@ -40,7 +40,7 @@ export class GetSettingsHandler implements IPCInvokeHandler<'get-settings'> { * Otherwise, loads user settings from data directories. * If this process fails, default settings are used. */ - static async initSettings() { + async initSettings() { try { // Create data directories if they don't exists for (const path of [dataPath, tempPath, themesPath]) { @@ -67,7 +67,7 @@ export class GetSettingsHandler implements IPCInvokeHandler<'get-settings'> { /** * Handles the 'set-settings' event. */ -export class SetSettingsHandler implements IPCEmitHandler<'set-settings'> { +class SetSettingsHandler implements IPCEmitHandler<'set-settings'> { event: 'set-settings' = 'set-settings' /** @@ -85,4 +85,7 @@ export class SetSettingsHandler implements IPCEmitHandler<'set-settings'> { const settingsJSON = JSON.stringify(settings, undefined, 2) await writeFile(settingsPath, settingsJSON, 'utf8') } -} \ No newline at end of file +} + +export const getSettingsHandler = new GetSettingsHandler() +export const setSettingsHandler = new SetSettingsHandler() \ No newline at end of file diff --git a/src/electron/ipc/SongDetailsHandler.ipc.ts b/src/electron/ipc/SongDetailsHandler.ipc.ts index 8ae2f9c..248200b 100644 --- a/src/electron/ipc/SongDetailsHandler.ipc.ts +++ b/src/electron/ipc/SongDetailsHandler.ipc.ts @@ -5,7 +5,7 @@ import { VersionResult } from '../shared/interfaces/songDetails.interface' /** * Handles the 'song-details' event. */ -export default class SongDetailsHandler implements IPCInvokeHandler<'song-details'> { +class SongDetailsHandler implements IPCInvokeHandler<'song-details'> { event: 'song-details' = 'song-details' /** @@ -27,4 +27,6 @@ export default class SongDetailsHandler implements IPCInvokeHandler<'song-detail WHERE songID = ${songID}; ` } -} \ No newline at end of file +} + +export const songDetailsHandler = new SongDetailsHandler() \ No newline at end of file diff --git a/src/electron/ipc/download/ChartDownload.ts b/src/electron/ipc/download/ChartDownload.ts new file mode 100644 index 0000000..834cdee --- /dev/null +++ b/src/electron/ipc/download/ChartDownload.ts @@ -0,0 +1,272 @@ +import { FileDownloader } from './FileDownloader' +import { tempPath } from '../../shared/Paths' +import { join } from 'path' +import { FileExtractor } from './FileExtractor' +import { sanitizeFilename, interpolate } from '../../shared/UtilFunctions' +import { emitIPCEvent } from '../../main' +import { promisify } from 'util' +import { randomBytes as _randomBytes, createHash } from 'crypto' +import { mkdir as _mkdir } from 'fs' +import { ProgressType, NewDownload } from 'src/electron/shared/interfaces/download.interface' +import { downloadHandler } from './DownloadHandler' +import { googleTimer } from './GoogleTimer' + +const randomBytes = promisify(_randomBytes) +const mkdir = promisify(_mkdir) + +export class ChartDownload { + + // This changes if the user needs to click 'retry' or 'continue' + run: () => void | Promise = this.beginDownload + cancel: () => void + + isArchive: boolean + isGoogle: boolean + title: string + header: string + description: string + percent = 0 + type: ProgressType + + private fileKeys: string[] + private fileValues: string[] + allFilesProgress = 0 + individualFileProgressPortion: number + + constructor(public versionID: number, private data: NewDownload) { + // Only iterate over the keys in data.links that have link values (not hashes) + this.fileKeys = Object.keys(data.links).filter(link => data.links[link].includes('.')) + this.fileValues = Object.values(data.links).filter(value => value.includes('.')) + + this.isArchive = this.fileKeys.includes('archive') + this.isGoogle = !!this.fileValues.find(value => value.toLocaleLowerCase().includes('google')) + this.individualFileProgressPortion = 80 / this.fileKeys.length + } + + /** + * Changes this download to reflect that it is waiting in the download queue. + */ + setInQueue() { + this.title = `${this.data.avTagName} - ${this.data.artist}` + this.header = '' + this.description = 'Waiting for other downloads to finish...' + this.type = 'good' + this.cancel = () => { } + emitIPCEvent('download-updated', this) + } + + /** + * Starts the download process. + */ + async beginDownload() { + // Create a temporary folder to store the downloaded files + let chartPath: string + try { + chartPath = await this.createDownloadFolder() + } catch (e) { + this.run = this.beginDownload // Retry action + this.error('Access Error', e.message) + return + } + + // For each actual download link in , download the file to + for (let i = 0; i < this.fileKeys.length; i++) { + // INITIALIZE DOWNLOADER + const typeHash = createHash('md5').update(this.fileValues[i]).digest('hex') + // stores the expected hash value found in the download header + const downloader = new FileDownloader(this.fileValues[i], chartPath, this.data.links[typeHash]) + const downloadComplete = this.addDownloadEventListeners(downloader, i) + + // DOWNLOAD THE NEXT FILE + if (this.isGoogle) { // If this is a google download... + // Wait for google rate limit + this.header = `[${this.fileKeys[i]}] (file ${i + 1}/${this.fileKeys.length})` + googleTimer.onTimerUpdate((remainingTime, totalTime) => { + this.description = `Waiting for Google rate limit... (${remainingTime}s)` + this.percent = this.allFilesProgress + interpolate(remainingTime, totalTime, 0, 0, this.individualFileProgressPortion / 2) + this.type = 'good' + emitIPCEvent('download-updated', this) + }) + this.cancel = () => { + googleTimer.removeCallbacks() + this.onDownloadStop() + downloader.cancelDownload() + } + await new Promise(resolve => googleTimer.onTimerReady(resolve)) + } + + this.cancel = () => { + this.onDownloadStop() + downloader.cancelDownload() + } + downloader.beginDownload() + await downloadComplete // Wait for this download to finish + } + + // INITIALIZE FILE EXTRACTOR + const destinationFolderName = sanitizeFilename(`${this.data.artist} - ${this.data.avTagName} (${this.data.charter})`) + const extractor = new FileExtractor(chartPath, this.isArchive, destinationFolderName) + this.cancel = () => extractor.cancelExtract() // Make cancel button cancel the file extraction + this.addExtractorEventListeners(extractor) + + // EXTRACT THE DOWNLOADED ARCHIVE + extractor.beginExtract() + } + + /** + * Attempts to create a unique folder in Bridge's data paths. Throws an error if this fails. + */ + private async createDownloadFolder() { + let retryCount = 0 + let chartPath = '' + + while (retryCount < 5) { + chartPath = join(tempPath, `chart_${(await randomBytes(5)).toString('hex')}`) + try { + await mkdir(chartPath) + return chartPath + } catch (e) { + console.log(`Error creating folder [${chartPath}], retrying with a different folder...`) + retryCount++ + } + } + + throw new Error(`Bridge was unable to create a directory at [${chartPath}]`) + } + + /** + * Stops the download and displays an error message. + */ + private error(header: string, description: string) { + this.header = header + this.description = description + this.type = 'error' + this.onDownloadStop() + emitIPCEvent('download-updated', this) + } + + /** + * If this was a google download, allows a new google download to start. + */ + private onDownloadStop() { + if (this.isGoogle) { + downloadHandler.isGoogleDownloading = false + downloadHandler.updateQueue() + } + } + + /** + * Defines what happens in response to `FileDownloader` events. + */ + private addDownloadEventListeners(downloader: FileDownloader, fileIndex: number) { + let fileProgress = 0 + + downloader.on('request', () => { + this.description = 'Sending request...' + fileProgress = this.individualFileProgressPortion / 2 + this.percent = this.allFilesProgress + fileProgress + this.type = 'good' + emitIPCEvent('download-updated', this) + }) + + downloader.on('warning', (continueAnyway) => { + this.description = 'WARNING' + this.run = continueAnyway + this.type = 'warning' + this.onDownloadStop() + emitIPCEvent('download-updated', this) + }) + + let filesize = -1 + downloader.on('download', (filename, _filesize) => { + this.header = `[${filename}] (file ${fileIndex + 1}/${this.fileKeys.length})` + if (_filesize != undefined) { + filesize = _filesize + this.description = 'Downloading... (0%)' + } else { + this.description = 'Downloading... (0 MB)' + } + this.type = 'good' + emitIPCEvent('download-updated', this) + }) + + downloader.on('downloadProgress', (bytesDownloaded) => { + if (filesize != -1) { + this.description = `Downloading... (${Math.round(1000 * bytesDownloaded / filesize) / 10}%)` + fileProgress = interpolate(bytesDownloaded, 0, filesize, this.individualFileProgressPortion / 2, this.individualFileProgressPortion) + this.percent = this.allFilesProgress + fileProgress + } else { + this.description = `Downloading... (${Math.round(bytesDownloaded / 1e+5) / 10} MB)` + this.percent = this.allFilesProgress + fileProgress + } + this.type = 'fastUpdate' + emitIPCEvent('download-updated', this) + }) + + downloader.on('error', (error, retry) => { + this.header = error.header + this.description = error.body + this.type = 'error' + this.run = () => { retry() } + this.onDownloadStop() + emitIPCEvent('download-updated', this) + }) + + // Returns a promise that resolves when the download is finished + return new Promise(resolve => { + downloader.on('complete', () => { + this.allFilesProgress += this.individualFileProgressPortion + emitIPCEvent('download-updated', this) + resolve() + }) + }) + } + + /** + * Defines what happens in response to `FileExtractor` events. + */ + private addExtractorEventListeners(extractor: FileExtractor) { + let archive = '' + + extractor.on('extract', (filename) => { + archive = filename + this.header = `[${archive}]` + this.description = 'Extracting...' + this.type = 'good' + emitIPCEvent('download-updated', this) + }) + + extractor.on('extractProgress', (percent, filecount) => { + this.header = `[${archive}] (${filecount} file${filecount == 1 ? '' : 's'} extracted)` + this.description = `Extracting... (${percent}%)` + this.percent = interpolate(percent, 0, 100, 80, 95) + this.type = 'fastUpdate' + emitIPCEvent('download-updated', this) + }) + + extractor.on('transfer', (filepath) => { + this.header = 'Moving files to library folder...' + this.description = filepath + this.percent = 95 + this.type = 'good' + emitIPCEvent('download-updated', this) + }) + + extractor.on('error', (error, retry) => { + this.header = error.header + this.description = error.body + this.type = 'error' + this.run = retry + emitIPCEvent('download-updated', this) + }) + + extractor.on('complete', (filepath) => { + this.header = 'Download complete.' + this.description = filepath + this.percent = 100 + this.type = 'done' + this.onDownloadStop() + emitIPCEvent('download-updated', this) + }) + } +} \ No newline at end of file diff --git a/src/electron/ipc/download/DownloadHandler.ts b/src/electron/ipc/download/DownloadHandler.ts index 6f6c579..cb7ecd9 100644 --- a/src/electron/ipc/download/DownloadHandler.ts +++ b/src/electron/ipc/download/DownloadHandler.ts @@ -1,223 +1,52 @@ -import { FileDownloader } from './FileDownloader' import { IPCEmitHandler } from '../../shared/IPCHandler' -import { createHash, randomBytes as _randomBytes } from 'crypto' -import { tempPath } from '../../shared/Paths' -import { promisify } from 'util' -import { join } from 'path' -import { Download, DownloadProgress } from '../../shared/interfaces/download.interface' -import { emitIPCEvent } from '../../main' +import { randomBytes as _randomBytes } from 'crypto' +import { Download } from '../../shared/interfaces/download.interface' import { mkdir as _mkdir } from 'fs' -import { FileExtractor } from './FileExtractor' -import { sanitizeFilename, interpolate } from '../../shared/UtilFunctions' -import { GetSettingsHandler } from '../SettingsHandler.ipc' +import { ChartDownload } from './ChartDownload' -const randomBytes = promisify(_randomBytes) -const mkdir = promisify(_mkdir) - -export class DownloadHandler implements IPCEmitHandler<'download'> { +class DownloadHandler implements IPCEmitHandler<'download'> { event: '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 } } = {} - private allFilesProgress = 0 + downloads: { [versionID: number]: ChartDownload } = {} + downloadQueue: ChartDownload[] = [] + isGoogleDownloading = false // This is a lock controlled by only one ChartDownload at a time 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: () => { } } - } - // after this point, (data.action == add), so data.data should be defined - - // Initialize download object - const download: DownloadProgress = { - versionID: data.versionID, - title: `${data.data.avTagName} - ${data.data.artist}`, - header: '', - description: '', - percent: 0, - type: 'good' + if (data.action == 'add') { + this.downloads[data.versionID] = new ChartDownload(data.versionID, data.data) } - // Create a temporary folder to store the downloaded files - let chartPath: string - try { - chartPath = await this.createDownloadFolder() - } catch (e) { - download.header = 'Access Error' - download.description = e.message - download.type = 'error' - this.downloadCallbacks[data.versionID].retry = () => { this.handler(data) } - emitIPCEvent('download-updated', download) - return + const download = this.downloads[data.versionID] + + if (data.action == 'cancel') { + download.cancel() // Might change isGoogleDownloading and call updateQueue() + this.downloadQueue = this.downloadQueue.filter(download => download.versionID != data.versionID) + this.downloads[data.versionID] = undefined + } else { + download.setInQueue() + this.downloadQueue.push(download) // Add, retry, or continue will re-add the download to the queue + this.updateQueue() } - - // For each actual download link in , download the file to - // Only iterate over the keys in data.links that have link values (not hashes) - const fileKeys = Object.keys(data.data.links).filter(link => data.data.links[link].includes('.')) - for (let i = 0; i < fileKeys.length; i++) { - // INITIALIZE DOWNLOADER - // stores the expected hash value found in the download header - const typeHash = createHash('md5').update(data.data.links[fileKeys[i]]).digest('hex') - const downloader = new FileDownloader(data.data.links[fileKeys[i]], chartPath, fileKeys.length, data.data.links[typeHash]) - this.downloadCallbacks[data.versionID].cancel = () => downloader.cancelDownload() // Make cancel button cancel this download - const downloadComplete = this.addDownloadEventListeners(downloader, download, fileKeys, i) - - // DOWNLOAD THE NEXT FILE - downloader.beginDownload() - await downloadComplete // Wait for this download to finish before downloading the next file - } - - // INITIALIZE FILE EXTRACTOR - 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() // Make cancel button cancel the file extraction - this.addExtractorEventListeners(extractor, download) - - // EXTRACT THE DOWNLOADED ARCHIVE - extractor.beginExtract() } - private async createDownloadFolder() { - let retryCount = 0 - while (true) { - const randomString = (await randomBytes(5)).toString('hex') - const chartPath = join(tempPath, `chart_${randomString}`) - try { - await mkdir(chartPath) - return chartPath - } catch (e) { - if (retryCount > 5) { - throw new Error(`Bridge was unable to create a directory at [${chartPath}]`) - } else { - console.log(`Error creating folder [${chartPath}], retrying with a different folder...`) - retryCount++ - } + /** + * Called when at least one download in the queue can potentially be started. + */ + updateQueue() { + this.downloadQueue.sort((cd1: ChartDownload, cd2: ChartDownload) => { + const value1 = (cd1.isGoogle ? 100 : 0) + (99 - cd1.allFilesProgress) + const value2 = (cd2.isGoogle ? 100 : 0) + (99 - cd2.allFilesProgress) + return value1 - value2 // Sorts in the order to get the most downloads completed early + }) + + while (this.downloadQueue[0] != undefined && !(this.downloadQueue[0].isGoogle && this.isGoogleDownloading)) { + const nextDownload = this.downloadQueue.shift() + nextDownload.run() + if (nextDownload.isGoogle) { + this.isGoogleDownloading = true } } } +} - private addDownloadEventListeners(downloader: FileDownloader, download: DownloadProgress, fileKeys: string[], i: number) { - const individualFileProgressPortion = 80 / fileKeys.length - let fileProgress = 0 - - downloader.on('wait', (waitTime) => { - download.header = `[${fileKeys[i]}] (file ${i + 1}/${fileKeys.length})` - download.description = `Waiting for Google rate limit... (${waitTime}s)` - download.type = 'wait' - }) - - downloader.on('waitProgress', (secondsRemaining, initialWaitTime) => { - download.description = `Waiting for Google rate limit... (${secondsRemaining}s)` - fileProgress = interpolate(secondsRemaining, initialWaitTime, 0, 0, individualFileProgressPortion / 2) - console.log(`${initialWaitTime} ... ${secondsRemaining} ... 0`) - download.percent = this.allFilesProgress + fileProgress - download.type = 'wait' - emitIPCEvent('download-updated', download) - }) - - downloader.on('request', () => { - download.description = `Sending request...` - fileProgress = individualFileProgressPortion / 2 - download.percent = this.allFilesProgress + fileProgress - download.type = 'good' - emitIPCEvent('download-updated', download) - }) - - downloader.on('warning', (continueAnyway) => { - download.description = 'WARNING' - this.downloadCallbacks[download.versionID].continue = continueAnyway - download.type = 'warning' - emitIPCEvent('download-updated', download) - }) - - let filesize = -1 - downloader.on('download', (filename, _filesize) => { - download.header = `[${filename}] (file ${i + 1}/${fileKeys.length})` - if (_filesize != undefined) { - filesize = _filesize - download.description = `Downloading... (0%)` - } else { - download.description = `Downloading... (0 MB)` - } - download.type = 'good' - emitIPCEvent('download-updated', download) - }) - - downloader.on('downloadProgress', (bytesDownloaded) => { - if (filesize != -1) { - download.description = `Downloading... (${Math.round(1000 * bytesDownloaded / filesize) / 10}%)` - fileProgress = interpolate(bytesDownloaded, 0, filesize, individualFileProgressPortion / 2, individualFileProgressPortion) - download.percent = this.allFilesProgress + fileProgress - } else { - download.description = `Downloading... (${Math.round(bytesDownloaded / 1e+5) / 10} MB)` - download.percent = this.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[download.versionID].retry = retry - emitIPCEvent('download-updated', download) - }) - - // Returns a promise that resolves when the download is finished - return new Promise(resolve => { - downloader.on('complete', () => { - emitIPCEvent('download-updated', download) - this.allFilesProgress += individualFileProgressPortion - resolve() - }) - }) - } - - private addExtractorEventListeners(extractor: FileExtractor, download: DownloadProgress) { - let archive = '' - - extractor.on('extract', (filename) => { - archive = filename - download.header = `[${archive}]` - download.description = `Extracting...` - download.type = 'good' - emitIPCEvent('download-updated', download) - }) - - extractor.on('extractProgress', (percent, filecount) => { - 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) - }) - - extractor.on('transfer', (filepath) => { - download.header = `Moving files to library folder...` - download.description = filepath - download.percent = 95 - download.type = 'good' - emitIPCEvent('download-updated', download) - }) - - extractor.on('complete', (filepath) => { - download.header = `Download complete.` - download.description = filepath - download.percent = 100 - download.type = 'done' - emitIPCEvent('download-updated', download) - }) - - extractor.on('error', (error, retry) => { - download.header = error.header - download.description = error.body - download.type = 'error' - this.downloadCallbacks[download.versionID].retry = retry - emitIPCEvent('download-updated', download) - }) - } -} \ No newline at end of file +export const downloadHandler = new DownloadHandler() \ No newline at end of file diff --git a/src/electron/ipc/download/FileDownloader.ts b/src/electron/ipc/download/FileDownloader.ts index d2b0236..02ca915 100644 --- a/src/electron/ipc/download/FileDownloader.ts +++ b/src/electron/ipc/download/FileDownloader.ts @@ -2,150 +2,67 @@ import { generateUUID, sanitizeFilename } from '../../shared/UtilFunctions' import * as fs from 'fs' import * as path from 'path' import * as needle from 'needle' -import { GetSettingsHandler } from '../SettingsHandler.ipc' +// TODO: replace needle with got (for cancel() method) (if before-headers event is possible?) +import { getSettingsHandler } from '../SettingsHandler.ipc' +const getSettings = getSettingsHandler.getSettings type EventCallback = { - 'wait': (waitTime: number) => void - 'waitProgress': (secondsRemaining: number, initialWaitTime: number) => void 'request': () => void 'warning': (continueAnyway: () => void) => void 'download': (filename: string, filesize?: number) => void 'downloadProgress': (bytesDownloaded: number) => void - 'complete': () => void 'error': (error: DownloadError, retry: () => void) => void + 'complete': () => void } type Callbacks = { [E in keyof EventCallback]: EventCallback[E] } export type DownloadError = { header: string, body: string } +/** + * Downloads a file from `url` to `destinationFolder` and verifies that its hash matches `expectedHash`. + * Will handle google drive virus scan warnings. Provides event listeners for download progress. + * On error, provides the ability to retry. + */ export class FileDownloader { private readonly RETRY_MAX = 2 - private static fileQueue: { // Stores the overall order that files should be downloaded - destinationFolder: string - fileCount: number - clock?: () => void - }[] - private static waitTime: number private callbacks = {} as Callbacks private retryCount: number private wasCanceled = false - constructor(private url: string, private destinationFolder: string, private numFiles: number, private expectedHash?: string) { - if (FileDownloader.fileQueue == undefined) { - // First initialization - FileDownloader.fileQueue = [] - let lastRateLimitDelay = GetSettingsHandler.getSettings().rateLimitDelay - FileDownloader.waitTime = 0 - setInterval(() => { - if (FileDownloader.waitTime > 0) { // Update current countdown if this setting changes - let newRateLimitDelay = GetSettingsHandler.getSettings().rateLimitDelay - if (newRateLimitDelay != lastRateLimitDelay) { - FileDownloader.waitTime -= Math.min(lastRateLimitDelay - newRateLimitDelay, FileDownloader.waitTime - 1) - lastRateLimitDelay = newRateLimitDelay - } - FileDownloader.waitTime-- - } - FileDownloader.fileQueue.forEach(download => { if (download.clock != undefined) download.clock() }) - if (FileDownloader.waitTime <= 0 && FileDownloader.fileQueue.length != 0) { - FileDownloader.waitTime = GetSettingsHandler.getSettings().rateLimitDelay - } - }, 1000) - } - } + /** + * @param url The download link. + * @param destinationFolder The path to where this file should be stored. + * @param expectedHash The hash header value that is expected for this file. + */ + constructor(private url: string, private destinationFolder: string, private expectedHash?: string) { } /** - * Calls when fires. - * @param event The event to listen for. - * @param callback The function to be called when the event fires. + * Calls `callback` when `event` fires. */ on(event: E, callback: EventCallback[E]) { this.callbacks[event] = callback } /** - * Wait RATE_LIMIT_DELAY seconds between each download, - * then download the file. + * Download the file. */ beginDownload() { // Check that the library folder has been specified - if (GetSettingsHandler.getSettings().libraryPath == undefined) { + if (getSettings().libraryPath == undefined) { this.callbacks.error({ header: 'Library folder not specified', body: 'Please go to the settings to set your library folder.' }, () => this.beginDownload()) return } - - // Skip the fileQueue if the file is not from Google - if (!this.url.toLocaleLowerCase().includes('google')) { - this.requestDownload() - return - } - // The starting point of a progress bar should be recalculated each clock cycle - // It will be what it would have been if rateLimitDelay was that value the entire time - this.initWaitTime() - // This is the number of seconds that had elapsed since the last file download (at the time of starting this download) - const initialTimeSinceLastDownload = GetSettingsHandler.getSettings().rateLimitDelay - FileDownloader.waitTime - const initialQueueCount = this.getQueueCount() - let waitTime = this.getWaitTime(initialTimeSinceLastDownload, initialQueueCount) - this.callbacks.wait(waitTime) - if (waitTime == 0) { - FileDownloader.waitTime = GetSettingsHandler.getSettings().rateLimitDelay - this.requestDownload() - return - } - - const fileQueue = FileDownloader.fileQueue.find(queue => queue.destinationFolder == this.destinationFolder) - fileQueue.clock = () => { - if (this.wasCanceled) { this.removeFromQueue(); return } // CANCEL POINT - waitTime = this.getWaitTime(GetSettingsHandler.getSettings().rateLimitDelay - FileDownloader.waitTime, this.getQueueCount()) - if (waitTime == 0) { - this.requestDownload() - fileQueue.clock = undefined - } - this.callbacks.waitProgress(waitTime, this.getWaitTime(initialTimeSinceLastDownload, this.getQueueCount())) - } - } - - private getWaitTime(timeSinceLastDownload: number, queueCount: number) { - const rateLimitDelay = GetSettingsHandler.getSettings().rateLimitDelay - return (queueCount * rateLimitDelay) + Math.max(0, rateLimitDelay - timeSinceLastDownload) - } - - private initWaitTime() { - this.retryCount = 0 - const entry = FileDownloader.fileQueue.find(entry => entry.destinationFolder == this.destinationFolder) - if (entry == undefined) { - // Note: assumes that either all the chart files are from Google, or none of the chart files are from Google - FileDownloader.fileQueue.push({ destinationFolder: this.destinationFolder, fileCount: this.numFiles }) - } + + this.requestDownload() } /** - * Returns the number of files in front of this file in the fileQueue - */ - private getQueueCount() { - let fileCount = 0 - for (let entry of FileDownloader.fileQueue) { - if (entry.destinationFolder != this.destinationFolder) { - fileCount += entry.fileCount - } else { - break - } - } - - return fileCount - } - - private removeFromQueue() { - const index = FileDownloader.fileQueue.findIndex(entry => entry.destinationFolder == this.destinationFolder) - FileDownloader.fileQueue.splice(index, 1) - } - - /** - * Sends a request to download the file at . + * Sends a request to download the file at `this.url`. * @param cookieHeader the "cookie=" header to include this request. */ private requestDownload(cookieHeader?: string) { - if (this.wasCanceled) { this.removeFromQueue(); return } // CANCEL POINT + if (this.wasCanceled) { return } // CANCEL POINT this.callbacks.request() let uuid = generateUUID() const req = needle.get(this.url, { @@ -176,7 +93,7 @@ export class FileDownloader { }) req.on('header', (statusCode, headers: Headers) => { - if (this.wasCanceled) { this.removeFromQueue(); return } // CANCEL POINT + if (this.wasCanceled) { return } // CANCEL POINT if (statusCode != 200) { this.callbacks.error({ header: 'Connection failed', body: `Server returned status code: ${statusCode}` }, () => this.beginDownload()) return @@ -187,23 +104,21 @@ export class FileDownloader { this.handleHTMLResponse(req, headers['set-cookie']) } else { const fileName = this.getDownloadFileName(headers) - const downloadHash = this.getDownloadHash(headers) - if (this.expectedHash !== undefined && downloadHash !== this.expectedHash) { + this.handleDownloadResponse(req, fileName, headers['content-length']) + + if (this.expectedHash !== undefined && this.getDownloadHash(headers) !== this.expectedHash) { req.pause() this.callbacks.warning(() => { - this.handleDownloadResponse(req, fileName, headers['content-length']) req.resume() }) - } else { - this.handleDownloadResponse(req, fileName, headers['content-length']) } } }) } /** - * A Google Drive HTML response to a download request means this is the "file too large to scan for viruses" warning. - * This function sends the request that results from clicking "download anyway". + * A Google Drive HTML response to a download request usually means this is the "file too large to scan for viruses" warning. + * This function sends the request that results from clicking "download anyway", or throws an error if it can't be found. * @param req The download request. * @param cookieHeader The "cookie=" header of this request. */ @@ -232,10 +147,10 @@ export class FileDownloader { } /** - * Pipes the data from a download response to and extracts it if is true. + * Pipes the data from a download response to `fileName`. * @param req The download request. * @param fileName The name of the output file. - * @param contentLength The number of bytes to be downloaded. + * @param contentLength The number of bytes to be downloaded. If undefined, download progress is indicated by MB, not %. */ private handleDownloadResponse(req: NodeJS.ReadableStream, fileName: string, contentLength?: number) { this.callbacks.download(fileName, contentLength) @@ -252,18 +167,13 @@ export class FileDownloader { }) req.on('end', () => { + if (this.wasCanceled) { return } // CANCEL POINT this.callbacks.complete() - const index = FileDownloader.fileQueue.findIndex(entry => entry.destinationFolder == this.destinationFolder) - FileDownloader.fileQueue[index].fileCount-- - if (FileDownloader.fileQueue[index].fileCount == 0) { - FileDownloader.fileQueue.splice(index, 1) - } }) } /** - * Extracts the downloaded file's filename from or , depending on the file's host server. - * @param url The URL of this request. + * Extracts the downloaded file's filename from `headers` or `this.url`, depending on the file's host server. * @param headers The response headers for this request. */ private getDownloadFileName(headers: Headers) { @@ -284,14 +194,13 @@ export class FileDownloader { } /** - * Extracts the downloaded file's hash from , depending on the file's host server. - * @param url The URL of the request. + * Extracts the downloaded file's hash from `headers`, depending on the file's host server. * @param headers The response headers for this request. */ private getDownloadHash(headers: Headers): string { if (headers['server'] && headers['server'] == 'cloudflare' || this.url.startsWith('https://public.fightthe.pw/')) { // Cloudflare and Chorus specific jazz - return String(headers['content-length']) // No good hash is provided in the header, so this is the next best thing + return String(headers['content-length']) // No actual hash is provided in the header, so this is the next best thing } else { // GDrive specific jazz return headers['x-goog-hash'] diff --git a/src/electron/ipc/download/FileExtractor.ts b/src/electron/ipc/download/FileExtractor.ts index c2c0ca3..77314c7 100644 --- a/src/electron/ipc/download/FileExtractor.ts +++ b/src/electron/ipc/download/FileExtractor.ts @@ -6,7 +6,8 @@ import { join, extname } from 'path' import * as node7z from 'node-7z' import * as zipBin from '7zip-bin' import * as unrarjs from 'node-unrar-js' -import { GetSettingsHandler } from '../SettingsHandler.ipc' +import { getSettingsHandler } from '../SettingsHandler.ipc' +const getSettings = getSettingsHandler.getSettings const readdir = promisify(_readdir) const unlink = promisify(_unlink) @@ -33,9 +34,7 @@ export class FileExtractor { constructor(private sourceFolder: string, private isArchive: boolean, private destinationFolderName: string) { } /** - * Calls when fires. - * @param event The event to listen for. - * @param callback The function to be called when the event fires. + * Calls `callback` when `event` fires. */ on(event: E, callback: EventCallback[E]) { this.callbacks[event] = callback @@ -45,7 +44,7 @@ export class FileExtractor { * Starts the chart extraction process. */ async beginExtract() { - this.libraryFolder = (await GetSettingsHandler.getSettings()).libraryPath + this.libraryFolder = getSettings().libraryPath const files = await readdir(this.sourceFolder) if (this.isArchive) { this.extract(files[0]) @@ -55,8 +54,7 @@ export class FileExtractor { } /** - * Extracts the file at to . - * @param filename The name of the archive file. + * Extracts the file at `filename` to `this.sourceFolder`. */ private extract(filename: string) { if (this.wasCanceled) { return } // CANCEL POINT @@ -64,6 +62,7 @@ export class FileExtractor { const source = join(this.sourceFolder, filename) if (extname(filename) == '.rar') { + // Use node-unrar-js to extract the archive try { let extractor = unrarjs.createExtractorFromFile(source, this.sourceFolder) @@ -73,7 +72,9 @@ export class FileExtractor { return } this.transfer(source) + } else { + // Use node-7z to extract the archive const stream = node7z.extractFull(source, this.sourceFolder, { $progress: true, $bin: zipBin.path7za }) @@ -88,11 +89,12 @@ export class FileExtractor { stream.on('end', () => { this.transfer(source) }) + } } /** - * Deletes the archive at , then transfers the extracted chart to . + * Deletes the archive at `archiveFilepath`, then transfers the extracted chart to `this.libraryFolder`. */ private async transfer(archiveFilepath?: string) { if (this.wasCanceled) { return } // CANCEL POINT @@ -109,33 +111,40 @@ export class FileExtractor { // Delete archive if (archiveFilepath != undefined) { - await unlink(archiveFilepath) + try { + await unlink(archiveFilepath) + } catch (e) { + if (e.code != 'ENOENT') { + throw new Error(`Could not delete the archive file at [${archiveFilepath}]`) + } + } } // Check if it extracted to a folder instead of a list of files - let files = await readdir(this.sourceFolder) - const isFolderArchive = (files.length < 2 && !(await lstat(join(this.sourceFolder, files[0]))).isFile()) + let sourceFolder = this.sourceFolder + let files = await readdir(sourceFolder) + const isFolderArchive = (files.length < 2 && !(await lstat(join(sourceFolder, files[0]))).isFile()) if (isFolderArchive) { - this.sourceFolder = join(this.sourceFolder, files[0]) - files = await readdir(this.sourceFolder) + sourceFolder = join(sourceFolder, files[0]) + files = await readdir(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)) - await unlink(join(this.sourceFolder, file)) + await copyFile(join(sourceFolder, file), join(destinationFolder, file)) + await unlink(join(sourceFolder, file)) } // Delete the temporary folders - await rmdir(this.sourceFolder) + await rmdir(sourceFolder) if (isFolderArchive) { - await rmdir(join(this.sourceFolder, '..')) + await rmdir(join(sourceFolder, '..')) } this.callbacks.complete(destinationFolder) } catch (e) { - this.callbacks.error({ header: 'Transfer Failed', body: `Unable to transfer downloaded files to the library folder: ${e.name}` }, undefined) + this.callbacks.error({ header: 'Transfer Failed', body: `Unable to transfer downloaded files to the library folder: ${e.name}` }, () => this.transfer(archiveFilepath)) } } diff --git a/src/electron/ipc/download/GoogleTimer.ts b/src/electron/ipc/download/GoogleTimer.ts new file mode 100644 index 0000000..efd3b3b --- /dev/null +++ b/src/electron/ipc/download/GoogleTimer.ts @@ -0,0 +1,50 @@ +import { getSettingsHandler } from '../SettingsHandler.ipc' +const getSettings = getSettingsHandler.getSettings + +class GoogleTimer { + + private rateLimitCounter = Infinity + private onReadyCallback: () => void + private updateCallback: (remainingTime: number, totalTime: number) => void + + constructor() { + setInterval(() => { + this.rateLimitCounter++ + if (this.isReady() && this.onReadyCallback != undefined) { + this.activateTimerReady() + } else if (this.updateCallback != undefined) { + const delay = getSettings().rateLimitDelay + this.updateCallback(delay - this.rateLimitCounter, delay) + } + }, 1000) + } + + onTimerReady(callback: () => void) { + this.onReadyCallback = callback + if (this.isReady()) { + this.activateTimerReady() + } + } + + onTimerUpdate(callback: (remainingTime: number, totalTime: number) => void) { + this.updateCallback = callback + } + + removeCallbacks() { + this.onReadyCallback = undefined + this.updateCallback = undefined + } + + private isReady() { + return this.rateLimitCounter > getSettings().rateLimitDelay + } + + private activateTimerReady() { + this.rateLimitCounter = 0 + const onReadyCallback = this.onReadyCallback + this.removeCallbacks() + onReadyCallback() + } +} + +export const googleTimer = new GoogleTimer() \ No newline at end of file diff --git a/src/electron/main.ts b/src/electron/main.ts index d7da266..166dcc1 100644 --- a/src/electron/main.ts +++ b/src/electron/main.ts @@ -5,7 +5,7 @@ import * as url from 'url' // IPC Handlers import { getIPCInvokeHandlers, getIPCEmitHandlers, IPCEmitEvents } from './shared/IPCHandler' import Database from './shared/Database' -import { GetSettingsHandler } from './ipc/SettingsHandler.ipc' +import { getSettingsHandler } from './ipc/SettingsHandler.ipc' let mainWindow: BrowserWindow const args = process.argv.slice(1) @@ -15,7 +15,7 @@ restrictToSingleInstance() handleOSXWindowClosed() app.on('ready', () => { // Load settings from file before the window is created - GetSettingsHandler.initSettings().then(createBridgeWindow) + getSettingsHandler.initSettings().then(createBridgeWindow) }) /** diff --git a/src/electron/shared/ElectronUtilFunctions.ts b/src/electron/shared/ElectronUtilFunctions.ts index 6aa5bcf..3a76aa4 100644 --- a/src/electron/shared/ElectronUtilFunctions.ts +++ b/src/electron/shared/ElectronUtilFunctions.ts @@ -1,10 +1,10 @@ import { basename } from 'path' -import { GetSettingsHandler } from '../ipc/SettingsHandler.ipc' +import { getSettingsHandler } from '../ipc/SettingsHandler.ipc' /** * @returns The relative filepath from the library folder to `absoluteFilepath`. */ export function getRelativeFilepath(absoluteFilepath: string) { - const settings = GetSettingsHandler.getSettings() + const settings = getSettingsHandler.getSettings() return basename(settings.libraryPath) + absoluteFilepath.substring(settings.libraryPath.length) } \ No newline at end of file diff --git a/src/electron/shared/IPCHandler.ts b/src/electron/shared/IPCHandler.ts index 19cc852..085da33 100644 --- a/src/electron/shared/IPCHandler.ts +++ b/src/electron/shared/IPCHandler.ts @@ -1,13 +1,13 @@ import { SongSearch, SongResult } from './interfaces/search.interface' import { VersionResult, AlbumArtResult } from './interfaces/songDetails.interface' -import SearchHandler from '../ipc/SearchHandler.ipc' -import SongDetailsHandler from '../ipc/SongDetailsHandler.ipc' -import AlbumArtHandler from '../ipc/AlbumArtHandler.ipc' +import { searchHandler } from '../ipc/SearchHandler.ipc' +import { songDetailsHandler } from '../ipc/SongDetailsHandler.ipc' +import { albumArtHandler } from '../ipc/AlbumArtHandler.ipc' import { Download, DownloadProgress } from './interfaces/download.interface' -import { DownloadHandler } from '../ipc/download/DownloadHandler' +import { downloadHandler } from '../ipc/download/DownloadHandler' import { Settings } from './Settings' -import BatchSongDetailsHandler from '../ipc/BatchSongDetailsHandler.ipc' -import { GetSettingsHandler, SetSettingsHandler } from '../ipc/SettingsHandler.ipc' +import { batchSongDetailsHandler } from '../ipc/BatchSongDetailsHandler.ipc' +import { getSettingsHandler, setSettingsHandler } from '../ipc/SettingsHandler.ipc' /** * To add a new IPC listener: @@ -19,11 +19,11 @@ import { GetSettingsHandler, SetSettingsHandler } from '../ipc/SettingsHandler.i export function getIPCInvokeHandlers(): IPCInvokeHandler[] { return [ - new GetSettingsHandler(), - new SearchHandler(), - new SongDetailsHandler(), - new BatchSongDetailsHandler(), - new AlbumArtHandler() + getSettingsHandler, + searchHandler, + songDetailsHandler, + batchSongDetailsHandler, + albumArtHandler ] } @@ -64,8 +64,8 @@ export interface IPCInvokeHandler { export function getIPCEmitHandlers(): IPCEmitHandler[] { return [ - new DownloadHandler(), - new SetSettingsHandler() + downloadHandler, + setSettingsHandler ] } diff --git a/src/electron/shared/interfaces/download.interface.ts b/src/electron/shared/interfaces/download.interface.ts index 1e795ff..ae4cf48 100644 --- a/src/electron/shared/interfaces/download.interface.ts +++ b/src/electron/shared/interfaces/download.interface.ts @@ -26,5 +26,11 @@ export interface DownloadProgress { header: string description: string percent: number - type: 'good' | 'warning' | 'error' | 'cancel' | 'done' | 'wait' -} \ No newline at end of file + type: ProgressType +} + +export type ProgressType = 'good' | 'warning' | 'error' | 'cancel' | 'done' | 'fastUpdate' +// export function downloadSorter(p1: DownloadProgress, p2: DownloadProgress) { +// return 0 +// // return p1 - p2 // negative if p1 < p2 +// } \ No newline at end of file