Better Caching & Global Downloads (#1372)

* Fixed a bug where cache TTL was using a field which always was 0.

* Updated Scan Series task (from UI) to always re-calculate what's on file and not rely on last update. This leads to more reliable results, despite extra overhead.

* Added image range processing on images for the reader, for slower networks or large files

* On manga (single) try to use prefetched image, rather than re-requesting an image on pagination

* Reduced some more latency when rendering first page of next chapter via continuous reading mode

* Fixed a bug where metadata filter, after updating a typeahead, collapsing filter area then re-opening, the filter would still be applied, but the typeahead wouldn't show the modification.

* Coded an idea around download reporting, commiting for history, might not go with it.

* Refactored the download indicator into it's own component. Cleaning up some code for download within card component

* Another throw away commit. Put in some temp code, not working but not sure if I'm ditching entirely.

* Updated download service to enable range processing (so downloads can resume) and to reduce re-zipping if we've just downloaded something.

* Refactored events widget download indicator to the correct design. I will be moving forward with this new functionality.

* Added Required fields to ProgressDTO

* Cleaned up the event widget and updated existing download progress to indicate preparing the download, rather than the download itself.

* Updated dependencies for security alerts

* Refactored all download code to be streamlined and globally handled

* Updated ScanSeries to find the highest folder path before library, not just within the files. This could lead to scan series missing files due to nested folders on same parent level.

* Updated the caching code to use a builtin annotation. Images are now caching correctly.

* Fixed a bad redirect on an auth guard

* Tweaked how long we allow cache for, as the cover update now doesn't work well.

* Fixed a bug on downloading bookmarks from multiple series, where it would just choose the first series id for the temp file.

* Added an extra check for downloading bookmarks

* UI Security updates, Fixed a bug on bookmark reader, the reader on last page would throw some errors and not show No Next Chapter toast.

* After scan, clear temp

* Code smells
This commit is contained in:
Joseph Milazzo 2022-07-13 10:45:14 -04:00 committed by GitHub
parent 45bbf422be
commit af4f35da5b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
58 changed files with 2309 additions and 624 deletions

View file

@ -1,4 +1,4 @@
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import { HttpClient, HttpErrorResponse, HttpEventType } from '@angular/common/http';
import { Inject, Injectable } from '@angular/core';
import { Series } from 'src/app/_models/series';
import { environment } from 'src/environments/environment';
@ -6,14 +6,39 @@ import { ConfirmService } from '../confirm.service';
import { Chapter } from 'src/app/_models/chapter';
import { Volume } from 'src/app/_models/volume';
import { ToastrService } from 'ngx-toastr';
import { asyncScheduler, Observable } from 'rxjs';
import { asyncScheduler, BehaviorSubject, Observable, tap, finalize, of, filter } from 'rxjs';
import { SAVER, Saver } from '../_providers/saver.provider';
import { download, Download } from '../_models/download';
import { PageBookmark } from 'src/app/_models/page-bookmark';
import { catchError, throttleTime } from 'rxjs/operators';
import { switchMap, takeWhile, throttleTime } from 'rxjs/operators';
import { AccountService } from 'src/app/_services/account.service';
export const DEBOUNCE_TIME = 100;
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;
}
/**
* 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;
@Injectable({
providedIn: 'root'
})
@ -25,67 +50,207 @@ export class DownloadService {
*/
public SIZE_WARNING = 104_857_600;
constructor(private httpClient: HttpClient, private confirmService: ConfirmService, private toastr: ToastrService, @Inject(SAVER) private save: Saver) { }
private downloadsSource: BehaviorSubject<DownloadEvent[]> = new BehaviorSubject<DownloadEvent[]>([]);
public activeDownloads$ = this.downloadsSource.asObservable();
constructor(private httpClient: HttpClient, private confirmService: ConfirmService,
private toastr: ToastrService, @Inject(SAVER) private save: Saver,
private accountService: AccountService) { }
/**
* Returns the entity subtitle (for the event widget) for a given entity
* @param downloadEntityType
* @param downloadEntity
* @returns
*/
downloadSubtitle(downloadEntityType: DownloadEntityType, downloadEntity: DownloadEntity | undefined) {
switch (downloadEntityType) {
case 'series':
return (downloadEntity as Series).name;
case 'volume':
return (downloadEntity as Volume).number + '';
case 'chapter':
return (downloadEntity as Chapter).number;
case 'bookmark':
return '';
case 'logs':
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);
break;
case 'chapter':
sizeCheckCall = this.downloadChapterSize((entity as Chapter).id);
downloadCall = this.downloadChapter(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;
}
public downloadSeriesSize(seriesId: number) {
this.accountService.currentUser$.pipe(switchMap(user => {
if (user && user.preferences.promptForDownloadSize) {
return sizeCheckCall;
}
return of(0);
}), switchMap(async (size) => {
return await this.confirmSize(size, entityType);
})
).pipe(filter(wantsToDownload => wantsToDownload), switchMap(() => {
return downloadCall.pipe(
tap((d) => {
if (callback) callback(d);
}),
takeWhile((val: Download) => {
return val.state != 'DONE';
}),
finalize(() => {
if (callback) callback(undefined);
}))
})).subscribe(() => {});
}
private downloadSeriesSize(seriesId: number) {
return this.httpClient.get<number>(this.baseUrl + 'download/series-size?seriesId=' + seriesId);
}
public downloadVolumeSize(volumeId: number) {
private downloadVolumeSize(volumeId: number) {
return this.httpClient.get<number>(this.baseUrl + 'download/volume-size?volumeId=' + volumeId);
}
public downloadChapterSize(chapterId: number) {
private downloadChapterSize(chapterId: number) {
return this.httpClient.get<number>(this.baseUrl + 'download/chapter-size?chapterId=' + chapterId);
}
downloadLogs() {
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, filename)
}));
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, filename);
}),
tap((d) => this.updateDownloadState(d, downloadType, subtitle)),
finalize(() => this.finalizeDownloadState(downloadType, subtitle))
);
}
downloadSeries(series: Series) {
private downloadSeries(series: Series) {
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, filename)
}));
).pipe(
throttleTime(DEBOUNCE_TIME, asyncScheduler, { leading: true, trailing: true }),
download((blob, filename) => {
this.save(blob, filename);
}),
tap((d) => this.updateDownloadState(d, downloadType, subtitle)),
finalize(() => this.finalizeDownloadState(downloadType, subtitle))
);
}
downloadChapter(chapter: Chapter) {
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) {
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});
} 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) {
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, filename)
}));
{observe: 'events', responseType: 'blob', reportProgress: true}
).pipe(
throttleTime(DEBOUNCE_TIME, asyncScheduler, { leading: true, trailing: true }),
download((blob, filename) => {
this.save(blob, filename);
}),
tap((d) => this.updateDownloadState(d, downloadType, subtitle)),
finalize(() => this.finalizeDownloadState(downloadType, subtitle))
);
}
downloadVolume(volume: Volume): Observable<Download> {
private downloadVolume(volume: Volume): Observable<Download> {
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, filename)
}));
).pipe(
throttleTime(DEBOUNCE_TIME, asyncScheduler, { leading: true, trailing: true }),
download((blob, filename) => {
this.save(blob, filename);
}),
tap((d) => this.updateDownloadState(d, downloadType, subtitle)),
finalize(() => this.finalizeDownloadState(downloadType, subtitle))
);
}
async confirmSize(size: number, entityType: 'volume' | 'chapter' | 'series' | 'reading list') {
private async confirmSize(size: number, entityType: DownloadEntityType) {
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(throttleTime(DEBOUNCE_TIME, asyncScheduler, { leading: true, trailing: true }), download((blob, filename) => {
this.save(blob, filename)
}));
}
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, filename);
}),
tap((d) => this.updateDownloadState(d, downloadType, subtitle)),
finalize(() => this.finalizeDownloadState(downloadType, subtitle))
);
}
/**
* Format bytes as human-readable text.