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.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");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
22
UI/Web/package-lock.json
generated
22
UI/Web/package-lock.json
generated
@ -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",
|
||||||
|
@ -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",
|
||||||
|
@ -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() {
|
||||||
|
@ -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">
|
||||||
|
@ -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 */});
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -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},
|
||||||
|
@ -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">
|
||||||
|
@ -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;
|
||||||
|
}));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
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 { 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.
|
||||||
|
@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -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>
|
||||||
|
@ -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 {
|
||||||
|
@ -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;
|
||||||
|
@ -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 { 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 { }
|
||||||
|
Loading…
x
Reference in New Issue
Block a user