Various refactoring

This commit is contained in:
Geomitron
2020-03-03 21:48:41 -05:00
parent 442736205e
commit 4ebf2db650
34 changed files with 503 additions and 329 deletions

View File

@@ -1,19 +1,23 @@
import { NgModule } from '@angular/core' import { NgModule } from '@angular/core'
import { Routes, RouterModule } from '@angular/router' import { Routes, RouterModule, RouteReuseStrategy } from '@angular/router'
import { BrowseComponent } from './components/browse/browse.component' import { BrowseComponent } from './components/browse/browse.component'
import { SettingsComponent } from './components/settings/settings.component' import { SettingsComponent } from './components/settings/settings.component'
import { TabPersistStrategy } from './core/tab-persist.strategy'
// TODO: replace these with the correct components
const routes: Routes = [ const routes: Routes = [
{ path: 'browse', component: BrowseComponent }, { path: 'browse', component: BrowseComponent, data: { shouldReuse: true } },
{ path: 'library', component: BrowseComponent }, { path: 'library', redirectTo: '/browse' },
{ path: 'settings', component: SettingsComponent }, { path: 'settings', component: SettingsComponent, data: { shouldReuse: true } },
{ path: 'about', component: BrowseComponent }, // TODO: replace these with the correct components { path: 'about', redirectTo: '/browse' },
{ path: '**', redirectTo: '/browse'} { path: '**', redirectTo: '/browse' }
] ]
@NgModule({ @NgModule({
imports: [RouterModule.forRoot(routes)], imports: [RouterModule.forRoot(routes)],
exports: [RouterModule] exports: [RouterModule],
providers: [
{ provide: RouteReuseStrategy, useClass: TabPersistStrategy },
]
}) })
export class AppRoutingModule { } export class AppRoutingModule { }

View File

@@ -1,4 +1,4 @@
import { Component, OnInit } from '@angular/core' import { Component } from '@angular/core'
import { SettingsService } from './core/services/settings.service' import { SettingsService } from './core/services/settings.service'
@Component({ @Component({
@@ -6,12 +6,7 @@ import { SettingsService } from './core/services/settings.service'
templateUrl: './app.component.html', templateUrl: './app.component.html',
styles: [] styles: []
}) })
export class AppComponent implements OnInit { export class AppComponent {
constructor(private settingsService: SettingsService) { } constructor(private settingsService: SettingsService) { }
ngOnInit() {
// Load settings
this.settingsService.getSettings()
}
} }

View File

@@ -4,15 +4,15 @@ import { NgModule } from '@angular/core'
import { AppRoutingModule } from './app-routing.module' import { AppRoutingModule } from './app-routing.module'
import { AppComponent } from './app.component' import { AppComponent } from './app.component'
import { ToolbarComponent } from './components/toolbar/toolbar.component' import { ToolbarComponent } from './components/toolbar/toolbar.component'
import { BrowseComponent } from './components/browse/browse.component'; import { BrowseComponent } from './components/browse/browse.component'
import { SearchBarComponent } from './components/browse/search-bar/search-bar.component'; import { SearchBarComponent } from './components/browse/search-bar/search-bar.component'
import { StatusBarComponent } from './components/browse/status-bar/status-bar.component'; import { StatusBarComponent } from './components/browse/status-bar/status-bar.component'
import { ResultTableComponent } from './components/browse/result-table/result-table.component'; import { ResultTableComponent } from './components/browse/result-table/result-table.component'
import { ChartSidebarComponent } from './components/browse/chart-sidebar/chart-sidebar.component'; import { ChartSidebarComponent } from './components/browse/chart-sidebar/chart-sidebar.component'
import { ResultTableRowComponent } from './components/browse/result-table/result-table-row/result-table-row.component'; import { ResultTableRowComponent } from './components/browse/result-table/result-table-row/result-table-row.component'
import { DownloadsModalComponent } from './components/browse/status-bar/downloads-modal/downloads-modal.component'; import { DownloadsModalComponent } from './components/browse/status-bar/downloads-modal/downloads-modal.component'
import { ProgressBarDirective } from './core/directives/progress-bar.directive'; import { ProgressBarDirective } from './core/directives/progress-bar.directive'
import { CheckboxDirective } from './core/directives/checkbox.directive'; import { CheckboxDirective } from './core/directives/checkbox.directive'
import { SettingsComponent } from './components/settings/settings.component' import { SettingsComponent } from './components/settings/settings.component'
@NgModule({ @NgModule({
@@ -34,7 +34,6 @@ import { SettingsComponent } from './components/settings/settings.component'
BrowserModule, BrowserModule,
AppRoutingModule AppRoutingModule
], ],
providers: [],
bootstrap: [AppComponent] bootstrap: [AppComponent]
}) })
export class AppModule { } export class AppModule { }

View File

@@ -20,7 +20,7 @@ export class BrowseComponent {
onResultsUpdated(results: SongResult[]) { onResultsUpdated(results: SongResult[]) {
this.resultTable.results = results this.resultTable.results = results
this.resultTable.onNewSearch() this.resultTable.onNewSearch()
this.resultTable.checkAll this.resultTable.checkAll(false)
this.chartSidebar.selectedVersion = undefined this.chartSidebar.selectedVersion = undefined
this.statusBar.resultCount = results.length this.statusBar.resultCount = results.length
this.statusBar.selectedResults = [] this.statusBar.selectedResults = []

View File

@@ -80,7 +80,9 @@ export class ChartSidebarComponent {
return 'Unknown' return 'Unknown'
} }
let seconds = Math.round(this.selectedVersion.song_length / 1000) let seconds = Math.round(this.selectedVersion.song_length / 1000)
if (seconds < 60) { return `${seconds} second${seconds == 1 ? '' : 's'}` } if (seconds < 60) {
return `${seconds} second${seconds == 1 ? '' : 's'}`
}
let minutes = Math.floor(seconds / 60) let minutes = Math.floor(seconds / 60)
let hours = 0 let hours = 0
while (minutes > 59) { while (minutes > 59) {
@@ -123,11 +125,11 @@ export class ChartSidebarComponent {
onDownloadClicked() { onDownloadClicked() {
this.downloadService.addDownload( this.downloadService.addDownload(
this.selectedVersion.versionID, { this.selectedVersion.versionID, {
avTagName: this.selectedVersion.avTagName, avTagName: this.selectedVersion.avTagName,
artist: this.songResult.artist, artist: this.songResult.artist,
charter: this.selectedVersion.charters, charter: this.selectedVersion.charters,
links: JSON.parse(this.selectedVersion.downloadLink) links: JSON.parse(this.selectedVersion.downloadLink)
}) })
} }
getVersions() { getVersions() {

View File

@@ -6,18 +6,18 @@
<i class="search icon"></i> <i class="search icon"></i>
</div> </div>
</div> </div>
<div #typeDropdown class="ui item dropdown"> <div #typeDropdown class="ui item dropdown">
Type <i class="dropdown icon"></i> Type <i class="dropdown icon"></i>
<div class="menu"> <div class="menu">
<!-- TODO: revise what items are displayed --> <!-- TODO: revise what items are displayed -->
<a class="item">Any</a> <a class="item">Any</a>
<a class="item">Name</a> <a class="item">Name</a>
<a class="item">Artist</a> <a class="item">Artist</a>
<a class="item">Album</a> <a class="item">Album</a>
<a class="item">Genre</a> <a class="item">Genre</a>
<a class="item">Year</a> <a class="item">Year</a>
<a class="item">Charter</a> <a class="item">Charter</a>
</div> </div>
</div> </div>
<!-- <div class="item right"> <!-- <div class="item right">
<button class="ui positive disabled button">Bulk Download</button> <button class="ui positive disabled button">Bulk Download</button>

View File

@@ -44,7 +44,7 @@ export class DownloadsModalComponent {
} }
getBackgroundColor(download: DownloadProgress) { getBackgroundColor(download: DownloadProgress) {
switch(download.type) { switch (download.type) {
case 'good': return 'unset' case 'good': return 'unset'
case 'done': return 'unset' case 'done': return 'unset'
case 'warning': return 'yellow' case 'warning': return 'yellow'

View File

@@ -60,15 +60,15 @@ export class StatusBarComponent {
const downloadSong = this.selectedResults.find(song => song.id == downloadVersion.songID) const downloadSong = this.selectedResults.find(song => song.id == downloadVersion.songID)
this.downloadService.addDownload( this.downloadService.addDownload(
downloadVersion.versionID, { downloadVersion.versionID, {
avTagName: downloadVersion.avTagName, avTagName: downloadVersion.avTagName,
artist: downloadSong.artist, artist: downloadSong.artist,
charter: downloadVersion.charters, charter: downloadVersion.charters,
links: JSON.parse(downloadVersion.downloadLink) links: JSON.parse(downloadVersion.downloadLink)
}) })
} }
} else { } else {
$('#selectedModal').modal('show') $('#selectedModal').modal('show')
//[download all charts for each song] [deselect these songs] [X] // [download all charts for each song] [deselect these songs] [X]
} }
} }
@@ -78,11 +78,11 @@ export class StatusBarComponent {
const downloadSong = this.selectedResults.find(song => song.id == version.songID) const downloadSong = this.selectedResults.find(song => song.id == version.songID)
this.downloadService.addDownload( this.downloadService.addDownload(
version.versionID, { version.versionID, {
avTagName: version.avTagName, avTagName: version.avTagName,
artist: downloadSong.artist, artist: downloadSong.artist,
charter: version.charters, charter: version.charters,
links: JSON.parse(version.downloadLink) links: JSON.parse(version.downloadLink)
}) })
} }
} }

View File

@@ -26,7 +26,7 @@ export class SettingsComponent implements OnInit, AfterViewInit {
} }
}) })
} }
async clearCache() { async clearCache() {
this.cacheSize = 'Please wait...' this.cacheSize = 'Please wait...'
await this.settingsService.clearCache() await this.settingsService.clearCache()
@@ -46,7 +46,7 @@ export class SettingsComponent implements OnInit, AfterViewInit {
} }
} }
async openLibraryDirectory() { openLibraryDirectory() {
this.electronService.openFolder(this.settingsService.libraryDirectory) this.electronService.openFolder(this.settingsService.libraryDirectory)
} }

View File

@@ -19,7 +19,7 @@ export class CheckboxDirective implements AfterViewInit {
}) })
} }
async check(isChecked: boolean) { check(isChecked: boolean) {
if (isChecked) { if (isChecked) {
$(this.checkbox.nativeElement).checkbox('check') $(this.checkbox.nativeElement).checkbox('check')
} else { } else {

View File

@@ -17,4 +17,4 @@ export class ProgressBarDirective {
this.progress = _.throttle((percent: number) => $progressBar.progress({ percent }), 100) this.progress = _.throttle((percent: number) => $progressBar.progress({ percent }), 100)
this.percent = 0 this.percent = 0
} }
} }

View File

@@ -26,19 +26,20 @@ export class DownloadService {
} }
addDownload(versionID: number, newDownload: NewDownload) { addDownload(versionID: number, newDownload: NewDownload) {
if (this.downloads.findIndex(download => download.versionID == versionID) != -1) { return } // Don't download something twice if (this.downloads.findIndex(download => download.versionID == versionID) == -1) { // Don't download something twice
this.electronService.receiveIPC('download-updated', result => { this.electronService.receiveIPC('download-updated', result => {
this.downloadUpdatedEmitter.emit(result) this.downloadUpdatedEmitter.emit(result)
// Update <this.downloads> with result // Update <this.downloads> with result
const thisDownloadIndex = this.downloads.findIndex(download => download.versionID == result.versionID) const thisDownloadIndex = this.downloads.findIndex(download => download.versionID == result.versionID)
if (thisDownloadIndex == -1) { if (thisDownloadIndex == -1) {
this.downloads.push(result) this.downloads.push(result)
} else { } else {
this.downloads[thisDownloadIndex] = result this.downloads[thisDownloadIndex] = result
} }
}) })
this.electronService.sendIPC('download', { action: 'add', versionID, data: newDownload }) this.electronService.sendIPC('download', { action: 'add', versionID, data: newDownload })
}
} }
onDownloadUpdated(callback: (download: DownloadProgress) => void) { onDownloadUpdated(callback: (download: DownloadProgress) => void) {

View File

@@ -11,21 +11,25 @@ export class SettingsService {
private settings: Settings private settings: Settings
private currentThemeLink: HTMLLinkElement private currentThemeLink: HTMLLinkElement
constructor(private electronService: ElectronService) { } constructor(private electronService: ElectronService) {
this.getSettings() // Should resolve immediately because GetSettingsHandler returns a value, not a promise
console.log(`QUICKLY RESOLVED SETTINGS: ${this.settings}`)
}
async getSettings() { async getSettings() {
if (this.settings == undefined) { if (this.settings == undefined) {
this.settings = await this.electronService.invoke('init-settings', undefined) this.settings = await this.electronService.invoke('get-settings', undefined)
} }
return this.settings return this.settings
} }
saveSettings() { saveSettings() {
if (this.settings != undefined) { if (this.settings != undefined) {
this.electronService.sendIPC('update-settings', this.settings) this.electronService.sendIPC('set-settings', this.settings)
} }
} }
// TODO: research how to make theme changes with fomantic UI
changeTheme(theme: string) { changeTheme(theme: string) {
if (this.currentThemeLink != undefined) this.currentThemeLink.remove() if (this.currentThemeLink != undefined) this.currentThemeLink.remove()
if (theme == 'Default') { return } if (theme == 'Default') { return }
@@ -40,12 +44,14 @@ export class SettingsService {
async getCacheSize() { async getCacheSize() {
return this.electronService.defaultSession.getCacheSize() return this.electronService.defaultSession.getCacheSize()
} }
async clearCache() { async clearCache() {
this.saveSettings() this.saveSettings()
return this.electronService.defaultSession.clearCache() return this.electronService.defaultSession.clearCache()
} }
// Individual getters/setters
// TODO: remove the undefined checks if the constructor gets the settings every time
get libraryDirectory() { get libraryDirectory() {
return this.settings == undefined ? '' : this.settings.libraryPath return this.settings == undefined ? '' : this.settings.libraryPath
} }

View File

@@ -0,0 +1,27 @@
import { RouteReuseStrategy, ActivatedRouteSnapshot, DetachedRouteHandle } from '@angular/router'
/**
* This makes each route with the 'reuse' data flag persist when not in focus.
*/
export class TabPersistStrategy extends RouteReuseStrategy {
private handles: { [path: string]: DetachedRouteHandle } = {}
shouldDetach(route: ActivatedRouteSnapshot) {
return route.data.shouldReuse || false
}
store(route: ActivatedRouteSnapshot, handle: DetachedRouteHandle) {
if (route.data.shouldReuse) {
this.handles[route.routeConfig.path] = handle
}
}
shouldAttach(route: ActivatedRouteSnapshot) {
return !!route.routeConfig && !!this.handles[route.routeConfig.path]
}
retrieve(route: ActivatedRouteSnapshot) {
if (!route.routeConfig) return null
return this.handles[route.routeConfig.path]
}
shouldReuseRoute(future: ActivatedRouteSnapshot, _curr: ActivatedRouteSnapshot) {
return future.data.shouldReuse || false
}
}

View File

@@ -2,16 +2,24 @@ import { IPCInvokeHandler } from '../shared/IPCHandler'
import Database from '../shared/Database' import Database from '../shared/Database'
import { AlbumArtResult } from '../shared/interfaces/songDetails.interface' import { AlbumArtResult } from '../shared/interfaces/songDetails.interface'
/**
* Handles the 'album-art' event.
*/
export default class AlbumArtHandler implements IPCInvokeHandler<'album-art'> { export default class AlbumArtHandler implements IPCInvokeHandler<'album-art'> {
event: 'album-art' = 'album-art' event: 'album-art' = 'album-art'
// TODO: add method documentation
/**
* @returns an `AlbumArtResult` object containing the album art for the song with `songID`.
*/
async handler(songID: number) { async handler(songID: number) {
const db = await Database.getInstance() const db = await Database.getInstance()
return db.sendQuery(this.getAlbumArtQuery(songID), 1) as Promise<AlbumArtResult> return db.sendQuery(this.getAlbumArtQuery(songID), 1) as Promise<AlbumArtResult>
} }
/**
* @returns a database query that returns the album art for the song with `songID`.
*/
private getAlbumArtQuery(songID: number) { private getAlbumArtQuery(songID: number) {
return ` return `
SELECT art SELECT art

View File

@@ -2,16 +2,24 @@ import { IPCInvokeHandler } from '../shared/IPCHandler'
import Database from '../shared/Database' import Database from '../shared/Database'
import { VersionResult } from '../shared/interfaces/songDetails.interface' import { VersionResult } from '../shared/interfaces/songDetails.interface'
/**
* Handles the 'batch-song-details' event.
*/
export default class BatchSongDetailsHandler implements IPCInvokeHandler<'batch-song-details'> { export default class BatchSongDetailsHandler implements IPCInvokeHandler<'batch-song-details'> {
event: 'batch-song-details' = 'batch-song-details' event: 'batch-song-details' = 'batch-song-details'
// TODO: add method documentation
/**
* @returns an array of all the chart versions with a songID found in `songIDs`.
*/
async handler(songIDs: number[]) { async handler(songIDs: number[]) {
const db = await Database.getInstance() const db = await Database.getInstance()
return db.sendQuery(this.getVersionQuery(songIDs)) as Promise<VersionResult[]> return db.sendQuery(this.getVersionQuery(songIDs)) as Promise<VersionResult[]>
} }
/**
* @returns a database query that returns all the chart versions with a songID found in `songIDs`.
*/
private getVersionQuery(songIDs: number[]) { private getVersionQuery(songIDs: number[]) {
return ` return `
SELECT * SELECT *

View File

@@ -1,51 +0,0 @@
import { exists as _exists, mkdir as _mkdir, readFile as _readFile } from 'fs'
import { dataPath, tempPath, themesPath, settingsPath } from '../shared/Paths'
import { promisify } from 'util'
import { IPCInvokeHandler } from '../shared/IPCHandler'
import { defaultSettings, Settings } from '../shared/Settings'
import SaveSettingsHandler from './SaveSettingsHandler.ipc'
const exists = promisify(_exists)
const mkdir = promisify(_mkdir)
const readFile = promisify(_readFile)
export default class InitSettingsHandler implements IPCInvokeHandler<'init-settings'> {
event: 'init-settings' = 'init-settings'
private static settings: Settings
static async getSettings() {
if (this.settings == undefined) {
this.settings = await InitSettingsHandler.initSettings()
}
return this.settings
}
async handler() {
return InitSettingsHandler.getSettings()
}
private static async initSettings(): Promise<Settings> {
try {
// Create data directories if they don't exists
for (const path of [dataPath, tempPath, themesPath]) {
if (!await exists(path)) {
await mkdir(path)
}
}
// Read/create settings
if (await exists(settingsPath)) {
return JSON.parse(await readFile(settingsPath, 'utf8'))
} else {
await SaveSettingsHandler.saveSettings(defaultSettings)
return defaultSettings
}
} catch (e) {
console.error('Failed to initialize settings!')
console.error('Several actions (including downloading) will unexpectedly fail')
console.error(e)
return defaultSettings
}
}
}

View File

@@ -1,20 +0,0 @@
import { writeFile as _writeFile } from 'fs'
import { IPCEmitHandler } from '../shared/IPCHandler'
import { Settings } from '../shared/Settings'
import { promisify } from 'util'
import { settingsPath } from '../shared/Paths'
const writeFile = promisify(_writeFile)
export default class SaveSettingsHandler implements IPCEmitHandler<'update-settings'> {
event: 'update-settings' = 'update-settings'
handler(settings: Settings) {
SaveSettingsHandler.saveSettings(settings)
}
static async saveSettings(settings: Settings) {
const settingsJSON = JSON.stringify(settings, undefined, 2)
await writeFile(settingsPath, settingsJSON, 'utf8')
}
}

View File

@@ -3,23 +3,34 @@ import Database from '../shared/Database'
import { SongSearch, SearchType, SongResult } from '../shared/interfaces/search.interface' import { SongSearch, SearchType, SongResult } from '../shared/interfaces/search.interface'
import { escape } from 'mysql' import { escape } from 'mysql'
/**
* Handles the 'song-search' event.
*/
export default class SearchHandler implements IPCInvokeHandler<'song-search'> { export default class SearchHandler implements IPCInvokeHandler<'song-search'> {
event: 'song-search' = 'song-search' event: 'song-search' = 'song-search'
// TODO: add method documentation
/**
* @returns the top 20 songs that match `search`.
*/
async handler(search: SongSearch) { async handler(search: SongSearch) {
const db = await Database.getInstance() const db = await Database.getInstance()
return db.sendQuery(this.getSearchQuery(search)) as Promise<SongResult[]> return db.sendQuery(this.getSearchQuery(search)) as Promise<SongResult[]>
} }
/**
* @returns a database query that returns the type of results expected by `search.type`.
*/
private getSearchQuery(search: SongSearch) { private getSearchQuery(search: SongSearch) {
switch(search.type) { switch (search.type) {
case SearchType.Any: return this.getGeneralSearchQuery(search.query) case SearchType.Any: return this.getGeneralSearchQuery(search.query)
default: return '<<<ERROR>>>' // TODO: add more search types default: return '<<<ERROR>>>' // TODO: add more search types
} }
} }
/**
* @returns a database query that returns the top 20 songs that match `search`.
*/
private getGeneralSearchQuery(searchString: string) { private getGeneralSearchQuery(searchString: string) {
return ` return `
SELECT id, name, artist, album, genre, year SELECT id, name, artist, album, genre, year

View File

@@ -0,0 +1,88 @@
import { exists as _exists, mkdir as _mkdir, readFile as _readFile, writeFile as _writeFile } from 'fs'
import { dataPath, tempPath, themesPath, settingsPath } from '../shared/Paths'
import { promisify } from 'util'
import { IPCInvokeHandler, IPCEmitHandler } from '../shared/IPCHandler'
import { defaultSettings, Settings } from '../shared/Settings'
const exists = promisify(_exists)
const mkdir = promisify(_mkdir)
const readFile = promisify(_readFile)
const writeFile = promisify(_writeFile)
let settings: Settings
/**
* Handles the 'get-settings' event.
*/
export 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()
}
/**
* @returns the current settings oject, or default settings if they couldn't be loaded.
*/
static getSettings() {
if (settings == undefined) {
return defaultSettings
} else {
return settings
}
}
/**
* If data directories don't exist, creates them and saves the default settings.
* Otherwise, loads user settings from data directories.
* If this process fails, default settings are used.
*/
static async initSettings() {
try {
// Create data directories if they don't exists
for (const path of [dataPath, tempPath, themesPath]) {
if (!await exists(path)) {
await mkdir(path)
}
}
// Read/create settings
if (await exists(settingsPath)) {
settings = JSON.parse(await readFile(settingsPath, 'utf8'))
} else {
await SetSettingsHandler.saveSettings(defaultSettings)
settings = defaultSettings
}
} catch (e) {
console.error('Failed to initialize settings! Default settings will be used.')
console.error(e)
settings = defaultSettings
}
}
}
/**
* Handles the 'set-settings' event.
*/
export class SetSettingsHandler implements IPCEmitHandler<'set-settings'> {
event: 'set-settings' = 'set-settings'
/**
* Updates Bridge's settings object to `newSettings` and saves them to Bridge's data directories.
*/
handler(newSettings: Settings) {
settings = newSettings
SetSettingsHandler.saveSettings(settings)
}
/**
* Saves `settings` to Bridge's data directories.
*/
static async saveSettings(settings: Settings) {
const settingsJSON = JSON.stringify(settings, undefined, 2)
await writeFile(settingsPath, settingsJSON, 'utf8')
}
}

View File

@@ -2,16 +2,24 @@ import { IPCInvokeHandler } from '../shared/IPCHandler'
import Database from '../shared/Database' import Database from '../shared/Database'
import { VersionResult } from '../shared/interfaces/songDetails.interface' import { VersionResult } from '../shared/interfaces/songDetails.interface'
/**
* Handles the 'song-details' event.
*/
export default class SongDetailsHandler implements IPCInvokeHandler<'song-details'> { export default class SongDetailsHandler implements IPCInvokeHandler<'song-details'> {
event: 'song-details' = 'song-details' event: 'song-details' = 'song-details'
// TODO: add method documentation
/**
* @returns the chart versions with `songID`.
*/
async handler(songID: number) { async handler(songID: number) {
const db = await Database.getInstance() const db = await Database.getInstance()
return db.sendQuery(this.getVersionQuery(songID)) as Promise<VersionResult[]> return db.sendQuery(this.getVersionQuery(songID)) as Promise<VersionResult[]>
} }
/**
* @returns a database query that returns the chart versions with `songID`.
*/
private getVersionQuery(songID: number) { private getVersionQuery(songID: number) {
return ` return `
SELECT * SELECT *

View File

@@ -4,11 +4,12 @@ import { createHash, randomBytes as _randomBytes } from 'crypto'
import { tempPath } from '../../shared/Paths' import { tempPath } from '../../shared/Paths'
import { promisify } from 'util' import { promisify } from 'util'
import { join } from 'path' import { join } from 'path'
import { Download, NewDownload, DownloadProgress } from '../../shared/interfaces/download.interface' import { Download, DownloadProgress } from '../../shared/interfaces/download.interface'
import { emitIPCEvent } from '../../main' import { emitIPCEvent } from '../../main'
import { mkdir as _mkdir } from 'fs' import { mkdir as _mkdir } from 'fs'
import { FileExtractor } from './FileExtractor' import { FileExtractor } from './FileExtractor'
import { sanitizeFilename, interpolate } from '../../shared/UtilFunctions' import { sanitizeFilename, interpolate } from '../../shared/UtilFunctions'
import { GetSettingsHandler } from '../SettingsHandler.ipc'
const randomBytes = promisify(_randomBytes) const randomBytes = promisify(_randomBytes)
const mkdir = promisify(_mkdir) const mkdir = promisify(_mkdir)
@@ -19,6 +20,7 @@ export class DownloadHandler implements IPCEmitHandler<'download'> {
// TODO: replace needle with got (for cancel() method) (if before-headers event is possible?) // TODO: replace needle with got (for cancel() method) (if before-headers event is possible?)
downloadCallbacks: { [versionID: number]: { cancel: () => void, retry: () => void, continue: () => void } } = {} downloadCallbacks: { [versionID: number]: { cancel: () => void, retry: () => void, continue: () => void } } = {}
private allFilesProgress = 0
async handler(data: Download) { async handler(data: Download) {
switch (data.action) { switch (data.action) {
@@ -27,8 +29,9 @@ export class DownloadHandler implements IPCEmitHandler<'download'> {
case 'continue': this.downloadCallbacks[data.versionID].continue(); return case 'continue': this.downloadCallbacks[data.versionID].continue(); return
case 'add': this.downloadCallbacks[data.versionID] = { cancel: () => { }, retry: () => { }, continue: () => { } } case 'add': this.downloadCallbacks[data.versionID] = { cancel: () => { }, retry: () => { }, continue: () => { } }
} }
// after this point, (data.action == add), so data.data should be defined
// data.action == add; data.data should be defined // Initialize download object
const download: DownloadProgress = { const download: DownloadProgress = {
versionID: data.versionID, versionID: data.versionID,
title: `${data.data.avTagName} - ${data.data.artist}`, title: `${data.data.avTagName} - ${data.data.artist}`,
@@ -37,102 +40,146 @@ export class DownloadHandler implements IPCEmitHandler<'download'> {
percent: 0, percent: 0,
type: 'good' type: 'good'
} }
const randomString = (await randomBytes(5)).toString('hex')
const chartPath = join(tempPath, `chart_${randomString}`)
await mkdir(chartPath)
let allFilesProgress = 0 // Create a temporary folder to store the downloaded files
// Only iterate over the keys in data.links that have link values (not hashes) let chartPath: string
const fileKeys = Object.keys(data.data.links).filter(link => data.data.links[link].includes('.')) try {
const individualFileProgressPortion = 80 / fileKeys.length chartPath = await this.createDownloadFolder()
for (let i = 0; i < fileKeys.length; i++) { } catch (e) {
const typeHash = createHash('md5').update(data.data.links[fileKeys[i]]).digest('hex') download.header = 'Access Error'
const downloader = await FileDownloader.asyncConstructor(data.data.links[fileKeys[i]], chartPath, fileKeys.length, data.data.links[typeHash]) download.description = e.message
this.downloadCallbacks[data.versionID].cancel = () => downloader.cancelDownload() // Make cancel button cancel this download download.type = 'error'
let fileProgress = 0 this.downloadCallbacks[data.versionID].retry = () => { this.handler(data) }
emitIPCEvent('download-updated', download)
let initialWaitTime: number return
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'
initialWaitTime = waitTime
})
downloader.on('waitProgress', (secondsRemaining) => {
download.description = `Waiting for Google rate limit... (${secondsRemaining}s)`
fileProgress = interpolate(secondsRemaining, initialWaitTime, 0, 0, individualFileProgressPortion / 2)
download.percent = allFilesProgress + fileProgress
download.type = 'wait'
emitIPCEvent('download-updated', download)
})
downloader.on('request', () => {
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)
})
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 = allFilesProgress + fileProgress
} else {
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)
})
// Wait for the 'complete' event before moving on to another file download
await new Promise<void>(resolve => {
downloader.on('complete', () => {
emitIPCEvent('download-updated', download)
allFilesProgress += individualFileProgressPortion
resolve()
})
downloader.beginDownload()
})
} }
// For each actual download link in <data.data.links>, download the file to <chartPath>
// 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
// <data.data.links[typeHash]> 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 destinationFolderName = sanitizeFilename(`${data.data.artist} - ${data.data.avTagName} (${data.data.charter})`)
const extractor = new FileExtractor(chartPath, fileKeys.includes('archive'), destinationFolderName) const extractor = new FileExtractor(chartPath, fileKeys.includes('archive'), destinationFolderName)
this.downloadCallbacks[data.versionID].cancel = () => extractor.cancelExtract() 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++
}
}
}
}
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<void>(resolve => {
downloader.on('complete', () => {
emitIPCEvent('download-updated', download)
this.allFilesProgress += individualFileProgressPortion
resolve()
})
})
}
private addExtractorEventListeners(extractor: FileExtractor, download: DownloadProgress) {
let archive = '' let archive = ''
extractor.on('extract', (filename) => { extractor.on('extract', (filename) => {
archive = filename archive = filename
download.header = `[${archive}]` download.header = `[${archive}]`
@@ -169,10 +216,8 @@ export class DownloadHandler implements IPCEmitHandler<'download'> {
download.header = error.header download.header = error.header
download.description = error.body download.description = error.body
download.type = 'error' download.type = 'error'
this.downloadCallbacks[data.versionID].retry = retry this.downloadCallbacks[download.versionID].retry = retry
emitIPCEvent('download-updated', download) emitIPCEvent('download-updated', download)
}) })
extractor.beginExtract()
} }
} }

View File

@@ -2,12 +2,11 @@ import { generateUUID, sanitizeFilename } from '../../shared/UtilFunctions'
import * as fs from 'fs' import * as fs from 'fs'
import * as path from 'path' import * as path from 'path'
import * as needle from 'needle' import * as needle from 'needle'
import InitSettingsHandler from '../InitSettingsHandler.ipc' import { GetSettingsHandler } from '../SettingsHandler.ipc'
import { Settings } from 'src/electron/shared/Settings'
type EventCallback = { type EventCallback = {
'wait': (waitTime: number) => void 'wait': (waitTime: number) => void
'waitProgress': (secondsRemaining: number) => void 'waitProgress': (secondsRemaining: number, initialWaitTime: number) => void
'request': () => void 'request': () => void
'warning': (continueAnyway: () => void) => void 'warning': (continueAnyway: () => void) => void
'download': (filename: string, filesize?: number) => void 'download': (filename: string, filesize?: number) => void
@@ -20,43 +19,41 @@ type Callbacks = { [E in keyof EventCallback]: EventCallback[E] }
export type DownloadError = { header: string, body: string } export type DownloadError = { header: string, body: string }
export class FileDownloader { export class FileDownloader {
private RATE_LIMIT_DELAY: number
private readonly RETRY_MAX = 2 private readonly RETRY_MAX = 2
private static fileQueue: { // Stores the overall order that files should be downloaded private static fileQueue: { // Stores the overall order that files should be downloaded
destinationFolder: string destinationFolder: string
fileCount: number fileCount: number
clock?: () => void clock?: () => void
}[] = [] }[]
private static waitTime: number private static waitTime: number
private static settings: Settings
private callbacks = {} as Callbacks private callbacks = {} as Callbacks
private retryCount: number private retryCount: number
private wasCanceled = false private wasCanceled = false
private constructor(private url: string, private destinationFolder: string, private numFiles: number, private expectedHash?: string) { } constructor(private url: string, private destinationFolder: string, private numFiles: number, private expectedHash?: string) {
static async asyncConstructor(url: string, destinationFolder: string, numFiles: number, expectedHash?: string) { if (FileDownloader.fileQueue == undefined) {
const downloader = new FileDownloader(url, destinationFolder, numFiles, expectedHash) // First initialization
if (FileDownloader.settings == undefined) { FileDownloader.fileQueue = []
await downloader.firstInit() 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)
} }
return downloader
} }
async firstInit() {
FileDownloader.settings = await InitSettingsHandler.getSettings()
FileDownloader.waitTime = 0
setInterval(() => {
if (FileDownloader.waitTime > 0) {
FileDownloader.waitTime--
}
FileDownloader.fileQueue.forEach(download => { if (download.clock != undefined) download.clock() })
if (FileDownloader.waitTime == 0 && FileDownloader.fileQueue.length != 0) {
FileDownloader.waitTime = this.RATE_LIMIT_DELAY
}
}, 1000)
}
/** /**
* Calls <callback> when <event> fires. * Calls <callback> when <event> fires.
* @param event The event to listen for. * @param event The event to listen for.
@@ -72,7 +69,7 @@ export class FileDownloader {
*/ */
beginDownload() { beginDownload() {
// Check that the library folder has been specified // Check that the library folder has been specified
if (FileDownloader.settings.libraryPath == undefined) { if (GetSettingsHandler.getSettings().libraryPath == undefined) {
this.callbacks.error({ header: 'Library folder not specified', body: 'Please go to the settings to set your library folder.' }, () => this.beginDownload()) this.callbacks.error({ header: 'Library folder not specified', body: 'Please go to the settings to set your library folder.' }, () => this.beginDownload())
return return
} }
@@ -82,12 +79,16 @@ export class FileDownloader {
this.requestDownload() this.requestDownload()
return 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.initWaitTime()
let queueWaitTime = this.getQueueWaitTime() // This is the number of seconds that had elapsed since the last file download (at the time of starting this download)
this.callbacks.wait(queueWaitTime + FileDownloader.waitTime) const initialTimeSinceLastDownload = GetSettingsHandler.getSettings().rateLimitDelay - FileDownloader.waitTime
if (queueWaitTime + FileDownloader.waitTime == 0) { const initialQueueCount = this.getQueueCount()
FileDownloader.waitTime = this.RATE_LIMIT_DELAY let waitTime = this.getWaitTime(initialTimeSinceLastDownload, initialQueueCount)
this.callbacks.wait(waitTime)
if (waitTime == 0) {
FileDownloader.waitTime = GetSettingsHandler.getSettings().rateLimitDelay
this.requestDownload() this.requestDownload()
return return
} }
@@ -95,17 +96,21 @@ export class FileDownloader {
const fileQueue = FileDownloader.fileQueue.find(queue => queue.destinationFolder == this.destinationFolder) const fileQueue = FileDownloader.fileQueue.find(queue => queue.destinationFolder == this.destinationFolder)
fileQueue.clock = () => { fileQueue.clock = () => {
if (this.wasCanceled) { this.removeFromQueue(); return } // CANCEL POINT if (this.wasCanceled) { this.removeFromQueue(); return } // CANCEL POINT
queueWaitTime = this.getQueueWaitTime() waitTime = this.getWaitTime(GetSettingsHandler.getSettings().rateLimitDelay - FileDownloader.waitTime, this.getQueueCount())
if (queueWaitTime + FileDownloader.waitTime == 0) { if (waitTime == 0) {
this.requestDownload() this.requestDownload()
fileQueue.clock = undefined fileQueue.clock = undefined
} }
this.callbacks.waitProgress(queueWaitTime + FileDownloader.waitTime) 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() { private initWaitTime() {
this.RATE_LIMIT_DELAY = FileDownloader.settings.rateLimitDelay
this.retryCount = 0 this.retryCount = 0
const entry = FileDownloader.fileQueue.find(entry => entry.destinationFolder == this.destinationFolder) const entry = FileDownloader.fileQueue.find(entry => entry.destinationFolder == this.destinationFolder)
if (entry == undefined) { if (entry == undefined) {
@@ -117,7 +122,7 @@ export class FileDownloader {
/** /**
* Returns the number of files in front of this file in the fileQueue * Returns the number of files in front of this file in the fileQueue
*/ */
private getQueueWaitTime() { private getQueueCount() {
let fileCount = 0 let fileCount = 0
for (let entry of FileDownloader.fileQueue) { for (let entry of FileDownloader.fileQueue) {
if (entry.destinationFolder != this.destinationFolder) { if (entry.destinationFolder != this.destinationFolder) {
@@ -127,7 +132,7 @@ export class FileDownloader {
} }
} }
return fileCount * this.RATE_LIMIT_DELAY return fileCount
} }
private removeFromQueue() { private removeFromQueue() {
@@ -207,9 +212,9 @@ export class FileDownloader {
req.on('data', data => virusScanHTML += data) req.on('data', data => virusScanHTML += data)
req.on('done', (err: Error) => { req.on('done', (err: Error) => {
if (!err) { if (!err) {
const confirmTokenRegex = /confirm=([0-9A-Za-z\-_]+)&/g try {
const confirmTokenResults = confirmTokenRegex.exec(virusScanHTML) const confirmTokenRegex = /confirm=([0-9A-Za-z\-_]+)&/g
if (confirmTokenResults != null) { const confirmTokenResults = confirmTokenRegex.exec(virusScanHTML)
const confirmToken = confirmTokenResults[1] const confirmToken = confirmTokenResults[1]
const downloadID = this.url.substr(this.url.indexOf('id=') + 'id='.length) const downloadID = this.url.substr(this.url.indexOf('id=') + 'id='.length)
this.url = `https://drive.google.com/uc?confirm=${confirmToken}&id=${downloadID}` this.url = `https://drive.google.com/uc?confirm=${confirmToken}&id=${downloadID}`
@@ -217,7 +222,7 @@ export class FileDownloader {
const NID = /NID=([^;]*);/.exec(cookieHeader)[1].replace('=', '%') const NID = /NID=([^;]*);/.exec(cookieHeader)[1].replace('=', '%')
const newHeader = `download_warning_${warningCode}=${confirmToken}; NID=${NID}` const newHeader = `download_warning_${warningCode}=${confirmToken}; NID=${NID}`
this.requestDownload(newHeader) this.requestDownload(newHeader)
} else { } catch(e) {
this.callbacks.error({ header: 'Invalid response', body: 'Download server returned HTML instead of a file.' }, () => this.beginDownload()) this.callbacks.error({ header: 'Invalid response', body: 'Download server returned HTML instead of a file.' }, () => this.beginDownload())
} }
} else { } else {

View File

@@ -6,7 +6,7 @@ import { join, extname } from 'path'
import * as node7z from 'node-7z' import * as node7z from 'node-7z'
import * as zipBin from '7zip-bin' import * as zipBin from '7zip-bin'
import * as unrarjs from 'node-unrar-js' import * as unrarjs from 'node-unrar-js'
import InitSettingsHandler from '../InitSettingsHandler.ipc' import { GetSettingsHandler } from '../SettingsHandler.ipc'
const readdir = promisify(_readdir) const readdir = promisify(_readdir)
const unlink = promisify(_unlink) const unlink = promisify(_unlink)
@@ -45,7 +45,7 @@ export class FileExtractor {
* Starts the chart extraction process. * Starts the chart extraction process.
*/ */
async beginExtract() { async beginExtract() {
this.libraryFolder = (await InitSettingsHandler.getSettings()).libraryPath this.libraryFolder = (await GetSettingsHandler.getSettings()).libraryPath
const files = await readdir(this.sourceFolder) const files = await readdir(this.sourceFolder)
if (this.isArchive) { if (this.isArchive) {
this.extract(files[0]) this.extract(files[0])

View File

@@ -5,6 +5,7 @@ import * as url from 'url'
// IPC Handlers // IPC Handlers
import { getIPCInvokeHandlers, getIPCEmitHandlers, IPCEmitEvents } from './shared/IPCHandler' import { getIPCInvokeHandlers, getIPCEmitHandlers, IPCEmitEvents } from './shared/IPCHandler'
import Database from './shared/Database' import Database from './shared/Database'
import { GetSettingsHandler } from './ipc/SettingsHandler.ipc'
let mainWindow: BrowserWindow let mainWindow: BrowserWindow
const args = process.argv.slice(1) const args = process.argv.slice(1)
@@ -12,7 +13,10 @@ const isDevBuild = args.some(val => val == '--dev')
restrictToSingleInstance() restrictToSingleInstance()
handleOSXWindowClosed() handleOSXWindowClosed()
app.on('ready', createBridgeWindow) app.on('ready', () => {
// Load settings from file before the window is created
GetSettingsHandler.initSettings().then(createBridgeWindow)
})
/** /**
* Only allow a single Bridge window to be open at any one time. * Only allow a single Bridge window to be open at any one time.

View File

@@ -50,6 +50,9 @@ export default class Database {
}) })
} }
/**
* Destroys the database connection.
*/
static closeConnection() { static closeConnection() {
if (this.database != undefined) { if (this.database != undefined) {
this.database.conn.destroy() this.database.conn.destroy()
@@ -57,10 +60,9 @@ export default class Database {
} }
/** /**
* Sends <query> to the database. * Sends `query` to the database.
* @param query The query string to be sent. * @param queryStatement The nth response statement to be returned. If undefined, the entire response is returned.
* @param queryStatement The nth response statement to be returned. * @returns the selected response statement, or an empty array if the query fails.
* @returns one of the responses as type <ResponseType[]>, or an empty array if the query fails.
*/ */
async sendQuery<ResponseType>(query: string, queryStatement?: number) { async sendQuery<ResponseType>(query: string, queryStatement?: number) {
return new Promise<ResponseType[] | ResponseType>(resolve => { return new Promise<ResponseType[] | ResponseType>(resolve => {

View File

@@ -1,11 +1,10 @@
import InitSettingsHandler from '../ipc/InitSettingsHandler.ipc'
import { basename } from 'path' import { basename } from 'path'
import { GetSettingsHandler } from '../ipc/SettingsHandler.ipc'
/** /**
* @param absoluteFilepath The absolute filepath to a folder * @returns The relative filepath from the library folder to `absoluteFilepath`.
* @returns The relative filepath from the scanned folder to <absoluteFilepath>
*/ */
export async function getRelativeFilepath(absoluteFilepath: string) { export function getRelativeFilepath(absoluteFilepath: string) {
const settings = await InitSettingsHandler.getSettings() const settings = GetSettingsHandler.getSettings()
return basename(settings.libraryPath) + absoluteFilepath.substring(settings.libraryPath.length) return basename(settings.libraryPath) + absoluteFilepath.substring(settings.libraryPath.length)
} }

View File

@@ -43,7 +43,7 @@ export function failDelete(filepath: string, error: any) {
*/ */
export function failEncoding(filepath: string, error: any) { export function failEncoding(filepath: string, error: any) {
console.error(`${red('ERROR:')} Failed to read text file (${getRelativeFilepath(filepath) console.error(`${red('ERROR:')} Failed to read text file (${getRelativeFilepath(filepath)
}):\nJavaScript cannot parse using the detected text encoding of (${error})`) }):\nJavaScript cannot parse using the detected text encoding of (${error})`)
} }
/** /**

View File

@@ -3,12 +3,11 @@ import { VersionResult, AlbumArtResult } from './interfaces/songDetails.interfac
import SearchHandler from '../ipc/SearchHandler.ipc' import SearchHandler from '../ipc/SearchHandler.ipc'
import SongDetailsHandler from '../ipc/SongDetailsHandler.ipc' import SongDetailsHandler from '../ipc/SongDetailsHandler.ipc'
import AlbumArtHandler from '../ipc/AlbumArtHandler.ipc' import AlbumArtHandler from '../ipc/AlbumArtHandler.ipc'
import { Download, NewDownload, DownloadProgress } from './interfaces/download.interface' import { Download, DownloadProgress } from './interfaces/download.interface'
import { DownloadHandler } from '../ipc/download/DownloadHandler' import { DownloadHandler } from '../ipc/download/DownloadHandler'
import { Settings } from './Settings' import { Settings } from './Settings'
import InitSettingsHandler from '../ipc/InitSettingsHandler.ipc'
import BatchSongDetailsHandler from '../ipc/BatchSongDetailsHandler.ipc' import BatchSongDetailsHandler from '../ipc/BatchSongDetailsHandler.ipc'
import SaveSettingsHandler from '../ipc/SaveSettingsHandler.ipc' import { GetSettingsHandler, SetSettingsHandler } from '../ipc/SettingsHandler.ipc'
/** /**
* To add a new IPC listener: * To add a new IPC listener:
@@ -20,7 +19,7 @@ import SaveSettingsHandler from '../ipc/SaveSettingsHandler.ipc'
export function getIPCInvokeHandlers(): IPCInvokeHandler<keyof IPCInvokeEvents>[] { export function getIPCInvokeHandlers(): IPCInvokeHandler<keyof IPCInvokeEvents>[] {
return [ return [
new InitSettingsHandler(), new GetSettingsHandler(),
new SearchHandler(), new SearchHandler(),
new SongDetailsHandler(), new SongDetailsHandler(),
new BatchSongDetailsHandler(), new BatchSongDetailsHandler(),
@@ -28,8 +27,11 @@ export function getIPCInvokeHandlers(): IPCInvokeHandler<keyof IPCInvokeEvents>[
] ]
} }
/**
* The list of possible async IPC events that return values, mapped to their input and output types.
*/
export type IPCInvokeEvents = { export type IPCInvokeEvents = {
'init-settings': { 'get-settings': {
input: undefined input: undefined
output: Settings output: Settings
} }
@@ -51,24 +53,34 @@ export type IPCInvokeEvents = {
} }
} }
/**
* Describes an object that handles the `E` async IPC event that will return a value.
*/
export interface IPCInvokeHandler<E extends keyof IPCInvokeEvents> { export interface IPCInvokeHandler<E extends keyof IPCInvokeEvents> {
event: E event: E
handler(data: IPCInvokeEvents[E]['input']): Promise<IPCInvokeEvents[E]['output']> | IPCInvokeEvents[E]['output'] handler(data: IPCInvokeEvents[E]['input']): Promise<IPCInvokeEvents[E]['output']> | IPCInvokeEvents[E]['output']
} }
export function getIPCEmitHandlers(): IPCEmitHandler<keyof IPCEmitEvents>[]{
export function getIPCEmitHandlers(): IPCEmitHandler<keyof IPCEmitEvents>[] {
return [ return [
new DownloadHandler(), new DownloadHandler(),
new SaveSettingsHandler() new SetSettingsHandler()
] ]
} }
/**
* The list of possible async IPC events that don't return values, mapped to their input types.
*/
export type IPCEmitEvents = { export type IPCEmitEvents = {
'download': Download 'download': Download
'download-updated': DownloadProgress 'download-updated': DownloadProgress
'update-settings': Settings 'set-settings': Settings
} }
/**
* Describes an object that handles the `E` async IPC event that will not return a value.
*/
export interface IPCEmitHandler<E extends keyof IPCEmitEvents> { export interface IPCEmitHandler<E extends keyof IPCEmitEvents> {
event: E event: E
handler(data: IPCEmitEvents[E]): void handler(data: IPCEmitEvents[E]): void

View File

@@ -1,9 +1,15 @@
/**
* Represents Bridge's user settings.
*/
export interface Settings { export interface Settings {
rateLimitDelay: number // Number of seconds to wait between each file download from Google servers rateLimitDelay: number // Number of seconds to wait between each file download from Google servers
theme: string // The name of the currently enabled UI theme theme: string // The name of the currently enabled UI theme
libraryPath: string // The path to the user's library libraryPath: string // The path to the user's library
} }
/**
* Bridge's default user settings.
*/
export const defaultSettings: Settings = { export const defaultSettings: Settings = {
rateLimitDelay: 31, rateLimitDelay: 31,
theme: 'Default', theme: 'Default',

View File

@@ -1,17 +1,17 @@
let sanitize = require('sanitize-filename') const sanitize = require('sanitize-filename')
/** /**
* @returns A random UUID * @returns a random UUID
*/ */
export function generateUUID() { // Public Domain/MIT export function generateUUID() { // Public Domain/MIT
var d = new Date().getTime()//Timestamp let d = new Date().getTime() // Timestamp
var d2 = Date.now() // Time in microseconds since page-load or 0 if unsupported let d2 = Date.now() // Time in microseconds since page-load or 0 if unsupported
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) { return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
var r = Math.random() * 16//random number between 0 and 16 let r = Math.random() * 16 // Random number between 0 and 16
if (d > 0) {//Use timestamp until depleted if (d > 0) { // Use timestamp until depleted
r = (d + r) % 16 | 0 r = (d + r) % 16 | 0
d = Math.floor(d / 16) d = Math.floor(d / 16)
} else {//Use microseconds since page-load if supported } else { // Use microseconds since page-load if supported
r = (d2 + r) % 16 | 0 r = (d2 + r) % 16 | 0
d2 = Math.floor(d2 / 16) d2 = Math.floor(d2 / 16)
} }
@@ -20,9 +20,8 @@ export function generateUUID() { // Public Domain/MIT
} }
/** /**
* Sanitizes a filename of any characters that cannot be part of a windows filename. * @returns `filename`, but with any invalid filename characters replaced with similar valid characters.
* @param filename The name of the file to sanitize. */
*/
export function sanitizeFilename(filename: string): string { export function sanitizeFilename(filename: string): string {
const newName = sanitize(filename, { const newName = sanitize(filename, {
replacement: ((invalidChar: string) => { replacement: ((invalidChar: string) => {
@@ -30,7 +29,7 @@ export function sanitizeFilename(filename: string): string {
case '/': return '-' case '/': return '-'
case '\\': return '-' case '\\': return '-'
case '"': return "'" case '"': return "'"
default: return '_' //TODO: add more cases for replacing invalid characters default: return '_' // TODO: add more cases for replacing invalid characters
} }
}) })
}) })
@@ -38,16 +37,14 @@ export function sanitizeFilename(filename: string): string {
} }
/** /**
* Converts <val> from the range (<fromA>, <fromB>) to the range (<toA>, <toB>). * Converts `val` from the range (`fromA`, `fromB`) to the range (`toA`, `toB`).
*/ */
export function interpolate(val: number, fromA: number, fromB: number, toA: number, toB: number) { export function interpolate(val: number, fromA: number, fromB: number, toA: number, toB: number) {
return ((val - fromA) / (fromB - fromA)) * (toB - toA) + toA return ((val - fromA) / (fromB - fromA)) * (toB - toA) + toA
} }
/** /**
* Splits <objectList> into multiple arrays, grouping by matching <key> values. * @returns `objectList` split into multiple arrays, where each array contains the objects with matching `key` values.
* @param objectList A list of objects.
* @param key A key from the list of objects.
*/ */
export function groupBy<T>(objectList: T[], key: keyof T) { export function groupBy<T>(objectList: T[], key: keyof T) {
const results: T[][] = [] const results: T[][] = []

View File

@@ -1,11 +1,14 @@
/**
* Represents a user's request to interact with the download system.
*/
export interface Download { export interface Download {
action: 'add' | 'retry' | 'continue' | 'cancel' action: 'add' | 'retry' | 'continue' | 'cancel'
versionID: number versionID: number
data ?: NewDownload data?: NewDownload // Should be defined if action == 'add'
} }
/** /**
* Contains the data required to start downloading a single chart * Contains the data required to start downloading a single chart.
*/ */
export interface NewDownload { export interface NewDownload {
avTagName: string avTagName: string
@@ -15,7 +18,7 @@ export interface NewDownload {
} }
/** /**
* Represents the download progress of a single chart * Represents the download progress of a single chart.
*/ */
export interface DownloadProgress { export interface DownloadProgress {
versionID: number versionID: number

View File

@@ -1,12 +1,21 @@
/**
* Represents a user's song search query.
*/
export interface SongSearch { export interface SongSearch {
query: string query: string
type: SearchType type: SearchType
} }
/**
* The list of possible search categories.
*/
export enum SearchType { export enum SearchType {
'Any', 'Name', 'Artist', 'Album', 'Genre', 'Year', 'Charter' 'Any', 'Name', 'Artist', 'Album', 'Genre', 'Year', 'Charter'
} }
/**
* Represents a single song search result.
*/
export interface SongResult { export interface SongResult {
id: number id: number
name: string name: string

View File

@@ -1,7 +1,13 @@
/**
* The image data for a song's album art.
*/
export interface AlbumArtResult { export interface AlbumArtResult {
art: Buffer art: Buffer
} }
/**
* Represents a single chart version.
*/
export interface VersionResult { export interface VersionResult {
versionID: number versionID: number
chartID: number chartID: number