AVIF Support & Much More! (#1992)

* Expand the list of potential favicon icons to grab.

* Added a url mapping functionality to use alternative urls for fetching icons

* Initial commit to streamline media encoding. No DB migration yet, No UI changes, no Task changes.

* Started refactoring code so that webp queries use encoding format instead.

* More refactoring to remove hardcoded webp references.

* Moved manual migrations to their own folder to keep things organized. Manually drop the obsolete webp keys.

* Removed old apis for converting media and now have one. Reworked where the conversion code was located and streamlined events and whatnot.

* Make favicon encode setting aware

* Cleaned up favicon conversion

* Updated format counter to now just use Extension from MangaFile now that it's been out a while.

* Tweaked jumpbar code to reduce a lookup to hashmap.

* Added AVIF (8-bit only) support.

* In UpdatePeopleList, use FirstOrDefault as Single adds extra checks that may not be needed.

* You can now remove weblinks from edit series page and you can leave empty cells, they will just be removed on backend.

* Forgot a file

* Don't prompt to write a review, just show the pencil. It's the same amount of clicks if you do, less if you dont.

* Fixed Refresh token using wrong Claim to look up the user.

* Refactored how we refresh authentication to perform it every 10 m ins to ensure we always stay authenticated.

* Changed Version update code to run more throughout the day. Updated some hangfire to newer method signatures.
This commit is contained in:
Joe Milazzo 2023-05-12 15:31:23 -05:00 committed by GitHub
parent c1989e2819
commit 70690b747e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
73 changed files with 778 additions and 566 deletions

View file

@ -115,10 +115,10 @@ export class AccountService implements OnDestroy {
this.currentUser = user;
this.currentUserSource.next(user);
this.stopRefreshTokenTimer();
if (this.currentUser !== undefined) {
this.startRefreshTokenTimer();
} else {
this.stopRefreshTokenTimer();
}
}
@ -264,7 +264,6 @@ export class AccountService implements OnDestroy {
private refreshToken() {
if (this.currentUser === null || this.currentUser === undefined) return of();
return this.httpClient.post<{token: string, refreshToken: string}>(this.baseUrl + 'account/refresh-token',
{token: this.currentUser.token, refreshToken: this.currentUser.refreshToken}).pipe(map(user => {
if (this.currentUser) {
@ -277,23 +276,23 @@ export class AccountService implements OnDestroy {
}));
}
/**
* Every 10 mins refresh the token
*/
private startRefreshTokenTimer() {
if (this.currentUser === null || this.currentUser === undefined) return;
if (this.refreshTokenTimeout !== undefined) {
if (this.currentUser === null || this.currentUser === undefined) {
this.stopRefreshTokenTimer();
return;
}
const jwtToken = JSON.parse(atob(this.currentUser.token.split('.')[1]));
// set a timeout to refresh the token 10 mins before it expires
const expires = new Date(jwtToken.exp * 1000);
const timeout = expires.getTime() - Date.now() - (60 * 10000);
this.refreshTokenTimeout = setTimeout(() => this.refreshToken().subscribe(() => {}), timeout);
this.stopRefreshTokenTimer();
this.refreshTokenTimeout = setInterval(() => this.refreshToken().subscribe(() => {}), (60 * 10_000));
}
private stopRefreshTokenTimer() {
if (this.refreshTokenTimeout !== undefined) {
clearTimeout(this.refreshTokenTimeout);
clearInterval(this.refreshTokenTimeout);
}
}

View file

@ -55,12 +55,8 @@ export class ServerService {
return this.httpClient.get<Job[]>(this.baseUrl + 'server/jobs');
}
convertBookmarks() {
return this.httpClient.post(this.baseUrl + 'server/convert-bookmarks', {});
}
convertCovers() {
return this.httpClient.post(this.baseUrl + 'server/convert-covers', {});
convertMedia() {
return this.httpClient.post(this.baseUrl + 'server/convert-media', {});
}
getMediaErrors() {

View file

@ -0,0 +1,7 @@
export enum EncodeFormat {
PNG = 0,
WebP = 1,
AVIF = 2
}
export const EncodeFormats = [{value: EncodeFormat.PNG, title: 'PNG'}, {value: EncodeFormat.WebP, title: 'WebP'}, {value: EncodeFormat.AVIF, title: 'AVIF'}];

View file

@ -1,3 +1,5 @@
import { EncodeFormat } from "./encode-format";
export interface ServerSettings {
cacheDirectory: string;
taskScan: string;
@ -10,8 +12,7 @@ export interface ServerSettings {
baseUrl: string;
bookmarksDirectory: string;
emailServiceUrl: string;
convertBookmarkToWebP: boolean;
convertCoverToWebP: boolean;
encodeMediaAs: EncodeFormat;
totalBackups: number;
totalLogs: number;
enableFolderWatching: boolean;

View file

@ -2,28 +2,17 @@
<form [formGroup]="settingsForm" *ngIf="serverSettings !== undefined" class="mb-2">
<div class="row g-0">
<p>WebP can drastically reduce space requirements for files. WebP is not supported on all browsers or versions. To learn if these settings are appropriate for your setup, visit <a href="https://caniuse.com/?search=webp" target="_blank" rel="noopener noreferrer">Can I Use</a>.</p>
<div *ngIf="settingsForm.dirty" class="alert alert-danger" role="alert">You must trigger the conversion to WebP task in Tasks Tab.</div>
<p>WebP/AVIF can drastically reduce space requirements for files. WebP/AVIF is not supported on all browsers or versions. To learn if these settings are appropriate for your setup, visit <a href="https://caniuse.com/?search=webp" target="_blank" rel="noopener noreferrer">Can I Use WebP</a> or <a href="https://caniuse.com/?search=avif" target="_blank" rel="noopener noreferrer">Can I Use AVIF</a>.
<b>You cannot convert back to PNG once you've gone to WebP/AVIF. You would need to refresh covers on your libraries to regenerate all covers. Bookmarks and favicons cannot be converted.</b></p>
<div *ngIf="settingsForm.dirty" class="alert alert-danger" role="alert">You must trigger the media conversion task in Tasks Tab.</div>
<div class="col-md-6 col-sm-12 mb-3">
<label for="bookmark-webp" class="form-label me-1" aria-describedby="settings-convertBookmarkToWebP-help">Save Bookmarks as WebP</label>
<i class="fa fa-info-circle" placement="right" [ngbTooltip]="convertBookmarkToWebPTooltip" role="button" tabindex="0"></i>
<ng-template #convertBookmarkToWebPTooltip>When saving bookmarks, convert them to WebP.</ng-template>
<span class="visually-hidden" id="settings-convertBookmarkToWebP-help"><ng-container [ngTemplateOutlet]="convertBookmarkToWebPTooltip"></ng-container></span>
<div class="form-check form-switch">
<input id="bookmark-webp" type="checkbox" class="form-check-input" formControlName="convertBookmarkToWebP" role="switch">
<label for="bookmark-webp" class="form-check-label" aria-describedby="settings-convertBookmarkToWebP-help">{{settingsForm.get('convertBookmarkToWebP')?.value ? 'WebP' : 'Original' }}</label>
</div>
</div>
<div class="col-md-6 col-sm-12 mb-3">
<label for="cover-webp" class="form-label me-1" aria-describedby="settings-convertCoverToWebP-help">Save Covers as WebP</label>
<i class="fa fa-info-circle" placement="right" [ngbTooltip]="convertCoverToWebPTooltip" role="button" tabindex="0"></i>
<ng-template #convertCoverToWebPTooltip>When generating covers, convert them to WebP.</ng-template>
<span class="visually-hidden" id="settings-convertCoverToWebP-help"><ng-container [ngTemplateOutlet]="convertBookmarkToWebPTooltip"></ng-container></span>
<div class="form-check form-switch">
<input id="cover-webp" type="checkbox" class="form-check-input" formControlName="convertCoverToWebP" role="switch">
<label for="cover-webp" class="form-check-label" aria-describedby="settings-convertCoverToWebP-help">{{settingsForm.get('convertCoverToWebP')?.value ? 'WebP' : 'Original' }}</label>
</div>
<label for="settings-media-encodeMediaAs" class="form-label me-1">Save Media As</label>
<i class="fa fa-info-circle" placement="right" [ngbTooltip]="encodeMediaAsTooltip" role="button" tabindex="0"></i>
<ng-template #encodeMediaAsTooltip>All media Kavita manages (covers, bookmarks, favicons) will be encoded as this type.</ng-template>
<span class="visually-hidden" id="settings-media-encodeMediaAs-help"><ng-container [ngTemplateOutlet]="encodeMediaAsTooltip"></ng-container></span>
<select class="form-select" aria-describedby="settings-media-encodeMediaAs-help" formControlName="encodeMediaAs" id="settings-media-encodeMediaAs">
<option *ngFor="let format of EncodeFormats" [value]="format.value">{{format.title}}</option>
</select>
</div>
</div>

View file

@ -6,6 +6,7 @@ import { SettingsService } from '../settings.service';
import { ServerSettings } from '../_models/server-settings';
import { DirectoryPickerComponent, DirectoryPickerResult } from '../_modals/directory-picker/directory-picker.component';
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
import { EncodeFormats } from '../_models/encode-format';
@Component({
selector: 'app-manage-media-settings',
@ -16,29 +17,28 @@ export class ManageMediaSettingsComponent implements OnInit {
serverSettings!: ServerSettings;
settingsForm: FormGroup = new FormGroup({});
get EncodeFormats() { return EncodeFormats; }
constructor(private settingsService: SettingsService, private toastr: ToastrService, private modalService: NgbModal, ) { }
ngOnInit(): void {
this.settingsService.getServerSettings().pipe(take(1)).subscribe((settings: ServerSettings) => {
this.serverSettings = settings;
this.settingsForm.addControl('convertBookmarkToWebP', new FormControl(this.serverSettings.convertBookmarkToWebP, [Validators.required]));
this.settingsForm.addControl('convertCoverToWebP', new FormControl(this.serverSettings.convertCoverToWebP, [Validators.required]));
this.settingsForm.addControl('encodeMediaAs', new FormControl(this.serverSettings.encodeMediaAs, [Validators.required]));
this.settingsForm.addControl('bookmarksDirectory', new FormControl(this.serverSettings.bookmarksDirectory, [Validators.required]));
});
}
resetForm() {
this.settingsForm.get('convertBookmarkToWebP')?.setValue(this.serverSettings.convertBookmarkToWebP);
this.settingsForm.get('convertCoverToWebP')?.setValue(this.serverSettings.convertCoverToWebP);
this.settingsForm.get('encodeMediaAs')?.setValue(this.serverSettings.encodeMediaAs);
this.settingsForm.get('bookmarksDirectory')?.setValue(this.serverSettings.bookmarksDirectory);
this.settingsForm.markAsPristine();
}
saveSettings() {
const modelSettings = Object.assign({}, this.serverSettings);
modelSettings.convertBookmarkToWebP = this.settingsForm.get('convertBookmarkToWebP')?.value;
modelSettings.convertCoverToWebP = this.settingsForm.get('convertCoverToWebP')?.value;
modelSettings.encodeMediaAs = parseInt(this.settingsForm.get('encodeMediaAs')?.value, 10);
this.settingsService.updateServerSettings(modelSettings).pipe(take(1)).subscribe(async (settings: ServerSettings) => {
this.serverSettings = settings;

View file

@ -50,7 +50,7 @@ export class ManageSettingsComponent implements OnInit {
this.settingsForm.addControl('totalBackups', new FormControl(this.serverSettings.totalBackups, [Validators.required, Validators.min(1), Validators.max(30)]));
this.settingsForm.addControl('totalLogs', new FormControl(this.serverSettings.totalLogs, [Validators.required, Validators.min(1), Validators.max(30)]));
this.settingsForm.addControl('enableFolderWatching', new FormControl(this.serverSettings.enableFolderWatching, [Validators.required]));
this.settingsForm.addControl('convertBookmarkToWebP', new FormControl(this.serverSettings.convertBookmarkToWebP, []));
this.settingsForm.addControl('encodeMediaAs', new FormControl(this.serverSettings.encodeMediaAs, []));
this.settingsForm.addControl('hostName', new FormControl(this.serverSettings.hostName, [Validators.pattern(/^(http:|https:)+[^\s]+[\w]$/)]));
this.serverService.getServerInfo().subscribe(info => {
@ -76,7 +76,7 @@ export class ManageSettingsComponent implements OnInit {
this.settingsForm.get('totalBackups')?.setValue(this.serverSettings.totalBackups);
this.settingsForm.get('totalLogs')?.setValue(this.serverSettings.totalLogs);
this.settingsForm.get('enableFolderWatching')?.setValue(this.serverSettings.enableFolderWatching);
this.settingsForm.get('convertBookmarkToWebP')?.setValue(this.serverSettings.convertBookmarkToWebP);
this.settingsForm.get('encodeMediaAs')?.setValue(this.serverSettings.encodeMediaAs);
this.settingsForm.get('hostName')?.setValue(this.serverSettings.hostName);
this.settingsForm.markAsPristine();
}

View file

@ -34,16 +34,10 @@ export class ManageTasksSettingsComponent implements OnInit {
recurringTasks$: Observable<Array<Job>> = of([]);
adhocTasks: Array<AdhocTask> = [
{
name: 'Convert Bookmarks to WebP',
description: 'Runs a long-running task which will convert all bookmarks to WebP. This is slow (especially on ARM devices).',
api: this.serverService.convertBookmarks(),
successMessage: 'Conversion of Bookmarks has been queued'
},
{
name: 'Convert Covers to WebP',
description: 'Runs a long-running task which will convert all existing covers to WebP. This is slow (especially on ARM devices).',
api: this.serverService.convertCovers(),
successMessage: 'Conversion of Covers has been queued'
name: 'Convert Media to Target Encoding',
description: 'Runs a long-running task which will convert all kavita-managed media to the target encoding. This is slow (especially on ARM devices).',
api: this.serverService.convertMedia(),
successMessage: 'Conversion of Media to Target Encoding has been queued'
},
{
name: 'Clear Cache',
@ -144,12 +138,6 @@ export class ManageTasksSettingsComponent implements OnInit {
});
}
runAdhocConvert() {
this.serverService.convertBookmarks().subscribe(() => {
this.toastr.success('Conversion of Bookmarks has been queued.');
});
}
runAdhoc(task: AdhocTask) {
task.api.subscribe((data: any) => {
if (task.successMessage.length > 0) {
@ -159,6 +147,8 @@ export class ManageTasksSettingsComponent implements OnInit {
if (task.successFunction) {
task.successFunction(data);
}
}, (err: any) => {
console.error('error: ', err);
});
}

View file

@ -357,10 +357,14 @@
</div>
</div>
<div class="col-lg-2">
<button class="btn btn-secondary" (click)="addWebLink()">
<button class="btn btn-secondary me-1" (click)="addWebLink()">
<i class="fa-solid fa-plus" aria-hidden="true"></i>
<span class="visually-hidden">Add Link</span>
</button>
<button class="btn btn-secondary" (click)="removeWebLink(i)">
<i class="fa-solid fa-xmark" aria-hidden="true"></i>
<span class="visually-hidden">Remove Link</span>
</button>
</div>
</div>
</ng-template>

View file

@ -175,7 +175,7 @@ export class EditSeriesModalComponent implements OnInit, OnDestroy {
this.editSeriesForm.get('releaseYear')?.patchValue(this.metadata.releaseYear);
this.WebLinks.forEach((link, index) => {
this.editSeriesForm.addControl('link' + index, new FormControl(link, [Validators.required]));
this.editSeriesForm.addControl('link' + index, new FormControl(link, []));
});
this.cdRef.markForCheck();
@ -521,7 +521,16 @@ export class EditSeriesModalComponent implements OnInit, OnDestroy {
addWebLink() {
this.metadata.webLinks += ',';
this.editSeriesForm.addControl('link' + (this.WebLinks.length - 1), new FormControl('', [Validators.required]));
this.editSeriesForm.addControl('link' + (this.WebLinks.length - 1), new FormControl('', []));
this.cdRef.markForCheck();
}
removeWebLink(index: number) {
const tokens = this.metadata.webLinks.split(',');
const tokenToRemove = tokens[index];
this.metadata.webLinks = tokens.filter(t => t != tokenToRemove).join(',');
this.editSeriesForm.removeControl('link' + index, {emitEvent: true});
this.cdRef.markForCheck();
}

View file

@ -128,7 +128,7 @@
</button>
</div>
<div class="col-auto ms-2">
<ngb-rating class="rating-star" [(rate)]="series.userRating" (rateChange)="updateRating($event)" (click)="promptToReview()" [resettable]="false">
<ngb-rating class="rating-star" [(rate)]="series.userRating" (rateChange)="updateRating($event)" [resettable]="false">
<ng-template let-fill="fill" let-index="index">
<span class="star" [class.filled]="(index < series.userRating) && series.userRating > 0">&#9733;</span>
</ng-template>

View file

@ -740,19 +740,6 @@ export class SeriesDetailComponent implements OnInit, OnDestroy, AfterContentChe
});
}
async promptToReview() {
// TODO: After a review has been set, we might just want to show an edit icon next to star rating which opens the review, instead of prompting each time.
const shouldPrompt = this.isNullOrEmpty(this.series.userReview);
const config = new ConfirmConfig();
config.header = 'Confirm';
config.content = 'Do you want to write a review?';
config.buttons.push({text: 'No', type: 'secondary'});
config.buttons.push({text: 'Yes', type: 'primary'});
if (shouldPrompt && await this.confirmService.confirm('Do you want to write a review?', config)) {
this.openReviewModal();
}
}
openReviewModal(force = false) {
const modalRef = this.modalService.open(ReviewSeriesModalComponent, { scrollable: true, size: 'lg' });
modalRef.componentInstance.series = this.series;

View file

@ -15,7 +15,7 @@
<meta name="theme-color" content="#000000">
<meta name="apple-mobile-web-app-status-bar-style" content="#000000">
<!-- Don't allow indexing from Bots -->
<meta name='robots' content='none' />
<meta name="robots" content="noindex,nofollow">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="mobile-web-app-capable" content="yes">