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
This commit is contained in:
Joseph Milazzo 2021-08-11 16:01:44 -05:00 committed by GitHub
parent 855f452d14
commit 89b68bc301
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 439 additions and 92 deletions

View File

@ -4,7 +4,6 @@ using System.IO;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using API.Comparators; using API.Comparators;
using API.DTOs;
using API.DTOs.Downloads; using API.DTOs.Downloads;
using API.Entities; using API.Entities;
using API.Entities.Enums; using API.Entities.Enums;
@ -27,6 +26,7 @@ namespace API.Controllers
private readonly IDirectoryService _directoryService; private readonly IDirectoryService _directoryService;
private readonly ICacheService _cacheService; private readonly ICacheService _cacheService;
private readonly NumericComparer _numericComparer; private readonly NumericComparer _numericComparer;
private const string DefaultContentType = "application/octet-stream"; // "application/zip"
public DownloadController(IUnitOfWork unitOfWork, IArchiveService archiveService, IDirectoryService directoryService, ICacheService cacheService) public DownloadController(IUnitOfWork unitOfWork, IArchiveService archiveService, IDirectoryService directoryService, ICacheService cacheService)
{ {
@ -62,6 +62,8 @@ namespace API.Controllers
public async Task<ActionResult> DownloadVolume(int volumeId) public async Task<ActionResult> DownloadVolume(int volumeId)
{ {
var files = await _unitOfWork.VolumeRepository.GetFilesForVolume(volumeId); var files = await _unitOfWork.VolumeRepository.GetFilesForVolume(volumeId);
var volume = await _unitOfWork.SeriesRepository.GetVolumeByIdAsync(volumeId);
var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(volume.SeriesId);
try try
{ {
if (files.Count == 1) if (files.Count == 1)
@ -70,7 +72,7 @@ namespace API.Controllers
} }
var (fileBytes, zipPath) = await _archiveService.CreateZipForDownload(files.Select(c => c.FilePath), var (fileBytes, zipPath) = await _archiveService.CreateZipForDownload(files.Select(c => c.FilePath),
$"download_{User.GetUsername()}_v{volumeId}"); $"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) catch (KavitaException ex)
{ {
@ -105,6 +107,9 @@ namespace API.Controllers
public async Task<ActionResult> DownloadChapter(int chapterId) public async Task<ActionResult> DownloadChapter(int chapterId)
{ {
var files = await _unitOfWork.VolumeRepository.GetFilesForChapterAsync(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 try
{ {
if (files.Count == 1) if (files.Count == 1)
@ -113,7 +118,7 @@ namespace API.Controllers
} }
var (fileBytes, zipPath) = await _archiveService.CreateZipForDownload(files.Select(c => c.FilePath), var (fileBytes, zipPath) = await _archiveService.CreateZipForDownload(files.Select(c => c.FilePath),
$"download_{User.GetUsername()}_c{chapterId}"); $"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) catch (KavitaException ex)
{ {
@ -125,6 +130,7 @@ namespace API.Controllers
public async Task<ActionResult> DownloadSeries(int seriesId) public async Task<ActionResult> DownloadSeries(int seriesId)
{ {
var files = await _unitOfWork.SeriesRepository.GetFilesForSeries(seriesId); var files = await _unitOfWork.SeriesRepository.GetFilesForSeries(seriesId);
var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId);
try try
{ {
if (files.Count == 1) if (files.Count == 1)
@ -133,7 +139,7 @@ namespace API.Controllers
} }
var (fileBytes, zipPath) = await _archiveService.CreateZipForDownload(files.Select(c => c.FilePath), var (fileBytes, zipPath) = await _archiveService.CreateZipForDownload(files.Select(c => c.FilePath),
$"download_{User.GetUsername()}_s{seriesId}"); $"download_{User.GetUsername()}_s{seriesId}");
return File(fileBytes, "application/zip", Path.GetFileNameWithoutExtension(zipPath) + ".zip"); return File(fileBytes, DefaultContentType, $"{series.Name}.zip");
} }
catch (KavitaException ex) catch (KavitaException ex)
{ {
@ -195,7 +201,7 @@ namespace API.Controllers
var (fileBytes, zipPath) = await _archiveService.CreateZipForDownload(totalFilePaths, var (fileBytes, zipPath) = await _archiveService.CreateZipForDownload(totalFilePaths,
tempFolder); tempFolder);
DirectoryService.ClearAndDeleteDirectory(fullExtractPath); DirectoryService.ClearAndDeleteDirectory(fullExtractPath);
return File(fileBytes, "application/zip", $"{series.Name} - Bookmarks.zip"); return File(fileBytes, DefaultContentType, $"{series.Name} - Bookmarks.zip");
} }
} }
} }

View File

@ -31,6 +31,7 @@
"bowser": "^2.11.0", "bowser": "^2.11.0",
"file-saver": "^2.0.5", "file-saver": "^2.0.5",
"lazysizes": "^5.3.2", "lazysizes": "^5.3.2",
"ng-circle-progress": "^1.6.0",
"ng-lazyload-image": "^9.1.0", "ng-lazyload-image": "^9.1.0",
"ng-sidebar": "^9.4.2", "ng-sidebar": "^9.4.2",
"ngx-toastr": "^13.2.1", "ngx-toastr": "^13.2.1",
@ -12742,6 +12743,19 @@
"integrity": "sha1-yobR/ogoFpsBICCOPchCS524NCw=", "integrity": "sha1-yobR/ogoFpsBICCOPchCS524NCw=",
"dev": true "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": { "node_modules/ng-lazyload-image": {
"version": "9.1.0", "version": "9.1.0",
"resolved": "https://registry.npmjs.org/ng-lazyload-image/-/ng-lazyload-image-9.1.0.tgz", "resolved": "https://registry.npmjs.org/ng-lazyload-image/-/ng-lazyload-image-9.1.0.tgz",
@ -30964,6 +30978,14 @@
"integrity": "sha1-yobR/ogoFpsBICCOPchCS524NCw=", "integrity": "sha1-yobR/ogoFpsBICCOPchCS524NCw=",
"dev": true "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": { "ng-lazyload-image": {
"version": "9.1.0", "version": "9.1.0",
"resolved": "https://registry.npmjs.org/ng-lazyload-image/-/ng-lazyload-image-9.1.0.tgz", "resolved": "https://registry.npmjs.org/ng-lazyload-image/-/ng-lazyload-image-9.1.0.tgz",

View File

@ -38,6 +38,7 @@
"bowser": "^2.11.0", "bowser": "^2.11.0",
"file-saver": "^2.0.5", "file-saver": "^2.0.5",
"lazysizes": "^5.3.2", "lazysizes": "^5.3.2",
"ng-circle-progress": "^1.6.0",
"ng-lazyload-image": "^9.1.0", "ng-lazyload-image": "^9.1.0",
"ng-sidebar": "^9.4.2", "ng-sidebar": "^9.4.2",
"ngx-toastr": "^13.2.1", "ngx-toastr": "^13.2.1",

View File

@ -1,7 +1,8 @@
import { Component, Input, OnInit } from '@angular/core'; import { Component, Input, OnInit } from '@angular/core';
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
import { ToastrService } from 'ngx-toastr'; 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 { DownloadService } from 'src/app/shared/_services/download.service';
import { PageBookmark } from 'src/app/_models/page-bookmark'; import { PageBookmark } from 'src/app/_models/page-bookmark';
import { Series } from 'src/app/_models/series'; import { Series } from 'src/app/_models/series';
@ -53,9 +54,14 @@ export class BookmarksModalComponent implements OnInit {
downloadBookmarks() { downloadBookmarks() {
this.isDownloading = true; this.isDownloading = true;
this.downloadService.downloadBookmarks(this.bookmarks, this.series.name).pipe(take(1)).subscribe(() => { this.downloadService.downloadBookmarks(this.bookmarks).pipe(
this.isDownloading = false; throttleTime(100, asyncScheduler, { leading: true, trailing: true }),
}); takeWhile(val => {
return val.state != 'DONE';
}),
finalize(() => {
this.isDownloading = false;
})).subscribe(() => {/* No Operation */});
} }
clearBookmarks() { clearBookmarks() {

View File

@ -16,7 +16,7 @@
<button ngbDropdownItem (click)="clearCache()" [disabled]="clearCacheInProgress"> <button ngbDropdownItem (click)="clearCache()" [disabled]="clearCacheInProgress">
Clear Cache Clear Cache
</button> </button>
<button ngbDropdownItem (click)="downloadService.downloadLogs()"> <button ngbDropdownItem (click)="downloadLogs()" [disabled]="downloadLogsInProgress">
Download Logs Download Logs
</button> </button>
<button ngbDropdownItem (click)="checkForUpdates()" [disabled]="hasCheckedForUpdate"> <button ngbDropdownItem (click)="checkForUpdates()" [disabled]="hasCheckedForUpdate">

View File

@ -1,7 +1,8 @@
import { Component, OnInit } from '@angular/core'; import { Component, OnInit } from '@angular/core';
import { FormControl, FormGroup, Validators } from '@angular/forms'; import { FormControl, FormGroup, Validators } from '@angular/forms';
import { ToastrService } from 'ngx-toastr'; 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 { DownloadService } from 'src/app/shared/_services/download.service';
import { ServerService } from 'src/app/_services/server.service'; import { ServerService } from 'src/app/_services/server.service';
import { SettingsService } from '../settings.service'; import { SettingsService } from '../settings.service';
@ -22,6 +23,7 @@ export class ManageSystemComponent implements OnInit {
clearCacheInProgress: boolean = false; clearCacheInProgress: boolean = false;
backupDBInProgress: boolean = false; backupDBInProgress: boolean = false;
hasCheckedForUpdate: boolean = false; hasCheckedForUpdate: boolean = false;
downloadLogsInProgress: boolean = false;
constructor(private settingsService: SettingsService, private toastr: ToastrService, constructor(private settingsService: SettingsService, private toastr: ToastrService,
private serverService: ServerService, public downloadService: DownloadService) { } private serverService: ServerService, public downloadService: DownloadService) { }
@ -87,4 +89,16 @@ export class ManageSystemComponent implements OnInit {
}); });
} }
downloadLogs() {
this.downloadLogsInProgress = true;
this.downloadService.downloadLogs().pipe(
throttleTime(100, asyncScheduler, { leading: true, trailing: true }),
takeWhile(val => {
return val.state != 'DONE';
}),
finalize(() => {
this.downloadLogsInProgress = false;
})).subscribe(() => {/* No Operation */});
}
} }

View File

@ -25,6 +25,7 @@ import { ReviewSeriesModalComponent } from './_modals/review-series-modal/review
import { CarouselModule } from './carousel/carousel.module'; import { CarouselModule } from './carousel/carousel.module';
import { NgxSliderModule } from '@angular-slider/ngx-slider'; import { NgxSliderModule } from '@angular-slider/ngx-slider';
import * as Sentry from '@sentry/angular'; import * as Sentry from '@sentry/angular';
import { environment } from 'src/environments/environment'; import { environment } from 'src/environments/environment';
import { version } from 'package.json'; import { version } from 'package.json';
@ -134,6 +135,7 @@ if (environment.production) {
countDuplicates: true, countDuplicates: true,
autoDismiss: true autoDismiss: true
}), }),
], ],
providers: [ providers: [
{provide: HTTP_INTERCEPTORS, useClass: ErrorInterceptor, multi: true}, {provide: HTTP_INTERCEPTORS, useClass: ErrorInterceptor, multi: true},

View File

@ -28,10 +28,14 @@
</button> </button>
</div> </div>
<div class="ml-2" *ngIf="isAdmin || hasDownloadingRole"> <div class="ml-2" *ngIf="isAdmin || hasDownloadingRole">
<button class="btn btn-secondary" (click)="downloadSeries()" title="Download Series"> <button class="btn btn-secondary" (click)="downloadSeries()" title="Download Series" [disabled]="downloadInProgress">
<span> <ng-container *ngIf="downloadInProgress; else notDownloading">
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
<span class="sr-only">Downloading...</span>
</ng-container>
<ng-template #notDownloading>
<i class="fa fa-arrow-alt-circle-down" aria-hidden="true"></i> <i class="fa fa-arrow-alt-circle-down" aria-hidden="true"></i>
</span> </ng-template>
</button> </button>
</div> </div>
<div class="ml-2"> <div class="ml-2">

View File

@ -3,7 +3,8 @@ import { Title } from '@angular/platform-browser';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { NgbModal, NgbRatingConfig } from '@ng-bootstrap/ng-bootstrap'; import { NgbModal, NgbRatingConfig } from '@ng-bootstrap/ng-bootstrap';
import { ToastrService } from 'ngx-toastr'; 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 { ConfirmConfig } from '../shared/confirm-dialog/_models/confirm-config';
import { ConfirmService } from '../shared/confirm.service'; import { ConfirmService } from '../shared/confirm.service';
import { TagBadgeCursor } from '../shared/tag-badge/tag-badge.component'; import { TagBadgeCursor } from '../shared/tag-badge/tag-badge.component';
@ -61,6 +62,8 @@ export class SeriesDetailComponent implements OnInit {
libraryType: LibraryType = LibraryType.Manga; libraryType: LibraryType = LibraryType.Manga;
seriesMetadata: SeriesMetadata | null = null; seriesMetadata: SeriesMetadata | null = null;
downloadInProgress: boolean = false;
/** /**
* If an action is currently being done, don't let the user kick off another action * 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; break;
case(Action.ScanLibrary): case(Action.ScanLibrary):
this.actionService.scanSeries(series, (series) => this.actionInProgress = false); this.actionService.scanSeries(series, () => this.actionInProgress = false);
break; break;
case(Action.RefreshMetadata): case(Action.RefreshMetadata):
this.actionService.refreshMetdata(series, (series) => this.actionInProgress = false); this.actionService.refreshMetdata(series, () => this.actionInProgress = false);
break; break;
case(Action.Delete): case(Action.Delete):
this.deleteSeries(series); this.deleteSeries(series);
break; break;
case(Action.Bookmarks): case(Action.Bookmarks):
this.actionService.openBookmarkModal(series, (series) => this.actionInProgress = false); this.actionService.openBookmarkModal(series, () => this.actionInProgress = false);
break; break;
default: default:
break; break;
@ -169,9 +172,6 @@ export class SeriesDetailComponent implements OnInit {
case(Action.Info): case(Action.Info):
this.openViewInfo(volume); this.openViewInfo(volume);
break; break;
case(Action.Download):
this.downloadService.downloadVolume(volume, this.series.name);
break;
default: default:
break; break;
} }
@ -188,9 +188,6 @@ export class SeriesDetailComponent implements OnInit {
case(Action.Info): case(Action.Info):
this.openViewInfo(chapter); this.openViewInfo(chapter);
break; break;
case(Action.Download):
this.downloadService.downloadChapter(chapter, this.series.name);
break;
default: default:
break; break;
} }
@ -285,7 +282,7 @@ export class SeriesDetailComponent implements OnInit {
} }
const seriesId = this.series.id; const seriesId = this.series.id;
this.actionService.markVolumeAsRead(seriesId, vol, (volume) => { this.actionService.markVolumeAsRead(seriesId, vol, () => {
this.setContinuePoint(); this.setContinuePoint();
this.actionInProgress = false; this.actionInProgress = false;
}); });
@ -297,7 +294,7 @@ export class SeriesDetailComponent implements OnInit {
} }
const seriesId = this.series.id; const seriesId = this.series.id;
this.actionService.markVolumeAsUnread(seriesId, vol, (volume) => { this.actionService.markVolumeAsUnread(seriesId, vol, () => {
this.setContinuePoint(); this.setContinuePoint();
this.actionInProgress = false; this.actionInProgress = false;
}); });
@ -309,7 +306,7 @@ export class SeriesDetailComponent implements OnInit {
} }
const seriesId = this.series.id; const seriesId = this.series.id;
this.actionService.markChapterAsRead(seriesId, chapter, (chapter) => { this.actionService.markChapterAsRead(seriesId, chapter, () => {
this.setContinuePoint(); this.setContinuePoint();
this.actionInProgress = false; this.actionInProgress = false;
}); });
@ -321,7 +318,7 @@ export class SeriesDetailComponent implements OnInit {
} }
const seriesId = this.series.id; const seriesId = this.series.id;
this.actionService.markChapterAsUnread(seriesId, chapter, (chapter) => { this.actionService.markChapterAsUnread(seriesId, chapter, () => {
this.setContinuePoint(); this.setContinuePoint();
this.actionInProgress = false; this.actionInProgress = false;
}); });
@ -433,6 +430,19 @@ export class SeriesDetailComponent implements OnInit {
} }
downloadSeries() { 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;
}));
});
} }
} }

View File

@ -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<T>(event: HttpEvent<T>): event is HttpResponse<T> {
return event.type === HttpEventType.Response;
}
function isHttpProgressEvent(
event: HttpEvent<unknown>
): 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<HttpEvent<Blob>>) => Observable<Download> {
return (source: Observable<HttpEvent<Blob>>) =>
source.pipe(
scan((previous: Download, event: HttpEvent<Blob>): 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;
}

View File

@ -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>('saver')
export function getSaver(): Saver {
return saveAs;
}

View File

@ -1,5 +1,5 @@
import { HttpClient, HttpHeaders, HttpParams, HttpResponse } from '@angular/common/http'; import { HttpClient, HttpHeaders, HttpResponse } from '@angular/common/http';
import { Injectable } from '@angular/core'; import { Inject, Injectable } from '@angular/core';
import { Series } from 'src/app/_models/series'; import { Series } from 'src/app/_models/series';
import { environment } from 'src/environments/environment'; import { environment } from 'src/environments/environment';
import { ConfirmService } from '../confirm.service'; import { ConfirmService } from '../confirm.service';
@ -7,8 +7,11 @@ import { saveAs } from 'file-saver';
import { Chapter } from 'src/app/_models/chapter'; import { Chapter } from 'src/app/_models/chapter';
import { Volume } from 'src/app/_models/volume'; import { Volume } from 'src/app/_models/volume';
import { ToastrService } from 'ngx-toastr'; 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 { PageBookmark } from 'src/app/_models/page-bookmark';
import { map, take } from 'rxjs/operators'; import { debounce, debounceTime, map, take } from 'rxjs/operators';
@Injectable({ @Injectable({
providedIn: 'root' providedIn: 'root'
@ -21,76 +24,68 @@ export class DownloadService {
*/ */
public SIZE_WARNING = 104_857_600; 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<number>(this.baseUrl + 'download/series-size?seriesId=' + seriesId); return this.httpClient.get<number>(this.baseUrl + 'download/series-size?seriesId=' + seriesId);
} }
private downloadVolumeSize(volumeId: number) { public downloadVolumeSize(volumeId: number) {
return this.httpClient.get<number>(this.baseUrl + 'download/volume-size?volumeId=' + volumeId); return this.httpClient.get<number>(this.baseUrl + 'download/volume-size?volumeId=' + volumeId);
} }
private downloadChapterSize(chapterId: number) { public downloadChapterSize(chapterId: number) {
return this.httpClient.get<number>(this.baseUrl + 'download/chapter-size?chapterId=' + chapterId); return this.httpClient.get<number>(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() { downloadLogs() {
this.httpClient.get(this.baseUrl + 'server/logs', {observe: 'response', responseType: 'blob' as 'text'}).subscribe(resp => { // 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.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) { downloadSeries(series: Series) {
this.downloadSeriesSize(series.id).subscribe(async size => { return this.httpClient.get(this.baseUrl + 'download/series?seriesId=' + series.id,
if (size >= this.SIZE_WARNING && !await this.confirmService.confirm('The series is ' + this.humanFileSize(size) + '. Are you sure you want to continue?')) { {observe: 'events', responseType: 'blob', reportProgress: true}
return; ).pipe(debounceTime(300), download((blob, filename) => {
} this.save(blob, filename)
this.downloadSeriesAPI(series.id).subscribe(resp => { }));
this.preformSave(resp.body || '', this.getFilenameFromHeader(resp.headers, series.name));
});
});
} }
downloadChapter(chapter: Chapter, seriesName: string) { downloadChapter(chapter: Chapter) {
this.downloadChapterSize(chapter.id).subscribe(async size => { return this.httpClient.get(this.baseUrl + 'download/chapter?chapterId=' + chapter.id,
if (size >= this.SIZE_WARNING && !await this.confirmService.confirm('The chapter is ' + this.humanFileSize(size) + '. Are you sure you want to continue?')) { {observe: 'events', responseType: 'blob', reportProgress: true}
return; ).pipe(debounceTime(300), download((blob, filename) => {
} this.save(blob, filename)
this.downloadChapterAPI(chapter.id).subscribe((resp: HttpResponse<string>) => { }));
this.preformSave(resp.body || '', this.getFilenameFromHeader(resp.headers, seriesName + ' - Chapter ' + chapter.number));
});
});
} }
downloadVolume(volume: Volume, seriesName: string) { downloadVolume(volume: Volume): Observable<Download> {
this.downloadVolumeSize(volume.id).subscribe(async size => { return this.httpClient.get(this.baseUrl + 'download/volume?volumeId=' + volume.id,
if (size >= this.SIZE_WARNING && !await this.confirmService.confirm('The chapter is ' + this.humanFileSize(size) + '. Are you sure you want to continue?')) { {observe: 'events', responseType: 'blob', reportProgress: true}
return; ).pipe(debounceTime(300), download((blob, filename) => {
} this.save(blob, filename)
this.downloadVolumeAPI(volume.id).subscribe(resp => { }));
this.preformSave(resp.body || '', this.getFilenameFromHeader(resp.headers, seriesName + ' - Volume ' + volume.name));
});
});
} }
downloadBookmarks(bookmarks: PageBookmark[], seriesName: string) { async confirmSize(size: number, entityType: 'volume' | 'chapter' | 'series') {
return this.httpClient.post(this.baseUrl + 'download/bookmarks', {bookmarks}, {observe: 'response', responseType: 'blob' as 'text'}).pipe(take(1), map(resp => { return (size < this.SIZE_WARNING || await this.confirmService.confirm('The ' + entityType + ' is ' + this.humanFileSize(size) + '. Are you sure you want to continue?'));
this.preformSave(resp.body || '', this.getFilenameFromHeader(resp.headers, seriesName)); }
}));
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) { private preformSave(res: string, filename: string) {
@ -99,6 +94,7 @@ export class DownloadService {
this.toastr.success('File downloaded successfully: ' + filename); this.toastr.success('File downloaded successfully: ' + filename);
} }
/** /**
* Attempts to parse out the filename from Content-Disposition header. * 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. * If it fails, will default to defaultName and add the correct extension. If no extension is found in header, will use zip.

View File

@ -1,6 +1,7 @@
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { Chapter } from 'src/app/_models/chapter'; import { Chapter } from 'src/app/_models/chapter';
import { MangaFormat } from 'src/app/_models/manga-format'; import { MangaFormat } from 'src/app/_models/manga-format';
import { Series } from 'src/app/_models/series';
import { Volume } from 'src/app/_models/volume'; import { Volume } from 'src/app/_models/volume';
export enum KEY_CODES { 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 <Volume>d;
}
asChapter(d: any) {
return <Chapter>d;
}
asSeries(d: any) {
return <Series>d;
}
} }

View File

@ -1,15 +1,24 @@
<div class="card"> <div class="card">
<div class="overlay" (click)="handleClick()"> <div class="overlay" (click)="handleClick()">
<img *ngIf="total > 0 || supressArchiveWarning" class="card-img-top lazyload" [src]="imageSerivce.placeholderImage" [attr.data-src]="imageUrl" <img *ngIf="total > 0 || supressArchiveWarning" class="card-img-top lazyload" [src]="imageSerivce.placeholderImage" [attr.data-src]="imageUrl"
(error)="imageSerivce.updateErroredImage($event)" aria-hidden="true" height="230px" width="158px"> (error)="imageSerivce.updateErroredImage($event)" aria-hidden="true" height="230px" width="158px">
<img *ngIf="total === 0 && !supressArchiveWarning" class="card-img-top lazyload" [src]="imageSerivce.errorImage" [attr.data-src]="imageUrl" <img *ngIf="total === 0 && !supressArchiveWarning" class="card-img-top lazyload" [src]="imageSerivce.errorImage" [attr.data-src]="imageUrl"
aria-hidden="true" height="230px" width="158px"> aria-hidden="true" height="230px" width="158px">
<div class="progress-banner" *ngIf="read < total && total > 0 && read !== (total -1)"> <div class="progress-banner" *ngIf="read < total && total > 0 && read !== (total -1)">
<p><ngb-progressbar type="primary" height="5px" [value]="read" [max]="total"></ngb-progressbar></p> <p><ngb-progressbar type="primary" height="5px" [value]="read" [max]="total"></ngb-progressbar></p>
<span class="download" *ngIf="download$ | async as download">
<app-circular-loader [currentValue]="download.progress"></app-circular-loader>
<span class="sr-only" role="status">
{{download.progress}}% downloaded
</span>
</span>
</div> </div>
<div class="error-banner" *ngIf="total === 0 && !supressArchiveWarning"> <div class="error-banner" *ngIf="total === 0 && !supressArchiveWarning">
Cannot Read Archive Cannot Read Archive
</div> </div>
<div class="not-read-badge" *ngIf="read === 0 && total > 0"></div> <div class="not-read-badge" *ngIf="read === 0 && total > 0"></div>
</div> </div>

View File

@ -52,6 +52,14 @@ $image-width: 160px;
} }
} }
.download {
width: 80px;
height: 80px;
position: absolute;
top: 25%;
right: 30%;
}
.not-read-badge { .not-read-badge {
position: absolute; position: absolute;
top: 0px; top: 0px;
@ -65,8 +73,8 @@ $image-width: 160px;
.overlay { .overlay {
height: $image-height; height: $image-height;
&:hover { &:hover {
//background-color: rgba(0, 0, 0, 0.4);
visibility: visible; visibility: visible;
.overlay-item { .overlay-item {

View File

@ -1,14 +1,17 @@
import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from '@angular/core'; import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from '@angular/core';
import { Subject } from 'rxjs'; import { ToastrService } from 'ngx-toastr';
import { takeUntil } from 'rxjs/operators'; import { asyncScheduler, Observable, Subject } from 'rxjs';
import { finalize, take, takeUntil, takeWhile, throttleTime } from 'rxjs/operators';
import { Chapter } from 'src/app/_models/chapter'; import { Chapter } from 'src/app/_models/chapter';
import { CollectionTag } from 'src/app/_models/collection-tag'; import { CollectionTag } from 'src/app/_models/collection-tag';
import { MangaFormat } from 'src/app/_models/manga-format'; import { MangaFormat } from 'src/app/_models/manga-format';
import { Series } from 'src/app/_models/series'; import { Series } from 'src/app/_models/series';
import { Volume } from 'src/app/_models/volume'; 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 { ImageService } from 'src/app/_services/image.service';
import { LibraryService } from 'src/app/_services/library.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'; import { UtilityService } from '../_services/utility.service';
@Component({ @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 supressArchiveWarning: boolean = false; // This will supress the cannot read archive warning when total pages is 0
format: MangaFormat = MangaFormat.UNKNOWN; format: MangaFormat = MangaFormat.UNKNOWN;
download$: Observable<Download> | null = null;
downloadInProgress: boolean = false;
get MangaFormat(): typeof MangaFormat { get MangaFormat(): typeof MangaFormat {
return MangaFormat; return MangaFormat;
} }
private readonly onDestroy = new Subject<void>(); private readonly onDestroy = new Subject<void>();
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 { ngOnInit(): void {
if (this.entity.hasOwnProperty('promoted') && this.entity.hasOwnProperty('title')) { 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; this.format = (this.entity as Series).format;
} }
ngOnDestroy() { ngOnDestroy() {
@ -75,11 +85,70 @@ export class CardItemComponent implements OnInit, OnDestroy {
} }
performAction(action: ActionItem<any>) { performAction(action: ActionItem<any>) {
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') { if (typeof action.callback === 'function') {
action.callback(action.action, this.entity); action.callback(action.action, this.entity);
} }
} }
isPromoted() { isPromoted() {
const tag = this.entity as CollectionTag; const tag = this.entity as CollectionTag;
return tag.hasOwnProperty('promoted') && tag.promoted; return tag.hasOwnProperty('promoted') && tag.promoted;

View File

@ -0,0 +1,44 @@
<!-- <div class="circular">
<div class="inner"></div>
<div class="number">
<span class="sr-only">{{currentValue}}%</span>
<i class="fa fa-angle-double-down" style="font-size: 36px;" aria-hidden="true"></i>
</div>
<div class="circle">
<div class="bar left" #left>
<div class="progress"></div>
</div>
<div class="bar right" #right>
<div class="progress"></div>
</div>
</div>
</div> -->
<ng-container *ngIf="currentValue > 0">
<div class="number">
<i class="fa fa-angle-double-down" style="font-size: 36px;" aria-hidden="true"></i>
</div>
<div style="width: 100px; height: 100px;">
<circle-progress
[percent]="currentValue"
[radius]="100"
[outerStrokeWidth]="15"
[innerStrokeWidth]="0"
[space] = "0"
[backgroundPadding]="0"
outerStrokeLinecap="butt"
[outerStrokeColor]="'#4ac694'"
[innerStrokeColor]="'transparent'"
titleFontSize= "24"
unitsFontSize= "24"
[showSubtitle] = "false"
[animation]="true"
[animationDuration]="300"
[startFromZero]="false"
[responsive]="true"
[backgroundOpacity]="0.5"
[backgroundColor]="'#000'"
></circle-progress>
</div>
</ng-container>

View File

@ -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);
}
}

View File

@ -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 {
}
}

View File

@ -18,7 +18,9 @@ import { ShowIfScrollbarDirective } from './show-if-scrollbar.directive';
import { A11yClickDirective } from './a11y-click.directive'; import { A11yClickDirective } from './a11y-click.directive';
import { SeriesFormatComponent } from './series-format/series-format.component'; import { SeriesFormatComponent } from './series-format/series-format.component';
import { UpdateNotificationModalComponent } from './update-notification/update-notification-modal.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({ @NgModule({
declarations: [ declarations: [
@ -35,7 +37,8 @@ import { UpdateNotificationModalComponent } from './update-notification/update-n
ShowIfScrollbarDirective, ShowIfScrollbarDirective,
A11yClickDirective, A11yClickDirective,
SeriesFormatComponent, SeriesFormatComponent,
UpdateNotificationModalComponent UpdateNotificationModalComponent,
CircularLoaderComponent
], ],
imports: [ imports: [
CommonModule, CommonModule,
@ -46,7 +49,8 @@ import { UpdateNotificationModalComponent } from './update-notification/update-n
NgbTooltipModule, NgbTooltipModule,
NgbCollapseModule, NgbCollapseModule,
LazyLoadImageModule, LazyLoadImageModule,
NgbPaginationModule // CardDetailLayoutComponent NgbPaginationModule, // CardDetailLayoutComponent
NgCircleProgressModule.forRoot()
], ],
exports: [ exports: [
RegisterMemberComponent, RegisterMemberComponent,
@ -59,7 +63,8 @@ import { UpdateNotificationModalComponent } from './update-notification/update-n
CardDetailLayoutComponent, CardDetailLayoutComponent,
ShowIfScrollbarDirective, ShowIfScrollbarDirective,
A11yClickDirective, A11yClickDirective,
SeriesFormatComponent SeriesFormatComponent,
] ],
providers: [{provide: SAVER, useFactory: getSaver}]
}) })
export class SharedModule { } export class SharedModule { }