More Metadata Stuff (#3537)

This commit is contained in:
Joe Milazzo 2025-02-08 15:37:12 -06:00 committed by GitHub
parent 8d3dcc637e
commit 53b13da0c9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
34 changed files with 4123 additions and 129 deletions

View file

@ -0,0 +1,16 @@
import {inject, Pipe, PipeTransform} from '@angular/core';
import {LibraryService} from "../_services/library.service";
import {Observable} from "rxjs";
@Pipe({
name: 'libraryName',
standalone: true
})
export class LibraryNamePipe implements PipeTransform {
private readonly libraryService = inject(LibraryService);
transform(libraryId: number): Observable<string> {
return this.libraryService.getLibraryName(libraryId);
}
}

View file

@ -0,0 +1,35 @@
import { Pipe, PipeTransform } from '@angular/core';
import {MetadataSettingField} from "../admin/_models/metadata-setting-field";
import {translate} from "@jsverse/transloco";
@Pipe({
name: 'metadataSettingFiled',
standalone: true
})
export class MetadataSettingFiledPipe implements PipeTransform {
transform(value: MetadataSettingField): string {
switch (value) {
case MetadataSettingField.AgeRating:
return translate('metadata-setting-field-pipe.age-rating');
case MetadataSettingField.People:
return translate('metadata-setting-field-pipe.people');
case MetadataSettingField.Covers:
return translate('metadata-setting-field-pipe.covers');
case MetadataSettingField.Summary:
return translate('metadata-setting-field-pipe.summary');
case MetadataSettingField.PublicationStatus:
return translate('metadata-setting-field-pipe.publication-status');
case MetadataSettingField.StartDate:
return translate('metadata-setting-field-pipe.start-date');
case MetadataSettingField.Genres:
return translate('metadata-setting-field-pipe.genres');
case MetadataSettingField.Tags:
return translate('metadata-setting-field-pipe.tags');
case MetadataSettingField.LocalizedName:
return translate('metadata-setting-field-pipe.localized-name');
}
}
}

View file

@ -0,0 +1,16 @@
export enum MetadataSettingField {
Summary = 1,
PublicationStatus = 2,
StartDate = 3,
Genres = 4,
Tags = 5,
LocalizedName = 6,
Covers = 7,
AgeRating = 8,
People = 9
}
export const allMetadataSettingField = Object.keys(MetadataSettingField)
.filter(key => !isNaN(Number(key)) && parseInt(key, 10) >= 0)
.map(key => parseInt(key, 10)) as MetadataSettingField[];

View file

@ -1,5 +1,6 @@
import {AgeRating} from "../../_models/metadata/age-rating";
import {PersonRole} from "../../_models/metadata/person";
import {MetadataSettingField} from "./metadata-setting-field";
export enum MetadataFieldType {
Genre = 0,
@ -22,6 +23,7 @@ export interface MetadataSettings {
enableRelationships: boolean;
enablePeople: boolean;
enableStartDate: boolean;
enableCoverImage: boolean;
enableLocalizedName: boolean;
enableGenres: boolean;
enableTags: boolean;
@ -31,4 +33,5 @@ export interface MetadataSettings {
blacklist: Array<string>;
whitelist: Array<string>;
personRoles: Array<PersonRole>;
overrides: Array<MetadataSettingField>;
}

View file

@ -24,7 +24,7 @@
[footerHeight]="50"
>
<ngx-datatable-column name="lastModifiedUtc" [sortable]="false" [draggable]="false" [resizeable]="false" [flexGrow]="3">
<ngx-datatable-column name="series.name" [sortable]="false" [draggable]="false" [resizeable]="false" [flexGrow]="3">
<ng-template let-column="column" ngx-datatable-header-template>
{{t('series-name-header')}}
</ng-template>
@ -34,6 +34,15 @@
</ng-template>
</ngx-datatable-column>
<ngx-datatable-column name="series.libraryId" [sortable]="false" [draggable]="false" [resizeable]="false" [flexGrow]="3">
<ng-template let-column="column" ngx-datatable-header-template>
{{t('library-name-header')}}
</ng-template>
<ng-template let-item="row" ngx-datatable-cell-template>
{{item.series.libraryId | libraryName | async}}
</ng-template>
</ngx-datatable-column>
<ngx-datatable-column name="status" [sortable]="false" [draggable]="false" [resizeable]="false" [flexGrow]="1">
<ng-template let-column="column" ngx-datatable-header-template>
@ -70,9 +79,13 @@
<ngx-datatable-column name="" [width]="20" [sortable]="false" [draggable]="false" [resizeable]="false" [flexGrow]="1">
<ng-template let-column="column" ngx-datatable-header-template>
{{t('actions-header')}}
</ng-template>
<ng-template let-item="row" let-idx="index" ngx-datatable-cell-template>
<app-card-actionables [actions]="actions" (actionHandler)="performAction($event, item.series)"></app-card-actionables>
<button class="btn btn-icon" (click)="fixMatch(item.series)">
<i class="fa-solid fa-magnifying-glass" aria-hidden="true"></i>
<span class="visually-hidden">{{t('match-alt', {seriesName: item.series.name})}}</span>
</button>
</ng-template>
</ngx-datatable-column>

View file

@ -4,9 +4,7 @@ import {Router} from "@angular/router";
import {TranslocoDirective} from "@jsverse/transloco";
import {ImageComponent} from "../../shared/image/image.component";
import {ImageService} from "../../_services/image.service";
import {CardActionablesComponent} from "../../_single-module/card-actionables/card-actionables.component";
import {Series} from "../../_models/series";
import {Action, ActionFactoryService, ActionItem} from "../../_services/action-factory.service";
import {ActionService} from "../../_services/action.service";
import {ManageService} from "../../_services/manage.service";
import {ManageMatchSeries} from "../../_models/kavitaplus/manage-match-series";
@ -19,46 +17,47 @@ import {MatchStateOptionPipe} from "../../_pipes/match-state.pipe";
import {UtcToLocalTimePipe} from "../../_pipes/utc-to-local-time.pipe";
import {debounceTime, distinctUntilChanged, switchMap, tap} from "rxjs";
import {DefaultValuePipe} from "../../_pipes/default-value.pipe";
import {LooseLeafOrDefaultNumber, SpecialVolumeNumber} from "../../_models/chapter";
import {ScrobbleEventType} from "../../_models/scrobbling/scrobble-event";
import {ColumnMode, NgxDatatableModule} from "@siemens/ngx-datatable";
import {LibraryNamePipe} from "../../_pipes/library-name.pipe";
import {AsyncPipe} from "@angular/common";
import {EVENTS, MessageHubService} from "../../_services/message-hub.service";
import {ScanSeriesEvent} from "../../_models/events/scan-series-event";
@Component({
selector: 'app-manage-matched-metadata',
standalone: true,
imports: [
TranslocoDirective,
ImageComponent,
CardActionablesComponent,
VirtualScrollerModule,
ReactiveFormsModule,
Select2Module,
MatchStateOptionPipe,
UtcToLocalTimePipe,
DefaultValuePipe,
NgxDatatableModule,
],
imports: [
TranslocoDirective,
ImageComponent,
VirtualScrollerModule,
ReactiveFormsModule,
Select2Module,
MatchStateOptionPipe,
UtcToLocalTimePipe,
DefaultValuePipe,
NgxDatatableModule,
LibraryNamePipe,
AsyncPipe,
],
templateUrl: './manage-matched-metadata.component.html',
styleUrl: './manage-matched-metadata.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ManageMatchedMetadataComponent implements OnInit {
protected readonly MatchState = MatchStateOption;
protected readonly ColumnMode = ColumnMode;
protected readonly allMatchStates = allMatchStates.filter(m => m !== MatchStateOption.Matched); // Matched will have too many
private readonly licenseService = inject(LicenseService);
private readonly actionFactory = inject(ActionFactoryService);
private readonly actionService = inject(ActionService);
private readonly router = inject(Router);
private readonly manageService = inject(ManageService);
private readonly messageHub = inject(MessageHubService);
private readonly cdRef = inject(ChangeDetectorRef);
protected readonly imageService = inject(ImageService);
isLoading: boolean = true;
data: Array<ManageMatchSeries> = [];
actions: Array<ActionItem<Series>> = this.actionFactory.getSeriesActions(this.fixMatch.bind(this))
.filter(item => item.action === Action.Match);
filterGroup = new FormGroup({
'matchState': new FormControl(MatchStateOption.Error, []),
});
@ -71,6 +70,15 @@ export class ManageMatchedMetadataComponent implements OnInit {
return;
}
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();
}
});
this.filterGroup.valueChanges.pipe(
debounceTime(300),
distinctUntilChanged(),
@ -86,7 +94,6 @@ export class ManageMatchedMetadataComponent implements OnInit {
).subscribe();
this.loadData().subscribe();
});
}
@ -108,21 +115,11 @@ export class ManageMatchedMetadataComponent implements OnInit {
}));
}
performAction(action: ActionItem<Series>, series: Series) {
if (action.callback) {
action.callback(action, series);
}
}
fixMatch(actionItem: ActionItem<Series>, series: Series) {
fixMatch(series: Series) {
this.actionService.matchSeries(series, result => {
if (!result) return;
this.loadData().subscribe();
});
}
protected readonly LooseLeafOrDefaultNumber = LooseLeafOrDefaultNumber;
protected readonly ScrobbleEventType = ScrobbleEventType;
protected readonly SpecialVolumeNumber = SpecialVolumeNumber;
protected readonly ColumnMode = ColumnMode;
}

View file

@ -29,6 +29,18 @@
}
</div>
<div class="row g-0 mt-4 mb-4">
@if(settingsForm.get('enableLocalizedName'); as formControl) {
<app-setting-switch [title]="t('localized-name-label')" [subtitle]="t('localized-name-tooltip')">
<ng-template #switch>
<div class="form-check form-switch float-end">
<input id="localized-name" type="checkbox" class="form-check-input" formControlName="enableLocalizedName">
</div>
</ng-template>
</app-setting-switch>
}
</div>
<div class="row g-0 mt-4 mb-4">
@if(settingsForm.get('enablePublicationStatus'); as formControl) {
<app-setting-switch [title]="t('derive-publication-status-label')" [subtitle]="t('derive-publication-status-tooltip')">
@ -65,6 +77,18 @@
}
</div>
<div class="row g-0 mt-4 mb-4">
@if(settingsForm.get('enableCoverImage'); as formControl) {
<app-setting-switch [title]="t('enable-cover-image-label')" [subtitle]="t('enable-cover-image-tooltip')">
<ng-template #switch>
<div class="form-check form-switch float-end">
<input id="enable-cover-image" type="checkbox" class="form-check-input" formControlName="enableCoverImage">
</div>
</ng-template>
</app-setting-switch>
}
</div>
@if(settingsForm.get('enablePeople'); as formControl) {
<div class="setting-section-break"></div>
@ -183,13 +207,13 @@
<div formArrayName="ageRatingMappings">
@for(mapping of ageRatingMappings.controls; track mapping; let i = $index) {
<div [formGroupName]="i" class="row mb-2">
<div class="col-md-4">
<div class="col-md-4 d-flex align-items-center justify-content-center">
<input type="text" class="form-control" formControlName="str" autocomplete="off" />
</div>
<div class="col-md-2">
<div class="col-md-2 d-flex align-items-center justify-content-center">
<i class="fa fa-arrow-right" aria-hidden="true"></i>
</div>
<div class="col-md-4">
<div class="col-md-4 d-flex align-items-center justify-content-center">
<select class="form-select" formControlName="rating">
@for (ageRating of ageRatings; track ageRating.value) {
<option [value]="ageRating.value">
@ -202,13 +226,21 @@
<button class="btn btn-icon" (click)="removeAgeRatingMappingRow(i)">
<i class="fa fa-trash-alt" aria-hidden="true"></i>
</button>
@if($last) {
<button class="btn btn-icon" (click)="addAgeRatingMapping()">
<i class="fa fa-plus" aria-hidden="true"></i>
</button>
}
</div>
</div>
} @empty {
<button class="btn btn-secondary" (click)="addAgeRatingMapping()">
<i class="fa fa-plus" aria-hidden="true"></i> {{t('add-age-rating-mapping-label')}}
</button>
}
<button class="btn btn-secondary" (click)="addAgeRatingMapping()">
<i class="fa fa-plus" aria-hidden="true"></i> {{t('add-age-rating-mapping-label')}}
</button>
</div>
<div class="setting-section-break"></div>
@ -252,15 +284,39 @@
<button class="btn btn-icon" (click)="removeFieldMappingRow(i)">
<i class="fa fa-trash-alt" aria-hidden="true"></i>
</button>
@if ($last) {
<button class="btn btn-icon" (click)="addFieldMapping()">
<i class="fa fa-plus" aria-hidden="true"></i>
</button>
}
</div>
</div>
} @empty {
<button class="btn btn-secondary" (click)="addFieldMapping()">
<i class="fa fa-plus" aria-hidden="true"></i> {{t('add-field-mapping-label')}}
</button>
}
<button class="btn btn-secondary float-end" (click)="addFieldMapping()">
<i class="fa fa-plus" aria-hidden="true"></i> {{t('add-field-mapping-label')}}
</button>
</div>
<div class="setting-section-break"></div>
@if (settingsForm.get('overrides')) {
<h5>{{t('overrides-label')}}</h5>
<p>{{t('overrides-description')}}</p>
<div class="row g-0 mt-4 mb-4" formArrayName="overrides">
@for(field of allMetadataSettingFields; track field; let i = $index) {
<div class="col-md-3">
<div class="form-check">
<input type="checkbox" class="form-check-input" [formControlName]="'override_' + i" [id]="'override-' + field">
<label class="form-check-label" [for]="'override-' + field">{{ field | metadataSettingFiled }}</label>
</div>
</div>
}
</div>
}
</form>
}
</ng-container>

View file

@ -17,6 +17,8 @@ import {MetadataFieldMapping, MetadataFieldType} from "../_models/metadata-setti
import {PersonRole} from "../../_models/metadata/person";
import {PersonRolePipe} from "../../_pipes/person-role.pipe";
import {NgClass} from "@angular/common";
import {allMetadataSettingField} from "../_models/metadata-setting-field";
import {MetadataSettingFiledPipe} from "../../_pipes/metadata-setting-filed.pipe";
@Component({
@ -31,6 +33,7 @@ import {NgClass} from "@angular/common";
TagBadgeComponent,
AgeRatingPipe,
PersonRolePipe,
MetadataSettingFiledPipe,
],
templateUrl: './manage-metadata-settings.component.html',
styleUrl: './manage-metadata-settings.component.scss',
@ -52,6 +55,7 @@ export class ManageMetadataSettingsComponent implements OnInit {
fieldMappings = this.fb.array([]);
personRoles: PersonRole[] = [PersonRole.Writer, PersonRole.CoverArtist, PersonRole.Character];
isLoaded = false;
allMetadataSettingFields = allMetadataSettingField;
ngOnInit(): void {
this.metadataService.getAllAgeRatings().subscribe(ratings => {
@ -66,6 +70,7 @@ export class ManageMetadataSettingsComponent implements OnInit {
this.settingService.getMetadataSettings().subscribe(settings => {
this.settingsForm.addControl('enabled', new FormControl(settings.enabled, []));
this.settingsForm.addControl('enableSummary', new FormControl(settings.enableSummary, []));
this.settingsForm.addControl('enableLocalizedName', new FormControl(settings.enableLocalizedName, []));
this.settingsForm.addControl('enablePublicationStatus', new FormControl(settings.enablePublicationStatus, []));
this.settingsForm.addControl('enableRelations', new FormControl(settings.enableRelationships, []));
this.settingsForm.addControl('enableGenres', new FormControl(settings.enableGenres, []));
@ -73,6 +78,7 @@ export class ManageMetadataSettingsComponent implements OnInit {
this.settingsForm.addControl('enableRelationships', new FormControl(settings.enableRelationships, []));
this.settingsForm.addControl('enablePeople', new FormControl(settings.enablePeople, []));
this.settingsForm.addControl('enableStartDate', new FormControl(settings.enableStartDate, []));
this.settingsForm.addControl('enableCoverImage', new FormControl(settings.enableCoverImage, []));
this.settingsForm.addControl('blacklist', new FormControl((settings.blacklist || '').join(','), []));
this.settingsForm.addControl('whitelist', new FormControl((settings.whitelist || '').join(','), []));
@ -86,6 +92,15 @@ export class ManageMetadataSettingsComponent implements OnInit {
)
));
this.settingsForm.addControl('overrides', this.fb.group(
Object.fromEntries(
this.allMetadataSettingFields.map((role, index) => [
`override_${index}`,
this.fb.control((settings.overrides || []).includes(role)),
])
)
));
if (settings.ageRatingMappings) {
Object.entries(settings.ageRatingMappings).forEach(([str, rating]) => {
@ -171,7 +186,10 @@ export class ManageMetadataSettingsComponent implements OnInit {
whitelist: (model.whitelist || '').split(',').map((item: string) => item.trim()),
personRoles: Object.entries(this.settingsForm.get('personRoles')!.value)
.filter(([_, value]) => value)
.map(([key, _]) => this.personRoles[parseInt(key.split('_')[1], 10)])
.map(([key, _]) => this.personRoles[parseInt(key.split('_')[1], 10)]),
overrides: Object.entries(this.settingsForm.get('overrides')!.value)
.filter(([_, value]) => value)
.map(([key, _]) => this.allMetadataSettingFields[parseInt(key.split('_')[1], 10)])
}
}