refactor(download): simplify file download to use browser native handling (#2639)

This commit is contained in:
ACX
2026-02-06 13:05:09 -07:00
committed by GitHub
parent 5183b0ddfc
commit b98663acf0
8 changed files with 21 additions and 407 deletions

View File

@@ -31,6 +31,5 @@
} @else {
<p-confirmDialog />
<p-toast></p-toast>
<app-download-progress-dialog/>
<router-outlet></router-outlet>
}

View File

@@ -12,7 +12,6 @@ import {MetadataBatchProgressNotification} from './shared/model/metadata-batch-p
import {MetadataProgressService} from './shared/service/metadata-progress.service';
import {BookdropFileNotification, BookdropFileService} from './features/bookdrop/service/bookdrop-file.service';
import {Subscription} from 'rxjs';
import {DownloadProgressDialogComponent} from './shared/components/download-progress-dialog/download-progress-dialog.component';
import {TaskProgressPayload, TaskService} from './features/settings/task-management/task.service';
import {LibraryService} from './features/book/service/library.service';
import {LibraryLoadingService} from './features/library-creator/library-loading.service';
@@ -23,7 +22,7 @@ import {scan, withLatestFrom} from 'rxjs/operators';
templateUrl: './app.component.html',
styleUrl: './app.component.scss',
standalone: true,
imports: [ConfirmDialog, Toast, RouterOutlet, DownloadProgressDialogComponent]
imports: [ConfirmDialog, Toast, RouterOutlet]
})
export class AppComponent implements OnInit, OnDestroy {

View File

@@ -355,10 +355,10 @@ export class BookService {
downloadAllFiles(book: Book): void {
const downloadUrl = `${this.url}/${book.id}/download-all`;
const fileName = book.metadata?.title
const filename = book.metadata?.title
? `${book.metadata.title.replace(/[^a-zA-Z0-9\-_]/g, '_')}.zip`
: `book-${book.id}.zip`;
this.fileDownloadService.downloadFile(downloadUrl, fileName);
this.fileDownloadService.downloadFile(downloadUrl, filename);
}
deleteAdditionalFile(bookId: number, fileId: number): Observable<void> {
@@ -527,8 +527,8 @@ export class BookService {
...(book.alternativeFormats || []),
...(book.supplementaryFiles || [])
].find((f: AdditionalFile) => f.id === fileId);
const downloadUrl = `${this.url}/${book!.id}/files/${fileId}/download`;
this.fileDownloadService.downloadFile(downloadUrl, additionalFile!.fileName!);
const downloadUrl = `${this.url}/${book.id}/files/${fileId}/download`;
this.fileDownloadService.downloadFile(downloadUrl, additionalFile?.fileName ?? 'file');
}
/*------------------ Progress & Status Tracking ------------------*/

View File

@@ -1,40 +0,0 @@
<p-dialog
[visible]="currentProgress.visible"
[header]="currentProgress.preparing ? 'Preparing Download' : 'Downloading File'"
[modal]="true"
[closable]="false"
[draggable]="false"
[resizable]="false"
[style]="{width: '450px'}">
<div class="download-content">
<div class="filename">{{ currentProgress.filename }}</div>
@if (currentProgress.preparing) {
<div class="preparing-container">
<i class="pi pi-spin pi-spinner" style="font-size: 1.5rem; margin-right: 0.5rem;"></i>
<span>Preparing download, please wait...</span>
</div>
} @else {
<div class="progress-bar-container">
<div class="progress-bar-background">
<div class="progress-bar-fill" [style.width.%]="currentProgress.progress"></div>
<div class="progress-bar-label">{{ currentProgress.progress }}%</div>
</div>
</div>
<div class="download-info">
<span>{{ formatBytes(currentProgress.loaded) }} / {{ formatBytes(currentProgress.total) }}</span>
</div>
}
</div>
<ng-template pTemplate="footer">
<p-button
label="Cancel"
icon="pi pi-times"
severity="secondary"
(onClick)="cancelDownload()">
</p-button>
</ng-template>
</p-dialog>

View File

@@ -1,62 +0,0 @@
.download-content {
padding: 1rem 0;
}
.filename {
font-weight: 600;
margin-bottom: 1rem;
word-break: break-word;
}
.progress-bar-container {
margin: 1rem 0;
}
.progress-bar-background {
width: 100%;
height: 1rem;
background-color: var(--p-surface-400);
border-radius: var(--border-radius, 4px);
overflow: hidden;
position: relative;
}
.progress-bar-fill {
height: 100%;
background: linear-gradient(90deg, var(--p-primary-500), var(--p-primary-800));
transition: width 0.3s ease-out;
will-change: width;
border-radius: var(--border-radius, 6px);
position: absolute;
top: 0;
left: 0;
}
.progress-bar-label {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-weight: 600;
font-size: 0.875rem;
color: var(--p-text-color);
z-index: 1;
text-shadow: 0 0 2px rgba(255, 255, 255, 0.8);
}
.download-info {
display: flex;
justify-content: space-between;
margin-top: 0.5rem;
font-size: 0.875rem;
color: var(--p-text-color);
}
.preparing-container {
display: flex;
align-items: center;
justify-content: center;
padding: 1.5rem 0;
color: var(--p-text-color);
font-size: 0.95rem;
}

View File

@@ -1,52 +0,0 @@
import {Component, inject, ChangeDetectionStrategy, ChangeDetectorRef, OnDestroy} from '@angular/core';
import {DialogModule} from 'primeng/dialog';
import {ButtonModule} from 'primeng/button';
import {Subscription} from 'rxjs';
import {DownloadProgress, DownloadProgressService} from '../../service/download-progress.service';
@Component({
selector: 'app-download-progress-dialog',
standalone: true,
imports: [DialogModule, ButtonModule],
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: './download-progress-dialog.component.html',
styleUrl: './download-progress-dialog.component.scss'
})
export class DownloadProgressDialogComponent implements OnDestroy {
private downloadProgressService = inject(DownloadProgressService);
private cdr = inject(ChangeDetectorRef);
private subscription: Subscription;
currentProgress: DownloadProgress = {
visible: false,
filename: '',
progress: 0,
loaded: 0,
total: 0,
preparing: false
};
constructor() {
this.subscription = this.downloadProgressService.downloadProgress$.subscribe(progress => {
this.currentProgress = progress;
this.cdr.markForCheck();
});
}
ngOnDestroy(): void {
this.subscription.unsubscribe();
}
cancelDownload(): void {
this.downloadProgressService.cancelDownload();
}
formatBytes(bytes: number): string {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i];
}
}

View File

@@ -1,132 +0,0 @@
import {Injectable} from '@angular/core';
import {BehaviorSubject, Subject} from 'rxjs';
export interface DownloadProgress {
visible: boolean;
filename: string;
progress: number;
loaded: number;
total: number;
preparing: boolean;
cancelSubject?: Subject<void>;
}
@Injectable({
providedIn: 'root'
})
export class DownloadProgressService {
private downloadProgressSubject = new BehaviorSubject<DownloadProgress>({
visible: false,
filename: '',
progress: 0,
loaded: 0,
total: 0,
preparing: false
});
private lastUpdateTime = 0;
private updateThrottleMs = 100;
private pendingUpdate: DownloadProgress | null = null;
private throttleTimer: ReturnType<typeof setTimeout> | null = null;
downloadProgress$ = this.downloadProgressSubject.asObservable();
isDownloadInProgress(): boolean {
return this.downloadProgressSubject.value.visible;
}
startDownload(filename: string, cancelSubject: Subject<void>, preparing: boolean = false): void {
this.lastUpdateTime = 0;
this.pendingUpdate = null;
if (this.throttleTimer) {
clearTimeout(this.throttleTimer);
this.throttleTimer = null;
}
this.downloadProgressSubject.next({
visible: true,
filename,
progress: 0,
loaded: 0,
total: 0,
preparing,
cancelSubject
});
}
updateProgress(loaded: number, total: number): void {
const current = this.downloadProgressSubject.value;
const progress = total > 0 ? Math.min(100, Math.round((loaded / total) * 100)) : 0;
const now = Date.now();
const newProgress: DownloadProgress = {
visible: current.visible,
filename: current.filename,
progress,
loaded,
total,
preparing: false, // No longer preparing once we have progress
cancelSubject: current.cancelSubject
};
if (progress === 100) {
if (this.throttleTimer) {
clearTimeout(this.throttleTimer);
this.throttleTimer = null;
}
this.downloadProgressSubject.next(newProgress);
this.lastUpdateTime = now;
this.pendingUpdate = null;
return;
}
if (now - this.lastUpdateTime >= this.updateThrottleMs) {
this.downloadProgressSubject.next(newProgress);
this.lastUpdateTime = now;
this.pendingUpdate = null;
if (this.throttleTimer) {
clearTimeout(this.throttleTimer);
this.throttleTimer = null;
}
} else {
this.pendingUpdate = newProgress;
if (!this.throttleTimer) {
const remainingTime = this.updateThrottleMs - (now - this.lastUpdateTime);
this.throttleTimer = setTimeout(() => {
if (this.pendingUpdate) {
this.downloadProgressSubject.next(this.pendingUpdate);
this.lastUpdateTime = Date.now();
this.pendingUpdate = null;
}
this.throttleTimer = null;
}, remainingTime);
}
}
}
completeDownload(): void {
if (this.throttleTimer) {
clearTimeout(this.throttleTimer);
this.throttleTimer = null;
}
this.pendingUpdate = null;
this.downloadProgressSubject.next({
visible: false,
filename: '',
progress: 0,
loaded: 0,
total: 0,
preparing: false
});
}
cancelDownload(): void {
const current = this.downloadProgressSubject.value;
current.cancelSubject?.next();
current.cancelSubject?.complete();
this.completeDownload();
}
}

View File

@@ -1,128 +1,30 @@
import {inject, Injectable} from '@angular/core';
import {HttpClient, HttpEvent, HttpEventType, HttpResponse} from '@angular/common/http';
import {MessageService} from 'primeng/api';
import {DownloadProgressService} from './download-progress.service';
import {Observable, Subject, throwError} from 'rxjs';
import {catchError, finalize, takeUntil, tap} from 'rxjs/operators';
import {HttpClient} from '@angular/common/http';
@Injectable({
providedIn: 'root'
})
export class FileDownloadService {
private http = inject(HttpClient);
private downloadProgressService = inject(DownloadProgressService);
private messageService = inject(MessageService);
downloadFile(url: string, defaultFilename: string): void {
const cancelSubject = new Subject<void>();
downloadFile(url: string, filename: string): void {
this.http.get(url, {responseType: 'blob', observe: 'response'}).subscribe(response => {
const blob = response.body;
if (!blob) return;
// Show preparing state immediately
this.downloadProgressService.startDownload(defaultFilename, cancelSubject, true);
this.initiateDownload(url)
.pipe(
takeUntil(cancelSubject),
tap(event => this.handleDownloadProgress(event, defaultFilename, cancelSubject)),
finalize(() => this.downloadProgressService.completeDownload()),
catchError(error => {
this.handleDownloadError(error);
return throwError(() => error);
})
)
.subscribe();
}
private initiateDownload(url: string): Observable<HttpEvent<Blob>> {
return this.http.get(url, {
responseType: 'blob',
observe: 'events',
reportProgress: true
const resolvedFilename = this.extractFilename(response.headers.get('Content-Disposition')) ?? filename;
const objectUrl = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = objectUrl;
link.download = resolvedFilename;
link.click();
URL.revokeObjectURL(objectUrl);
});
}
private handleDownloadProgress(
event: HttpEvent<Blob>,
defaultFilename: string,
cancelSubject: Subject<void>
): void {
if (event.type === HttpEventType.Response) {
this.handleDownloadComplete(event, defaultFilename);
} else if (event.type === HttpEventType.DownloadProgress) {
this.updateProgress(event, defaultFilename, cancelSubject);
}
}
private updateProgress(
event: any,
defaultFilename: string,
cancelSubject: Subject<void>
): void {
if (event.total) {
// Download already started with preparing state, just update progress
this.downloadProgressService.updateProgress(event.loaded, event.total);
}
}
private handleDownloadComplete(response: HttpResponse<Blob>, defaultFilename: string): void {
const filename = this.extractFilenameFromResponse(response, defaultFilename);
const blob = response.body;
if (!blob) {
throw new Error('No file content received');
}
this.triggerBrowserDownload(blob, filename);
this.showSuccessMessage(filename);
}
private extractFilenameFromResponse(response: HttpResponse<Blob>, defaultFilename: string): string {
const contentDisposition = response.headers.get('Content-Disposition');
if (contentDisposition) {
const encodedFilename = contentDisposition.match(/filename\*=UTF-8''([\w%\-\.]+)(?:; ?|$)/i)?.[1];
return encodedFilename ? decodeURIComponent(encodedFilename) : defaultFilename;
}
return defaultFilename;
}
private triggerBrowserDownload(blob: Blob, filename: string): void {
const objectUrl = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = objectUrl;
link.download = filename;
link.style.display = 'none';
document.body.appendChild(link);
link.click();
setTimeout(() => {
try {
if (link && link.parentNode) {
link.parentNode.removeChild(link);
}
} catch (e) {
// Ignore errors during cleanup, may occur if DOM is not available
}
window.URL.revokeObjectURL(objectUrl);
}, 100);
}
private handleDownloadError(error: unknown): void {
if ((error as { name?: string })?.name !== 'AbortError') {
this.messageService.add({
severity: 'error',
summary: 'Download Failed',
detail: 'An error occurred while downloading the file. Please try again.',
life: 5000
});
}
}
private showSuccessMessage(filename: string): void {
this.messageService.add({
severity: 'success',
summary: 'Download Complete',
detail: `${filename} has been downloaded successfully.`,
life: 3000
});
private extractFilename(contentDisposition: string | null): string | null {
if (!contentDisposition) return null;
const match = contentDisposition.match(/filename\*=UTF-8''([\w%\-.]+)/i);
return match ? decodeURIComponent(match[1]) : null;
}
}