mirror of
https://github.com/Myxelium/Bridge-Multi.git
synced 2026-04-09 05:09:39 +00:00
Fix downloads
This commit is contained in:
@@ -70,7 +70,8 @@
|
||||
"@angular-eslint/use-lifecycle-interface": "error",
|
||||
"arrow-parens": ["error", "as-needed"],
|
||||
"comma-dangle": ["error", "always-multiline"],
|
||||
"prefer-arrow/prefer-arrow-functions": ["error", { "allowStandaloneDeclarations": true }]
|
||||
"prefer-arrow/prefer-arrow-functions": ["error", { "allowStandaloneDeclarations": true }],
|
||||
"no-fallthrough": ["error", { "commentPattern": "break[\\s\\w]*omitted" }]
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -35,7 +35,6 @@
|
||||
"@angular/router": "^17.0.4",
|
||||
"bootstrap-icons": "^1.11.2",
|
||||
"bottleneck": "^2.19.5",
|
||||
"comparators": "^3.0.2",
|
||||
"dayjs": "^1.11.10",
|
||||
"electron-unhandled": "^4.0.1",
|
||||
"electron-updater": "^4.3.1",
|
||||
@@ -43,6 +42,7 @@
|
||||
"lodash": "^4.17.21",
|
||||
"mkdirp": "^3.0.1",
|
||||
"mv": "^2.1.1",
|
||||
"parse-sng": "^3.1.1",
|
||||
"rimraf": "^5.0.5",
|
||||
"rxjs": "~7.8.1",
|
||||
"sanitize-filename": "^1.6.3",
|
||||
|
||||
16
pnpm-lock.yaml
generated
16
pnpm-lock.yaml
generated
@@ -35,9 +35,6 @@ dependencies:
|
||||
bottleneck:
|
||||
specifier: ^2.19.5
|
||||
version: 2.19.5
|
||||
comparators:
|
||||
specifier: ^3.0.2
|
||||
version: 3.0.5
|
||||
dayjs:
|
||||
specifier: ^1.11.10
|
||||
version: 1.11.10
|
||||
@@ -59,6 +56,9 @@ dependencies:
|
||||
mv:
|
||||
specifier: ^2.1.1
|
||||
version: 2.1.1
|
||||
parse-sng:
|
||||
specifier: ^3.1.1
|
||||
version: 3.1.1
|
||||
rimraf:
|
||||
specifier: ^5.0.5
|
||||
version: 5.0.5
|
||||
@@ -4556,10 +4556,6 @@ packages:
|
||||
engines: {node: '>=4.0.0'}
|
||||
dev: true
|
||||
|
||||
/comparators@3.0.5:
|
||||
resolution: {integrity: sha512-xWeNJYdL57+ySi53YVnHFFKBI7SF/o7xMHDXGVD5bX7lb4ECPmKWXwrOr65lZRvowqgb9VHFt+aPr4TjqvyyiQ==}
|
||||
dev: false
|
||||
|
||||
/compare-version@0.1.2:
|
||||
resolution: {integrity: sha512-pJDh5/4wrEnXX/VWRZvruAGHkzKdr46z11OlTPN+VrATlWWhSKewNCJ1futCO5C7eJB3nPMFZA1LeYtcFboZ2A==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -8438,8 +8434,8 @@ packages:
|
||||
engines: {node: '>= 0.10'}
|
||||
dev: true
|
||||
|
||||
/parse-sng@3.1.0:
|
||||
resolution: {integrity: sha512-cZerIg4Ds2BZ6eSp0mg0JNP0r104DId88kGDjvNeQqmuo8tYBZKRHDEzhTlG5JNVaRGcxec5YWqX2edVKLEgoA==}
|
||||
/parse-sng@3.1.1:
|
||||
resolution: {integrity: sha512-BOU9Y4LE5VsEc76lZonVXcbKrbGbetzdP28avnWvioNEhACzTEhtqDdtNmgsXiMO3lvkveRcM5wp6hEniSIQkA==}
|
||||
dependencies:
|
||||
binary-parser: 2.2.1
|
||||
events: 3.3.0
|
||||
@@ -9260,7 +9256,7 @@ packages:
|
||||
lodash: 4.17.21
|
||||
midievents: 2.0.0
|
||||
midifile: 2.0.0
|
||||
parse-sng: 3.1.0
|
||||
parse-sng: 3.1.1
|
||||
sanitize-filename: 1.6.3
|
||||
sharp: 0.32.6
|
||||
stream-audio-fingerprint: github.com/Geomitron/stream-audio-fingerprint/197e8a7ff60165b18bf1debc23dab4814996a20d
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<app-search-bar></app-search-bar>
|
||||
<div class="flex flex-1 overflow-y-hidden">
|
||||
<!-- TODO: adjust sizing -->
|
||||
<div class="flex flex-1 overflow-hidden">
|
||||
<div
|
||||
class="basis-2/3 flex-1 overflow-y-auto scrollbar scrollbar-w-2 scrollbar-h-2 scrollbar-track-base-300 scrollbar-thumb-neutral scrollbar-thumb-rounded-full"
|
||||
(scroll)="resultTable.tableScrolled($event)">
|
||||
|
||||
@@ -3,6 +3,7 @@ import { FormControl } from '@angular/forms'
|
||||
|
||||
import { capitalize, chain, compact, flatMap, intersection, round, sortBy } from 'lodash'
|
||||
import { ChartIssueType, Difficulty, FolderIssueType, Instrument, MetadataIssueType, NoteIssueType, TrackIssueType } from 'scan-chart'
|
||||
import { DownloadService } from 'src-angular/app/core/services/download.service'
|
||||
import { SearchService } from 'src-angular/app/core/services/search.service'
|
||||
import { SettingsService } from 'src-angular/app/core/services/settings.service'
|
||||
import { ChartData } from 'src-shared/interfaces/search.interface'
|
||||
@@ -38,6 +39,7 @@ export class ChartSidebarComponent implements OnInit {
|
||||
constructor(
|
||||
private renderer: Renderer2,
|
||||
private searchService: SearchService,
|
||||
private downloadService: DownloadService,
|
||||
public settingsService: SettingsService
|
||||
) { }
|
||||
|
||||
@@ -278,17 +280,11 @@ export class ChartSidebarComponent implements OnInit {
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds the selected version to the download queue.
|
||||
* Adds the selected chart to the download queue.
|
||||
*/
|
||||
onDownloadClicked() {
|
||||
// TODO
|
||||
// this.downloadService.addDownload(
|
||||
// this.selectedChart.versionID, {
|
||||
// chartName: this.selectedChart.chartName,
|
||||
// artist: this.songResult!.artist,
|
||||
// charter: this.selectedChart.charters,
|
||||
// driveData: this.selectedChart.driveData,
|
||||
// })
|
||||
this.downloadService.addDownload(this.selectedChart!.md5, `${this.selectedChart!.artist ?? 'Unknown Artist'
|
||||
} - ${this.selectedChart!.name ?? 'Unknown Name'} (${this.selectedChart!.charter ?? 'Unknown Charter'})`)
|
||||
}
|
||||
|
||||
public showMenu() {
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
<table id="resultTable" class="table table-zebra table-pin-rows">
|
||||
<!-- TODO: maybe have some of these tags customizable? E.g. small/large/compact/padded -->
|
||||
<!-- TODO: learn semantic themes in order to change the $mobileBreakpoint global variable (better search table adjustment) -->
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="collapsing" id="checkboxColumn">
|
||||
|
||||
@@ -107,16 +107,6 @@ export class SearchBarComponent implements OnInit, AfterViewInit {
|
||||
return dayjs().format('YYYY-MM-DD')
|
||||
}
|
||||
|
||||
// TODO: run this when infinite scroll should happen
|
||||
// @HostListener("window:scroll", [])
|
||||
// onScroll(): void {
|
||||
// if ((window.innerHeight + window.scrollY) >= document.body.offsetHeight) {
|
||||
// if (this.searchService.areMorePages && !this.searchService.searchLoading) {
|
||||
// this.searchService.search(this.searchControl.value || '*', true).subscribe()
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
initializeAdvancedSearchForm() {
|
||||
this.advancedSearchForm = this.getAdvancedSearchForm() as ReturnType<this['getAdvancedSearchForm']>
|
||||
|
||||
|
||||
@@ -1,24 +1,64 @@
|
||||
<div *ngIf="downloads.length > 0" class="ui segments">
|
||||
<div *ngFor="let download of downloads; trackBy: trackByVersionID" class="ui segment" [style.background-color]="getBackgroundColor(download)">
|
||||
<h3 id="downloadTitle">
|
||||
<span style="flex-grow: 1">{{ download.title }}</span>
|
||||
<i class="inside right close icon" (click)="cancelDownload(download.versionID)"></i>
|
||||
</h3>
|
||||
|
||||
<div id="downloadText">
|
||||
<span style="flex-grow: 1">{{ download.header }}</span>
|
||||
<span *ngIf="!download.isLink" class="description">{{ download.description }}</span>
|
||||
<span *ngIf="download.isLink" class="description">
|
||||
<a (click)="showFile(download.description)">{{ download.description }}</a>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div id="downloadProgressDiv">
|
||||
<progress [value]="download.percent" max="100" class="progress w-96" [class.progress-error]="download.type === 'error'"></progress>
|
||||
<button *ngIf="download.type === 'error'" class="ui right attached labeled compact icon button" (click)="retryDownload(download.versionID)">
|
||||
<i class="redo icon"></i>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex flex-col gap-4 min-h-0">
|
||||
<div
|
||||
class="flex flex-col gap-1 p-1 overflow-y-auto max-h-[75vh] scrollbar scrollbar-w-2 scrollbar-h-2 scrollbar-thumb-neutral scrollbar-thumb-rounded-full">
|
||||
@for (download of downloadService.downloads; track download.md5) {
|
||||
<div
|
||||
class="card bg-neutral text-neutral-content shadow-xl"
|
||||
[class.border-error]="download.type === 'error'"
|
||||
[class.border-2]="download.type === 'error'">
|
||||
<div class="card-body">
|
||||
<div class="card-actions justify-end">
|
||||
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2" (click)="downloadService.cancelDownload(download.md5)">
|
||||
<i class="bi bi-x-lg text-lg"></i>
|
||||
</button>
|
||||
</div>
|
||||
<h2 class="card-title">{{ download.chartName }}</h2>
|
||||
<progress
|
||||
[attr.value]="download.percent"
|
||||
max="100"
|
||||
class="progress progress-primary w-full"
|
||||
[class.progress-error]="download.type === 'error'"></progress>
|
||||
<div class="flex justify-between gap-4">
|
||||
<div>
|
||||
<p class="text-lg">
|
||||
@if (download.type === 'done') {
|
||||
<a class="link link-hover" (click)="showFile(download.body)">{{ download.header }}</a>
|
||||
} @else {
|
||||
{{ download.header }}
|
||||
}
|
||||
</p>
|
||||
@if (download.type !== 'done') {
|
||||
@if (download.isPath) {
|
||||
<p>
|
||||
<a (click)="showFile(download.body)" class="link">{{ download.body }}</a>
|
||||
</p>
|
||||
} @else {
|
||||
<p>{{ download.body }}</p>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
<div *ngIf="download.type === 'error'" class="flex flex-col justify-end">
|
||||
<button class="btn btn-outline btn-error flex-nowrap" (click)="downloadService.retryDownload(download.md5)">
|
||||
<i class="bi bi-arrow-repeat"></i>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="downloadTitle">
|
||||
<span style="flex-grow: 1"></span>
|
||||
<i class="inside right close icon" (click)="downloadService.cancelDownload(download.md5)"></i>
|
||||
</div>
|
||||
<div id="downloadText">
|
||||
<span style="flex-grow: 1"></span>
|
||||
<span *ngIf="!download.isPath" class="description"></span>
|
||||
<span *ngIf="download.isPath" class="description"> </span>
|
||||
</div>
|
||||
<div id="downloadProgressDiv"></div>
|
||||
}
|
||||
</div>
|
||||
<div *ngIf="downloadService.completedCount > 1" class="flex justify-end">
|
||||
<button class="btn btn-success btn-sm" (click)="downloadService.cancelAllCompleted()">Clear completed</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { ChangeDetectorRef, Component } from '@angular/core'
|
||||
import { Component, HostBinding } from '@angular/core'
|
||||
|
||||
import { DownloadProgress } from '../../../../../../src-shared/interfaces/download.interface'
|
||||
import { DownloadService } from '../../../../core/services/download.service'
|
||||
|
||||
@Component({
|
||||
@@ -8,45 +7,9 @@ import { DownloadService } from '../../../../core/services/download.service'
|
||||
templateUrl: './downloads-modal.component.html',
|
||||
})
|
||||
export class DownloadsModalComponent {
|
||||
@HostBinding('class.contents') contents = true
|
||||
|
||||
downloads: DownloadProgress[] = []
|
||||
|
||||
constructor(private downloadService: DownloadService, ref: ChangeDetectorRef) {
|
||||
window.electron.on.queueUpdated(order => {
|
||||
this.downloads.sort((a, b) => order.indexOf(a.versionID) - order.indexOf(b.versionID))
|
||||
})
|
||||
|
||||
downloadService.onDownloadUpdated(download => {
|
||||
const index = this.downloads.findIndex(thisDownload => thisDownload.versionID === download.versionID)
|
||||
if (download.type === 'cancel') {
|
||||
this.downloads = this.downloads.filter(thisDownload => thisDownload.versionID !== download.versionID)
|
||||
} else if (index === -1) {
|
||||
this.downloads.push(download)
|
||||
} else {
|
||||
this.downloads[index] = download
|
||||
}
|
||||
ref.detectChanges()
|
||||
})
|
||||
}
|
||||
|
||||
trackByVersionID(_index: number, item: DownloadProgress) {
|
||||
return item.versionID
|
||||
}
|
||||
|
||||
cancelDownload(versionID: number) {
|
||||
this.downloadService.cancelDownload(versionID)
|
||||
}
|
||||
|
||||
retryDownload(versionID: number) {
|
||||
this.downloadService.retryDownload(versionID)
|
||||
}
|
||||
|
||||
getBackgroundColor(download: DownloadProgress) {
|
||||
switch (download.type) {
|
||||
case 'error': return '#a63a3a'
|
||||
default: return undefined
|
||||
}
|
||||
}
|
||||
constructor(public downloadService: DownloadService) { }
|
||||
|
||||
showFile(filepath: string) {
|
||||
window.electron.emit.showFile(filepath)
|
||||
|
||||
@@ -7,18 +7,20 @@
|
||||
Download {{ selectedGroupIds.length }} Results
|
||||
</button>
|
||||
</div>
|
||||
<button *ngIf="downloading" (click)="showDownloads()" class="btn btn-sm btn-ghost w-[30vw]">
|
||||
<progress [value]="percent" max="100" class="progress h-3 rounded-md" [class.progress-error]="error"></progress>
|
||||
<button *ngIf="downloadService.downloadCount > 0" (click)="downloadsModal.showModal()" class="btn btn-sm btn-ghost w-[30vw]">
|
||||
<progress
|
||||
[attr.value]="downloadService.totalDownloadingPercent"
|
||||
max="100"
|
||||
class="progress h-3 rounded-md"
|
||||
[class.progress-error]="downloadService.anyErrorsExist"></progress>
|
||||
</button>
|
||||
|
||||
<div id="downloadsModal" class="ui modal">
|
||||
<i class="inside close icon"></i>
|
||||
<div class="header">
|
||||
<span>Downloads</span>
|
||||
<div *ngIf="multipleCompleted" class="ui positive compact button" (click)="clearCompleted()">Clear completed</div>
|
||||
<dialog #downloadsModal class="modal whitespace-normal">
|
||||
<div class="modal-box bg-base-100 text-base-content flex flex-col gap-2 w-9/12 max-w-7xl min-h-0 overflow-y-clip">
|
||||
<app-downloads-modal />
|
||||
</div>
|
||||
<div class="scrolling content">
|
||||
<app-downloads-modal></app-downloads-modal>
|
||||
</div>
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop">
|
||||
<button>close</button>
|
||||
</form>
|
||||
</dialog>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ChangeDetectorRef, Component } from '@angular/core'
|
||||
import { Component, ElementRef, ViewChild } from '@angular/core'
|
||||
|
||||
import { keys, pickBy } from 'lodash'
|
||||
|
||||
@@ -12,6 +12,8 @@ import { SelectionService } from '../../../core/services/selection.service'
|
||||
})
|
||||
export class StatusBarComponent {
|
||||
|
||||
@ViewChild('downloadsModal', { static: false }) downloadsModalComponent: ElementRef
|
||||
|
||||
multipleCompleted = false
|
||||
downloading = false
|
||||
error = false
|
||||
@@ -23,19 +25,14 @@ export class StatusBarComponent {
|
||||
chartGroups: any[][]
|
||||
|
||||
constructor(
|
||||
private downloadService: DownloadService,
|
||||
public downloadService: DownloadService,
|
||||
public searchService: SearchService,
|
||||
private selectionService: SelectionService,
|
||||
ref: ChangeDetectorRef
|
||||
) {
|
||||
downloadService.onDownloadUpdated(() => {
|
||||
setTimeout(() => { // Make sure this is the last callback executed to get the accurate downloadCount
|
||||
this.downloading = downloadService.downloadCount > 0
|
||||
this.multipleCompleted = downloadService.completedCount > 1
|
||||
this.percent = downloadService.totalDownloadingPercent
|
||||
this.error = downloadService.anyErrorsExist
|
||||
ref.detectChanges()
|
||||
}, 0)
|
||||
this.downloadService.downloadCountChanges.subscribe(downloadCount => {
|
||||
if (downloadCount === 0) {
|
||||
this.downloadsModalComponent.nativeElement.close()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -43,11 +40,6 @@ export class StatusBarComponent {
|
||||
return keys(pickBy(this.selectionService.selections)).map(k => Number(k))
|
||||
}
|
||||
|
||||
showDownloads() {
|
||||
// TODO
|
||||
// $('#downloadsModal').modal('show')
|
||||
}
|
||||
|
||||
async downloadSelected() {
|
||||
// this.chartGroups = []
|
||||
// // TODO
|
||||
@@ -105,6 +97,6 @@ export class StatusBarComponent {
|
||||
}
|
||||
|
||||
clearCompleted() {
|
||||
this.downloadService.cancelCompleted()
|
||||
this.downloadService.cancelAllCompleted()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,29 +1,28 @@
|
||||
import { EventEmitter, Injectable } from '@angular/core'
|
||||
import { EventEmitter, Injectable, NgZone } from '@angular/core'
|
||||
|
||||
import { DownloadProgress, NewDownload } from '../../../../src-shared/interfaces/download.interface'
|
||||
import { assign } from 'lodash'
|
||||
|
||||
import { DownloadProgress } from '../../../../src-shared/interfaces/download.interface'
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class DownloadService {
|
||||
|
||||
private downloadUpdatedEmitter = new EventEmitter<DownloadProgress>()
|
||||
private downloads: DownloadProgress[] = []
|
||||
public downloadCountChanges = new EventEmitter<number>()
|
||||
public downloads: DownloadProgress[] = []
|
||||
|
||||
constructor() {
|
||||
window.electron.on.downloadUpdated(result => {
|
||||
// Update <this.downloads> with result
|
||||
const thisDownloadIndex = this.downloads.findIndex(download => download.versionID === result.versionID)
|
||||
if (result.type === 'cancel') {
|
||||
this.downloads = this.downloads.filter(download => download.versionID !== result.versionID)
|
||||
} else if (thisDownloadIndex === -1) {
|
||||
this.downloads.push(result)
|
||||
constructor(zone: NgZone) {
|
||||
window.electron.on.downloadQueueUpdate(download => zone.run(() => {
|
||||
const downloadIndex = this.downloads.findIndex(d => d.md5 === download.md5)
|
||||
if (download.type === 'cancel') {
|
||||
this.downloads = this.downloads.filter(d => d.md5 !== this.downloads[downloadIndex].md5)
|
||||
} else if (downloadIndex === -1) {
|
||||
this.downloads.push(download)
|
||||
} else {
|
||||
this.downloads[thisDownloadIndex] = result
|
||||
assign(this.downloads[downloadIndex], download)
|
||||
}
|
||||
|
||||
this.downloadUpdatedEmitter.emit(result)
|
||||
})
|
||||
}))
|
||||
}
|
||||
|
||||
get downloadCount() {
|
||||
@@ -39,50 +38,62 @@ export class DownloadService {
|
||||
let count = 0
|
||||
for (const download of this.downloads) {
|
||||
if (!download.stale) {
|
||||
total += download.percent
|
||||
total += download.percent ?? 0
|
||||
count++
|
||||
}
|
||||
}
|
||||
return total / count
|
||||
if (this.anyErrorsExist && total === 0) { return null }
|
||||
return count ? total / count : 0
|
||||
}
|
||||
|
||||
get anyErrorsExist() {
|
||||
return this.downloads.find(download => download.type === 'error') ? true : false
|
||||
}
|
||||
|
||||
addDownload(versionID: number, newDownload: NewDownload) {
|
||||
if (!this.downloads.find(download => download.versionID === versionID)) { // Don't download something twice
|
||||
if (this.downloads.every(download => download.type === 'done')) { // Reset overall progress bar if it finished
|
||||
this.downloads.forEach(download => download.stale = true)
|
||||
addDownload(md5: string, chartName: string) {
|
||||
if (!this.downloads.find(d => d.md5 === md5)) { // Don't download something twice
|
||||
if (this.downloads.every(d => d.type === 'done')) { // Reset overall progress bar if it finished
|
||||
this.downloads.forEach(d => d.stale = true)
|
||||
}
|
||||
window.electron.emit.download({ action: 'add', versionID, data: newDownload })
|
||||
this.downloads.push({
|
||||
md5,
|
||||
chartName,
|
||||
header: 'Waiting for other downloads to finish...',
|
||||
body: '',
|
||||
percent: 0,
|
||||
type: 'good',
|
||||
isPath: false,
|
||||
})
|
||||
window.electron.emit.download({ action: 'add', md5, chartName })
|
||||
}
|
||||
this.downloadCountChanges.emit(this.downloadCount)
|
||||
}
|
||||
|
||||
onDownloadUpdated(callback: (download: DownloadProgress) => void) {
|
||||
this.downloadUpdatedEmitter.subscribe(callback)
|
||||
cancelDownload(md5: string) {
|
||||
window.electron.emit.download({ action: 'remove', md5 })
|
||||
this.downloadCountChanges.emit(this.downloadCount - 1)
|
||||
}
|
||||
|
||||
cancelDownload(versionID: number) {
|
||||
const removedDownload = this.downloads.find(download => download.versionID === versionID)!
|
||||
if (['error', 'done'].includes(removedDownload.type)) {
|
||||
this.downloads = this.downloads.filter(download => download.versionID !== versionID)
|
||||
removedDownload.type = 'cancel'
|
||||
this.downloadUpdatedEmitter.emit(removedDownload)
|
||||
} else {
|
||||
window.electron.emit.download({ action: 'cancel', versionID })
|
||||
}
|
||||
}
|
||||
|
||||
cancelCompleted() {
|
||||
cancelAllCompleted() {
|
||||
for (const download of this.downloads) {
|
||||
if (download.type === 'done') {
|
||||
this.cancelDownload(download.versionID)
|
||||
window.electron.emit.download({ action: 'remove', md5: download.md5 })
|
||||
}
|
||||
}
|
||||
this.downloads = this.downloads.filter(d => d.type !== 'done')
|
||||
this.downloadCountChanges.emit(this.downloadCount)
|
||||
}
|
||||
|
||||
retryDownload(versionID: number) {
|
||||
window.electron.emit.download({ action: 'retry', versionID })
|
||||
retryDownload(md5: string) {
|
||||
const chartDownload = this.downloads.find(d => d.md5 === md5)
|
||||
if (chartDownload) {
|
||||
chartDownload.type = 'good'
|
||||
chartDownload.header = 'Waiting to retry...'
|
||||
chartDownload.body = ''
|
||||
chartDownload.isPath = false
|
||||
chartDownload.percent = 0
|
||||
|
||||
window.electron.emit.download({ action: 'retry', md5 })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,25 +159,3 @@ export class SearchService {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: maybe use this, or delete it
|
||||
/**
|
||||
* Orders `versionResults` by lastModified date, but prefer the
|
||||
* non-pack version if it's only a few days older.
|
||||
*/
|
||||
// sortChart(versionResults: VersionResult[]) {
|
||||
// const dates: { [versionID: number]: number } = {}
|
||||
// versionResults.forEach(version => dates[version.versionID] = new Date(version.lastModified).getTime())
|
||||
// versionResults.sort((v1, v2) => {
|
||||
// const diff = dates[v2.versionID] - dates[v1.versionID]
|
||||
// if (Math.abs(diff) < 6.048e+8 && v1.driveData.inChartPack !== v2.driveData.inChartPack) {
|
||||
// if (v1.driveData.inChartPack) {
|
||||
// return 1 // prioritize v2
|
||||
// } else {
|
||||
// return -1 // prioritize v1
|
||||
// }
|
||||
// } else {
|
||||
// return diff
|
||||
// }
|
||||
// })
|
||||
// }
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { randomBytes } from 'crypto'
|
||||
import { basename, parse } from 'path'
|
||||
import sanitize from 'sanitize-filename'
|
||||
import { inspect } from 'util'
|
||||
|
||||
import { lower } from '../src-shared/UtilFunctions'
|
||||
@@ -26,3 +28,26 @@ export function hasVideoExtension(name: string) {
|
||||
export function devLog(message: unknown) {
|
||||
emitIpcEvent('errorLog', typeof message === 'string' ? message : inspect(message))
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns `filename` with all invalid filename characters replaced.
|
||||
*/
|
||||
export function sanitizeFilename(filename: string): string {
|
||||
const newFilename = sanitize(filename, {
|
||||
replacement: ((invalidChar: string) => {
|
||||
switch (invalidChar) {
|
||||
case '<': return '❮'
|
||||
case '>': return '❯'
|
||||
case ':': return '꞉'
|
||||
case '"': return "'"
|
||||
case '/': return '/'
|
||||
case '\\': return '⧵'
|
||||
case '|': return '⏐'
|
||||
case '?': return '?'
|
||||
case '*': return '⁎'
|
||||
default: return '_'
|
||||
}
|
||||
}),
|
||||
})
|
||||
return (newFilename === '' ? randomBytes(5).toString('hex') : newFilename)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { IpcInvokeHandlers, IpcToMainEmitHandlers } from '../src-shared/interfaces/ipc.interface'
|
||||
import { download } from './ipc/download/DownloadHandler'
|
||||
import { download } from './ipc/DownloadHandler.ipc'
|
||||
import { getSettings, setSettings } from './ipc/SettingsHandler.ipc'
|
||||
import { downloadUpdate, getCurrentVersion, getUpdateAvailable, quitAndInstall, retryUpdate } from './ipc/UpdateHandler.ipc'
|
||||
import { isMaximized, maximize, minimize, openUrl, quit, restore, showFile, showFolder, showOpenDialog, toggleDevTools } from './ipc/UtilHandlers.ipc'
|
||||
|
||||
12
src-electron/ipc/DownloadHandler.ipc.ts
Normal file
12
src-electron/ipc/DownloadHandler.ipc.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Download } from '../../src-shared/interfaces/download.interface'
|
||||
import { DownloadQueue } from './download/DownloadQueue'
|
||||
|
||||
const downloadQueue: DownloadQueue = new DownloadQueue()
|
||||
|
||||
export async function download(data: Download) {
|
||||
switch (data.action) {
|
||||
case 'add': downloadQueue.add(data.md5, data.chartName!); break
|
||||
case 'retry': downloadQueue.retry(data.md5); break
|
||||
case 'remove': downloadQueue.remove(data.md5); break
|
||||
}
|
||||
}
|
||||
@@ -1,286 +1,305 @@
|
||||
import { parse } from 'path'
|
||||
import { randomUUID } from 'crypto'
|
||||
import EventEmitter from 'events'
|
||||
import { createWriteStream, WriteStream } from 'fs'
|
||||
import { access, constants } from 'fs/promises'
|
||||
import { round, throttle } from 'lodash'
|
||||
import { mkdirp } from 'mkdirp'
|
||||
import mv from 'mv'
|
||||
import { SngStream } from 'parse-sng'
|
||||
import { join } from 'path'
|
||||
import { rimraf } from 'rimraf'
|
||||
import { inspect } from 'util'
|
||||
|
||||
import { NewDownload, ProgressType } from '../../../src-shared/interfaces/download.interface'
|
||||
import { sanitizeFilename } from '../../../src-shared/UtilFunctions'
|
||||
import { hasVideoExtension } from '../../ElectronUtilFunctions'
|
||||
import { emitIpcEvent } from '../../main'
|
||||
import { settings } from '../SettingsHandler.ipc'
|
||||
// import { FileDownloader, getDownloader } from './FileDownloader'
|
||||
import { FilesystemChecker } from './FilesystemChecker'
|
||||
import { FileTransfer } from './FileTransfer'
|
||||
import { tempPath } from '../../../src-shared/Paths'
|
||||
import { sanitizeFilename } from '../../ElectronUtilFunctions'
|
||||
import { getSettings } from '../SettingsHandler.ipc'
|
||||
|
||||
interface EventCallback {
|
||||
/** Note: this will not be the last event if `retry()` is called. */
|
||||
'error': () => void
|
||||
'complete': () => void
|
||||
export interface DownloadMessage {
|
||||
header: string
|
||||
body: string
|
||||
isPath?: boolean
|
||||
}
|
||||
type Callbacks = { [E in keyof EventCallback]: EventCallback[E] }
|
||||
|
||||
export interface DownloadError { header: string; body: string; isLink?: boolean }
|
||||
interface ChartDownloadEvents {
|
||||
'progress': (message: DownloadMessage, percent: number | null) => void
|
||||
'error': (err: DownloadMessage) => void
|
||||
'end': (destinationPath: string) => void
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
|
||||
export declare interface ChartDownload {
|
||||
/**
|
||||
* Registers `listener` to be called when download progress has occured.
|
||||
* `percent` is a number between 0 and 100, or `null` if the progress is indeterminate.
|
||||
* Progress events are throttled to avoid performance issues with rapid updates.
|
||||
*/
|
||||
on(event: 'progress', listener: (message: DownloadMessage, percent: number | null) => void): void
|
||||
|
||||
/**
|
||||
* Registers `listener` to be called if the download process threw an exception. If this is called, the "end" event won't happen.
|
||||
*/
|
||||
on(event: 'error', listener: (err: DownloadMessage) => void): void
|
||||
|
||||
/**
|
||||
* Registers `listener` to be called when the chart has been fully downloaded. If this is called, the "error" event won't happen.
|
||||
*/
|
||||
on(event: 'end', listener: (destinationPath: string) => void): void
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
|
||||
export class ChartDownload {
|
||||
|
||||
private retryFn: undefined | (() => void | Promise<void>)
|
||||
private cancelFn: undefined | (() => void)
|
||||
private _canceled = false
|
||||
|
||||
private callbacks = {} as Callbacks
|
||||
// TODO
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
private files: any[]
|
||||
private percent = 0 // Needs to be stored here because errors won't know the exact percent
|
||||
private eventEmitter = new EventEmitter()
|
||||
|
||||
private stepCompletedCount = 0
|
||||
private tempPath: string
|
||||
private wasCanceled = false
|
||||
|
||||
private readonly individualFileProgressPortion: number
|
||||
private readonly destinationFolderName: string
|
||||
private destinationName: string
|
||||
private isSng: boolean
|
||||
|
||||
private _allFilesProgress = 0
|
||||
get allFilesProgress() { return this._allFilesProgress }
|
||||
private _hasFailed = false
|
||||
/** If this chart download needs to be retried */
|
||||
get hasFailed() { return this._hasFailed }
|
||||
get isArchive() { return this.data.driveData.isArchive }
|
||||
get hash() { return this.data.driveData.filesHash }
|
||||
private showProgress = throttle((description: string, percent: number | null = null) => {
|
||||
this.eventEmitter.emit('progress', { header: description, body: '' }, percent)
|
||||
}, 10, { leading: true, trailing: true })
|
||||
|
||||
constructor(public versionID: number, private data: NewDownload) {
|
||||
this.updateGUI('', 'Waiting for other downloads to finish...', 'good')
|
||||
this.files = this.filterDownloadFiles(data.driveData.files)
|
||||
this.individualFileProgressPortion = 80 / this.files.length
|
||||
if (data.driveData.inChartPack) {
|
||||
this.destinationFolderName = sanitizeFilename(parse(data.driveData.files[0].name).name)
|
||||
} else {
|
||||
this.destinationFolderName = sanitizeFilename(`${this.data.artist} - ${this.data.chartName} (${this.data.charter})`)
|
||||
constructor(public readonly md5: string, private chartName: string) { }
|
||||
|
||||
on<T extends keyof ChartDownloadEvents>(event: T, listener: ChartDownloadEvents[T]) {
|
||||
this.eventEmitter.on(event, listener)
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks the target directory to determine if it is accessible.
|
||||
*
|
||||
* Checks the target directory if the chart already exists.
|
||||
*
|
||||
* Downloads the chart to a temporary directory.
|
||||
*
|
||||
* Moves the chart to the target directory.
|
||||
*/
|
||||
async startOrRetry() {
|
||||
try {
|
||||
switch (this.stepCompletedCount) {
|
||||
case 0: await this.checkFilesystem(); this.stepCompletedCount++; if (this._canceled) { return } // break omitted
|
||||
case 1: await this.downloadChart(); this.stepCompletedCount++; if (this._canceled) { return } // break omitted
|
||||
case 2: await this.transferChart(); this.stepCompletedCount++; if (this._canceled) { return } // break omitted
|
||||
}
|
||||
} catch (err) {
|
||||
this.showProgress.cancel()
|
||||
if (err.header && (err.body || err.body === '')) {
|
||||
this.eventEmitter.emit('error', err)
|
||||
} else {
|
||||
this.eventEmitter.emit('error', { header: 'Unknown Error', body: inspect(err) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls `callback` when `event` fires. (no events will be fired after `this.cancel()` is called)
|
||||
*/
|
||||
on<E extends keyof EventCallback>(event: E, callback: EventCallback[E]) {
|
||||
this.callbacks[event] = callback
|
||||
}
|
||||
|
||||
// TODO
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
filterDownloadFiles(files: any[]) {
|
||||
return files.filter(file => {
|
||||
return (file.name !== 'ch.dat') && (settings.downloadVideos || !hasVideoExtension(file.name))
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Retries the last failed step if it is running.
|
||||
*/
|
||||
retry() { // Only allow it to be called once
|
||||
if (this.retryFn !== undefined) {
|
||||
this._hasFailed = false
|
||||
const retryFn = this.retryFn
|
||||
this.retryFn = undefined
|
||||
retryFn()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the GUI to indicate that a retry will be attempted.
|
||||
*/
|
||||
displayRetrying() {
|
||||
this.updateGUI('', 'Waiting for other downloads to finish to retry...', 'good')
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancels the download if it is running.
|
||||
*/
|
||||
cancel() { // Only allow it to be called once
|
||||
if (this.cancelFn !== undefined) {
|
||||
const cancelFn = this.cancelFn
|
||||
this.cancelFn = undefined
|
||||
cancelFn()
|
||||
cancel() {
|
||||
this.showProgress.cancel()
|
||||
this._canceled = true
|
||||
if (this.tempPath) {
|
||||
rimraf(this.tempPath).catch(() => { /** Do nothing */ }) // Delete temp folder
|
||||
}
|
||||
this.updateGUI('', '', 'cancel')
|
||||
this.wasCanceled = true
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the GUI with new information about this chart download.
|
||||
*/
|
||||
private updateGUI(header: string, description: string, type: ProgressType, isLink = false) {
|
||||
if (this.wasCanceled) { return }
|
||||
|
||||
emitIpcEvent('downloadUpdated', {
|
||||
versionID: this.versionID,
|
||||
title: `${this.data.chartName} - ${this.data.artist}`,
|
||||
header: header,
|
||||
description: description,
|
||||
percent: this.percent,
|
||||
type: type,
|
||||
isLink,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the retry function, update the GUI, and call the `error` callback.
|
||||
*/
|
||||
private handleError(err: DownloadError, retry: () => void) {
|
||||
this._hasFailed = true
|
||||
this.retryFn = retry
|
||||
this.updateGUI(err.header, err.body, 'error', err.isLink === true)
|
||||
this.callbacks.error()
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the download process.
|
||||
*/
|
||||
async beginDownload() {
|
||||
// CHECK FILESYSTEM ACCESS
|
||||
const checker = new FilesystemChecker(this.destinationFolderName)
|
||||
this.cancelFn = () => checker.cancelCheck()
|
||||
|
||||
const checkerComplete = this.addFilesystemCheckerEventListeners(checker)
|
||||
checker.beginCheck()
|
||||
await checkerComplete
|
||||
|
||||
// DOWNLOAD FILES
|
||||
for (let i = 0; i < this.files.length; i++) {
|
||||
let wasCanceled = false
|
||||
this.cancelFn = () => { wasCanceled = true }
|
||||
// const downloader = getDownloader(this.files[i].webContentLink, join(this.tempPath, this.files[i].name))
|
||||
if (wasCanceled) { return }
|
||||
// this.cancelFn = () => downloader.cancelDownload()
|
||||
|
||||
// const downloadComplete = this.addDownloadEventListeners(downloader, i)
|
||||
// downloader.beginDownload()
|
||||
// await downloadComplete
|
||||
private async checkFilesystem() {
|
||||
this.showProgress('Loading settings...')
|
||||
const settings = await getSettings()
|
||||
if (this._canceled) { return }
|
||||
if (!settings.libraryPath) {
|
||||
throw { header: 'Library folder not specified', body: 'Please go to the settings to set your library folder.' }
|
||||
}
|
||||
|
||||
// EXTRACT FILES
|
||||
if (this.isArchive) {
|
||||
// const extractor = new FileExtractor(this.tempPath)
|
||||
// this.cancelFn = () => extractor.cancelExtract()
|
||||
|
||||
// const extractComplete = this.addExtractorEventListeners(extractor)
|
||||
// extractor.beginExtract()
|
||||
// await extractComplete
|
||||
try {
|
||||
this.showProgress('Checking library path...')
|
||||
await access(settings.libraryPath, constants.W_OK)
|
||||
if (this._canceled) { return }
|
||||
} catch (err) {
|
||||
throw { header: 'Failed to access library folder', body: inspect(err) }
|
||||
}
|
||||
|
||||
// TRANSFER FILES
|
||||
const transfer = new FileTransfer(this.tempPath, this.destinationFolderName)
|
||||
this.cancelFn = () => transfer.cancelTransfer()
|
||||
this.isSng = settings.isSng
|
||||
this.destinationName = sanitizeFilename(this.isSng ? `${this.chartName}.sng` : this.chartName)
|
||||
this.showProgress('Checking for any duplicate charts...')
|
||||
const destinationPath = join(settings.libraryPath, this.destinationName)
|
||||
const isDuplicate = await access(destinationPath, constants.F_OK).then(() => true).catch(() => false)
|
||||
if (this._canceled) { return }
|
||||
if (isDuplicate) {
|
||||
throw { header: 'This chart already exists in your library folder', body: destinationPath, isPath: true }
|
||||
}
|
||||
|
||||
const transferComplete = this.addTransferEventListeners(transfer)
|
||||
transfer.beginTransfer()
|
||||
await transferComplete
|
||||
|
||||
this.callbacks.complete()
|
||||
this.tempPath = join(tempPath, randomUUID())
|
||||
try {
|
||||
this.showProgress('Creating temporary download folder...')
|
||||
await mkdirp(this.tempPath)
|
||||
if (this._canceled) { return }
|
||||
} catch (err) {
|
||||
throw { header: 'Failed to create temporary download folder', body: inspect(err) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines what happens in reponse to `FilesystemChecker` events.
|
||||
* @returns a `Promise` that resolves when the filesystem has been checked.
|
||||
*/
|
||||
private addFilesystemCheckerEventListeners(checker: FilesystemChecker) {
|
||||
checker.on('start', () => {
|
||||
this.updateGUI('Checking filesystem...', '', 'good')
|
||||
})
|
||||
private async downloadChart() {
|
||||
const sngResponse = await fetch(`https://files.enchor.us/${this.md5}.sng`, { mode: 'cors', referrerPolicy: 'no-referrer' })
|
||||
if (!sngResponse.ok || !sngResponse.body) {
|
||||
throw { header: 'Failed to download the chart file', body: `Response code ${sngResponse.status}: ${sngResponse.statusText}` }
|
||||
}
|
||||
const fileSize = BigInt(sngResponse.headers.get('Content-Length')!)
|
||||
|
||||
checker.on('error', this.handleError.bind(this))
|
||||
if (this.isSng) {
|
||||
const writeStream = createWriteStream(join(this.tempPath, this.destinationName))
|
||||
const reader = sngResponse.body.getReader()
|
||||
let downloadedByteCount = BigInt(0)
|
||||
|
||||
return new Promise<void>(resolve => {
|
||||
checker.on('complete', tempPath => {
|
||||
this.tempPath = tempPath
|
||||
resolve()
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
while (true) {
|
||||
let result: ReadableStreamReadResult<Uint8Array>
|
||||
try {
|
||||
result = await reader.read()
|
||||
} catch (err) {
|
||||
throw { header: 'Failed to download the chart file', body: inspect(err) }
|
||||
}
|
||||
|
||||
if (this._canceled) {
|
||||
await reader.cancel()
|
||||
writeStream.end()
|
||||
return
|
||||
}
|
||||
|
||||
if (result.done) { writeStream.end(); return }
|
||||
|
||||
downloadedByteCount += BigInt(result.value.length)
|
||||
const downloadPercent = round(100 * Number(downloadedByteCount / BigInt(1000)) / Number(fileSize / BigInt(1000)), 1)
|
||||
this.showProgress(`Downloading... (${downloadPercent}%)`, downloadPercent)
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
writeStream.write(result.value, err => {
|
||||
if (err) {
|
||||
reject({ header: 'Failed to download the chart file', body: inspect(err) })
|
||||
} else {
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
if (writeStream.writableNeedDrain) {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
writeStream.once('drain', resolve)
|
||||
writeStream.once('error', err => reject({ header: 'Failed to download the chart file', body: inspect(err) }))
|
||||
})
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const sngStream = new SngStream(() => sngResponse.body!, { generateSongIni: true })
|
||||
let downloadedByteCount = BigInt(0)
|
||||
|
||||
await mkdirp(join(this.tempPath, this.destinationName))
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
sngStream.on('file', async (fileName, fileStream) => {
|
||||
let writeStream: WriteStream
|
||||
let reader: ReadableStreamDefaultReader<Uint8Array>
|
||||
try {
|
||||
writeStream = createWriteStream(join(this.tempPath, this.destinationName, fileName))
|
||||
writeStream.on('error', () => { /** Surpress unhandled promise rejection */ })
|
||||
reader = fileStream.getReader()
|
||||
} catch (err) {
|
||||
reject(err)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
while (true) {
|
||||
let result: ReadableStreamReadResult<Uint8Array>
|
||||
try {
|
||||
result = await reader.read()
|
||||
} catch (err) {
|
||||
throw { header: 'Failed to download the chart file', body: inspect(err) }
|
||||
}
|
||||
|
||||
if (this._canceled) {
|
||||
await reader.cancel()
|
||||
writeStream.end()
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
|
||||
if (result.done) { writeStream.end(); return }
|
||||
|
||||
downloadedByteCount += BigInt(result.value.length)
|
||||
const downloadPercent =
|
||||
round(100 * Number(downloadedByteCount / BigInt(1000)) / Number(fileSize / BigInt(1000)), 1)
|
||||
this.showProgress(`Downloading "${fileName}"... (${downloadPercent}%)`, downloadPercent)
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
writeStream.write(result.value, err => {
|
||||
if (err) {
|
||||
reject({ header: 'Failed to download the chart file', body: inspect(err) })
|
||||
} else {
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
if (writeStream.writableNeedDrain) {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
writeStream.once('drain', resolve)
|
||||
writeStream.once('error', err => reject({
|
||||
header: 'Failed to download the chart file',
|
||||
body: inspect(err),
|
||||
}))
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
try {
|
||||
await reader.cancel()
|
||||
} catch (err) { /** ignore; error already reported */ }
|
||||
writeStream.end()
|
||||
reject(err)
|
||||
}
|
||||
})
|
||||
sngStream.on('end', resolve)
|
||||
sngStream.on('error', err => reject(err))
|
||||
|
||||
sngStream.start()
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines what happens in response to `FileDownloader` events.
|
||||
* @returns a `Promise` that resolves when the download finishes.
|
||||
*/
|
||||
// private addDownloadEventListeners(downloader: FileDownloader, fileIndex: number) {
|
||||
// let downloadHeader = `[${this.files[fileIndex].name}] (file ${fileIndex + 1}/${this.files.length})`
|
||||
// let downloadStartPoint = 0 // How far into the individual file progress portion the download progress starts
|
||||
// let fileProgress = 0
|
||||
private async transferChart() {
|
||||
const settings = await getSettings()
|
||||
if (this._canceled) { return }
|
||||
|
||||
// downloader.on('waitProgress', (remainingSeconds: number, totalSeconds: number) => {
|
||||
// downloadStartPoint = this.individualFileProgressPortion / 2
|
||||
// this.percent =
|
||||
// this._allFilesProgress + interpolate(remainingSeconds, totalSeconds, 0, 0, this.individualFileProgressPortion / 2)
|
||||
// this.updateGUI(downloadHeader, `Waiting for Google rate limit... (${remainingSeconds}s)`, 'good')
|
||||
// })
|
||||
|
||||
// downloader.on('requestSent', () => {
|
||||
// fileProgress = downloadStartPoint
|
||||
// this.percent = this._allFilesProgress + fileProgress
|
||||
// this.updateGUI(downloadHeader, 'Sending request...', 'good')
|
||||
// })
|
||||
|
||||
// downloader.on('downloadProgress', (bytesDownloaded: number) => {
|
||||
// downloadHeader = `[${this.files[fileIndex].name}] (file ${fileIndex + 1}/${this.files.length})`
|
||||
// const size = Number(this.files[fileIndex].size)
|
||||
// fileProgress = interpolate(bytesDownloaded, 0, size, downloadStartPoint, this.individualFileProgressPortion)
|
||||
// this.percent = this._allFilesProgress + fileProgress
|
||||
// this.updateGUI(downloadHeader, `Downloading... (${Math.round(1000 * bytesDownloaded / size) / 10}%)`, 'good')
|
||||
// })
|
||||
|
||||
// downloader.on('error', this.handleError.bind(this))
|
||||
|
||||
// return new Promise<void>(resolve => {
|
||||
// downloader.on('complete', () => {
|
||||
// this._allFilesProgress += this.individualFileProgressPortion
|
||||
// resolve()
|
||||
// })
|
||||
// })
|
||||
// }
|
||||
|
||||
/**
|
||||
* Defines what happens in response to `FileExtractor` events.
|
||||
* @returns a `Promise` that resolves when the extraction finishes.
|
||||
*/
|
||||
// private addExtractorEventListeners(extractor: FileExtractor) {
|
||||
// let archive = ''
|
||||
|
||||
// extractor.on('start', filename => {
|
||||
// archive = filename
|
||||
// this.updateGUI(`[${archive}]`, 'Extracting...', 'good')
|
||||
// })
|
||||
|
||||
// extractor.on('extractProgress', (percent, filecount) => {
|
||||
// this.percent = interpolate(percent, 0, 100, 80, 95)
|
||||
// this.updateGUI(`[${archive}] (${filecount} file${filecount === 1 ? '' : 's'} extracted)`, `Extracting... (${percent}%)`, 'good')
|
||||
// })
|
||||
|
||||
// extractor.on('error', this.handleError.bind(this))
|
||||
|
||||
// return new Promise<void>(resolve => {
|
||||
// extractor.on('complete', () => {
|
||||
// this.percent = 95
|
||||
// resolve()
|
||||
// })
|
||||
// })
|
||||
// }
|
||||
|
||||
/**
|
||||
* Defines what happens in response to `FileTransfer` events.
|
||||
* @returns a `Promise` that resolves when the transfer finishes.
|
||||
*/
|
||||
private addTransferEventListeners(transfer: FileTransfer) {
|
||||
let destinationFolder: string
|
||||
|
||||
transfer.on('start', _destinationFolder => {
|
||||
destinationFolder = _destinationFolder
|
||||
this.updateGUI('Moving files to library folder...', destinationFolder, 'good', true)
|
||||
this.showProgress('Moving chart to library folder...', 100)
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
if (settings.libraryPath) {
|
||||
const destinationPath = join(settings.libraryPath, this.destinationName)
|
||||
mv(join(this.tempPath, this.destinationName), destinationPath, { mkdirp: true }, err => {
|
||||
if (err) {
|
||||
reject({ header: 'Failed to move chart to library folder', body: inspect(err) })
|
||||
} else {
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
} else {
|
||||
reject({ header: 'Library folder not specified', body: 'Please go to the settings to set your library folder.' })
|
||||
}
|
||||
})
|
||||
|
||||
transfer.on('error', this.handleError.bind(this))
|
||||
this.showProgress('Deleting temporary folder...')
|
||||
try {
|
||||
await rimraf(this.tempPath)
|
||||
} catch (err) {
|
||||
throw { header: 'Failed to delete temporary folder', body: inspect(err) }
|
||||
}
|
||||
|
||||
return new Promise<void>(resolve => {
|
||||
transfer.on('complete', () => {
|
||||
this.percent = 100
|
||||
this.updateGUI('Download complete.', destinationFolder, 'done', true)
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
const destinationPath = join(settings.libraryPath!, this.destinationName)
|
||||
this.showProgress.cancel()
|
||||
this.eventEmitter.emit('end', destinationPath)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
import { Download } from '../../../src-shared/interfaces/download.interface'
|
||||
import { ChartDownload } from './ChartDownload'
|
||||
import { DownloadQueue } from './DownloadQueue'
|
||||
|
||||
const downloadQueue: DownloadQueue = new DownloadQueue()
|
||||
const retryWaiting: ChartDownload[] = []
|
||||
|
||||
let currentDownload: ChartDownload | undefined = undefined
|
||||
|
||||
export async function download(data: Download) {
|
||||
switch (data.action) {
|
||||
case 'add': addDownload(data); break
|
||||
case 'retry': retryDownload(data); break
|
||||
case 'cancel': cancelDownload(data); break
|
||||
}
|
||||
}
|
||||
|
||||
function addDownload(data: Download) {
|
||||
const filesHash = data.data!.driveData.filesHash // Note: using versionID would cause chart packs to download multiple times
|
||||
if (currentDownload?.hash === filesHash || downloadQueue.isDownloadingLink(filesHash)) {
|
||||
return
|
||||
}
|
||||
|
||||
const newDownload = new ChartDownload(data.versionID, data.data!)
|
||||
addDownloadEventListeners(newDownload)
|
||||
if (currentDownload === undefined) {
|
||||
currentDownload = newDownload
|
||||
newDownload.beginDownload()
|
||||
} else {
|
||||
downloadQueue.push(newDownload)
|
||||
}
|
||||
}
|
||||
|
||||
function retryDownload(data: Download) {
|
||||
const index = retryWaiting.findIndex(download => download.versionID === data.versionID)
|
||||
if (index !== -1) {
|
||||
const retryDownload = retryWaiting.splice(index, 1)[0]
|
||||
retryDownload.displayRetrying()
|
||||
if (currentDownload === undefined) {
|
||||
currentDownload = retryDownload
|
||||
retryDownload.retry()
|
||||
} else {
|
||||
downloadQueue.push(retryDownload)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function cancelDownload(data: Download) {
|
||||
if (currentDownload?.versionID === data.versionID) {
|
||||
currentDownload.cancel()
|
||||
currentDownload = undefined
|
||||
startNextDownload()
|
||||
} else {
|
||||
downloadQueue.remove(data.versionID)
|
||||
}
|
||||
}
|
||||
|
||||
function addDownloadEventListeners(download: ChartDownload) {
|
||||
download.on('complete', () => {
|
||||
currentDownload = undefined
|
||||
startNextDownload()
|
||||
})
|
||||
|
||||
download.on('error', () => {
|
||||
if (currentDownload) {
|
||||
retryWaiting.push(currentDownload)
|
||||
currentDownload = undefined
|
||||
}
|
||||
startNextDownload()
|
||||
})
|
||||
}
|
||||
|
||||
function startNextDownload() {
|
||||
currentDownload = downloadQueue.shift()
|
||||
if (currentDownload) {
|
||||
if (currentDownload.hasFailed) {
|
||||
currentDownload.retry()
|
||||
} else {
|
||||
currentDownload.beginDownload()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,51 +1,110 @@
|
||||
import Comparators, { Comparator } from 'comparators'
|
||||
|
||||
import { emitIpcEvent } from '../../main'
|
||||
import { ChartDownload } from './ChartDownload'
|
||||
|
||||
export class DownloadQueue {
|
||||
|
||||
private downloadQueue: ChartDownload[] = []
|
||||
private retryQueue: ChartDownload[] = []
|
||||
private erroredQueue: ChartDownload[] = []
|
||||
|
||||
isDownloadingLink(filesHash: string) {
|
||||
return this.downloadQueue.some(download => download.hash === filesHash)
|
||||
private downloadRunning = false
|
||||
|
||||
private isChartInQueue(md5: string) {
|
||||
if (this.downloadQueue.find(cd => cd.md5 === md5)) { return true }
|
||||
if (this.retryQueue.find(cd => cd.md5 === md5)) { return true }
|
||||
if (this.erroredQueue.find(cd => cd.md5 === md5)) { return true }
|
||||
return false
|
||||
}
|
||||
|
||||
isEmpty() {
|
||||
return this.downloadQueue.length === 0
|
||||
}
|
||||
add(md5: string, chartName: string) {
|
||||
if (!this.isChartInQueue(md5)) {
|
||||
const chartDownload = new ChartDownload(md5, chartName)
|
||||
this.downloadQueue.push(chartDownload)
|
||||
|
||||
push(chartDownload: ChartDownload) {
|
||||
this.downloadQueue.push(chartDownload)
|
||||
this.sort()
|
||||
}
|
||||
chartDownload.on('progress', (message, percent) => emitIpcEvent('downloadQueueUpdate', {
|
||||
md5,
|
||||
chartName,
|
||||
header: message.header,
|
||||
body: message.body,
|
||||
percent,
|
||||
type: 'good',
|
||||
isPath: false,
|
||||
}))
|
||||
chartDownload.on('error', err => {
|
||||
emitIpcEvent('downloadQueueUpdate', {
|
||||
md5,
|
||||
chartName,
|
||||
header: err.header,
|
||||
body: err.body,
|
||||
percent: null,
|
||||
type: 'error',
|
||||
isPath: err.isPath ?? false,
|
||||
})
|
||||
|
||||
shift() {
|
||||
return this.downloadQueue.shift()
|
||||
}
|
||||
this.downloadQueue = this.downloadQueue.filter(cd => cd !== chartDownload)
|
||||
this.erroredQueue.push(chartDownload)
|
||||
this.downloadRunning = false
|
||||
this.moveQueue()
|
||||
})
|
||||
chartDownload.on('end', destinationPath => {
|
||||
emitIpcEvent('downloadQueueUpdate', {
|
||||
md5,
|
||||
chartName,
|
||||
header: 'Download complete',
|
||||
body: destinationPath,
|
||||
percent: 100,
|
||||
type: 'done',
|
||||
isPath: true,
|
||||
})
|
||||
|
||||
get(versionID: number) {
|
||||
return this.downloadQueue.find(download => download.versionID === versionID)
|
||||
}
|
||||
this.downloadQueue = this.downloadQueue.filter(cd => cd !== chartDownload)
|
||||
this.downloadRunning = false
|
||||
this.moveQueue()
|
||||
})
|
||||
|
||||
remove(versionID: number) {
|
||||
const index = this.downloadQueue.findIndex(download => download.versionID === versionID)
|
||||
if (index !== -1) {
|
||||
this.downloadQueue[index].cancel()
|
||||
this.downloadQueue.splice(index, 1)
|
||||
emitIpcEvent('queueUpdated', this.downloadQueue.map(download => download.versionID))
|
||||
this.moveQueue()
|
||||
}
|
||||
}
|
||||
|
||||
private sort() {
|
||||
let comparator: Comparator<unknown> | undefined = Comparators.comparing('allFilesProgress', { reversed: true })
|
||||
remove(md5: string) {
|
||||
if (this.downloadQueue[0]?.md5 === md5) {
|
||||
this.downloadQueue[0].cancel()
|
||||
this.downloadRunning = false
|
||||
}
|
||||
this.downloadQueue = this.downloadQueue.filter(cd => cd.md5 !== md5)
|
||||
this.retryQueue = this.retryQueue.filter(cd => cd.md5 !== md5)
|
||||
this.erroredQueue = this.erroredQueue.filter(cd => cd.md5 !== md5)
|
||||
|
||||
const prioritizeArchives = true
|
||||
if (prioritizeArchives) {
|
||||
comparator = comparator.thenComparing?.('isArchive', { reversed: true }) || undefined
|
||||
emitIpcEvent('downloadQueueUpdate', {
|
||||
md5,
|
||||
chartName: 'Canceled',
|
||||
header: '',
|
||||
body: '',
|
||||
percent: null,
|
||||
type: 'cancel',
|
||||
isPath: false,
|
||||
})
|
||||
}
|
||||
|
||||
retry(md5: string) {
|
||||
const erroredChartDownload = this.erroredQueue.find(cd => cd.md5 === md5)
|
||||
if (erroredChartDownload) {
|
||||
this.erroredQueue = this.erroredQueue.filter(cd => cd.md5 !== md5)
|
||||
this.retryQueue.push(erroredChartDownload)
|
||||
}
|
||||
|
||||
this.downloadQueue.sort(comparator)
|
||||
emitIpcEvent('queueUpdated', this.downloadQueue.map(download => download.versionID))
|
||||
this.moveQueue()
|
||||
}
|
||||
|
||||
private moveQueue() {
|
||||
if (!this.downloadRunning) {
|
||||
if (this.retryQueue.length) {
|
||||
this.downloadQueue.unshift(this.retryQueue.shift()!)
|
||||
}
|
||||
if (this.downloadQueue.length) {
|
||||
this.downloadRunning = true
|
||||
this.downloadQueue[0].startOrRetry()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,368 +0,0 @@
|
||||
// import Bottleneck from 'bottleneck'
|
||||
// import { createWriteStream, writeFile as _writeFile } from 'fs'
|
||||
// import { google } from 'googleapis'
|
||||
// import * as needle from 'needle'
|
||||
// import { join } from 'path'
|
||||
// import { Readable } from 'stream'
|
||||
// import { inspect, promisify } from 'util'
|
||||
|
||||
// import { devLog } from '../../shared/ElectronUtilFunctions'
|
||||
// import { tempPath } from '../../shared/Paths'
|
||||
// import { AnyFunction } from '../../shared/UtilFunctions'
|
||||
// import { DownloadError } from './ChartDownload'
|
||||
// // TODO: replace needle with got (for cancel() method) (if before-headers event is possible?)
|
||||
// import { googleTimer } from './GoogleTimer'
|
||||
|
||||
// const drive = google.drive('v3')
|
||||
// const limiter = new Bottleneck({
|
||||
// minTime: 200, // Wait 200 ms between API requests
|
||||
// })
|
||||
|
||||
// const RETRY_MAX = 2
|
||||
// const writeFile = promisify(_writeFile)
|
||||
|
||||
// interface EventCallback {
|
||||
// 'waitProgress': (remainingSeconds: number, totalSeconds: number) => void
|
||||
// /** Note: this event can be called multiple times if the connection times out or a large file is downloaded */
|
||||
// 'requestSent': () => void
|
||||
// 'downloadProgress': (bytesDownloaded: number) => void
|
||||
// /** Note: after calling retry, the event lifecycle restarts */
|
||||
// 'error': (err: DownloadError, retry: () => void) => void
|
||||
// 'complete': () => void
|
||||
// }
|
||||
// type Callbacks = { [E in keyof EventCallback]: EventCallback[E] }
|
||||
// export type FileDownloader = APIFileDownloader | SlowFileDownloader
|
||||
|
||||
// const downloadErrors = {
|
||||
// timeout: (type: string) => { return { header: 'Timeout', body: `The download server could not be reached. (type=${type})` } },
|
||||
// connectionError: (err: Error) => { return { header: 'Connection Error', body: `${err.name}: ${err.message}` } },
|
||||
// responseError: (statusCode: string) => { return { header: 'Connection failed', body: `Server returned status code: ${statusCode}` } },
|
||||
// htmlError: (path: string) => { return { header: 'Download server returned HTML instead of a file.', body: path, isLink: true } },
|
||||
// linkError: (url: string) => { return { header: 'Invalid link', body: `The download link is not formatted correctly: ${url}` } },
|
||||
// }
|
||||
|
||||
// /**
|
||||
// * Downloads a file from `url` to `fullPath`.
|
||||
// * Will handle google drive virus scan warnings. Provides event listeners for download progress.
|
||||
// * On error, provides the ability to retry.
|
||||
// * Will only send download requests once every `getSettings().rateLimitDelay` seconds.
|
||||
// */
|
||||
// class SlowFileDownloader {
|
||||
|
||||
// private callbacks = {} as Callbacks
|
||||
// private retryCount: number
|
||||
// private wasCanceled = false
|
||||
// private req: NodeJS.ReadableStream
|
||||
|
||||
// /**
|
||||
// * @param url The download link.
|
||||
// * @param fullPath The full path to where this file should be stored (including the filename).
|
||||
// */
|
||||
// constructor(private url: string, private fullPath: string) { }
|
||||
|
||||
// /**
|
||||
// * Calls `callback` when `event` fires. (no events will be fired after `this.cancelDownload()` is called)
|
||||
// */
|
||||
// on<E extends keyof EventCallback>(event: E, callback: EventCallback[E]) {
|
||||
// this.callbacks[event] = callback
|
||||
// }
|
||||
|
||||
// /**
|
||||
// * Download the file after waiting for the google rate limit.
|
||||
// */
|
||||
// beginDownload() {
|
||||
// googleTimer.on('waitProgress', this.cancelable((remainingSeconds, totalSeconds) => {
|
||||
// this.callbacks.waitProgress(remainingSeconds, totalSeconds)
|
||||
// }))
|
||||
|
||||
// googleTimer.on('complete', this.cancelable(() => {
|
||||
// this.requestDownload()
|
||||
// }))
|
||||
// }
|
||||
|
||||
// /**
|
||||
// * Sends a request to download the file at `this.url`.
|
||||
// * @param cookieHeader the "cookie=" header to include this request.
|
||||
// */
|
||||
// private requestDownload(cookieHeader?: string) {
|
||||
// this.callbacks.requestSent()
|
||||
// this.req = needle.get(this.url, {
|
||||
// 'follow_max': 10,
|
||||
// 'open_timeout': 5000,
|
||||
// 'headers': Object.assign({
|
||||
// 'Referer': this.url,
|
||||
// 'Accept': '*/*',
|
||||
// },
|
||||
// (cookieHeader ? { 'Cookie': cookieHeader } : undefined)
|
||||
// ),
|
||||
// })
|
||||
|
||||
// this.req.on('timeout', this.cancelable((type: string) => {
|
||||
// this.retryCount++
|
||||
// if (this.retryCount <= RETRY_MAX) {
|
||||
// devLog(`TIMEOUT: Retry attempt ${this.retryCount}...`)
|
||||
// this.requestDownload(cookieHeader)
|
||||
// } else {
|
||||
// this.failDownload(downloadErrors.timeout(type))
|
||||
// }
|
||||
// }))
|
||||
|
||||
// this.req.on('err', this.cancelable((err: Error) => {
|
||||
// this.failDownload(downloadErrors.connectionError(err))
|
||||
// }))
|
||||
|
||||
// this.req.on('header', this.cancelable((statusCode, headers: Headers) => {
|
||||
// if (statusCode !== 200) {
|
||||
// this.failDownload(downloadErrors.responseError(statusCode))
|
||||
// return
|
||||
// }
|
||||
|
||||
// if (headers['content-type'].startsWith('text/html')) {
|
||||
// this.handleHTMLResponse(headers['set-cookie'])
|
||||
// } else {
|
||||
// this.handleDownloadResponse()
|
||||
// }
|
||||
// }))
|
||||
// }
|
||||
|
||||
// /**
|
||||
// * 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 generates an error if it can't be found.
|
||||
// * @param cookieHeader The "cookie=" header of this request.
|
||||
// */
|
||||
// private handleHTMLResponse(cookieHeader: string) {
|
||||
// let virusScanHTML = ''
|
||||
// this.req.on('data', this.cancelable(data => virusScanHTML += data))
|
||||
// this.req.on('done', this.cancelable((err: Error) => {
|
||||
// if (err) {
|
||||
// this.failDownload(downloadErrors.connectionError(err))
|
||||
// } else {
|
||||
// try {
|
||||
// const confirmTokenRegex = /confirm=([0-9A-Za-z\-_]+)&/g
|
||||
// const confirmTokenResults = confirmTokenRegex.exec(virusScanHTML)
|
||||
// const confirmToken = confirmTokenResults[1]
|
||||
// const downloadID = this.url.substr(this.url.indexOf('id=') + 'id='.length)
|
||||
// this.url = `https://drive.google.com/uc?confirm=${confirmToken}&id=${downloadID}`
|
||||
// const warningCode = /download_warning_([^=]*)=/.exec(cookieHeader)[1]
|
||||
// const NID = /NID=([^;]*);/.exec(cookieHeader)[1].replace('=', '%')
|
||||
// const newHeader = `download_warning_${warningCode}=${confirmToken}; NID=${NID}`
|
||||
// this.requestDownload(newHeader)
|
||||
// } catch (e) {
|
||||
// this.saveHTMLError(virusScanHTML).then(path => {
|
||||
// this.failDownload(downloadErrors.htmlError(path))
|
||||
// })
|
||||
// }
|
||||
// }
|
||||
// }))
|
||||
// }
|
||||
|
||||
// /**
|
||||
// * Pipes the data from a download response to `this.fullPath`.
|
||||
// * @param req The download request.
|
||||
// */
|
||||
// private handleDownloadResponse() {
|
||||
// this.callbacks.downloadProgress(0)
|
||||
// let downloadedSize = 0
|
||||
// this.req.pipe(createWriteStream(this.fullPath))
|
||||
// this.req.on('data', this.cancelable(data => {
|
||||
// downloadedSize += data.length
|
||||
// this.callbacks.downloadProgress(downloadedSize)
|
||||
// }))
|
||||
|
||||
// this.req.on('err', this.cancelable((err: Error) => {
|
||||
// this.failDownload(downloadErrors.connectionError(err))
|
||||
// }))
|
||||
|
||||
// this.req.on('end', this.cancelable(() => {
|
||||
// this.callbacks.complete()
|
||||
// }))
|
||||
// }
|
||||
|
||||
// private async saveHTMLError(text: string) {
|
||||
// const errorPath = join(tempPath, 'HTMLError.html')
|
||||
// await writeFile(errorPath, text)
|
||||
// return errorPath
|
||||
// }
|
||||
|
||||
// /**
|
||||
// * Display an error message and provide a function to retry the download.
|
||||
// */
|
||||
// private failDownload(error: DownloadError) {
|
||||
// this.callbacks.error(error, this.cancelable(() => this.beginDownload()))
|
||||
// }
|
||||
|
||||
// /**
|
||||
// * Stop the process of downloading the file. (no more events will be fired after this is called)
|
||||
// */
|
||||
// cancelDownload() {
|
||||
// this.wasCanceled = true
|
||||
// googleTimer.cancelTimer() // Prevents timer from trying to activate a download and resetting
|
||||
// if (this.req) {
|
||||
// // TODO: destroy request
|
||||
// }
|
||||
// }
|
||||
|
||||
// /**
|
||||
// * Wraps a function that is able to be prevented if `this.cancelDownload()` was called.
|
||||
// */
|
||||
// private cancelable<F extends AnyFunction>(fn: F) {
|
||||
// return (...args: Parameters<F>): ReturnType<F> => {
|
||||
// if (this.wasCanceled) { return }
|
||||
// return fn(...Array.from(args))
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
// /**
|
||||
// * Downloads a file from `url` to `fullPath`.
|
||||
// * On error, provides the ability to retry.
|
||||
// */
|
||||
// class APIFileDownloader {
|
||||
// private readonly URL_REGEX = /uc\?id=([^&]*)&export=download/u
|
||||
|
||||
// private callbacks = {} as Callbacks
|
||||
// private retryCount: number
|
||||
// private wasCanceled = false
|
||||
// private fileID: string
|
||||
// private downloadStream: Readable
|
||||
|
||||
// /**
|
||||
// * @param url The download link.
|
||||
// * @param fullPath The full path to where this file should be stored (including the filename).
|
||||
// */
|
||||
// constructor(private url: string, private fullPath: string) {
|
||||
// // url looks like: "https://drive.google.com/uc?id=1TlxtOZlVgRgX7-1tyW0d5QzXVfL-MC3Q&export=download"
|
||||
// this.fileID = this.URL_REGEX.exec(url)[1]
|
||||
// }
|
||||
|
||||
// /**
|
||||
// * Calls `callback` when `event` fires. (no events will be fired after `this.cancelDownload()` is called)
|
||||
// */
|
||||
// on<E extends keyof EventCallback>(event: E, callback: EventCallback[E]) {
|
||||
// this.callbacks[event] = callback
|
||||
// }
|
||||
|
||||
// /**
|
||||
// * Download the file after waiting for the google rate limit.
|
||||
// */
|
||||
// beginDownload() {
|
||||
// if (this.fileID === undefined) {
|
||||
// this.failDownload(downloadErrors.linkError(this.url))
|
||||
// }
|
||||
|
||||
// this.startDownloadStream()
|
||||
// }
|
||||
|
||||
// /**
|
||||
// * Uses the Google Drive API to start a download stream for the file with `this.fileID`.
|
||||
// */
|
||||
// private startDownloadStream() {
|
||||
// limiter.schedule(this.cancelable(async () => {
|
||||
// this.callbacks.requestSent()
|
||||
// try {
|
||||
// this.downloadStream = (await drive.files.get({
|
||||
// fileId: this.fileID,
|
||||
// alt: 'media',
|
||||
// }, {
|
||||
// responseType: 'stream',
|
||||
// })).data
|
||||
|
||||
// if (this.wasCanceled) { return }
|
||||
|
||||
// this.handleDownloadResponse()
|
||||
// } catch (err) {
|
||||
// this.retryCount++
|
||||
// if (this.retryCount <= RETRY_MAX) {
|
||||
// devLog(`Failed to get file: Retry attempt ${this.retryCount}...`)
|
||||
// if (this.wasCanceled) { return }
|
||||
// this.startDownloadStream()
|
||||
// } else {
|
||||
// devLog(inspect(err))
|
||||
// if (err?.code && err?.response?.statusText) {
|
||||
// this.failDownload(downloadErrors.responseError(`${err.code} (${err.response.statusText})`))
|
||||
// } else {
|
||||
// this.failDownload(downloadErrors.responseError(err?.code ?? 'unknown'))
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }))
|
||||
// }
|
||||
|
||||
// /**
|
||||
// * Pipes the data from a download response to `this.fullPath`.
|
||||
// * @param req The download request.
|
||||
// */
|
||||
// private handleDownloadResponse() {
|
||||
// this.callbacks.downloadProgress(0)
|
||||
// let downloadedSize = 0
|
||||
// const writeStream = createWriteStream(this.fullPath)
|
||||
|
||||
// try {
|
||||
// this.downloadStream.pipe(writeStream)
|
||||
// } catch (err) {
|
||||
// this.failDownload(downloadErrors.connectionError(err))
|
||||
// }
|
||||
|
||||
// this.downloadStream.on('data', this.cancelable((chunk: Buffer) => {
|
||||
// downloadedSize += chunk.length
|
||||
// }))
|
||||
|
||||
// const progressUpdater = setInterval(() => {
|
||||
// this.callbacks.downloadProgress(downloadedSize)
|
||||
// }, 100)
|
||||
|
||||
// this.downloadStream.on('error', this.cancelable((err: Error) => {
|
||||
// clearInterval(progressUpdater)
|
||||
// this.failDownload(downloadErrors.connectionError(err))
|
||||
// }))
|
||||
|
||||
// this.downloadStream.on('end', this.cancelable(() => {
|
||||
// clearInterval(progressUpdater)
|
||||
// writeStream.end()
|
||||
// this.downloadStream.destroy()
|
||||
// this.downloadStream = null
|
||||
|
||||
// this.callbacks.complete()
|
||||
// }))
|
||||
// }
|
||||
|
||||
// /**
|
||||
// * Display an error message and provide a function to retry the download.
|
||||
// */
|
||||
// private failDownload(error: DownloadError) {
|
||||
// this.callbacks.error(error, this.cancelable(() => this.beginDownload()))
|
||||
// }
|
||||
|
||||
// /**
|
||||
// * Stop the process of downloading the file. (no more events will be fired after this is called)
|
||||
// */
|
||||
// cancelDownload() {
|
||||
// this.wasCanceled = true
|
||||
// googleTimer.cancelTimer() // Prevents timer from trying to activate a download and resetting
|
||||
// if (this.downloadStream) {
|
||||
// this.downloadStream.destroy()
|
||||
// }
|
||||
// }
|
||||
|
||||
// /**
|
||||
// * Wraps a function that is able to be prevented if `this.cancelDownload()` was called.
|
||||
// */
|
||||
// private cancelable<F extends AnyFunction>(fn: F) {
|
||||
// return (...args: Parameters<F>): ReturnType<F> => {
|
||||
// if (this.wasCanceled) { return }
|
||||
// return fn(...Array.from(args))
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
// /**
|
||||
// * Downloads a file from `url` to `fullPath`.
|
||||
// * Will handle google drive virus scan warnings. Provides event listeners for download progress.
|
||||
// * On error, provides the ability to retry.
|
||||
// * Will only send download requests once every `getSettings().rateLimitDelay` seconds if a Google account has not been authenticated.
|
||||
// * @param url The download link.
|
||||
// * @param fullPath The full path to where this file should be stored (including the filename).
|
||||
// */
|
||||
// export function getDownloader(url: string, fullPath: string): FileDownloader {
|
||||
// return new SlowFileDownloader(url, fullPath)
|
||||
// }
|
||||
@@ -1,170 +0,0 @@
|
||||
// import * as zipBin from '7zip-bin'
|
||||
// import { mkdir as _mkdir, readdir, unlink } from 'fs'
|
||||
// import * as node7z from 'node-7z'
|
||||
// import * as unrarjs from 'node-unrar-js' // TODO find better rar library that has async extraction
|
||||
// import { FailReason } from 'node-unrar-js/dist/js/extractor'
|
||||
// import { extname, join } from 'path'
|
||||
// import { promisify } from 'util'
|
||||
|
||||
// import { devLog } from '../../shared/ElectronUtilFunctions'
|
||||
// import { AnyFunction } from '../../shared/UtilFunctions'
|
||||
// import { DownloadError } from './ChartDownload'
|
||||
|
||||
// const mkdir = promisify(_mkdir)
|
||||
|
||||
// interface EventCallback {
|
||||
// 'start': (filename: string) => void
|
||||
// 'extractProgress': (percent: number, fileCount: number) => void
|
||||
// 'error': (err: DownloadError, retry: () => void | Promise<void>) => void
|
||||
// 'complete': () => void
|
||||
// }
|
||||
// type Callbacks = { [E in keyof EventCallback]: EventCallback[E] }
|
||||
|
||||
// const extractErrors = {
|
||||
// readError: (err: NodeJS.ErrnoException) => ({ header: `Failed to read file (${err.code})`, body: `${err.name}: ${err.message}` }),
|
||||
// emptyError: () => ({ header: 'Failed to extract archive', body: 'File archive was downloaded but could not be found' }),
|
||||
// rarmkdirError: (err: NodeJS.ErrnoException, sourceFile: string) => {
|
||||
// return { header: `Extracting archive failed. (${err.code})`, body: `${err.name}: ${err.message} (${sourceFile})` }
|
||||
// },
|
||||
// rarextractError: (result: { reason: FailReason; msg: string }, sourceFile: string) => {
|
||||
// return { header: `Extracting archive failed: ${result.reason}`, body: `${result.msg} (${sourceFile})` }
|
||||
// },
|
||||
// }
|
||||
|
||||
// export class FileExtractor {
|
||||
|
||||
// private callbacks = {} as Callbacks
|
||||
// private wasCanceled = false
|
||||
// constructor(private sourceFolder: string) { }
|
||||
|
||||
// /**
|
||||
// * Calls `callback` when `event` fires. (no events will be fired after `this.cancelExtract()` is called)
|
||||
// */
|
||||
// on<E extends keyof EventCallback>(event: E, callback: EventCallback[E]) {
|
||||
// this.callbacks[event] = callback
|
||||
// }
|
||||
|
||||
// /**
|
||||
// * Extract the chart from `this.sourceFolder`. (assumes there is exactly one archive file in that folder)
|
||||
// */
|
||||
// beginExtract() {
|
||||
// setTimeout(this.cancelable(() => {
|
||||
// readdir(this.sourceFolder, (err, files) => {
|
||||
// if (err) {
|
||||
// this.callbacks.error(extractErrors.readError(err), () => this.beginExtract())
|
||||
// } else if (files.length === 0) {
|
||||
// this.callbacks.error(extractErrors.emptyError(), () => this.beginExtract())
|
||||
// } else {
|
||||
// this.callbacks.start(files[0])
|
||||
// this.extract(join(this.sourceFolder, files[0]), extname(files[0]) === '.rar')
|
||||
// }
|
||||
// })
|
||||
// }), 100) // Wait for filesystem to process downloaded file
|
||||
// }
|
||||
|
||||
// /**
|
||||
// * Extracts the file at `fullPath` to `this.sourceFolder`.
|
||||
// */
|
||||
// private async extract(fullPath: string, useRarExtractor: boolean) {
|
||||
// if (useRarExtractor) {
|
||||
// await this.extractRar(fullPath) // Use node-unrar-js to extract the archive
|
||||
// } else {
|
||||
// this.extract7z(fullPath) // Use node-7z to extract the archive
|
||||
// }
|
||||
// }
|
||||
|
||||
// /**
|
||||
// * Extracts a .rar archive found at `fullPath` and puts the extracted results in `this.sourceFolder`.
|
||||
// * @throws an `ExtractError` if this fails.
|
||||
// */
|
||||
// private async extractRar(fullPath: string) {
|
||||
// const extractor = unrarjs.createExtractorFromFile(fullPath, this.sourceFolder)
|
||||
|
||||
// const fileList = extractor.getFileList()
|
||||
|
||||
// if (fileList[0].state !== 'FAIL') {
|
||||
|
||||
// // Create directories for nested archives (because unrarjs didn't feel like handling that automatically)
|
||||
// const headers = fileList[1].fileHeaders
|
||||
// for (const header of headers) {
|
||||
// if (header.flags.directory) {
|
||||
// try {
|
||||
// await mkdir(join(this.sourceFolder, header.name), { recursive: true })
|
||||
// } catch (err) {
|
||||
// this.callbacks.error(
|
||||
// extractErrors.rarmkdirError(err, fullPath),
|
||||
// () => this.extract(fullPath, extname(fullPath) === '.rar'),
|
||||
// )
|
||||
// return
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
// const extractResult = extractor.extractAll()
|
||||
|
||||
// if (extractResult[0].state === 'FAIL') {
|
||||
// this.callbacks.error(
|
||||
// extractErrors.rarextractError(extractResult[0], fullPath),
|
||||
// () => this.extract(fullPath, extname(fullPath) === '.rar'),
|
||||
// )
|
||||
// } else {
|
||||
// this.deleteArchive(fullPath)
|
||||
// }
|
||||
// }
|
||||
|
||||
// /**
|
||||
// * Extracts a .zip or .7z archive found at `fullPath` and puts the extracted results in `this.sourceFolder`.
|
||||
// */
|
||||
// private extract7z(fullPath: string) {
|
||||
// const zipBinPath = zipBin.path7za.replace('app.asar', 'app.asar.unpacked') // I love electron-builder packaging :)
|
||||
// const stream = node7z.extractFull(fullPath, this.sourceFolder, { $progress: true, $bin: zipBinPath })
|
||||
|
||||
// stream.on('progress', this.cancelable((progress: { percent: number; fileCount: number }) => {
|
||||
// this.callbacks.extractProgress(progress.percent, isNaN(progress.fileCount) ? 0 : progress.fileCount)
|
||||
// }))
|
||||
|
||||
// let extractErrorOccured = false
|
||||
// stream.on('error', this.cancelable(() => {
|
||||
// extractErrorOccured = true
|
||||
// devLog(`Failed to extract [${fullPath}]; retrying with .rar extractor...`)
|
||||
// this.extract(fullPath, true)
|
||||
// }))
|
||||
|
||||
// stream.on('end', this.cancelable(() => {
|
||||
// if (!extractErrorOccured) {
|
||||
// this.deleteArchive(fullPath)
|
||||
// }
|
||||
// }))
|
||||
// }
|
||||
|
||||
// /**
|
||||
// * Tries to delete the archive at `fullPath`.
|
||||
// */
|
||||
// private deleteArchive(fullPath: string) {
|
||||
// unlink(fullPath, this.cancelable(err => {
|
||||
// if (err && err.code !== 'ENOENT') {
|
||||
// devLog(`Warning: failed to delete archive at [${fullPath}]`)
|
||||
// }
|
||||
|
||||
// this.callbacks.complete()
|
||||
// }))
|
||||
// }
|
||||
|
||||
// /**
|
||||
// * Stop the process of extracting the file. (no more events will be fired after this is called)
|
||||
// */
|
||||
// cancelExtract() {
|
||||
// this.wasCanceled = true
|
||||
// }
|
||||
|
||||
// /**
|
||||
// * Wraps a function that is able to be prevented if `this.cancelExtract()` was called.
|
||||
// */
|
||||
// private cancelable<F extends AnyFunction>(fn: F) {
|
||||
// return (...args: Parameters<F>): ReturnType<F> => {
|
||||
// if (this.wasCanceled) { return }
|
||||
// return fn(...Array.from(args))
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
@@ -1,121 +0,0 @@
|
||||
import { Dirent, readdir as _readdir } from 'fs'
|
||||
import mv from 'mv'
|
||||
import { join } from 'path'
|
||||
import { rimraf } from 'rimraf'
|
||||
import { promisify } from 'util'
|
||||
|
||||
import { settings } from '../SettingsHandler.ipc'
|
||||
import { DownloadError } from './ChartDownload'
|
||||
|
||||
const readdir = promisify(_readdir)
|
||||
|
||||
interface EventCallback {
|
||||
'start': (destinationFolder: string) => void
|
||||
'error': (err: DownloadError, retry: () => void | Promise<void>) => void
|
||||
'complete': () => void
|
||||
}
|
||||
type Callbacks = { [E in keyof EventCallback]: EventCallback[E] }
|
||||
|
||||
const transferErrors = {
|
||||
libraryError: () => ({ header: 'Library folder not specified', body: 'Please go to the settings to set your library folder.' }),
|
||||
readError: (err: NodeJS.ErrnoException) => fsError(err, 'Failed to read file.'),
|
||||
deleteError: (err: NodeJS.ErrnoException) => fsError(err, 'Failed to delete file.'),
|
||||
rimrafError: (err: NodeJS.ErrnoException) => fsError(err, 'Failed to delete folder.'),
|
||||
mvError: (err: NodeJS.ErrnoException) => fsError(
|
||||
err,
|
||||
`Failed to move folder to library.${err.code === 'EPERM' ? ' (does the chart already exist?)' : ''}`,
|
||||
),
|
||||
}
|
||||
|
||||
function fsError(err: NodeJS.ErrnoException, description: string) {
|
||||
return { header: description, body: `${err.name}: ${err.message}` }
|
||||
}
|
||||
|
||||
export class FileTransfer {
|
||||
|
||||
private callbacks = {} as Callbacks
|
||||
private wasCanceled = false
|
||||
private destinationFolder: string | null
|
||||
private nestedSourceFolder: string // The top-level folder that is copied to the library folder
|
||||
constructor(private sourceFolder: string, destinationFolderName: string) {
|
||||
this.destinationFolder = settings.libraryPath ? join(settings.libraryPath, destinationFolderName) : null
|
||||
this.nestedSourceFolder = sourceFolder
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls `callback` when `event` fires. (no events will be fired after `this.cancelTransfer()` is called)
|
||||
*/
|
||||
on<E extends keyof EventCallback>(event: E, callback: EventCallback[E]) {
|
||||
this.callbacks[event] = callback
|
||||
}
|
||||
|
||||
async beginTransfer() {
|
||||
if (!this.destinationFolder) {
|
||||
this.callbacks.error(transferErrors.libraryError(), () => this.beginTransfer())
|
||||
} else {
|
||||
this.callbacks.start(this.destinationFolder)
|
||||
await this.cleanFolder()
|
||||
if (this.wasCanceled) { return }
|
||||
this.moveFolder()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fixes common problems with the download chart folder.
|
||||
*/
|
||||
private async cleanFolder() {
|
||||
let files: Dirent[]
|
||||
try {
|
||||
files = await readdir(this.nestedSourceFolder, { withFileTypes: true })
|
||||
} catch (err) {
|
||||
this.callbacks.error(transferErrors.readError(err), () => this.cleanFolder())
|
||||
return
|
||||
}
|
||||
|
||||
// Remove nested folders
|
||||
if (files.length === 1 && !files[0].isFile()) {
|
||||
this.nestedSourceFolder = join(this.nestedSourceFolder, files[0].name)
|
||||
await this.cleanFolder()
|
||||
return
|
||||
}
|
||||
|
||||
// Delete '__MACOSX' folder
|
||||
for (const file of files) {
|
||||
if (!file.isFile() && file.name === '__MACOSX') {
|
||||
try {
|
||||
await rimraf(join(this.nestedSourceFolder, file.name))
|
||||
} catch (err) {
|
||||
this.callbacks.error(transferErrors.rimrafError(err), () => this.cleanFolder())
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// TODO: handle other common problems, like chart/audio files not named correctly
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves the downloaded chart to the library path.
|
||||
*/
|
||||
private moveFolder() {
|
||||
if (!this.destinationFolder) {
|
||||
this.callbacks.error(transferErrors.libraryError(), () => this.moveFolder())
|
||||
} else {
|
||||
mv(this.nestedSourceFolder, this.destinationFolder, { mkdirp: true }, err => {
|
||||
if (err) {
|
||||
this.callbacks.error(transferErrors.mvError(err), () => this.moveFolder())
|
||||
} else {
|
||||
rimraf(this.sourceFolder) // Delete temp folder
|
||||
this.callbacks.complete()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the process of transfering the file. (no more events will be fired after this is called)
|
||||
*/
|
||||
cancelTransfer() {
|
||||
this.wasCanceled = true
|
||||
}
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
import { access, constants, mkdir } from 'fs'
|
||||
import { join } from 'path'
|
||||
|
||||
import { tempPath } from '../../../src-shared/Paths'
|
||||
import { AnyFunction } from '../../../src-shared/UtilFunctions'
|
||||
import { devLog } from '../../ElectronUtilFunctions'
|
||||
import { settings } from '../SettingsHandler.ipc'
|
||||
import { DownloadError } from './ChartDownload'
|
||||
|
||||
interface EventCallback {
|
||||
'start': () => void
|
||||
'error': (err: DownloadError, retry: () => void | Promise<void>) => void
|
||||
'complete': (tempPath: string) => void
|
||||
}
|
||||
type Callbacks = { [E in keyof EventCallback]: EventCallback[E] }
|
||||
|
||||
const filesystemErrors = {
|
||||
libraryError: () => ({ header: 'Library folder not specified', body: 'Please go to the settings to set your library folder.' }),
|
||||
libraryAccess: (err: NodeJS.ErrnoException) => fsError(err, 'Failed to access library folder.'),
|
||||
destinationFolderExists: (destinationPath: string) => {
|
||||
return { header: 'This chart already exists in your library folder.', body: destinationPath, isLink: true }
|
||||
},
|
||||
mkdirError: (err: NodeJS.ErrnoException) => fsError(err, 'Failed to create temporary folder.'),
|
||||
}
|
||||
|
||||
function fsError(err: NodeJS.ErrnoException, description: string) {
|
||||
return { header: description, body: `${err.name}: ${err.message}` }
|
||||
}
|
||||
|
||||
export class FilesystemChecker {
|
||||
|
||||
private callbacks = {} as Callbacks
|
||||
private wasCanceled = false
|
||||
constructor(private destinationFolderName: string) { }
|
||||
|
||||
/**
|
||||
* Calls `callback` when `event` fires. (no events will be fired after `this.cancelDownload()` is called)
|
||||
*/
|
||||
on<E extends keyof EventCallback>(event: E, callback: EventCallback[E]) {
|
||||
this.callbacks[event] = callback
|
||||
}
|
||||
|
||||
/**
|
||||
* Check that the filesystem is set up for the download.
|
||||
*/
|
||||
beginCheck() {
|
||||
this.callbacks.start()
|
||||
this.checkLibraryFolder()
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies that the user has specified a library folder.
|
||||
*/
|
||||
private checkLibraryFolder() {
|
||||
if (settings.libraryPath === undefined) {
|
||||
this.callbacks.error(filesystemErrors.libraryError(), () => this.beginCheck())
|
||||
} else {
|
||||
access(settings.libraryPath, constants.W_OK, this.cancelable(err => {
|
||||
if (err) {
|
||||
this.callbacks.error(filesystemErrors.libraryAccess(err), () => this.beginCheck())
|
||||
} else {
|
||||
this.checkDestinationFolder()
|
||||
}
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks that the destination folder doesn't already exist.
|
||||
*/
|
||||
private checkDestinationFolder() {
|
||||
if (!settings.libraryPath) {
|
||||
this.callbacks.error(filesystemErrors.libraryError(), () => this.beginCheck())
|
||||
} else {
|
||||
const destinationPath = join(settings.libraryPath, this.destinationFolderName)
|
||||
access(destinationPath, constants.F_OK, this.cancelable(err => {
|
||||
if (err) { // File does not exist
|
||||
this.createDownloadFolder()
|
||||
} else {
|
||||
this.callbacks.error(filesystemErrors.destinationFolderExists(destinationPath), () => this.beginCheck())
|
||||
}
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to create a unique folder in Bridge's data paths.
|
||||
*/
|
||||
private async createDownloadFolder(retryCount = 0) {
|
||||
const tempChartPath = join(tempPath, `chart_TODO_MAKE_UNIQUE`)
|
||||
|
||||
mkdir(tempChartPath, this.cancelable(err => {
|
||||
if (err) {
|
||||
if (retryCount < 5) {
|
||||
devLog(`Error creating folder [${tempChartPath}], retrying with a different folder...`)
|
||||
this.createDownloadFolder(retryCount + 1)
|
||||
} else {
|
||||
this.callbacks.error(filesystemErrors.mkdirError(err), () => this.createDownloadFolder())
|
||||
}
|
||||
} else {
|
||||
this.callbacks.complete(tempChartPath)
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the process of checking the filesystem permissions. (no more events will be fired after this is called)
|
||||
*/
|
||||
cancelCheck() {
|
||||
this.wasCanceled = true
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps a function that is able to be prevented if `this.cancelCheck()` was called.
|
||||
*/
|
||||
private cancelable<F extends AnyFunction>(fn: F) {
|
||||
return (...args: Parameters<F>): ReturnType<F> | void => {
|
||||
if (this.wasCanceled) { return }
|
||||
return fn(...Array.from(args))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
|
||||
|
||||
interface EventCallback {
|
||||
'waitProgress': (remainingSeconds: number, totalSeconds: number) => void
|
||||
'complete': () => void
|
||||
}
|
||||
type Callbacks = { [E in keyof EventCallback]?: EventCallback[E] }
|
||||
|
||||
class GoogleTimer {
|
||||
|
||||
private rateLimitCounter = Infinity
|
||||
private callbacks: Callbacks = {}
|
||||
|
||||
/**
|
||||
* Initializes the timer to call the callbacks if they are defined.
|
||||
*/
|
||||
constructor() {
|
||||
setInterval(() => {
|
||||
this.rateLimitCounter++
|
||||
this.updateCallbacks()
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls `callback` when `event` fires. (no events will be fired after `this.cancelTimer()` is called)
|
||||
*/
|
||||
on<E extends keyof EventCallback>(event: E, callback: EventCallback[E]) {
|
||||
this.callbacks[event] = callback
|
||||
this.updateCallbacks() // Fire events immediately after the listeners have been added
|
||||
}
|
||||
|
||||
/**
|
||||
* Check the state of the callbacks and call them if necessary.
|
||||
*/
|
||||
private updateCallbacks() {
|
||||
if (this.hasTimerEnded() && this.callbacks.complete !== undefined) {
|
||||
this.endTimer()
|
||||
} else if (this.callbacks.waitProgress !== undefined) {
|
||||
const delay = 31
|
||||
this.callbacks.waitProgress(delay - this.rateLimitCounter, delay)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prevents the callbacks from activating when the timer ends.
|
||||
*/
|
||||
cancelTimer() {
|
||||
this.callbacks = {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if enough time has elapsed since the last timer activation.
|
||||
*/
|
||||
private hasTimerEnded() {
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Activates the completion callback and resets the timer.
|
||||
*/
|
||||
private endTimer() {
|
||||
this.rateLimitCounter = 0
|
||||
const completeCallback = this.callbacks.complete
|
||||
this.callbacks = {}
|
||||
completeCallback?.()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Important: this instance cannot be used by more than one file download at a time.
|
||||
*/
|
||||
export const googleTimer = new GoogleTimer()
|
||||
@@ -9,7 +9,7 @@ import { dataPath } from '../src-shared/Paths'
|
||||
import { retryUpdate } from './ipc/UpdateHandler.ipc'
|
||||
import { getIpcInvokeHandlers, getIpcToMainEmitHandlers } from './IpcHandler'
|
||||
|
||||
electronUnhandled({ showDialog: true })
|
||||
electronUnhandled({ showDialog: true, logger: err => console.log('Error: Unhandled Rejection:', err) })
|
||||
|
||||
export let mainWindow: BrowserWindow
|
||||
const args = process.argv.slice(1)
|
||||
@@ -147,7 +147,6 @@ function getLoadUrl() {
|
||||
})
|
||||
}
|
||||
|
||||
// TODO: await mainWindow first
|
||||
export function emitIpcEvent<E extends keyof IpcFromMainEmitEvents>(event: E, data: IpcFromMainEmitEvents[E]) {
|
||||
mainWindow.webContents.send(event, data)
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ const electronApi: ContextBridgeApi = {
|
||||
updateAvailable: getListenerAdder('updateAvailable'),
|
||||
updateProgress: getListenerAdder('updateProgress'),
|
||||
updateDownloaded: getListenerAdder('updateDownloaded'),
|
||||
downloadUpdated: getListenerAdder('downloadUpdated'),
|
||||
downloadQueueUpdate: getListenerAdder('downloadQueueUpdate'),
|
||||
queueUpdated: getListenerAdder('queueUpdated'),
|
||||
maximized: getListenerAdder('maximized'),
|
||||
minimized: getListenerAdder('minimized'),
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import _ from 'lodash'
|
||||
import sanitize from 'sanitize-filename'
|
||||
import { Difficulty, Instrument } from 'scan-chart'
|
||||
|
||||
import { ChartData } from './interfaces/search.interface'
|
||||
@@ -24,29 +23,6 @@ export function driveLink(fileId: string) {
|
||||
return `https://drive.google.com/open?id=${fileId}`
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns `filename` with all invalid filename characters replaced.
|
||||
*/
|
||||
export function sanitizeFilename(filename: string): string {
|
||||
const newFilename = sanitize(filename, {
|
||||
replacement: ((invalidChar: string) => {
|
||||
switch (invalidChar) {
|
||||
case '<': return '❮'
|
||||
case '>': return '❯'
|
||||
case ':': return '꞉'
|
||||
case '"': return "'"
|
||||
case '/': return '/'
|
||||
case '\\': return '⧵'
|
||||
case '|': return '⏐'
|
||||
case '?': return '?'
|
||||
case '*': return '⁎'
|
||||
default: return '_'
|
||||
}
|
||||
}),
|
||||
})
|
||||
return (newFilename === '' ? 'TODO_MAKE_UNIQUE' : newFilename)
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns `text` converted to lower case.
|
||||
*/
|
||||
|
||||
@@ -3,38 +3,25 @@
|
||||
* Represents a user's request to interact with the download system.
|
||||
*/
|
||||
export interface Download {
|
||||
action: 'add' | 'retry' | 'cancel'
|
||||
versionID: number
|
||||
data?: NewDownload // Should be defined if action === 'add'
|
||||
}
|
||||
|
||||
/**
|
||||
* Contains the data required to start downloading a single chart.
|
||||
*/
|
||||
export interface NewDownload {
|
||||
chartName: string
|
||||
artist: string
|
||||
charter: string
|
||||
// TODO
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
driveData: any
|
||||
// driveData: DriveChart & { inChartPack: boolean }
|
||||
action: 'add' | 'remove' | 'retry'
|
||||
md5: string
|
||||
chartName?: string // Should be defined if action === 'add'
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents the download progress of a single chart.
|
||||
*/
|
||||
export interface DownloadProgress {
|
||||
versionID: number
|
||||
title: string
|
||||
md5: string
|
||||
chartName: string
|
||||
header: string
|
||||
description: string
|
||||
percent: number
|
||||
body: string
|
||||
percent: number | null
|
||||
type: ProgressType
|
||||
/** If `description` contains a filepath that can be clicked */
|
||||
isLink: boolean
|
||||
/** If `body` contains a filepath that can be clicked */
|
||||
isPath: boolean
|
||||
/** If the download should not appear in the total download progress */
|
||||
stale?: boolean
|
||||
}
|
||||
|
||||
export type ProgressType = 'good' | 'error' | 'cancel' | 'done'
|
||||
export type ProgressType = 'good' | 'error' | 'done' | 'cancel'
|
||||
|
||||
@@ -80,7 +80,7 @@ export interface IpcFromMainEmitEvents {
|
||||
updateAvailable: UpdateInfo | null
|
||||
updateProgress: UpdateProgress
|
||||
updateDownloaded: void
|
||||
downloadUpdated: DownloadProgress
|
||||
downloadQueueUpdate: DownloadProgress
|
||||
queueUpdated: number[]
|
||||
maximized: void
|
||||
minimized: void
|
||||
|
||||
@@ -25,7 +25,6 @@
|
||||
"dom"
|
||||
],
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"typeRoots": ["./node_modules/@types"],
|
||||
"noErrorTruncation": true,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user