Kavita/UI/Web/src/app/shared/_services/download.service.ts
2024-01-28 09:37:38 -08:00

401 lines
15 KiB
TypeScript

import { HttpClient } from '@angular/common/http';
import {DestroyRef, inject, Inject, Injectable} from '@angular/core';
import { Series } from 'src/app/_models/series';
import { environment } from 'src/environments/environment';
import { ConfirmService } from '../confirm.service';
import { Chapter } from 'src/app/_models/chapter';
import { Volume } from 'src/app/_models/volume';
import {
asyncScheduler,
BehaviorSubject,
Observable,
tap,
finalize,
of,
filter, Subject,
} from 'rxjs';
import { download, Download } from '../_models/download';
import { PageBookmark } from 'src/app/_models/readers/page-bookmark';
import {switchMap, take, takeWhile, throttleTime} from 'rxjs/operators';
import { AccountService } from 'src/app/_services/account.service';
import { BytesPipe } from 'src/app/_pipes/bytes.pipe';
import {translate} from "@ngneat/transloco";
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import {SAVER, Saver} from "../../_providers/saver.provider";
import {UtilityService} from "./utility.service";
import {CollectionTag} from "../../_models/collection-tag";
import {RecentlyAddedItem} from "../../_models/recently-added-item";
import {NextExpectedChapter} from "../../_models/series-detail/next-expected-chapter";
export const DEBOUNCE_TIME = 100;
const bytesPipe = new BytesPipe();
export interface DownloadEvent {
/**
* Type of entity being downloaded
*/
entityType: DownloadEntityType;
/**
* What to show user. For example, for Series, we might show series name.
*/
subTitle: string;
/**
* Progress of the download itself
*/
progress: number;
/**
* Entity id. For entities without id like logs or bookmarks, uses 0 instead
*/
id: number;
}
/**
* Valid entity types for downloading
*/
export type DownloadEntityType = 'volume' | 'chapter' | 'series' | 'bookmark' | 'logs';
/**
* Valid entities for downloading. Undefined exclusively for logs.
*/
export type DownloadEntity = Series | Volume | Chapter | PageBookmark[] | undefined;
export type QueueableDownloadType = Chapter | Volume;
@Injectable({
providedIn: 'root'
})
export class DownloadService {
private baseUrl = environment.apiUrl;
/**
* Size in bytes in which to inform the user for confirmation before download starts. Defaults to 100 MB.
*/
public SIZE_WARNING = 104_857_600;
private downloadsSource: BehaviorSubject<DownloadEvent[]> = new BehaviorSubject<DownloadEvent[]>([]);
/**
* Active Downloads
*/
public activeDownloads$ = this.downloadsSource.asObservable();
private downloadQueue: BehaviorSubject<QueueableDownloadType[]> = new BehaviorSubject<QueueableDownloadType[]>([]);
/**
* Queued Downloads
*/
public queuedDownloads$ = this.downloadQueue.asObservable();
private readonly destroyRef = inject(DestroyRef);
private readonly confirmService = inject(ConfirmService);
private readonly accountService = inject(AccountService);
private readonly httpClient = inject(HttpClient);
private readonly utilityService = inject(UtilityService);
constructor(@Inject(SAVER) private save: Saver) {
this.downloadQueue.subscribe((queue) => {
if (queue.length > 0) {
const entity = queue.shift();
console.log('Download Queue shifting entity: ', entity);
if (entity === undefined) return;
this.processDownload(entity);
}
});
}
/**
* Returns the entity subtitle (for the event widget) for a given entity
* @param downloadEntityType
* @param downloadEntity
* @returns
*/
downloadSubtitle(downloadEntityType: DownloadEntityType | undefined, downloadEntity: DownloadEntity | undefined) {
switch (downloadEntityType) {
case 'series':
return (downloadEntity as Series).name;
case 'volume':
return (downloadEntity as Volume).minNumber + '';
case 'chapter':
return (downloadEntity as Chapter).minNumber;
case 'bookmark':
return '';
case 'logs':
return '';
}
return '';
}
/**
* Downloads the entity to the user's system. This handles everything around downloads. This will prompt the user based on size checks and UserPreferences.PromptForDownload.
* This will perform the download at a global level, if you need a handle to the download in question, use downloadService.activeDownloads$ and perform a filter on it.
* @param entityType
* @param entity
* @param callback Optional callback. Returns the download or undefined (if the download is complete).
*/
download(entityType: DownloadEntityType, entity: DownloadEntity, callback?: (d: Download | undefined) => void) {
let sizeCheckCall: Observable<number>;
let downloadCall: Observable<Download>;
switch (entityType) {
case 'series':
sizeCheckCall = this.downloadSeriesSize((entity as Series).id);
downloadCall = this.downloadSeries(entity as Series);
break;
case 'volume':
sizeCheckCall = this.downloadVolumeSize((entity as Volume).id);
downloadCall = this.downloadVolume(entity as Volume);
//this.enqueueDownload(entity as Volume);
break;
case 'chapter':
sizeCheckCall = this.downloadChapterSize((entity as Chapter).id);
downloadCall = this.downloadChapter(entity as Chapter);
//this.enqueueDownload(entity as Chapter);
break;
case 'bookmark':
sizeCheckCall = of(0);
downloadCall = this.downloadBookmarks(entity as PageBookmark[]);
break;
case 'logs':
sizeCheckCall = of(0);
downloadCall = this.downloadLogs();
break;
default:
return;
}
this.accountService.currentUser$.pipe(take(1), switchMap(user => {
if (user && user.preferences.promptForDownloadSize) {
return sizeCheckCall;
}
return of(0);
}), switchMap(async (size) => {
return await this.confirmSize(size, entityType);
})
).pipe(filter(wantsToDownload => {
return wantsToDownload;
}),
filter(_ => downloadCall !== undefined),
switchMap(() => {
return (downloadCall || of(undefined)).pipe(
tap((d) => {
if (callback) callback(d);
}),
takeWhile((val: Download) => {
return val.state != 'DONE';
}),
finalize(() => {
if (callback) callback(undefined);
}))
}), takeUntilDestroyed(this.destroyRef)
).subscribe(() => {});
}
private downloadSeriesSize(seriesId: number) {
return this.httpClient.get<number>(this.baseUrl + 'download/series-size?seriesId=' + seriesId);
}
private downloadVolumeSize(volumeId: number) {
return this.httpClient.get<number>(this.baseUrl + 'download/volume-size?volumeId=' + volumeId);
}
private downloadChapterSize(chapterId: number) {
return this.httpClient.get<number>(this.baseUrl + 'download/chapter-size?chapterId=' + chapterId);
}
private downloadLogs() {
const downloadType = 'logs';
const subtitle = this.downloadSubtitle(downloadType, undefined);
return this.httpClient.get(this.baseUrl + 'server/logs',
{observe: 'events', responseType: 'blob', reportProgress: true}
).pipe(
throttleTime(DEBOUNCE_TIME, asyncScheduler, { leading: true, trailing: true }),
download((blob, filename) => {
this.save(blob, decodeURIComponent(filename));
}),
tap((d) => this.updateDownloadState(d, downloadType, subtitle, 0)),
finalize(() => this.finalizeDownloadState(downloadType, subtitle))
);
}
private getIdKey(entity: Chapter | Volume) {
if (this.utilityService.isVolume(entity)) return 'volumeId';
if (this.utilityService.isChapter(entity)) return 'chapterId';
if (this.utilityService.isSeries(entity)) return 'seriesId';
return 'id';
}
private getDownloadEntityType(entity: Chapter | Volume): DownloadEntityType {
if (this.utilityService.isVolume(entity)) return 'volume';
if (this.utilityService.isChapter(entity)) return 'chapter';
if (this.utilityService.isSeries(entity)) return 'series';
return 'logs'; // This is a hack but it will never occur
}
private downloadEntity<T>(entity: Chapter | Volume): Observable<any> {
const downloadEntityType = this.getDownloadEntityType(entity);
const subtitle = this.downloadSubtitle(downloadEntityType, entity);
const idKey = this.getIdKey(entity);
const url = `${this.baseUrl}download/${downloadEntityType}?${idKey}=${entity.id}`;
return this.httpClient.get(url, { observe: 'events', responseType: 'blob', reportProgress: true }).pipe(
throttleTime(DEBOUNCE_TIME, asyncScheduler, { leading: true, trailing: true }),
download((blob, filename) => {
this.save(blob, decodeURIComponent(filename));
}),
tap((d) => this.updateDownloadState(d, downloadEntityType, subtitle, entity.id)),
finalize(() => this.finalizeDownloadState(downloadEntityType, subtitle))
);
}
private downloadSeries(series: Series) {
// TODO: Call backend for all the volumes and loose leaf chapters then enqueque them all
const downloadType = 'series';
const subtitle = this.downloadSubtitle(downloadType, series);
return this.httpClient.get(this.baseUrl + 'download/series?seriesId=' + series.id,
{observe: 'events', responseType: 'blob', reportProgress: true}
).pipe(
throttleTime(DEBOUNCE_TIME, asyncScheduler, { leading: true, trailing: true }),
download((blob, filename) => {
this.save(blob, decodeURIComponent(filename));
}),
tap((d) => this.updateDownloadState(d, downloadType, subtitle, series.id)),
finalize(() => this.finalizeDownloadState(downloadType, subtitle))
);
}
private finalizeDownloadState(entityType: DownloadEntityType, entitySubtitle: string) {
let values = this.downloadsSource.getValue();
values = values.filter(v => !(v.entityType === entityType && v.subTitle === entitySubtitle));
this.downloadsSource.next(values);
}
private updateDownloadState(d: Download, entityType: DownloadEntityType, entitySubtitle: string, id: number) {
let values = this.downloadsSource.getValue();
if (d.state === 'PENDING') {
const index = values.findIndex(v => v.entityType === entityType && v.subTitle === entitySubtitle);
if (index >= 0) return; // Don't let us duplicate add
values.push({entityType: entityType, subTitle: entitySubtitle, progress: 0, id});
} else if (d.state === 'IN_PROGRESS') {
const index = values.findIndex(v => v.entityType === entityType && v.subTitle === entitySubtitle);
if (index >= 0) {
values[index].progress = d.progress;
}
} else if (d.state === 'DONE') {
values = values.filter(v => !(v.entityType === entityType && v.subTitle === entitySubtitle));
}
this.downloadsSource.next(values);
}
private downloadChapter(chapter: Chapter) {
return this.downloadEntity(chapter);
// const downloadType = 'chapter';
// const subtitle = this.downloadSubtitle(downloadType, chapter);
// return this.httpClient.get(this.baseUrl + 'download/chapter?chapterId=' + chapter.id,
// {observe: 'events', responseType: 'blob', reportProgress: true}
// ).pipe(
// throttleTime(DEBOUNCE_TIME, asyncScheduler, { leading: true, trailing: true }),
// download((blob, filename) => {
// this.save(blob, decodeURIComponent(filename));
// }),
// tap((d) => this.updateDownloadState(d, downloadType, subtitle, chapter.id)),
// finalize(() => this.finalizeDownloadState(downloadType, subtitle))
// );
}
private downloadVolume(volume: Volume) {
return this.downloadEntity(volume);
// const downloadType = 'volume';
// const subtitle = this.downloadSubtitle(downloadType, volume);
// return this.httpClient.get(this.baseUrl + 'download/volume?volumeId=' + volume.id,
// {observe: 'events', responseType: 'blob', reportProgress: true}
// ).pipe(
// throttleTime(DEBOUNCE_TIME, asyncScheduler, { leading: true, trailing: true }),
// download((blob, filename) => {
// this.save(blob, decodeURIComponent(filename));
// }),
// tap((d) => this.updateDownloadState(d, downloadType, subtitle, volume.id)),
// finalize(() => this.finalizeDownloadState(downloadType, subtitle))
// );
}
private async confirmSize(size: number, entityType: DownloadEntityType) {
return (size < this.SIZE_WARNING ||
await this.confirmService.confirm(translate('toasts.confirm-download-size',
{entityType: translate('entity-type.' + entityType), size: bytesPipe.transform(size)})));
}
private downloadBookmarks(bookmarks: PageBookmark[]) {
const downloadType = 'bookmark';
const subtitle = this.downloadSubtitle(downloadType, bookmarks);
return this.httpClient.post(this.baseUrl + 'download/bookmarks', {bookmarks},
{observe: 'events', responseType: 'blob', reportProgress: true}
).pipe(
throttleTime(DEBOUNCE_TIME, asyncScheduler, { leading: true, trailing: true }),
download((blob, filename) => {
this.save(blob, decodeURIComponent(filename));
}),
tap((d) => this.updateDownloadState(d, downloadType, subtitle, 0)),
finalize(() => this.finalizeDownloadState(downloadType, subtitle))
);
}
private processDownload(entity: QueueableDownloadType): void {
const downloadObservable = this.downloadEntity(entity);
console.log('Process Download called for entity: ', entity);
// When we consume one, we need to take it off the queue
downloadObservable.subscribe((downloadEvent) => {
// Download completed, process the next item in the queue
if (downloadEvent.state === 'DONE') {
this.processNextDownload();
}
});
}
private processNextDownload(): void {
const currentQueue = this.downloadQueue.value;
if (currentQueue.length > 0) {
const nextEntity = currentQueue[0];
this.processDownload(nextEntity);
}
}
private enqueueDownload(entity: QueueableDownloadType): void {
const currentQueue = this.downloadQueue.value;
const newQueue = [...currentQueue, entity];
this.downloadQueue.next(newQueue);
// If the queue was empty, start processing the download
if (currentQueue.length === 0) {
this.processNextDownload();
}
}
mapToEntityType(events: DownloadEvent[], entity: Series | Volume | Chapter | CollectionTag | PageBookmark | RecentlyAddedItem | NextExpectedChapter) {
if(this.utilityService.isSeries(entity)) {
return events.find(e => e.entityType === 'series' && e.id == entity.id
&& e.subTitle === this.downloadSubtitle('series', (entity as Series))) || null;
}
if(this.utilityService.isVolume(entity)) {
return events.find(e => e.entityType === 'volume' && e.id == entity.id
&& e.subTitle === this.downloadSubtitle('volume', (entity as Volume))) || null;
}
if(this.utilityService.isChapter(entity)) {
return events.find(e => e.entityType === 'chapter' && e.id == entity.id
&& e.subTitle === this.downloadSubtitle('chapter', (entity as Chapter))) || null;
}
// Is PageBookmark[]
if(entity.hasOwnProperty('length')) {
return events.find(e => e.entityType === 'bookmark'
&& e.subTitle === this.downloadSubtitle('bookmark', [(entity as PageBookmark)])) || null;
}
return null;
}
}