Ability to turn off Metadata Parsing (#3872)

This commit is contained in:
Joe Milazzo 2025-06-23 18:57:14 -05:00 committed by GitHub
parent fa8d778c8d
commit 36aa5f5c85
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
63 changed files with 4257 additions and 186 deletions

View file

@ -5,6 +5,11 @@
box-shadow: 0 2px 5px rgba(0,0,0,0.2);
transition: transform 0.2s ease, background 0.3s ease;
cursor: pointer;
&.not-selectable:hover {
cursor: not-allowed;
background-color: var(--bs-card-color, #2c2c2c) !important;
}
}
.tag-card:hover {

View file

@ -0,0 +1,120 @@
import {AbstractControl, FormArray, FormControl, FormGroup} from '@angular/forms';
interface ValidationIssue {
path: string;
controlType: string;
value: any;
errors: { [key: string]: any } | null;
status: string;
disabled: boolean;
}
export function analyzeFormGroupValidation(formGroup: FormGroup, basePath: string = ''): ValidationIssue[] {
const issues: ValidationIssue[] = [];
function analyzeControl(control: AbstractControl, path: string): void {
// Determine control type for better debugging
let controlType = 'AbstractControl';
if (control instanceof FormGroup) {
controlType = 'FormGroup';
} else if (control instanceof FormArray) {
controlType = 'FormArray';
} else if (control instanceof FormControl) {
controlType = 'FormControl';
}
// Add issue if control has validation errors or is invalid
if (control.invalid || control.errors || control.disabled) {
issues.push({
path: path || 'root',
controlType,
value: control.value,
errors: control.errors,
status: control.status,
disabled: control.disabled
});
}
// Recursively check nested controls
if (control instanceof FormGroup) {
Object.keys(control.controls).forEach(key => {
const childPath = path ? `${path}.${key}` : key;
analyzeControl(control.controls[key], childPath);
});
} else if (control instanceof FormArray) {
control.controls.forEach((childControl, index) => {
const childPath = path ? `${path}[${index}]` : `[${index}]`;
analyzeControl(childControl, childPath);
});
}
}
analyzeControl(formGroup, basePath);
return issues;
}
export function printFormGroupValidation(formGroup: FormGroup, basePath: string = ''): void {
const issues = analyzeFormGroupValidation(formGroup, basePath);
console.group(`🔍 FormGroup Validation Analysis (${basePath || 'root'})`);
console.log(`Overall Status: ${formGroup.status}`);
console.log(`Overall Valid: ${formGroup.valid}`);
console.log(`Total Issues Found: ${issues.length}`);
if (issues.length === 0) {
console.log('✅ No validation issues found!');
} else {
console.log('\n📋 Detailed Issues:');
issues.forEach((issue, index) => {
console.group(`${index + 1}. ${issue.path} (${issue.controlType})`);
console.log(`Status: ${issue.status}`);
console.log(`Value:`, issue.value);
console.log(`Disabled: ${issue.disabled}`);
if (issue.errors) {
console.log('Validation Errors:');
Object.entries(issue.errors).forEach(([errorKey, errorValue]) => {
console.log(`${errorKey}:`, errorValue);
});
} else {
console.log('No specific validation errors (but control is invalid)');
}
console.groupEnd();
});
}
console.groupEnd();
}
// Alternative function that returns a formatted string instead of console logging
export function getFormGroupValidationReport(formGroup: FormGroup, basePath: string = ''): string {
const issues = analyzeFormGroupValidation(formGroup, basePath);
let report = `FormGroup Validation Report (${basePath || 'root'})\n`;
report += `Overall Status: ${formGroup.status}\n`;
report += `Overall Valid: ${formGroup.valid}\n`;
report += `Total Issues Found: ${issues.length}\n\n`;
if (issues.length === 0) {
report += '✅ No validation issues found!';
} else {
report += 'Detailed Issues:\n';
issues.forEach((issue, index) => {
report += `\n${index + 1}. ${issue.path} (${issue.controlType})\n`;
report += ` Status: ${issue.status}\n`;
report += ` Value: ${JSON.stringify(issue.value)}\n`;
report += ` Disabled: ${issue.disabled}\n`;
if (issue.errors) {
report += ' Validation Errors:\n';
Object.entries(issue.errors).forEach(([errorKey, errorValue]) => {
report += `${errorKey}: ${JSON.stringify(errorValue)}\n`;
});
} else {
report += ' No specific validation errors (but control is invalid)\n';
}
});
}
return report;
}

View file

@ -0,0 +1,4 @@
export interface ExternalMatchRateLimitErrorEvent {
seriesId: number;
seriesName: string;
}

View file

@ -31,6 +31,7 @@ export interface Library {
manageReadingLists: boolean;
allowScrobbling: boolean;
allowMetadataMatching: boolean;
enableMetadata: boolean;
collapseSeriesRelationships: boolean;
libraryFileTypes: Array<FileTypeGroup>;
excludePatterns: Array<string>;

View file

@ -1,15 +1,16 @@
import { Injectable } from '@angular/core';
import { HubConnection, HubConnectionBuilder } from '@microsoft/signalr';
import { BehaviorSubject, ReplaySubject } from 'rxjs';
import { environment } from 'src/environments/environment';
import { LibraryModifiedEvent } from '../_models/events/library-modified-event';
import { NotificationProgressEvent } from '../_models/events/notification-progress-event';
import { ThemeProgressEvent } from '../_models/events/theme-progress-event';
import { UserUpdateEvent } from '../_models/events/user-update-event';
import { User } from '../_models/user';
import {Injectable} from '@angular/core';
import {HubConnection, HubConnectionBuilder} from '@microsoft/signalr';
import {BehaviorSubject, ReplaySubject} from 'rxjs';
import {environment} from 'src/environments/environment';
import {LibraryModifiedEvent} from '../_models/events/library-modified-event';
import {NotificationProgressEvent} from '../_models/events/notification-progress-event';
import {ThemeProgressEvent} from '../_models/events/theme-progress-event';
import {UserUpdateEvent} from '../_models/events/user-update-event';
import {User} from '../_models/user';
import {DashboardUpdateEvent} from "../_models/events/dashboard-update-event";
import {SideNavUpdateEvent} from "../_models/events/sidenav-update-event";
import {SiteThemeUpdatedEvent} from "../_models/events/site-theme-updated-event";
import {ExternalMatchRateLimitErrorEvent} from "../_models/events/external-match-rate-limit-error-event";
export enum EVENTS {
UpdateAvailable = 'UpdateAvailable',
@ -114,6 +115,10 @@ export enum EVENTS {
* A Person merged has been merged into another
*/
PersonMerged = 'PersonMerged',
/**
* A Rate limit error was hit when matching a series with Kavita+
*/
ExternalMatchRateLimitError = 'ExternalMatchRateLimitError'
}
export interface Message<T> {
@ -236,6 +241,13 @@ export class MessageHubService {
});
});
this.hubConnection.on(EVENTS.ExternalMatchRateLimitError, resp => {
this.messagesSource.next({
event: EVENTS.ExternalMatchRateLimitError,
payload: resp.body as ExternalMatchRateLimitErrorEvent
});
});
this.hubConnection.on(EVENTS.NotificationProgress, (resp: NotificationProgressEvent) => {
this.messagesSource.next({
event: EVENTS.NotificationProgress,

View file

@ -266,13 +266,13 @@ export class ReaderService {
getQueryParamsObject(incognitoMode: boolean = false, readingListMode: boolean = false, readingListId: number = -1) {
let params: {[key: string]: any} = {};
if (incognitoMode) {
params['incognitoMode'] = true;
}
const params: {[key: string]: any} = {};
params['incognitoMode'] = incognitoMode;
if (readingListMode) {
params['readingListId'] = readingListId;
}
return params;
}

View file

@ -1,7 +1,7 @@
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, inject, OnInit} from '@angular/core';
import {LicenseService} from "../../_services/license.service";
import {Router} from "@angular/router";
import {TranslocoDirective} from "@jsverse/transloco";
import {translate, TranslocoDirective} from "@jsverse/transloco";
import {ImageComponent} from "../../shared/image/image.component";
import {ImageService} from "../../_services/image.service";
import {Series} from "../../_models/series";
@ -23,6 +23,8 @@ import {EVENTS, MessageHubService} from "../../_services/message-hub.service";
import {ScanSeriesEvent} from "../../_models/events/scan-series-event";
import {LibraryTypePipe} from "../../_pipes/library-type.pipe";
import {allKavitaPlusMetadataApplicableTypes} from "../../_models/library/library";
import {ExternalMatchRateLimitErrorEvent} from "../../_models/events/external-match-rate-limit-error-event";
import {ToastrService} from "ngx-toastr";
@Component({
selector: 'app-manage-matched-metadata',
@ -55,6 +57,7 @@ export class ManageMatchedMetadataComponent implements OnInit {
private readonly manageService = inject(ManageService);
private readonly messageHub = inject(MessageHubService);
private readonly cdRef = inject(ChangeDetectorRef);
private readonly toastr = inject(ToastrService);
protected readonly imageService = inject(ImageService);
@ -74,12 +77,19 @@ export class ManageMatchedMetadataComponent implements OnInit {
}
this.messageHub.messages$.subscribe(message => {
if (message.event !== EVENTS.ScanSeries) return;
const evt = message.payload as ScanSeriesEvent;
if (this.data.filter(d => d.series.id === evt.seriesId).length > 0) {
this.loadData();
if (message.event == EVENTS.ScanSeries) {
const evt = message.payload as ScanSeriesEvent;
if (this.data.filter(d => d.series.id === evt.seriesId).length > 0) {
this.loadData();
}
}
if (message.event == EVENTS.ExternalMatchRateLimitError) {
const evt = message.payload as ExternalMatchRateLimitErrorEvent;
this.toastr.error(translate('toasts.external-match-rate-error', {seriesName: evt.seriesName}))
}
});
this.filterGroup.valueChanges.pipe(

View file

@ -19,7 +19,7 @@
>
<ng-template #cardItem let-item let-position="idx">
<div class="tag-card" (click)="openFilter(FilterField.Genres, item.id)">
<div class="tag-card" [ngClass]="{'not-selectable': item.seriesCount === 0}" (click)="openFilter(FilterField.Genres, item)">
<div class="tag-name">{{ item.title }}</div>
<div class="tag-meta">
<span>{{t('series-count', {num: item.seriesCount | compactNumber})}}</span>

View file

@ -1,6 +1,6 @@
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, inject, OnInit} from '@angular/core';
import {CardDetailLayoutComponent} from "../../cards/card-detail-layout/card-detail-layout.component";
import {DecimalPipe} from "@angular/common";
import {DecimalPipe, NgClass} from "@angular/common";
import {
SideNavCompanionBarComponent
} from "../../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component";
@ -24,7 +24,8 @@ import {Title} from "@angular/platform-browser";
DecimalPipe,
SideNavCompanionBarComponent,
TranslocoDirective,
CompactNumberPipe
CompactNumberPipe,
NgClass
],
templateUrl: './browse-genres.component.html',
styleUrl: './browse-genres.component.scss',
@ -62,7 +63,8 @@ export class BrowseGenresComponent implements OnInit {
});
}
openFilter(field: FilterField, value: string | number) {
this.filterUtilityService.applyFilter(['all-series'], field, FilterComparison.Equal, `${value}`).subscribe();
openFilter(field: FilterField, genre: BrowseGenre) {
if (genre.seriesCount === 0) return; // We don't yet have an issue page
this.filterUtilityService.applyFilter(['all-series'], field, FilterComparison.Equal, `${genre.id}`).subscribe();
}
}

View file

@ -19,7 +19,7 @@
>
<ng-template #cardItem let-item let-position="idx">
<div class="tag-card" (click)="openFilter(FilterField.Tags, item.id)">
<div class="tag-card" [ngClass]="{'not-selectable': item.seriesCount === 0}" (click)="openFilter(FilterField.Tags, item)">
<div class="tag-name">{{ item.title }}</div>
<div class="tag-meta">
<span>{{t('series-count', {num: item.seriesCount | compactNumber})}}</span>

View file

@ -1,6 +1,6 @@
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, inject, OnInit} from '@angular/core';
import {CardDetailLayoutComponent} from "../../cards/card-detail-layout/card-detail-layout.component";
import {DecimalPipe} from "@angular/common";
import {DecimalPipe, NgClass} from "@angular/common";
import {
SideNavCompanionBarComponent
} from "../../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component";
@ -25,7 +25,8 @@ import {Title} from "@angular/platform-browser";
DecimalPipe,
SideNavCompanionBarComponent,
TranslocoDirective,
CompactNumberPipe
CompactNumberPipe,
NgClass
],
templateUrl: './browse-tags.component.html',
styleUrl: './browse-tags.component.scss',
@ -61,7 +62,8 @@ export class BrowseTagsComponent implements OnInit {
});
}
openFilter(field: FilterField, value: string | number) {
this.filterUtilityService.applyFilter(['all-series'], field, FilterComparison.Equal, `${value}`).subscribe();
openFilter(field: FilterField, tag: BrowseTag) {
if (tag.seriesCount === 0) return; // We don't yet have an issue page
this.filterUtilityService.applyFilter(['all-series'], field, FilterComparison.Equal, `${tag.id}`).subscribe();
}
}

View file

@ -229,14 +229,17 @@
</div>
}
<app-draggable-ordered-list [items]="items" (orderUpdated)="orderUpdated($event)" [accessibilityMode]="accessibilityMode"
[disabled]="!(formGroup.get('edit')?.value || false)" [showRemoveButton]="formGroup.get('edit')?.value || false">
<app-draggable-ordered-list [items]="items" [accessibilityMode]="accessibilityMode"
[disabled]="!(formGroup.get('edit')?.value || false)"
(orderUpdated)="orderUpdated($event)"
(itemRemove)="removeItem($event)"
[showRemoveButton]="formGroup.get('edit')?.value || false">
<ng-template #draggableItem let-item let-position="idx">
<app-reading-list-item [ngClass]="{'content-container': items.length < 100, 'non-virtualized-container': items.length >= 100}" [item]="item"
[position]="position" [libraryTypes]="libraryTypes"
[promoted]="item.promoted" (read)="readChapter($event)"
(remove)="itemRemoved($event, position)"
(remove)="removeItem($event)"
[showRemove]="false"/>
</ng-template>
</app-draggable-ordered-list>

View file

@ -25,7 +25,8 @@ import {ImageService} from 'src/app/_services/image.service';
import {ReadingListService} from 'src/app/_services/reading-list.service';
import {
DraggableOrderedListComponent,
IndexUpdateEvent
IndexUpdateEvent,
ItemRemoveEvent
} from '../draggable-ordered-list/draggable-ordered-list.component';
import {forkJoin, startWith, tap} from 'rxjs';
import {ReaderService} from 'src/app/_services/reader.service';
@ -321,6 +322,7 @@ export class ReadingListDetailComponent implements OnInit {
}
editReadingList(readingList: ReadingList) {
if (!readingList) return;
this.actionService.editReadingList(readingList, (readingList: ReadingList) => {
// Reload information around list
this.readingListService.getReadingList(this.listId).subscribe(rl => {
@ -347,10 +349,10 @@ export class ReadingListDetailComponent implements OnInit {
});
}
itemRemoved(item: ReadingListItem, position: number) {
removeItem(removeEvent: ItemRemoveEvent) {
if (!this.readingList) return;
this.readingListService.deleteItem(this.readingList.id, item.id).subscribe(() => {
this.items.splice(position, 1);
this.readingListService.deleteItem(this.readingList.id, removeEvent.item.id).subscribe(() => {
this.items.splice(removeEvent.position, 1);
this.items = [...this.items];
this.cdRef.markForCheck();
this.toastr.success(translate('toasts.item-removed'));

View file

@ -18,10 +18,10 @@
{{item.title}}
<div class="actions float-end">
@if (showRemove) {
<button class="btn btn-danger" (click)="remove.emit(item)">
<span>
<i class="fa fa-trash me-1" aria-hidden="true"></i>
</span>
<button class="btn btn-danger" (click)="removeItem(item)">
<span>
<i class="fa fa-trash me-1" aria-hidden="true"></i>
</span>
<span class="d-none d-md-inline-block">{{t('remove')}}</span>
</button>
}

View file

@ -9,6 +9,7 @@ import {ImageComponent} from '../../../shared/image/image.component';
import {TranslocoDirective} from "@jsverse/transloco";
import {SeriesFormatComponent} from "../../../shared/series-format/series-format.component";
import {ReadMoreComponent} from "../../../shared/read-more/read-more.component";
import {ItemRemoveEvent} from "../draggable-ordered-list/draggable-ordered-list.component";
@Component({
selector: 'app-reading-list-item',
@ -33,9 +34,16 @@ export class ReadingListItemComponent {
@Input() promoted: boolean = false;
@Output() read: EventEmitter<ReadingListItem> = new EventEmitter();
@Output() remove: EventEmitter<ReadingListItem> = new EventEmitter();
@Output() remove: EventEmitter<ItemRemoveEvent> = new EventEmitter();
readChapter(item: ReadingListItem) {
this.read.emit(item);
}
removeItem(item: ReadingListItem) {
this.remove.emit({
item: item,
position: item.order
});
}
}

View file

@ -61,7 +61,8 @@ export class ExternalRatingComponent implements OnInit {
ngOnInit() {
this.reviewService.overallRating(this.seriesId, this.chapterId).subscribe(r => {
this.overallRating = r.averageScore;
});
this.cdRef.markForCheck();
});
}
updateRating(rating: number) {
@ -92,6 +93,4 @@ export class ExternalRatingComponent implements OnInit {
return '';
}
protected readonly RatingAuthority = RatingAuthority;
}

View file

@ -127,6 +127,16 @@
</app-setting-item>
</div>
<div class="row g-0 mt-4 mb-4">
<app-setting-switch [title]="t('enable-metadata-label')" [subtitle]="t('enable-metadata-tooltip')">
<ng-template #switch>
<div class="form-check form-switch float-end">
<input type="checkbox" id="enable-metadata" role="switch" formControlName="enableMetadata" class="form-check-input">
</div>
</ng-template>
</app-setting-switch>
</div>
<div class="row g-0 mt-4 mb-4">
<app-setting-switch [title]="t('manage-collection-label')" [subtitle]="t('manage-collection-tooltip')">
<ng-template #switch>

View file

@ -105,15 +105,16 @@ export class LibrarySettingsModalComponent implements OnInit {
libraryForm: FormGroup = new FormGroup({
name: new FormControl<string>('', { nonNullable: true, validators: [Validators.required] }),
type: new FormControl<LibraryType>(LibraryType.Manga, { nonNullable: true, validators: [Validators.required] }),
folderWatching: new FormControl<boolean>(true, { nonNullable: true, validators: [Validators.required] }),
includeInDashboard: new FormControl<boolean>(true, { nonNullable: true, validators: [Validators.required] }),
includeInRecommended: new FormControl<boolean>(true, { nonNullable: true, validators: [Validators.required] }),
includeInSearch: new FormControl<boolean>(true, { nonNullable: true, validators: [Validators.required] }),
manageCollections: new FormControl<boolean>(false, { nonNullable: true, validators: [Validators.required] }),
manageReadingLists: new FormControl<boolean>(false, { nonNullable: true, validators: [Validators.required] }),
allowScrobbling: new FormControl<boolean>(true, { nonNullable: true, validators: [Validators.required] }),
allowMetadataMatching: new FormControl<boolean>(true, { nonNullable: true, validators: [Validators.required] }),
collapseSeriesRelationships: new FormControl<boolean>(false, { nonNullable: true, validators: [Validators.required] }),
folderWatching: new FormControl<boolean>(true, { nonNullable: true, validators: [] }),
includeInDashboard: new FormControl<boolean>(true, { nonNullable: true, validators: [] }),
includeInRecommended: new FormControl<boolean>(true, { nonNullable: true, validators: [] }),
includeInSearch: new FormControl<boolean>(true, { nonNullable: true, validators: [] }),
manageCollections: new FormControl<boolean>(false, { nonNullable: true, validators: [] }),
manageReadingLists: new FormControl<boolean>(false, { nonNullable: true, validators: [] }),
allowScrobbling: new FormControl<boolean>(true, { nonNullable: true, validators: [] }),
allowMetadataMatching: new FormControl<boolean>(true, { nonNullable: true, validators: [] }),
collapseSeriesRelationships: new FormControl<boolean>(false, { nonNullable: true, validators: [] }),
enableMetadata: new FormControl<boolean>(true, { nonNullable: true, validators: [] }), // required validator doesn't check value, just if true
});
selectedFolders: string[] = [];
@ -155,7 +156,7 @@ export class LibrarySettingsModalComponent implements OnInit {
this.libraryForm.get('allowScrobbling')?.disable();
if (this.IsMetadataDownloadEligible) {
this.libraryForm.get('allowMetadataMatching')?.setValue(this.library.allowMetadataMatching);
this.libraryForm.get('allowMetadataMatching')?.setValue(this.library.allowMetadataMatching ?? true);
this.libraryForm.get('allowMetadataMatching')?.enable();
} else {
this.libraryForm.get('allowMetadataMatching')?.setValue(false);
@ -184,6 +185,20 @@ export class LibrarySettingsModalComponent implements OnInit {
this.setValues();
// Turn on/off manage collections/rl
this.libraryForm.get('enableMetadata')?.valueChanges.pipe(
tap(enabled => {
const manageCollectionsFc = this.libraryForm.get('manageCollections');
const manageReadingListsFc = this.libraryForm.get('manageReadingLists');
manageCollectionsFc?.setValue(enabled);
manageReadingListsFc?.setValue(enabled);
this.cdRef.markForCheck();
}),
takeUntilDestroyed(this.destroyRef)
).subscribe();
// This needs to only apply after first render
this.libraryForm.get('type')?.valueChanges.pipe(
tap((type: LibraryType) => {
@ -257,6 +272,8 @@ export class LibrarySettingsModalComponent implements OnInit {
this.libraryForm.get('collapseSeriesRelationships')?.setValue(this.library.collapseSeriesRelationships);
this.libraryForm.get('allowScrobbling')?.setValue(this.IsKavitaPlusEligible ? this.library.allowScrobbling : false);
this.libraryForm.get('allowMetadataMatching')?.setValue(this.IsMetadataDownloadEligible ? this.library.allowMetadataMatching : false);
this.libraryForm.get('excludePatterns')?.setValue(this.excludePatterns ? this.library.excludePatterns : false);
this.libraryForm.get('enableMetadata')?.setValue(this.library.enableMetadata, true);
this.selectedFolders = this.library.folders;
this.madeChanges = false;

View file

@ -1129,6 +1129,8 @@
"include-in-dashboard-tooltip": "Should series from the library be included on the Dashboard. This affects all streams, like On Deck, Recently Updated, Recently Added, or any custom additions.",
"include-in-search-label": "Include in Search",
"include-in-search-tooltip": "Should series and any derived information (genres, people, files) from the library be included in search results.",
"enable-metadata-label": "Enable Metadata (ComicInfo/Epub/PDF)",
"enable-metadata-tooltip": "Allow Kavita to read metadata files which override filename parsing.",
"force-scan": "Force Scan",
"force-scan-tooltip": "This will force a scan on the library, treating like a fresh scan",
"reset": "{{common.reset}}",
@ -2743,7 +2745,8 @@
"webtoon-override": "Switching to Webtoon mode due to images representing a webtoon.",
"scrobble-gen-init": "Enqueued a job to generate scrobble events from past reading history and ratings, syncing them with connected services.",
"series-bound-to-reading-profile": "Series bound to Reading Profile {{name}}",
"library-bound-to-reading-profile": "Library bound to Reading Profile {{name}}"
"library-bound-to-reading-profile": "Library bound to Reading Profile {{name}}",
"external-match-rate-error": "Kavita ran out of rate looking up {{seriesName}}. Try again in 5 minutes."
},
"read-time-pipe": {