Cleanup from the Release (#2127)

* Added an FAQ link on the Kavita+ tab.

* Don't query Kavita+ for ratings on comic libraries as there is no upstream provider yet.

* Jumpbar keys are a little hard to click

* Fixed an issue where libraries that don't allow scrobbling could be scrobbled when generating past history with read events.

* Made the min/max release year on metadata filter number and removed the spin arrows for styling.

* Fixed disable tabs color contrast due to bootstrap undocumented change.

* Refactored whole codebase to unify caching mechanism. Upped the default cache memory amount to 75 to account for the extra data load. Still LRU.

Fixed an issue where Cache key was using Port instead.

Refactored all the Configuration code to use strongly typed deserialization.

* Fixed an issue where get latest progress would throw an exception if there was no progress due to LINQ and MAX query.

* Fixed a bug where Send to Device wasn't present on Series cards.

* Hooked up the ability to change the cache size for Kavita via the UI.
This commit is contained in:
Joe Milazzo 2023-07-12 16:06:30 -05:00 committed by GitHub
parent 1ed8889d08
commit 81da9dc444
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
37 changed files with 402 additions and 272 deletions

View file

@ -40,7 +40,7 @@ export class ActionService implements OnDestroy {
private readingListModalRef: NgbModalRef | null = null;
private collectionModalRef: NgbModalRef | null = null;
constructor(private libraryService: LibraryService, private seriesService: SeriesService,
constructor(private libraryService: LibraryService, private seriesService: SeriesService,
private readerService: ReaderService, private toastr: ToastrService, private modalService: NgbModal,
private confirmService: ConfirmService, private memberService: MemberService, private deviceSerivce: DeviceService) { }
@ -53,7 +53,7 @@ export class ActionService implements OnDestroy {
* Request a file scan for a given Library
* @param library Partial Library, must have id and name populated
* @param callback Optional callback to perform actions after API completes
* @returns
* @returns
*/
async scanLibrary(library: Partial<Library>, callback?: LibraryActionCallback) {
if (!library.hasOwnProperty('id') || library.id === undefined) {
@ -76,7 +76,7 @@ export class ActionService implements OnDestroy {
* Request a refresh of Metadata for a given Library
* @param library Partial Library, must have id and name populated
* @param callback Optional callback to perform actions after API completes
* @returns
* @returns
*/
async refreshMetadata(library: Partial<Library>, callback?: LibraryActionCallback) {
if (!library.hasOwnProperty('id') || library.id === undefined) {
@ -112,7 +112,7 @@ export class ActionService implements OnDestroy {
* Request an analysis of files for a given Library (currently just word count)
* @param library Partial Library, must have id and name populated
* @param callback Optional callback to perform actions after API completes
* @returns
* @returns
*/
async analyzeFiles(library: Partial<Library>, callback?: LibraryActionCallback) {
if (!library.hasOwnProperty('id') || library.id === undefined) {
@ -285,7 +285,7 @@ export class ActionService implements OnDestroy {
* @param seriesId Series Id
* @param volumes Volumes, should have id, chapters and pagesRead populated
* @param chapters? Chapters, should have id
* @param callback Optional callback to perform actions after API completes
* @param callback Optional callback to perform actions after API completes
*/
markMultipleAsRead(seriesId: number, volumes: Array<Volume>, chapters?: Array<Chapter>, callback?: VoidActionCallback) {
this.readerService.markMultipleRead(seriesId, volumes.map(v => v.id), chapters?.map(c => c.id)).pipe(take(1)).subscribe(() => {
@ -306,7 +306,7 @@ export class ActionService implements OnDestroy {
* Mark all chapters and the volumes as Unread. All volumes must belong to a series
* @param seriesId Series Id
* @param volumes Volumes, should have id, chapters and pagesRead populated
* @param callback Optional callback to perform actions after API completes
* @param callback Optional callback to perform actions after API completes
*/
markMultipleAsUnread(seriesId: number, volumes: Array<Volume>, chapters?: Array<Chapter>, callback?: VoidActionCallback) {
this.readerService.markMultipleUnread(seriesId, volumes.map(v => v.id), chapters?.map(c => c.id)).pipe(take(1)).subscribe(() => {
@ -326,7 +326,7 @@ export class ActionService implements OnDestroy {
/**
* Mark all series as Read.
* @param series Series, should have id, pagesRead populated
* @param callback Optional callback to perform actions after API completes
* @param callback Optional callback to perform actions after API completes
*/
markMultipleSeriesAsRead(series: Array<Series>, callback?: VoidActionCallback) {
this.readerService.markMultipleSeriesRead(series.map(v => v.id)).pipe(take(1)).subscribe(() => {
@ -342,9 +342,9 @@ export class ActionService implements OnDestroy {
}
/**
* Mark all series as Unread.
* Mark all series as Unread.
* @param series Series, should have id, pagesRead populated
* @param callback Optional callback to perform actions after API completes
* @param callback Optional callback to perform actions after API completes
*/
markMultipleSeriesAsUnread(series: Array<Series>, callback?: VoidActionCallback) {
this.readerService.markMultipleSeriesUnread(series.map(v => v.id)).pipe(take(1)).subscribe(() => {
@ -425,9 +425,9 @@ export class ActionService implements OnDestroy {
/**
* Adds a set of series to a collection tag
* @param series
* @param callback
* @returns
* @param series
* @param callback
* @returns
*/
addMultipleSeriesToCollectionTag(series: Array<Series>, callback?: BooleanActionCallback) {
if (this.collectionModalRef != null) { return; }
@ -452,7 +452,7 @@ export class ActionService implements OnDestroy {
addSeriesToReadingList(series: Series, callback?: SeriesActionCallback) {
if (this.readingListModalRef != null) { return; }
this.readingListModalRef = this.modalService.open(AddToListModalComponent, { scrollable: true, size: 'md' });
this.readingListModalRef.componentInstance.seriesId = series.id;
this.readingListModalRef.componentInstance.seriesId = series.id;
this.readingListModalRef.componentInstance.title = series.name;
this.readingListModalRef.componentInstance.type = ADD_FLOW.Series;
@ -474,7 +474,7 @@ export class ActionService implements OnDestroy {
addVolumeToReadingList(volume: Volume, seriesId: number, callback?: VolumeActionCallback) {
if (this.readingListModalRef != null) { return; }
this.readingListModalRef = this.modalService.open(AddToListModalComponent, { scrollable: true, size: 'md' });
this.readingListModalRef.componentInstance.seriesId = seriesId;
this.readingListModalRef.componentInstance.seriesId = seriesId;
this.readingListModalRef.componentInstance.volumeId = volume.id;
this.readingListModalRef.componentInstance.type = ADD_FLOW.Volume;
@ -496,7 +496,7 @@ export class ActionService implements OnDestroy {
addChapterToReadingList(chapter: Chapter, seriesId: number, callback?: ChapterActionCallback) {
if (this.readingListModalRef != null) { return; }
this.readingListModalRef = this.modalService.open(AddToListModalComponent, { scrollable: true, size: 'md' });
this.readingListModalRef.componentInstance.seriesId = seriesId;
this.readingListModalRef.componentInstance.seriesId = seriesId;
this.readingListModalRef.componentInstance.chapterId = chapter.id;
this.readingListModalRef.componentInstance.type = ADD_FLOW.Chapter;
@ -517,7 +517,7 @@ export class ActionService implements OnDestroy {
editReadingList(readingList: ReadingList, callback?: ReadingListActionCallback) {
const readingListModalRef = this.modalService.open(EditReadingListModalComponent, { scrollable: true, size: 'lg' });
readingListModalRef.componentInstance.readingList = readingList;
readingListModalRef.componentInstance.readingList = readingList;
readingListModalRef.closed.pipe(take(1)).subscribe((list) => {
if (callback && list !== undefined) {
callback(readingList);
@ -535,7 +535,7 @@ export class ActionService implements OnDestroy {
* @param seriesId Series Id
* @param volumes Volumes, should have id, chapters and pagesRead populated
* @param chapters? Chapters, should have id
* @param callback Optional callback to perform actions after API completes
* @param callback Optional callback to perform actions after API completes
*/
async deleteMultipleSeries(seriesIds: Array<Series>, callback?: BooleanActionCallback) {
if (!await this.confirmService.confirm('Are you sure you want to delete ' + seriesIds.length + ' series? It will not modify files on disk.')) {
@ -578,15 +578,13 @@ export class ActionService implements OnDestroy {
});
}
private async promptIfForce(extraContent: string = '') {
// Prompt user if we should do a force or not
const config = this.confirmService.defaultConfirm;
config.header = 'Force Scan';
config.buttons = [
{text: 'Yes', type: 'secondary'},
{text: 'No', type: 'primary'},
];
const msg = 'Do you want to force this scan? This is will ignore optimizations that reduce processing and I/O. ' + extraContent;
return !await this.confirmService.confirm(msg, config); // Not because primary is the false state
sendSeriesToDevice(seriesId: number, device: Device, callback?: VoidActionCallback) {
this.deviceSerivce.sendSeriesTo(seriesId, device.id).subscribe(() => {
this.toastr.success('File(s) emailed to ' + device.name);
if (callback) {
callback();
}
});
}
}

View file

@ -19,7 +19,7 @@ export class DeviceService {
constructor(private httpClient: HttpClient, private accountService: AccountService) {
// Ensure we are authenticated before we make an authenticated api call.
// Ensure we are authenticated before we make an authenticated api call.
this.accountService.currentUser$.subscribe(user => {
if (!user) {
this.devicesSource.next([]);
@ -54,5 +54,9 @@ export class DeviceService {
return this.httpClient.post(this.baseUrl + 'device/send-to', {deviceId, chapterIds}, TextResonse);
}
sendSeriesTo(seriesId: number, deviceId: number) {
return this.httpClient.post(this.baseUrl + 'device/send-series-to', {deviceId, seriesId}, TextResonse);
}
}

View file

@ -17,4 +17,5 @@ export interface ServerSettings {
totalLogs: number;
enableFolderWatching: boolean;
hostName: string;
cacheSize: number;
}

View file

@ -36,7 +36,7 @@
<app-manage-tasks-settings></app-manage-tasks-settings>
</ng-container>
<ng-container *ngIf="tab.fragment === TabID.KavitaPlus">
<p>Kavita+ is a premium subscription service which unlocks features for all users on this Kavita instance. Buy a subscription to unlock <a href="https://wiki.kavitareader.com/en/kavita-plus" target="_blank" rel="noreferrer nofollow">premium benefits</a> today!</p>
<p>Kavita+ is a premium subscription service which unlocks features for all users on this Kavita instance. Buy a subscription to unlock <a href="https://wiki.kavitareader.com/en/kavita-plus" target="_blank" rel="noreferrer nofollow">premium benefits</a> today! <a href="https://wiki.kavitareader.com/en/kavita-plus" target="_blank" rel="noreferrer nofollow">FAQ</a></p>
<app-license></app-license>
</ng-container>
<ng-container *ngIf="tab.fragment === TabID.Plugins">

View file

@ -1,7 +1,7 @@
<div class="container-fluid">
<form [formGroup]="settingsForm" *ngIf="serverSettings !== undefined">
<div class="alert alert-warning" role="alert">
<strong>Notice:</strong> Changing Port, Base Url or IPs requires a manual restart of Kavita to take effect.
<strong>Notice:</strong> Changing Port, Base Url, Cache Size or IPs requires a manual restart of Kavita to take effect.
</div>
<div class="mb-3">
@ -97,13 +97,32 @@
</div>
</div>
<div class="mb-3">
<label for="stat-collection" class="form-label" aria-describedby="collection-info">Allow Anonymous Usage Collection</label>
<p class="accent" id="collection-info">Send anonymous usage data to Kavita's servers. This includes information on certain features used, number of files, OS version, Kavita install version, CPU, and memory. We will use this information to prioritize features, bug fixes, and performance tuning. Requires restart to take effect. See the <a href="https://wiki.kavitareader.com/en/faq" rel="noopener noreferrer" target="_blank" referrerpolicy="no-refer">wiki</a> for what is collected.</p>
<div class="form-check form-switch">
<input id="stat-collection" type="checkbox" aria-label="Stat Collection" class="form-check-input" formControlName="allowStatCollection" role="switch">
<label for="stat-collection" class="form-check-label">Send Data</label>
</div>
<div class="row g-0 mb-2 mt-3">
<div class="col-md-4 col-sm-12 pe-2">
<label for="cache-size" class="form-label">Cache Size</label>&nbsp;<i class="fa fa-info-circle" placement="right" [ngbTooltip]="cacheSizeTooltip" role="button" tabindex="0"></i>
<ng-template #cacheSizeTooltip>The amount of memory allowed for caching heavy APIs. Default is 50MB.</ng-template>
<span class="visually-hidden" id="cache-size-help">The amount of memory allowed for caching heavy APIs. Default is 50MB.</span>
<input id="cache-size" aria-describedby="cache-size-help" class="form-control" formControlName="cacheSize"
type="number" inputmode="numeric" step="5" min="50" onkeypress="return event.charCode >= 48 && event.charCode <= 57"
[class.is-invalid]="settingsForm.get('cacheSize')?.invalid && settingsForm.get('cacheSize')?.touched">
<ng-container *ngIf="settingsForm.get('cacheSize')?.errors as errors">
<p class="invalid-feedback" *ngIf="errors.min">
You must have at least 50 MB.
</p>
<p class="invalid-feedback" *ngIf="errors.required">
This field is required
</p>
</ng-container>
</div>
</div>
<div class="mb-3 mt-3">
<label for="stat-collection" class="form-label" aria-describedby="collection-info">Allow Anonymous Usage Collection</label>
<p class="accent" id="collection-info">Send anonymous usage data to Kavita's servers. This includes information on certain features used, number of files, OS version, Kavita install version, CPU, and memory. We will use this information to prioritize features, bug fixes, and performance tuning. Requires restart to take effect. See the <a href="https://wiki.kavitareader.com/en/faq" rel="noopener noreferrer" target="_blank" referrerpolicy="no-refer">wiki</a> for what is collected.</p>
<div class="form-check form-switch">
<input id="stat-collection" type="checkbox" aria-label="Stat Collection" class="form-check-input" formControlName="allowStatCollection" role="switch">
<label for="stat-collection" class="form-check-label">Send Data</label>
</div>
</div>
<!-- TODO: Move this to Plugins tab once we build out some basic tables -->

View file

@ -52,6 +52,7 @@ export class ManageSettingsComponent implements OnInit {
this.settingsForm.addControl('baseUrl', new FormControl(this.serverSettings.baseUrl, [Validators.pattern(/^(\/[\w-]+)*\/$/)]));
this.settingsForm.addControl('emailServiceUrl', new FormControl(this.serverSettings.emailServiceUrl, [Validators.required]));
this.settingsForm.addControl('totalBackups', new FormControl(this.serverSettings.totalBackups, [Validators.required, Validators.min(1), Validators.max(30)]));
this.settingsForm.addControl('cacheSize', new FormControl(this.serverSettings.cacheSize, [Validators.required, Validators.min(50)]));
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('encodeMediaAs', new FormControl(this.serverSettings.encodeMediaAs, []));
@ -82,6 +83,7 @@ export class ManageSettingsComponent implements OnInit {
this.settingsForm.get('enableFolderWatching')?.setValue(this.serverSettings.enableFolderWatching);
this.settingsForm.get('encodeMediaAs')?.setValue(this.serverSettings.encodeMediaAs);
this.settingsForm.get('hostName')?.setValue(this.serverSettings.hostName);
this.settingsForm.get('cacheSize')?.setValue(this.serverSettings.cacheSize);
this.settingsForm.markAsPristine();
}
@ -127,5 +129,5 @@ export class ManageSettingsComponent implements OnInit {
});
}
}

View file

@ -56,44 +56,44 @@
}
.btn {
text-decoration: none;
color: hsla(0,0%,100%,.7);
height: 25px;
text-align: center;
-webkit-tap-highlight-color: transparent;
background: none;
border: 0;
border-radius: 0;
cursor: pointer;
line-height: inherit;
margin: 0;
outline: none;
padding: 0;
text-align: inherit;
text-decoration: none;
touch-action: manipulation;
transition: color .2s;
-webkit-user-select: none;
user-select: none;
text-decoration: none;
color: hsla(0,0%,100%,.7);
height: 25px;
text-align: center;
padding: 0px 5px;
-webkit-tap-highlight-color: transparent;
background: none;
border: 0;
border-radius: 0;
cursor: pointer;
line-height: inherit;
margin: 0;
outline: none;
text-align: inherit;
text-decoration: none;
touch-action: manipulation;
transition: color .2s;
-webkit-user-select: none;
user-select: none;
&:hover {
color: var(--primary-color);
}
&:hover {
color: var(--primary-color);
}
.active {
font-weight: bold;
}
.active {
font-weight: bold;
}
&.disabled {
color: lightgrey;
cursor: not-allowed;
}
}
&.disabled {
color: lightgrey;
cursor: not-allowed;
}
}
}
.virtual-scroller, virtual-scroller {
width: 100%;
//height: calc(100vh - 160px); // 64 is a random number, 523 for me.
//height: calc(100vh - 160px); // 64 is a random number, 523 for me.
height: calc(var(--vh) * 100 - 173px);
//height: calc(100vh - 160px);
//background-color: red;
@ -107,4 +107,4 @@ virtual-scroller.empty {
h2 {
display: inline-block;
word-break: break-all;
}
}

View file

@ -21,6 +21,7 @@ import { RelationKind } from 'src/app/_models/series-detail/relation-kind';
import {CommonModule} from "@angular/common";
import {CardItemComponent} from "../card-item/card-item.component";
import {RelationshipPipe} from "../../pipe/relationship.pipe";
import {Device} from "../../_models/device/device";
@Component({
selector: 'app-series-card',
@ -120,6 +121,10 @@ export class SeriesCardComponent implements OnInit, OnChanges {
case (Action.AnalyzeFiles):
this.actionService.analyzeFilesForSeries(series);
break;
case Action.SendTo:
const device = (action._extra!.data as Device);
this.actionService.sendSeriesToDevice(series.id, device);
break;
default:
break;
}

View file

@ -5,13 +5,11 @@ import {
EventEmitter,
HostListener,
inject,
OnDestroy,
OnInit
} from '@angular/core';
import { Title } from '@angular/platform-browser';
import { ActivatedRoute, Router } from '@angular/router';
import { Subject } from 'rxjs';
import { debounceTime, take, takeUntil } from 'rxjs/operators';
import { take } from 'rxjs/operators';
import { BulkSelectionService } from '../cards/bulk-selection.service';
import { KEY_CODES, UtilityService } from '../shared/_services/utility.service';
import { SeriesAddedEvent } from '../_models/events/series-added-event';
@ -39,6 +37,7 @@ import { NgFor, NgIf, DecimalPipe } from '@angular/common';
import { NgbNav, NgbNavItem, NgbNavItemRole, NgbNavLink, NgbNavContent, NgbNavOutlet } from '@ng-bootstrap/ng-bootstrap';
import { CardActionablesComponent } from '../cards/card-item/card-actionables/card-actionables.component';
import { SideNavCompanionBarComponent } from '../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component';
import {Device} from "../_models/device/device";
@Component({
selector: 'app-library-detail',
@ -234,6 +233,8 @@ export class LibraryDetailComponent implements OnInit {
}
}
performAction(action: ActionItem<any>) {
if (typeof action.callback === 'function') {
action.callback(action, undefined);

View file

@ -329,14 +329,14 @@
<form [formGroup]="releaseYearRange" class="d-flex justify-content-between">
<div class="mb-3">
<label for="release-year-min" class="form-label">Release</label>
<input type="text" id="release-year-min" formControlName="min" class="form-control" style="width: 62px" placeholder="Min" (keyup.enter)="apply()">
<input type="number" id="release-year-min" formControlName="min" class="form-control custom-number" style="width: 62px" placeholder="Min" (keyup.enter)="apply()">
</div>
<div style="margin-top: 37px !important;">
<i class="fa-solid fa-minus" aria-hidden="true"></i>
</div>
<div class="mb-3" style="margin-top: 0.5rem">
<label for="release-year-max" class="form-label"><span class="visually-hidden">Max</span></label>
<input type="text" id="release-year-max" formControlName="max" class="form-control" style="width: 62px" placeholder="Max" (keyup.enter)="apply()">
<input type="number" id="release-year-max" formControlName="max" class="form-control custom-number" style="width: 62px" placeholder="Max" (keyup.enter)="apply()">
</div>
</form>
</div>

View file

@ -0,0 +1,12 @@
/* Works for Chrome, Safari, Edge, Opera */
input::-webkit-outer-spin-button,
input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
/* Works for Firefox */
input[type="number"] {
-moz-appearance: textfield;
}

View file

@ -137,8 +137,8 @@ export class MetadataFilterComponent implements OnInit {
});
this.releaseYearRange = new FormGroup({
min: new FormControl({value: undefined, disabled: this.filterSettings.releaseYearDisabled}, [Validators.min(1000), Validators.max(9999)]),
max: new FormControl({value: undefined, disabled: this.filterSettings.releaseYearDisabled}, [Validators.min(1000), Validators.max(9999)])
min: new FormControl({value: undefined, disabled: this.filterSettings.releaseYearDisabled}, [Validators.min(1000), Validators.max(9999), Validators.maxLength(4), Validators.minLength(4)]),
max: new FormControl({value: undefined, disabled: this.filterSettings.releaseYearDisabled}, [Validators.min(1000), Validators.max(9999), Validators.maxLength(4), Validators.minLength(4)])
});
this.readProgressGroup.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(changes => {

View file

@ -22,8 +22,6 @@ export class ChangeEmailComponent implements OnInit {
form: FormGroup = new FormGroup({});
user: User | undefined = undefined;
hasChangePasswordAbility: Observable<boolean> = of(false);
passwordsMatch = false;
errors: string[] = [];
isViewMode: boolean = true;
emailLink: string = '';

View file

@ -1,3 +1,7 @@
.nav {
--bs-nav-link-disabled-color: rgb(154 187 219 / 75%);
}
.nav-link {
color: var(--nav-link-text-color);
@ -19,11 +23,11 @@
.nav-tabs {
border-color: var(--nav-tab-border-color);
.nav-link {
color: var(--nav-link-text-color);
position: relative;
&.active, &:focus {
color: var(--nav-tab-active-text-color);
background-color: var(--nav-tab-bg-color);
@ -37,7 +41,7 @@
&.active::before {
transform: scaleY(1);
}
&:hover {
color: var(--nav-tab-hover-text-color);
background-color: var(--nav-tab-hover-bg-color);