From 89b68bc3015ac12ead9820b3d34bd7fc36c31d31 Mon Sep 17 00:00:00 2001 From: Joseph Milazzo Date: Wed, 11 Aug 2021 16:01:44 -0500 Subject: [PATCH] Download Refactor (#483) # Added - New: Cards when processing a download shows a spinner for the progress of the download # Changed - Changed: Downloads now always take the backend filename and are streamed in a more optimal manner, reducing the javascript processing that was needed previously. ================================== * Started refactor of downloader to be more UX friendly and much faster. * Completed refactor of Volume download to use a new mechanism. Downloads are streamed over and filename used exclusively from header. Backend has additional DB calls to get the Series Name information to make filenames nice. * download service has been updated so all download functions use new event based observable. Duplicates code for downloading, but much cleaner and faster. * Small code cleanup --- API/Controllers/DownloadController.cs | 16 ++- UI/Web/package-lock.json | 22 ++++ UI/Web/package.json | 1 + .../bookmarks-modal.component.ts | 14 ++- .../manage-system.component.html | 2 +- .../manage-system/manage-system.component.ts | 16 ++- UI/Web/src/app/app.module.ts | 2 + .../series-detail.component.html | 10 +- .../series-detail/series-detail.component.ts | 40 ++++--- UI/Web/src/app/shared/_models/download.ts | 75 +++++++++++++ .../app/shared/_providers/saver.provider.ts | 10 ++ .../app/shared/_services/download.service.ts | 100 +++++++++--------- .../app/shared/_services/utility.service.ts | 25 +++++ .../shared/card-item/card-item.component.html | 11 +- .../shared/card-item/card-item.component.scss | 10 +- .../shared/card-item/card-item.component.ts | 77 +++++++++++++- .../circular-loader.component.html | 44 ++++++++ .../circular-loader.component.scss | 23 ++++ .../circular-loader.component.ts | 18 ++++ UI/Web/src/app/shared/shared.module.ts | 15 ++- 20 files changed, 439 insertions(+), 92 deletions(-) create mode 100644 UI/Web/src/app/shared/_models/download.ts create mode 100644 UI/Web/src/app/shared/_providers/saver.provider.ts create mode 100644 UI/Web/src/app/shared/circular-loader/circular-loader.component.html create mode 100644 UI/Web/src/app/shared/circular-loader/circular-loader.component.scss create mode 100644 UI/Web/src/app/shared/circular-loader/circular-loader.component.ts diff --git a/API/Controllers/DownloadController.cs b/API/Controllers/DownloadController.cs index cc4fd0214..62fab915c 100644 --- a/API/Controllers/DownloadController.cs +++ b/API/Controllers/DownloadController.cs @@ -4,7 +4,6 @@ using System.IO; using System.Linq; using System.Threading.Tasks; using API.Comparators; -using API.DTOs; using API.DTOs.Downloads; using API.Entities; using API.Entities.Enums; @@ -27,6 +26,7 @@ namespace API.Controllers private readonly IDirectoryService _directoryService; private readonly ICacheService _cacheService; private readonly NumericComparer _numericComparer; + private const string DefaultContentType = "application/octet-stream"; // "application/zip" public DownloadController(IUnitOfWork unitOfWork, IArchiveService archiveService, IDirectoryService directoryService, ICacheService cacheService) { @@ -62,6 +62,8 @@ namespace API.Controllers public async Task DownloadVolume(int volumeId) { var files = await _unitOfWork.VolumeRepository.GetFilesForVolume(volumeId); + var volume = await _unitOfWork.SeriesRepository.GetVolumeByIdAsync(volumeId); + var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(volume.SeriesId); try { if (files.Count == 1) @@ -70,7 +72,7 @@ namespace API.Controllers } var (fileBytes, zipPath) = await _archiveService.CreateZipForDownload(files.Select(c => c.FilePath), $"download_{User.GetUsername()}_v{volumeId}"); - return File(fileBytes, "application/zip", Path.GetFileNameWithoutExtension(zipPath) + ".zip"); + return File(fileBytes, DefaultContentType, $"{series.Name} - Volume {volume.Number}.zip"); } catch (KavitaException ex) { @@ -105,6 +107,9 @@ namespace API.Controllers public async Task DownloadChapter(int chapterId) { var files = await _unitOfWork.VolumeRepository.GetFilesForChapterAsync(chapterId); + var chapter = await _unitOfWork.VolumeRepository.GetChapterAsync(chapterId); + var volume = await _unitOfWork.SeriesRepository.GetVolumeByIdAsync(chapter.VolumeId); + var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(volume.SeriesId); try { if (files.Count == 1) @@ -113,7 +118,7 @@ namespace API.Controllers } var (fileBytes, zipPath) = await _archiveService.CreateZipForDownload(files.Select(c => c.FilePath), $"download_{User.GetUsername()}_c{chapterId}"); - return File(fileBytes, "application/zip", Path.GetFileNameWithoutExtension(zipPath) + ".zip"); + return File(fileBytes, DefaultContentType, $"{series.Name} - Chapter {chapter.Number}.zip"); } catch (KavitaException ex) { @@ -125,6 +130,7 @@ namespace API.Controllers public async Task DownloadSeries(int seriesId) { var files = await _unitOfWork.SeriesRepository.GetFilesForSeries(seriesId); + var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId); try { if (files.Count == 1) @@ -133,7 +139,7 @@ namespace API.Controllers } var (fileBytes, zipPath) = await _archiveService.CreateZipForDownload(files.Select(c => c.FilePath), $"download_{User.GetUsername()}_s{seriesId}"); - return File(fileBytes, "application/zip", Path.GetFileNameWithoutExtension(zipPath) + ".zip"); + return File(fileBytes, DefaultContentType, $"{series.Name}.zip"); } catch (KavitaException ex) { @@ -195,7 +201,7 @@ namespace API.Controllers var (fileBytes, zipPath) = await _archiveService.CreateZipForDownload(totalFilePaths, tempFolder); DirectoryService.ClearAndDeleteDirectory(fullExtractPath); - return File(fileBytes, "application/zip", $"{series.Name} - Bookmarks.zip"); + return File(fileBytes, DefaultContentType, $"{series.Name} - Bookmarks.zip"); } } } diff --git a/UI/Web/package-lock.json b/UI/Web/package-lock.json index 0a5ec258b..7763d7647 100644 --- a/UI/Web/package-lock.json +++ b/UI/Web/package-lock.json @@ -31,6 +31,7 @@ "bowser": "^2.11.0", "file-saver": "^2.0.5", "lazysizes": "^5.3.2", + "ng-circle-progress": "^1.6.0", "ng-lazyload-image": "^9.1.0", "ng-sidebar": "^9.4.2", "ngx-toastr": "^13.2.1", @@ -12742,6 +12743,19 @@ "integrity": "sha1-yobR/ogoFpsBICCOPchCS524NCw=", "dev": true }, + "node_modules/ng-circle-progress": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/ng-circle-progress/-/ng-circle-progress-1.6.0.tgz", + "integrity": "sha512-HD7Uthog/QjRBFKrrnbOrm313CrkkWiTxENR7PjUy9lSUkuys5HdT0+E8UiHDk8VLSxC/pMmrx3eyYLhNq7EnQ==", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@angular/common": ">=9.1.0", + "@angular/core": ">=9.1.0", + "rxjs": ">=6.4.0" + } + }, "node_modules/ng-lazyload-image": { "version": "9.1.0", "resolved": "https://registry.npmjs.org/ng-lazyload-image/-/ng-lazyload-image-9.1.0.tgz", @@ -30964,6 +30978,14 @@ "integrity": "sha1-yobR/ogoFpsBICCOPchCS524NCw=", "dev": true }, + "ng-circle-progress": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/ng-circle-progress/-/ng-circle-progress-1.6.0.tgz", + "integrity": "sha512-HD7Uthog/QjRBFKrrnbOrm313CrkkWiTxENR7PjUy9lSUkuys5HdT0+E8UiHDk8VLSxC/pMmrx3eyYLhNq7EnQ==", + "requires": { + "tslib": "^2.0.0" + } + }, "ng-lazyload-image": { "version": "9.1.0", "resolved": "https://registry.npmjs.org/ng-lazyload-image/-/ng-lazyload-image-9.1.0.tgz", diff --git a/UI/Web/package.json b/UI/Web/package.json index f8ab03458..606b81efe 100644 --- a/UI/Web/package.json +++ b/UI/Web/package.json @@ -38,6 +38,7 @@ "bowser": "^2.11.0", "file-saver": "^2.0.5", "lazysizes": "^5.3.2", + "ng-circle-progress": "^1.6.0", "ng-lazyload-image": "^9.1.0", "ng-sidebar": "^9.4.2", "ngx-toastr": "^13.2.1", diff --git a/UI/Web/src/app/_modals/bookmarks-modal/bookmarks-modal.component.ts b/UI/Web/src/app/_modals/bookmarks-modal/bookmarks-modal.component.ts index 098f06bf4..0fd65ac42 100644 --- a/UI/Web/src/app/_modals/bookmarks-modal/bookmarks-modal.component.ts +++ b/UI/Web/src/app/_modals/bookmarks-modal/bookmarks-modal.component.ts @@ -1,7 +1,8 @@ import { Component, Input, OnInit } from '@angular/core'; import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; import { ToastrService } from 'ngx-toastr'; -import { take } from 'rxjs/operators'; +import { asyncScheduler } from 'rxjs'; +import { finalize, take, takeWhile, throttleTime } from 'rxjs/operators'; import { DownloadService } from 'src/app/shared/_services/download.service'; import { PageBookmark } from 'src/app/_models/page-bookmark'; import { Series } from 'src/app/_models/series'; @@ -53,9 +54,14 @@ export class BookmarksModalComponent implements OnInit { downloadBookmarks() { this.isDownloading = true; - this.downloadService.downloadBookmarks(this.bookmarks, this.series.name).pipe(take(1)).subscribe(() => { - this.isDownloading = false; - }); + this.downloadService.downloadBookmarks(this.bookmarks).pipe( + throttleTime(100, asyncScheduler, { leading: true, trailing: true }), + takeWhile(val => { + return val.state != 'DONE'; + }), + finalize(() => { + this.isDownloading = false; + })).subscribe(() => {/* No Operation */}); } clearBookmarks() { diff --git a/UI/Web/src/app/admin/manage-system/manage-system.component.html b/UI/Web/src/app/admin/manage-system/manage-system.component.html index 4a2cf4b64..ce1c0e342 100644 --- a/UI/Web/src/app/admin/manage-system/manage-system.component.html +++ b/UI/Web/src/app/admin/manage-system/manage-system.component.html @@ -16,7 +16,7 @@ -
-
diff --git a/UI/Web/src/app/series-detail/series-detail.component.ts b/UI/Web/src/app/series-detail/series-detail.component.ts index cb99db874..adb715678 100644 --- a/UI/Web/src/app/series-detail/series-detail.component.ts +++ b/UI/Web/src/app/series-detail/series-detail.component.ts @@ -3,7 +3,8 @@ import { Title } from '@angular/platform-browser'; import { ActivatedRoute, Router } from '@angular/router'; import { NgbModal, NgbRatingConfig } from '@ng-bootstrap/ng-bootstrap'; import { ToastrService } from 'ngx-toastr'; -import { take } from 'rxjs/operators'; +import { asyncScheduler } from 'rxjs'; +import { finalize, take, takeWhile, throttleTime } from 'rxjs/operators'; import { ConfirmConfig } from '../shared/confirm-dialog/_models/confirm-config'; import { ConfirmService } from '../shared/confirm.service'; import { TagBadgeCursor } from '../shared/tag-badge/tag-badge.component'; @@ -61,6 +62,8 @@ export class SeriesDetailComponent implements OnInit { libraryType: LibraryType = LibraryType.Manga; seriesMetadata: SeriesMetadata | null = null; + downloadInProgress: boolean = false; + /** * If an action is currently being done, don't let the user kick off another action */ @@ -142,16 +145,16 @@ export class SeriesDetailComponent implements OnInit { }); break; case(Action.ScanLibrary): - this.actionService.scanSeries(series, (series) => this.actionInProgress = false); + this.actionService.scanSeries(series, () => this.actionInProgress = false); break; case(Action.RefreshMetadata): - this.actionService.refreshMetdata(series, (series) => this.actionInProgress = false); + this.actionService.refreshMetdata(series, () => this.actionInProgress = false); break; case(Action.Delete): this.deleteSeries(series); break; case(Action.Bookmarks): - this.actionService.openBookmarkModal(series, (series) => this.actionInProgress = false); + this.actionService.openBookmarkModal(series, () => this.actionInProgress = false); break; default: break; @@ -169,9 +172,6 @@ export class SeriesDetailComponent implements OnInit { case(Action.Info): this.openViewInfo(volume); break; - case(Action.Download): - this.downloadService.downloadVolume(volume, this.series.name); - break; default: break; } @@ -188,9 +188,6 @@ export class SeriesDetailComponent implements OnInit { case(Action.Info): this.openViewInfo(chapter); break; - case(Action.Download): - this.downloadService.downloadChapter(chapter, this.series.name); - break; default: break; } @@ -285,7 +282,7 @@ export class SeriesDetailComponent implements OnInit { } const seriesId = this.series.id; - this.actionService.markVolumeAsRead(seriesId, vol, (volume) => { + this.actionService.markVolumeAsRead(seriesId, vol, () => { this.setContinuePoint(); this.actionInProgress = false; }); @@ -297,7 +294,7 @@ export class SeriesDetailComponent implements OnInit { } const seriesId = this.series.id; - this.actionService.markVolumeAsUnread(seriesId, vol, (volume) => { + this.actionService.markVolumeAsUnread(seriesId, vol, () => { this.setContinuePoint(); this.actionInProgress = false; }); @@ -309,7 +306,7 @@ export class SeriesDetailComponent implements OnInit { } const seriesId = this.series.id; - this.actionService.markChapterAsRead(seriesId, chapter, (chapter) => { + this.actionService.markChapterAsRead(seriesId, chapter, () => { this.setContinuePoint(); this.actionInProgress = false; }); @@ -321,7 +318,7 @@ export class SeriesDetailComponent implements OnInit { } const seriesId = this.series.id; - this.actionService.markChapterAsUnread(seriesId, chapter, (chapter) => { + this.actionService.markChapterAsUnread(seriesId, chapter, () => { this.setContinuePoint(); this.actionInProgress = false; }); @@ -433,6 +430,19 @@ export class SeriesDetailComponent implements OnInit { } downloadSeries() { - this.downloadService.downloadSeries(this.series); + + this.downloadService.downloadSeriesSize(this.series.id).pipe(take(1)).subscribe(async (size) => { + const wantToDownload = await this.downloadService.confirmSize(size, 'series'); + if (!wantToDownload) { return; } + this.downloadInProgress = true; + this.downloadService.downloadSeries(this.series).pipe( + throttleTime(100, asyncScheduler, { leading: true, trailing: true }), + takeWhile(val => { + return val.state != 'DONE'; + }), + finalize(() => { + this.downloadInProgress = false; + })); + }); } } diff --git a/UI/Web/src/app/shared/_models/download.ts b/UI/Web/src/app/shared/_models/download.ts new file mode 100644 index 000000000..7c26eafc9 --- /dev/null +++ b/UI/Web/src/app/shared/_models/download.ts @@ -0,0 +1,75 @@ +import { + HttpEvent, + HttpEventType, + HttpHeaders, + HttpProgressEvent, + HttpResponse + } from "@angular/common/http"; + import { Observable } from "rxjs"; + import { distinctUntilChanged, scan, map, tap } from "rxjs/operators"; + + function isHttpResponse(event: HttpEvent): event is HttpResponse { + return event.type === HttpEventType.Response; + } + + function isHttpProgressEvent( + event: HttpEvent + ): event is HttpProgressEvent { + return ( + event.type === HttpEventType.DownloadProgress || + event.type === HttpEventType.UploadProgress + ); + } + +export interface Download { + content: Blob | null; + progress: number; + state: "PENDING" | "IN_PROGRESS" | "DONE"; + filename?: string; +} + +export function download(saver?: (b: Blob, filename: string) => void): (source: Observable>) => Observable { + return (source: Observable>) => + source.pipe( + scan((previous: Download, event: HttpEvent): Download => { + if (isHttpProgressEvent(event)) { + return { + progress: event.total + ? Math.round((100 * event.loaded) / event.total) + : previous.progress, + state: 'IN_PROGRESS', + content: null + } + } + if (isHttpResponse(event)) { + if (saver && event.body) { + saver(event.body, getFilename(event.headers, '')) + } + return { + progress: 100, + state: 'DONE', + content: event.body, + filename: getFilename(event.headers, '') + } + } + return previous; + }, + {state: 'PENDING', progress: 0, content: null} + ) + ) + } + + +function getFilename(headers: HttpHeaders, defaultName: string) { + const tokens = (headers.get('content-disposition') || '').split(';'); + let filename = tokens[1].replace('filename=', '').replace(/"/ig, '').trim(); + if (filename.startsWith('download_') || filename.startsWith('kavita_download_')) { + const ext = filename.substring(filename.lastIndexOf('.'), filename.length); + if (defaultName !== '') { + return defaultName + ext; + } + return filename.replace('kavita_', '').replace('download_', ''); + //return defaultName + ext; + } + return filename; + } \ No newline at end of file diff --git a/UI/Web/src/app/shared/_providers/saver.provider.ts b/UI/Web/src/app/shared/_providers/saver.provider.ts new file mode 100644 index 000000000..bd3d35ec9 --- /dev/null +++ b/UI/Web/src/app/shared/_providers/saver.provider.ts @@ -0,0 +1,10 @@ +import {InjectionToken} from '@angular/core' +import { saveAs } from 'file-saver'; + +export type Saver = (blob: Blob, filename?: string) => void + +export const SAVER = new InjectionToken('saver') + +export function getSaver(): Saver { + return saveAs; +} \ No newline at end of file diff --git a/UI/Web/src/app/shared/_services/download.service.ts b/UI/Web/src/app/shared/_services/download.service.ts index 6b254541c..b2f5185ec 100644 --- a/UI/Web/src/app/shared/_services/download.service.ts +++ b/UI/Web/src/app/shared/_services/download.service.ts @@ -1,5 +1,5 @@ -import { HttpClient, HttpHeaders, HttpParams, HttpResponse } from '@angular/common/http'; -import { Injectable } from '@angular/core'; +import { HttpClient, HttpHeaders, HttpResponse } from '@angular/common/http'; +import { Inject, Injectable } from '@angular/core'; import { Series } from 'src/app/_models/series'; import { environment } from 'src/environments/environment'; import { ConfirmService } from '../confirm.service'; @@ -7,8 +7,11 @@ import { saveAs } from 'file-saver'; import { Chapter } from 'src/app/_models/chapter'; import { Volume } from 'src/app/_models/volume'; import { ToastrService } from 'ngx-toastr'; +import { Observable } from 'rxjs'; +import { SAVER, Saver } from '../_providers/saver.provider'; +import { download, Download } from '../_models/download'; import { PageBookmark } from 'src/app/_models/page-bookmark'; -import { map, take } from 'rxjs/operators'; +import { debounce, debounceTime, map, take } from 'rxjs/operators'; @Injectable({ providedIn: 'root' @@ -21,76 +24,68 @@ export class DownloadService { */ public SIZE_WARNING = 104_857_600; - constructor(private httpClient: HttpClient, private confirmService: ConfirmService, private toastr: ToastrService) { } + constructor(private httpClient: HttpClient, private confirmService: ConfirmService, private toastr: ToastrService, @Inject(SAVER) private save: Saver) { } - private downloadSeriesSize(seriesId: number) { + public downloadSeriesSize(seriesId: number) { return this.httpClient.get(this.baseUrl + 'download/series-size?seriesId=' + seriesId); } - private downloadVolumeSize(volumeId: number) { + public downloadVolumeSize(volumeId: number) { return this.httpClient.get(this.baseUrl + 'download/volume-size?volumeId=' + volumeId); } - private downloadChapterSize(chapterId: number) { + public downloadChapterSize(chapterId: number) { return this.httpClient.get(this.baseUrl + 'download/chapter-size?chapterId=' + chapterId); } - private downloadSeriesAPI(seriesId: number) { - return this.httpClient.get(this.baseUrl + 'download/series?seriesId=' + seriesId, {observe: 'response', responseType: 'blob' as 'text'}); - } - - private downloadVolumeAPI(volumeId: number) { - return this.httpClient.get(this.baseUrl + 'download/volume?volumeId=' + volumeId, {observe: 'response', responseType: 'blob' as 'text'}); - } - - private downloadChapterAPI(chapterId: number) { - return this.httpClient.get(this.baseUrl + 'download/chapter?chapterId=' + chapterId, {observe: 'response', responseType: 'blob' as 'text'}); - } - downloadLogs() { - this.httpClient.get(this.baseUrl + 'server/logs', {observe: 'response', responseType: 'blob' as 'text'}).subscribe(resp => { - this.preformSave(resp.body || '', this.getFilenameFromHeader(resp.headers, 'logs')); - }); + // this.httpClient.get(this.baseUrl + 'server/logs', {observe: 'response', responseType: 'blob' as 'text'}).subscribe(resp => { + // this.preformSave(resp.body || '', this.getFilenameFromHeader(resp.headers, 'logs')); + // }); + + return this.httpClient.get(this.baseUrl + 'server/logs', + {observe: 'events', responseType: 'blob', reportProgress: true} + ).pipe(debounceTime(300), download((blob, filename) => { + this.save(blob, filename) + })); + } downloadSeries(series: Series) { - this.downloadSeriesSize(series.id).subscribe(async size => { - if (size >= this.SIZE_WARNING && !await this.confirmService.confirm('The series is ' + this.humanFileSize(size) + '. Are you sure you want to continue?')) { - return; - } - this.downloadSeriesAPI(series.id).subscribe(resp => { - this.preformSave(resp.body || '', this.getFilenameFromHeader(resp.headers, series.name)); - }); - }); + return this.httpClient.get(this.baseUrl + 'download/series?seriesId=' + series.id, + {observe: 'events', responseType: 'blob', reportProgress: true} + ).pipe(debounceTime(300), download((blob, filename) => { + this.save(blob, filename) + })); } - downloadChapter(chapter: Chapter, seriesName: string) { - this.downloadChapterSize(chapter.id).subscribe(async size => { - if (size >= this.SIZE_WARNING && !await this.confirmService.confirm('The chapter is ' + this.humanFileSize(size) + '. Are you sure you want to continue?')) { - return; - } - this.downloadChapterAPI(chapter.id).subscribe((resp: HttpResponse) => { - this.preformSave(resp.body || '', this.getFilenameFromHeader(resp.headers, seriesName + ' - Chapter ' + chapter.number)); - }); - }); + downloadChapter(chapter: Chapter) { + return this.httpClient.get(this.baseUrl + 'download/chapter?chapterId=' + chapter.id, + {observe: 'events', responseType: 'blob', reportProgress: true} + ).pipe(debounceTime(300), download((blob, filename) => { + this.save(blob, filename) + })); } - downloadVolume(volume: Volume, seriesName: string) { - this.downloadVolumeSize(volume.id).subscribe(async size => { - if (size >= this.SIZE_WARNING && !await this.confirmService.confirm('The chapter is ' + this.humanFileSize(size) + '. Are you sure you want to continue?')) { - return; - } - this.downloadVolumeAPI(volume.id).subscribe(resp => { - this.preformSave(resp.body || '', this.getFilenameFromHeader(resp.headers, seriesName + ' - Volume ' + volume.name)); - }); - }); + downloadVolume(volume: Volume): Observable { + return this.httpClient.get(this.baseUrl + 'download/volume?volumeId=' + volume.id, + {observe: 'events', responseType: 'blob', reportProgress: true} + ).pipe(debounceTime(300), download((blob, filename) => { + this.save(blob, filename) + })); } - downloadBookmarks(bookmarks: PageBookmark[], seriesName: string) { - return this.httpClient.post(this.baseUrl + 'download/bookmarks', {bookmarks}, {observe: 'response', responseType: 'blob' as 'text'}).pipe(take(1), map(resp => { - this.preformSave(resp.body || '', this.getFilenameFromHeader(resp.headers, seriesName)); - })); + async confirmSize(size: number, entityType: 'volume' | 'chapter' | 'series') { + return (size < this.SIZE_WARNING || await this.confirmService.confirm('The ' + entityType + ' is ' + this.humanFileSize(size) + '. Are you sure you want to continue?')); + } + + downloadBookmarks(bookmarks: PageBookmark[]) { + return this.httpClient.post(this.baseUrl + 'download/bookmarks', {bookmarks}, + {observe: 'events', responseType: 'blob', reportProgress: true} + ).pipe(debounceTime(300), download((blob, filename) => { + this.save(blob, filename) + })); } private preformSave(res: string, filename: string) { @@ -99,6 +94,7 @@ export class DownloadService { this.toastr.success('File downloaded successfully: ' + filename); } + /** * Attempts to parse out the filename from Content-Disposition header. * If it fails, will default to defaultName and add the correct extension. If no extension is found in header, will use zip. diff --git a/UI/Web/src/app/shared/_services/utility.service.ts b/UI/Web/src/app/shared/_services/utility.service.ts index 8d67bb7b6..3c2ed50d5 100644 --- a/UI/Web/src/app/shared/_services/utility.service.ts +++ b/UI/Web/src/app/shared/_services/utility.service.ts @@ -1,6 +1,7 @@ import { Injectable } from '@angular/core'; import { Chapter } from 'src/app/_models/chapter'; import { MangaFormat } from 'src/app/_models/manga-format'; +import { Series } from 'src/app/_models/series'; import { Volume } from 'src/app/_models/volume'; export enum KEY_CODES { @@ -85,4 +86,28 @@ export class UtilityService { } } + isVolume(d: any) { + return d != null && d.hasOwnProperty('chapters'); + } + + isChapter(d: any) { + return d != null && d.hasOwnProperty('volumeId'); + } + + isSeries(d: any) { + return d != null && d.hasOwnProperty('originalName'); + } + + asVolume(d: any) { + return d; + } + + asChapter(d: any) { + return d; + } + + asSeries(d: any) { + return d; + } + } diff --git a/UI/Web/src/app/shared/card-item/card-item.component.html b/UI/Web/src/app/shared/card-item/card-item.component.html index 8e36c4589..3e3aefaef 100644 --- a/UI/Web/src/app/shared/card-item/card-item.component.html +++ b/UI/Web/src/app/shared/card-item/card-item.component.html @@ -1,15 +1,24 @@
-
+

+ + + + + {{download.progress}}% downloaded + +
Cannot Read Archive
+ +
diff --git a/UI/Web/src/app/shared/card-item/card-item.component.scss b/UI/Web/src/app/shared/card-item/card-item.component.scss index 6a8877400..722c71c4e 100644 --- a/UI/Web/src/app/shared/card-item/card-item.component.scss +++ b/UI/Web/src/app/shared/card-item/card-item.component.scss @@ -52,6 +52,14 @@ $image-width: 160px; } } +.download { + width: 80px; + height: 80px; + position: absolute; + top: 25%; + right: 30%; +} + .not-read-badge { position: absolute; top: 0px; @@ -65,8 +73,8 @@ $image-width: 160px; .overlay { height: $image-height; + &:hover { - //background-color: rgba(0, 0, 0, 0.4); visibility: visible; .overlay-item { diff --git a/UI/Web/src/app/shared/card-item/card-item.component.ts b/UI/Web/src/app/shared/card-item/card-item.component.ts index 4fa0fbbc5..b14282af0 100644 --- a/UI/Web/src/app/shared/card-item/card-item.component.ts +++ b/UI/Web/src/app/shared/card-item/card-item.component.ts @@ -1,14 +1,17 @@ import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from '@angular/core'; -import { Subject } from 'rxjs'; -import { takeUntil } from 'rxjs/operators'; +import { ToastrService } from 'ngx-toastr'; +import { asyncScheduler, Observable, Subject } from 'rxjs'; +import { finalize, take, takeUntil, takeWhile, throttleTime } from 'rxjs/operators'; import { Chapter } from 'src/app/_models/chapter'; import { CollectionTag } from 'src/app/_models/collection-tag'; import { MangaFormat } from 'src/app/_models/manga-format'; import { Series } from 'src/app/_models/series'; import { Volume } from 'src/app/_models/volume'; -import { ActionItem } from 'src/app/_services/action-factory.service'; +import { Action, ActionItem } from 'src/app/_services/action-factory.service'; import { ImageService } from 'src/app/_services/image.service'; import { LibraryService } from 'src/app/_services/library.service'; +import { Download } from '../_models/download'; +import { DownloadService } from '../_services/download.service'; import { UtilityService } from '../_services/utility.service'; @Component({ @@ -32,13 +35,18 @@ export class CardItemComponent implements OnInit, OnDestroy { supressArchiveWarning: boolean = false; // This will supress the cannot read archive warning when total pages is 0 format: MangaFormat = MangaFormat.UNKNOWN; + download$: Observable | null = null; + downloadInProgress: boolean = false; + get MangaFormat(): typeof MangaFormat { return MangaFormat; } private readonly onDestroy = new Subject(); - constructor(public imageSerivce: ImageService, private libraryService: LibraryService, public utilityService: UtilityService) {} + constructor(public imageSerivce: ImageService, private libraryService: LibraryService, + public utilityService: UtilityService, private downloadService: DownloadService, + private toastr: ToastrService) {} ngOnInit(): void { if (this.entity.hasOwnProperty('promoted') && this.entity.hasOwnProperty('title')) { @@ -54,6 +62,8 @@ export class CardItemComponent implements OnInit, OnDestroy { }); } this.format = (this.entity as Series).format; + + } ngOnDestroy() { @@ -75,11 +85,70 @@ export class CardItemComponent implements OnInit, OnDestroy { } performAction(action: ActionItem) { + if (action.action == Action.Download) { + if (this.downloadInProgress === true) { + this.toastr.info('Download is already in progress. Please wait.'); + return; + } + + if (this.utilityService.isVolume(this.entity)) { + const volume = this.utilityService.asVolume(this.entity); + this.downloadService.downloadVolumeSize(volume.id).pipe(take(1)).subscribe(async (size) => { + const wantToDownload = await this.downloadService.confirmSize(size, 'volume'); + if (!wantToDownload) { return; } + this.downloadInProgress = true; + this.download$ = this.downloadService.downloadVolume(volume).pipe( + throttleTime(100, asyncScheduler, { leading: true, trailing: true }), + takeWhile(val => { + return val.state != 'DONE'; + }), + finalize(() => { + this.download$ = null; + this.downloadInProgress = false; + })); + }); + } else if (this.utilityService.isChapter(this.entity)) { + const chapter = this.utilityService.asChapter(this.entity); + this.downloadService.downloadChapterSize(chapter.id).pipe(take(1)).subscribe(async (size) => { + const wantToDownload = await this.downloadService.confirmSize(size, 'chapter'); + if (!wantToDownload) { return; } + this.downloadInProgress = true; + this.download$ = this.downloadService.downloadChapter(chapter).pipe( + throttleTime(100, asyncScheduler, { leading: true, trailing: true }), + takeWhile(val => { + return val.state != 'DONE'; + }), + finalize(() => { + this.download$ = null; + this.downloadInProgress = false; + })); + }); + } else if (this.utilityService.isSeries(this.entity)) { + const series = this.utilityService.asSeries(this.entity); + this.downloadService.downloadSeriesSize(series.id).pipe(take(1)).subscribe(async (size) => { + const wantToDownload = await this.downloadService.confirmSize(size, 'series'); + if (!wantToDownload) { return; } + this.downloadInProgress = true; + this.download$ = this.downloadService.downloadSeries(series).pipe( + throttleTime(100, asyncScheduler, { leading: true, trailing: true }), + takeWhile(val => { + return val.state != 'DONE'; + }), + finalize(() => { + this.download$ = null; + this.downloadInProgress = false; + })); + }); + } + return; // Don't propagate the download from a card + } + if (typeof action.callback === 'function') { action.callback(action.action, this.entity); } } + isPromoted() { const tag = this.entity as CollectionTag; return tag.hasOwnProperty('promoted') && tag.promoted; diff --git a/UI/Web/src/app/shared/circular-loader/circular-loader.component.html b/UI/Web/src/app/shared/circular-loader/circular-loader.component.html new file mode 100644 index 000000000..a8a8b8e50 --- /dev/null +++ b/UI/Web/src/app/shared/circular-loader/circular-loader.component.html @@ -0,0 +1,44 @@ + + + +
+ +
+
+ +
+ +
diff --git a/UI/Web/src/app/shared/circular-loader/circular-loader.component.scss b/UI/Web/src/app/shared/circular-loader/circular-loader.component.scss new file mode 100644 index 000000000..d921509d7 --- /dev/null +++ b/UI/Web/src/app/shared/circular-loader/circular-loader.component.scss @@ -0,0 +1,23 @@ +$background: rgba(0, 0, 0, .4); +$primary-color: #4ac694; + + +.number { + position: absolute; + top:50%; + left:50%; + z-index:10; + font-size:18px; + font-weight:500; + color:$primary-color; + animation: MoveUpDown 1s linear infinite; +} + +@keyframes MoveUpDown { + 0%, 100% { + transform: translateY(0); + } + 50% { + transform: translateY(-10px); + } +} \ No newline at end of file diff --git a/UI/Web/src/app/shared/circular-loader/circular-loader.component.ts b/UI/Web/src/app/shared/circular-loader/circular-loader.component.ts new file mode 100644 index 000000000..e0a2a4728 --- /dev/null +++ b/UI/Web/src/app/shared/circular-loader/circular-loader.component.ts @@ -0,0 +1,18 @@ +import { Component, ElementRef, Input, OnChanges, OnInit, Renderer2, ViewChild } from '@angular/core'; + +@Component({ + selector: 'app-circular-loader', + templateUrl: './circular-loader.component.html', + styleUrls: ['./circular-loader.component.scss'] +}) +export class CircularLoaderComponent implements OnInit { + + @Input() currentValue: number = 0; + @Input() maxValue: number = 0; + + constructor(private renderer: Renderer2) { } + + ngOnInit(): void { + } + +} diff --git a/UI/Web/src/app/shared/shared.module.ts b/UI/Web/src/app/shared/shared.module.ts index 6209c1b7b..87604f378 100644 --- a/UI/Web/src/app/shared/shared.module.ts +++ b/UI/Web/src/app/shared/shared.module.ts @@ -18,7 +18,9 @@ import { ShowIfScrollbarDirective } from './show-if-scrollbar.directive'; import { A11yClickDirective } from './a11y-click.directive'; import { SeriesFormatComponent } from './series-format/series-format.component'; import { UpdateNotificationModalComponent } from './update-notification/update-notification-modal.component'; - +import { SAVER, getSaver } from './_providers/saver.provider'; +import { CircularLoaderComponent } from './circular-loader/circular-loader.component'; +import { NgCircleProgressModule } from 'ng-circle-progress'; @NgModule({ declarations: [ @@ -35,7 +37,8 @@ import { UpdateNotificationModalComponent } from './update-notification/update-n ShowIfScrollbarDirective, A11yClickDirective, SeriesFormatComponent, - UpdateNotificationModalComponent + UpdateNotificationModalComponent, + CircularLoaderComponent ], imports: [ CommonModule, @@ -46,7 +49,8 @@ import { UpdateNotificationModalComponent } from './update-notification/update-n NgbTooltipModule, NgbCollapseModule, LazyLoadImageModule, - NgbPaginationModule // CardDetailLayoutComponent + NgbPaginationModule, // CardDetailLayoutComponent + NgCircleProgressModule.forRoot() ], exports: [ RegisterMemberComponent, @@ -59,7 +63,8 @@ import { UpdateNotificationModalComponent } from './update-notification/update-n CardDetailLayoutComponent, ShowIfScrollbarDirective, A11yClickDirective, - SeriesFormatComponent - ] + SeriesFormatComponent, + ], + providers: [{provide: SAVER, useFactory: getSaver}] }) export class SharedModule { }