mirror of
https://github.com/booklore-app/booklore.git
synced 2026-02-18 00:17:53 +01:00
refactor(download): simplify file download to use browser native handling (#2639)
This commit is contained in:
@@ -31,6 +31,5 @@
|
||||
} @else {
|
||||
<p-confirmDialog />
|
||||
<p-toast></p-toast>
|
||||
<app-download-progress-dialog/>
|
||||
<router-outlet></router-outlet>
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
|
||||
@@ -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 ------------------*/
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user