WebP Support (#581)

* Added trackby so when series scan event comes through, cards can update too

* Added chapter boundary toasts on book reader

* Handle closing the reader when in a reading list

* Somehow the trackby save didn't happen

* Fixed an issue where after opening a chapter info modal, then trying to open another in specials tab it would fail due to a pass by reference issue with our factory.

* When a series update occurs, if we loose specials tab, but we were on it, reselect volumes/chapters tab

* Fixed an issue where older releases would show as available, even though they were already installed.

* Converted tabs within modals to use vertical orientation (except on mobile)

* Implemented webp support. Only Safari does not support this format natively. MacOS users can use an alternative browser.

* Refactored ScannerService and MetadataService to be fully async
This commit is contained in:
Joseph Milazzo 2021-09-15 17:25:18 -07:00 committed by GitHub
parent d92cfb0b2b
commit 2725e6042b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 102 additions and 65 deletions

View File

@ -156,6 +156,7 @@ namespace API.Tests.Parser
[InlineData("test.png", true)]
[InlineData(".test.jpg", false)]
[InlineData("!test.jpg", false)]
[InlineData("test.webp", true)]
public void IsImageTest(string filename, bool expected)
{
Assert.Equal(expected, IsImage(filename));

View File

@ -10,7 +10,7 @@ namespace API.Interfaces.Services
/// </summary>
/// <param name="libraryId"></param>
/// <param name="forceUpdate"></param>
void RefreshMetadata(int libraryId, bool forceUpdate = false);
Task RefreshMetadata(int libraryId, bool forceUpdate = false);
public bool UpdateMetadata(Chapter chapter, bool forceUpdate);
public bool UpdateMetadata(Volume volume, bool forceUpdate);

View File

@ -13,7 +13,7 @@ namespace API.Parser
public const string DefaultVolume = "0";
private static readonly TimeSpan RegexTimeout = TimeSpan.FromMilliseconds(500);
public const string ImageFileExtensions = @"^(\.png|\.jpeg|\.jpg)";
public const string ImageFileExtensions = @"^(\.png|\.jpeg|\.jpg|\.webp)";
public const string ArchiveFileExtensions = @"\.cbz|\.zip|\.rar|\.cbr|\.tar.gz|\.7zip|\.7z|\.cb7|\.cbt";
public const string BookFileExtensions = @"\.epub|\.pdf";
public const string MacOsMetadataFileStartsWith = @"._";

View File

@ -187,10 +187,10 @@ namespace API.Services
/// <remarks>This can be heavy on memory first run</remarks>
/// <param name="libraryId"></param>
/// <param name="forceUpdate">Force updating cover image even if underlying file has not been modified or chapter already has a cover image</param>
public void RefreshMetadata(int libraryId, bool forceUpdate = false)
public async Task RefreshMetadata(int libraryId, bool forceUpdate = false)
{
var sw = Stopwatch.StartNew();
var library = Task.Run(() => _unitOfWork.LibraryRepository.GetFullLibraryForIdAsync(libraryId)).GetAwaiter().GetResult();
var library = await _unitOfWork.LibraryRepository.GetFullLibraryForIdAsync(libraryId);
// PERF: See if we can break this up into multiple threads that process 20 series at a time then save so we can reduce amount of memory used
_logger.LogInformation("Beginning metadata refresh of {LibraryName}", library.Name);
@ -213,7 +213,7 @@ namespace API.Services
}
if (_unitOfWork.HasChanges() && Task.Run(() => _unitOfWork.CommitAsync()).Result)
if (_unitOfWork.HasChanges() && await _unitOfWork.CommitAsync())
{
_logger.LogInformation("Updated metadata for {LibraryName} in {ElapsedMilliseconds} milliseconds", library.Name, sw.ElapsedMilliseconds);
}
@ -228,7 +228,7 @@ namespace API.Services
public async Task RefreshMetadataForSeries(int libraryId, int seriesId, bool forceUpdate = false)
{
var sw = Stopwatch.StartNew();
var library = Task.Run(() => _unitOfWork.LibraryRepository.GetFullLibraryForIdAsync(libraryId)).GetAwaiter().GetResult();
var library = await _unitOfWork.LibraryRepository.GetFullLibraryForIdAsync(libraryId);
var series = library.Series.SingleOrDefault(s => s.Id == seriesId);
if (series == null)

View File

@ -108,7 +108,7 @@ namespace API.Services.Tasks
"Processed {TotalFiles} files and {ParsedSeriesCount} series in {ElapsedScanTime} milliseconds for {SeriesName}",
totalFiles, parsedSeries.Keys.Count, sw.ElapsedMilliseconds + scanElapsedTime, series.Name);
CleanupDbEntities();
await CleanupDbEntities();
BackgroundJob.Enqueue(() => _metadataService.RefreshMetadataForSeries(libraryId, seriesId, forceUpdate));
BackgroundJob.Enqueue(() => _cacheService.CleanupChapters(chapterIds));
// Tell UI that this series is done
@ -128,7 +128,7 @@ namespace API.Services.Tasks
[AutomaticRetry(Attempts = 0, OnAttemptsExceeded = AttemptsExceededAction.Delete)]
public async Task ScanLibraries()
{
var libraries = Task.Run(() => _unitOfWork.LibraryRepository.GetLibrariesAsync()).Result.ToList();
var libraries = await _unitOfWork.LibraryRepository.GetLibrariesAsync();
foreach (var lib in libraries)
{
await ScanLibrary(lib.Id, false);
@ -151,8 +151,7 @@ namespace API.Services.Tasks
Library library;
try
{
library = Task.Run(() => _unitOfWork.LibraryRepository.GetFullLibraryForIdAsync(libraryId)).GetAwaiter()
.GetResult();
library = await _unitOfWork.LibraryRepository.GetFullLibraryForIdAsync(libraryId);
}
catch (Exception ex)
{
@ -174,7 +173,7 @@ namespace API.Services.Tasks
UpdateLibrary(library, series);
_unitOfWork.LibraryRepository.Update(library);
if (Task.Run(() => _unitOfWork.CommitAsync()).Result)
if (await _unitOfWork.CommitAsync())
{
_logger.LogInformation(
"Processed {TotalFiles} files and {ParsedSeriesCount} series in {ElapsedScanTime} milliseconds for {LibraryName}",
@ -186,7 +185,7 @@ namespace API.Services.Tasks
"There was a critical error that resulted in a failed scan. Please check logs and rescan");
}
CleanupAbandonedChapters();
await CleanupAbandonedChapters();
BackgroundJob.Enqueue(() => _metadataService.RefreshMetadata(libraryId, forceUpdate));
await _messageHub.Clients.All.SendAsync(SignalREvents.ScanLibrary, MessageFactory.ScanLibraryEvent(libraryId, "complete"));
@ -195,9 +194,9 @@ namespace API.Services.Tasks
/// <summary>
/// Remove any user progress rows that no longer exist since scan library ran and deleted series/volumes/chapters
/// </summary>
private void CleanupAbandonedChapters()
private async Task CleanupAbandonedChapters()
{
var cleanedUp = Task.Run(() => _unitOfWork.AppUserProgressRepository.CleanupAbandonedChapters()).Result;
var cleanedUp = await _unitOfWork.AppUserProgressRepository.CleanupAbandonedChapters();
_logger.LogInformation("Removed {Count} abandoned progress rows", cleanedUp);
}
@ -205,10 +204,10 @@ namespace API.Services.Tasks
/// <summary>
/// Cleans up any abandoned rows due to removals from Scan loop
/// </summary>
private void CleanupDbEntities()
private async Task CleanupDbEntities()
{
CleanupAbandonedChapters();
var cleanedUp = Task.Run( () => _unitOfWork.CollectionTagRepository.RemoveTagsWithoutSeries()).Result;
await CleanupAbandonedChapters();
var cleanedUp = await _unitOfWork.CollectionTagRepository.RemoveTagsWithoutSeries();
_logger.LogInformation("Removed {Count} abandoned collection tags", cleanedUp);
}

View File

@ -113,17 +113,10 @@ export class ActionFactoryService {
this.chapterActions.push({
action: Action.Edit,
title: 'Edit',
title: 'Info',
callback: this.dummyCallback,
requiresAdmin: false
});
// this.readingListActions.push({
// action: Action.Promote, // Should I just use CollectionTag modal-like instead?
// title: 'Delete',
// callback: this.dummyCallback,
// requiresAdmin: true
// });
}
if (this.hasDownloadRole || this.isAdmin) {
@ -145,33 +138,39 @@ export class ActionFactoryService {
}
getLibraryActions(callback: (action: Action, library: Library) => void) {
this.libraryActions.forEach(action => action.callback = callback);
return this.libraryActions;
const actions = this.libraryActions.map(a => {return {...a}});
actions.forEach(action => action.callback = callback);
return actions;
}
getSeriesActions(callback: (action: Action, series: Series) => void) {
this.seriesActions.forEach(action => action.callback = callback);
return this.seriesActions;
const actions = this.seriesActions.map(a => {return {...a}});
actions.forEach(action => action.callback = callback);
return actions;
}
getVolumeActions(callback: (action: Action, volume: Volume) => void) {
this.volumeActions.forEach(action => action.callback = callback);
return this.volumeActions;
const actions = this.volumeActions.map(a => {return {...a}});
actions.forEach(action => action.callback = callback);
return actions;
}
getChapterActions(callback: (action: Action, chapter: Chapter) => void) {
this.chapterActions.forEach(action => action.callback = callback);
return this.chapterActions;
const actions = this.chapterActions.map(a => {return {...a}});
actions.forEach(action => action.callback = callback);
return actions;
}
getCollectionTagActions(callback: (action: Action, collectionTag: CollectionTag) => void) {
this.collectionTagActions.forEach(action => action.callback = callback);
return this.collectionTagActions;
const actions = this.collectionTagActions.map(a => {return {...a}});
actions.forEach(action => action.callback = callback);
return actions;
}
getReadingListActions(callback: (action: Action, readingList: ReadingList) => void) {
this.readingListActions.forEach(action => action.callback = callback);
return this.readingListActions;
const actions = this.readingListActions.map(a => {return {...a}});
actions.forEach(action => action.callback = callback);
return actions;
}
filterBookmarksForFormat(action: ActionItem<Series>, series: Series) {

View File

@ -1,8 +1,8 @@
import { Injectable, OnDestroy } from '@angular/core';
import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap';
import { ToastrService } from 'ngx-toastr';
import { forkJoin, Subject } from 'rxjs';
import { take, takeUntil } from 'rxjs/operators';
import { Subject } from 'rxjs';
import { take } from 'rxjs/operators';
import { BookmarksModalComponent } from '../cards/_modals/bookmarks-modal/bookmarks-modal.component';
import { AddToListModalComponent, ADD_FLOW } from '../reading-list/_modals/add-to-list-modal/add-to-list-modal.component';
import { EditReadingListModalComponent } from '../reading-list/_modals/edit-reading-list-modal/edit-reading-list-modal.component';

View File

@ -150,6 +150,7 @@ export class ReaderService {
return newRoute;
}
getQueryParamsObject(incognitoMode: boolean = false, readingListMode: boolean = false, readingListId: number = -1) {
let params: {[key: string]: any} = {};
if (incognitoMode) {

View File

@ -2,10 +2,8 @@
<div class="card w-100 mb-2" style="width: 18rem;">
<div class="card-body">
<h5 class="card-title">{{update.updateTitle}}&nbsp;
<span class="badge badge-secondary" *ngIf="update.updateVersion <= update.currentVersion; else available">Installed</span>
<ng-template #available>
<span class="badge badge-secondary">Available</span>
</ng-template>
<span class="badge badge-secondary" *ngIf="update.updateVersion === update.currentVersion">Installed</span>
<span class="badge badge-secondary" *ngIf="update.updateVersion > update.currentVersion">Available</span>
</h5>
<pre class="card-text update-body" [innerHtml]="update.updateBody | safeHtml"></pre>
<a *ngIf="!update.isDocker" href="{{update.updateUrl}}" class="btn btn-{{indx === 0 ? 'primary' : 'secondary'}} float-right" target="_blank">Download</a>

View File

@ -172,8 +172,6 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
*/
lastSeenScrollPartPath: string = '';
/**
* Hack: Override background color for reader and restore it onDestroy
*/
@ -479,10 +477,10 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
if (this.nextChapterId === CHAPTER_ID_NOT_FETCHED || this.nextChapterId === this.chapterId) {
this.readerService.getNextChapter(this.seriesId, this.volumeId, this.chapterId, this.readingListId).pipe(take(1)).subscribe(chapterId => {
this.nextChapterId = chapterId;
this.loadChapter(chapterId, 'next');
this.loadChapter(chapterId, 'Next');
});
} else {
this.loadChapter(this.nextChapterId, 'next');
this.loadChapter(this.nextChapterId, 'Next');
}
}
@ -502,14 +500,14 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
if (this.prevChapterId === CHAPTER_ID_NOT_FETCHED || this.prevChapterId === this.chapterId) {
this.readerService.getPrevChapter(this.seriesId, this.volumeId, this.chapterId, this.readingListId).pipe(take(1)).subscribe(chapterId => {
this.prevChapterId = chapterId;
this.loadChapter(chapterId, 'prev');
this.loadChapter(chapterId, 'Prev');
});
} else {
this.loadChapter(this.prevChapterId, 'prev');
this.loadChapter(this.prevChapterId, 'Prev');
}
}
loadChapter(chapterId: number, direction: 'next' | 'prev') {
loadChapter(chapterId: number, direction: 'Next' | 'Prev') {
if (chapterId >= 0) {
this.chapterId = chapterId;
this.continuousChaptersStack.push(chapterId);
@ -517,11 +515,12 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
const newRoute = this.readerService.getNextChapterUrl(this.router.url, this.chapterId, this.incognitoMode, this.readingListMode, this.readingListId);
window.history.replaceState({}, '', newRoute);
this.init();
this.toastr.info(direction + ' chapter loaded', '', {timeOut: 3000});
} else {
// This will only happen if no actual chapter can be found
this.toastr.warning('Could not find ' + direction + ' chapter');
this.isLoading = false;
if (direction === 'prev') {
if (direction === 'Prev') {
this.prevPageDisabled = true;
} else {
this.nextPageDisabled = true;
@ -535,7 +534,11 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
}
closeReader() {
this.location.back();
if (this.readingListMode) {
this.router.navigateByUrl('lists/' + this.readingListId);
} else {
this.location.back();
}
}
resetSettings() {

View File

@ -6,13 +6,13 @@
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body scrollable-modal">
<form [formGroup]="editSeriesForm">
<ul ngbNav #nav="ngbNav" [(activeId)]="active" class="nav-tabs">
<div class="modal-body scrollable-modal {{utilityService.getActiveBreakpoint() === Breakpoint.Mobile ? '' : 'd-flex'}}">
<ul ngbNav #nav="ngbNav" [(activeId)]="active" class="nav-pills" orientation="{{utilityService.getActiveBreakpoint() === Breakpoint.Mobile ? 'horizontal' : 'vertical'}}" style="min-width: 135px;">
<li [ngbNavItem]="tabs[0]">
<a ngbNavLink>{{tabs[0]}}</a>
<ng-template ngbNavContent>
<form [formGroup]="editSeriesForm">
<div class="row no-gutters">
<div class="form-group" style="width: 100%">
<label for="name">Name</label>
@ -77,6 +77,7 @@
<textarea id="summary" class="form-control" formControlName="summary" rows="4"></textarea>
</div>
</div>
</form>
</ng-template>
</li>
@ -151,9 +152,8 @@
</ng-template>
</li>
</ul>
</form>
<div [ngbNavOutlet]="nav" class="mt-3"></div>
<div [ngbNavOutlet]="nav" class="tab-content {{utilityService.getActiveBreakpoint() === Breakpoint.Mobile ? 'mt-3' : 'ml-4 flex-fill'}}"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" (click)="close()">Close</button>

View File

@ -3,7 +3,7 @@ import { FormBuilder, FormControl, FormGroup } from '@angular/forms';
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
import { forkJoin, Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
import { UtilityService } from 'src/app/shared/_services/utility.service';
import { Breakpoint, UtilityService } from 'src/app/shared/_services/utility.service';
import { TypeaheadSettings } from 'src/app/typeahead/typeahead-settings';
import { Chapter } from 'src/app/_models/chapter';
import { CollectionTag } from 'src/app/_models/collection-tag';
@ -43,6 +43,10 @@ export class EditSeriesModalComponent implements OnInit, OnDestroy {
*/
selectedCover: string = '';
get Breakpoint(): typeof Breakpoint {
return Breakpoint;
}
constructor(public modal: NgbActiveModal,
private seriesService: SeriesService,
public utilityService: UtilityService,

View File

@ -94,7 +94,6 @@ export class SeriesCardComponent implements OnInit, OnChanges {
if (closeResult.success) {
if (closeResult.coverImageUpdate) {
this.imageUrl = this.imageService.randomize(this.imageService.getSeriesCoverImage(closeResult.series.id));
console.log('image url: ', this.imageUrl);
}
this.seriesService.getSeries(data.id).subscribe(series => {
this.data = series;

View File

@ -468,7 +468,11 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
}
closeReader() {
this.location.back();
if (this.readingListMode) {
this.router.navigateByUrl('lists/' + this.readingListId);
} else {
this.location.back();
}
}
updateTitle(chapterInfo: ChapterInfo) {

View File

@ -102,8 +102,8 @@
<a ngbNavLink>Specials</a>
<ng-template ngbNavContent>
<div class="row">
<div *ngFor="let chapter of specials">
<app-card-item class="col-auto" *ngIf="chapter.isSpecial" [entity]="chapter" [title]="chapter.title || chapter.range" (click)="openChapter(chapter)"
<div *ngFor="let chapter of specials; trackBy: trackByChapterIdentity">
<app-card-item class="col-auto" *ngIf="chapter.isSpecial" [entity]="chapter" [title]="chapter.title || chapter.range" (click)="openChapter(chapter)"
[imageUrl]="imageService.getChapterCoverImage(chapter.id)"
[read]="chapter.pagesRead" [total]="chapter.pages" [actions]="chapterActions"></app-card-item>
</div>
@ -114,12 +114,12 @@
<a ngbNavLink>Volumes/Chapters</a>
<ng-template ngbNavContent>
<div class="row">
<div *ngFor="let volume of volumes">
<div *ngFor="let volume of volumes; trackBy: trackByVolumeIdentity">
<app-card-item class="col-auto" *ngIf="volume.number != 0" [entity]="volume" [title]="'Volume ' + volume.name" (click)="openVolume(volume)"
[imageUrl]="imageService.getVolumeCoverImage(volume.id) + '&offset=' + coverImageOffset"
[read]="volume.pagesRead" [total]="volume.pages" [actions]="volumeActions"></app-card-item>
</div>
<div *ngFor="let chapter of chapters">
<div *ngFor="let chapter of chapters; trackBy: trackByChapterIdentity">
<app-card-item class="col-auto" *ngIf="!chapter.isSpecial" [entity]="chapter" [title]="'Chapter ' + chapter.range" (click)="openChapter(chapter)"
[imageUrl]="imageService.getChapterCoverImage(chapter.id) + '&offset=' + coverImageOffset"
[read]="chapter.pagesRead" [total]="chapter.pages" [actions]="chapterActions"></app-card-item>

View File

@ -79,6 +79,15 @@ export class SeriesDetailComponent implements OnInit, OnDestroy {
*/
actionInProgress: boolean = false;
/**
* Track by function for Volume to tell when to refresh card data
*/
trackByVolumeIdentity = (index: number, item: Volume) => `${item.name}_${item.pagesRead}`;
/**
* Track by function for Chapter to tell when to refresh card data
*/
trackByChapterIdentity = (index: number, item: Chapter) => `${item.title}_${item.number}_${item.pagesRead}`;
private onDestroy: Subject<void> = new Subject();
@ -296,6 +305,11 @@ export class SeriesDetailComponent implements OnInit, OnDestroy {
this.hasNonSpecialVolumeChapters = false;
}
// If an update occured and we were on specials, re-activate Volumes/Chapters
if (!this.hasSpecials && this.activeTabId != 2) {
this.activeTabId = 2;
}
this.isLoading = false;
});
}, err => {

View File

@ -18,6 +18,13 @@ export enum KEY_CODES {
DELETE = 'Delete'
}
export enum Breakpoint {
Mobile = 768,
Tablet = 1280,
Desktop = 1440
}
@Injectable({
providedIn: 'root'
})
@ -111,4 +118,12 @@ export class UtilityService {
return <Series>d;
}
getActiveBreakpoint(): Breakpoint {
if (window.innerWidth <= Breakpoint.Mobile) return Breakpoint.Mobile;
else if (window.innerWidth > Breakpoint.Mobile && window.innerWidth <= Breakpoint.Tablet) return Breakpoint.Tablet;
else if (window.innerWidth > Breakpoint.Tablet) return Breakpoint.Desktop
return Breakpoint.Desktop;
}
}