mirror of
https://github.com/Kareadita/Kavita.git
synced 2025-07-09 03:04:19 -04:00
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:
parent
855f452d14
commit
89b68bc301
@ -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<ActionResult> 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<ActionResult> 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<ActionResult> 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
22
UI/Web/package-lock.json
generated
22
UI/Web/package-lock.json
generated
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -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() {
|
||||
|
@ -16,7 +16,7 @@
|
||||
<button ngbDropdownItem (click)="clearCache()" [disabled]="clearCacheInProgress">
|
||||
Clear Cache
|
||||
</button>
|
||||
<button ngbDropdownItem (click)="downloadService.downloadLogs()">
|
||||
<button ngbDropdownItem (click)="downloadLogs()" [disabled]="downloadLogsInProgress">
|
||||
Download Logs
|
||||
</button>
|
||||
<button ngbDropdownItem (click)="checkForUpdates()" [disabled]="hasCheckedForUpdate">
|
||||
|
@ -1,7 +1,8 @@
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
import { FormControl, FormGroup, Validators } from '@angular/forms';
|
||||
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 { ServerService } from 'src/app/_services/server.service';
|
||||
import { SettingsService } from '../settings.service';
|
||||
@ -22,6 +23,7 @@ export class ManageSystemComponent implements OnInit {
|
||||
clearCacheInProgress: boolean = false;
|
||||
backupDBInProgress: boolean = false;
|
||||
hasCheckedForUpdate: boolean = false;
|
||||
downloadLogsInProgress: boolean = false;
|
||||
|
||||
constructor(private settingsService: SettingsService, private toastr: ToastrService,
|
||||
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 */});
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -25,6 +25,7 @@ import { ReviewSeriesModalComponent } from './_modals/review-series-modal/review
|
||||
import { CarouselModule } from './carousel/carousel.module';
|
||||
import { NgxSliderModule } from '@angular-slider/ngx-slider';
|
||||
|
||||
|
||||
import * as Sentry from '@sentry/angular';
|
||||
import { environment } from 'src/environments/environment';
|
||||
import { version } from 'package.json';
|
||||
@ -134,6 +135,7 @@ if (environment.production) {
|
||||
countDuplicates: true,
|
||||
autoDismiss: true
|
||||
}),
|
||||
|
||||
],
|
||||
providers: [
|
||||
{provide: HTTP_INTERCEPTORS, useClass: ErrorInterceptor, multi: true},
|
||||
|
@ -28,10 +28,14 @@
|
||||
</button>
|
||||
</div>
|
||||
<div class="ml-2" *ngIf="isAdmin || hasDownloadingRole">
|
||||
<button class="btn btn-secondary" (click)="downloadSeries()" title="Download Series">
|
||||
<span>
|
||||
<button class="btn btn-secondary" (click)="downloadSeries()" title="Download Series" [disabled]="downloadInProgress">
|
||||
<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>
|
||||
</span>
|
||||
</ng-template>
|
||||
</button>
|
||||
</div>
|
||||
<div class="ml-2">
|
||||
|
@ -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;
|
||||
}));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
75
UI/Web/src/app/shared/_models/download.ts
Normal file
75
UI/Web/src/app/shared/_models/download.ts
Normal 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;
|
||||
}
|
10
UI/Web/src/app/shared/_providers/saver.provider.ts
Normal file
10
UI/Web/src/app/shared/_providers/saver.provider.ts
Normal 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;
|
||||
}
|
@ -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<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);
|
||||
}
|
||||
|
||||
private downloadChapterSize(chapterId: number) {
|
||||
public downloadChapterSize(chapterId: number) {
|
||||
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() {
|
||||
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<string>) => {
|
||||
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<Download> {
|
||||
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.
|
||||
|
@ -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 <Volume>d;
|
||||
}
|
||||
|
||||
asChapter(d: any) {
|
||||
return <Chapter>d;
|
||||
}
|
||||
|
||||
asSeries(d: any) {
|
||||
return <Series>d;
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -1,15 +1,24 @@
|
||||
<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"
|
||||
(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"
|
||||
aria-hidden="true" height="230px" width="158px">
|
||||
<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>
|
||||
|
||||
<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 class="error-banner" *ngIf="total === 0 && !supressArchiveWarning">
|
||||
Cannot Read Archive
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div class="not-read-badge" *ngIf="read === 0 && total > 0"></div>
|
||||
</div>
|
||||
|
@ -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 {
|
||||
|
@ -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<Download> | null = null;
|
||||
downloadInProgress: boolean = false;
|
||||
|
||||
get MangaFormat(): typeof MangaFormat {
|
||||
return MangaFormat;
|
||||
}
|
||||
|
||||
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 {
|
||||
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<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') {
|
||||
action.callback(action.action, this.entity);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
isPromoted() {
|
||||
const tag = this.entity as CollectionTag;
|
||||
return tag.hasOwnProperty('promoted') && tag.promoted;
|
||||
|
@ -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>
|
@ -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);
|
||||
}
|
||||
}
|
@ -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 {
|
||||
}
|
||||
|
||||
}
|
@ -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 { }
|
||||
|
Loading…
x
Reference in New Issue
Block a user