Metadata Downloading (#3525)

This commit is contained in:
Joe Milazzo 2025-02-05 16:16:44 -06:00 committed by GitHub
parent eb66763078
commit f4fd7230ea
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
108 changed files with 6296 additions and 484 deletions

View file

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

View file

@ -52,6 +52,10 @@ export interface Preferences {
collapseSeriesRelationships: boolean;
shareReviews: boolean;
locale: string;
// Kavita+
aniListScrobblingEnabled: boolean;
wantToReadSync: boolean;
}
export const readingDirections = [{text: 'left-to-right', value: ReadingDirection.LeftToRight}, {text: 'right-to-left', value: ReadingDirection.RightToLeft}];

View file

@ -242,7 +242,7 @@ export class SeriesService {
}
updateMatch(seriesId: number, series: ExternalSeriesDetail) {
return this.httpClient.post<string>(this.baseUrl + 'series/update-match?seriesId=' + seriesId, series, TextResonse);
return this.httpClient.post<string>(this.baseUrl + 'series/update-match?seriesId=' + seriesId + '&aniListId=' + series.aniListId, {}, TextResonse);
}
updateDontMatch(seriesId: number, dontMatch: boolean) {

View file

@ -1,4 +1,5 @@
<ng-container *transloco="let t; read:'match-series-result-item'">
<div class="d-flex p-1 clickable" (click)="selectItem()">
<div style="width: 32px" class="me-1">
@if (item.series.coverUrl) {
@ -11,7 +12,7 @@
@for(synm of item.series.synonyms; track synm; let last = $last) {
{{synm}}
@if (!last) {
<span>, </span>
<span>, </span>
}
}
</div>
@ -23,16 +24,27 @@
</div>
</div>
<div class="d-flex p-1 justify-content-between">
<span class="me-1"><a (click)="$event.stopPropagation()" [href]="item.series.siteUrl" rel="noreferrer noopener" target="_blank">{{t('details')}}</a></span>
@if ((item.series.volumeCount || 0) > 0 || (item.series.chapterCount || 0) > 0) {
<span class="me-1">{{t('volume-count', {num: item.series.volumeCount})}}</span>
<span class="me-1">{{t('chapter-count', {num: item.series.chapterCount})}}</span>
} @else {
<span class="me-1">{{t('releasing')}}</span>
}
@if (isSelected) {
<div class="d-flex p-1 justify-content-center">
<app-loading [absolute]="false" [loading]="true"></app-loading>
<span class="ms-2">{{t('updating-metadata-status')}}</span>
</div>
} @else {
<div class="d-flex p-1 justify-content-between">
<span class="me-1"><a (click)="$event.stopPropagation()" [href]="item.series.siteUrl" rel="noreferrer noopener" target="_blank">{{t('details')}}</a></span>
@if ((item.series.volumeCount || 0) > 0 || (item.series.chapterCount || 0) > 0) {
<span class="me-1">{{t('volume-count', {num: item.series.volumeCount})}}</span>
<span class="me-1">{{t('chapter-count', {num: item.series.chapterCount})}}</span>
} @else {
<span class="me-1">{{t('releasing')}}</span>
}
<span class="me-1">{{item.series.plusMediaFormat | plusMediaFormat}}</span>
<span class="me-1">({{item.matchRating | translocoPercent}})</span>
</div>
}
<span class="me-1">{{item.series.plusMediaFormat | plusMediaFormat}}</span>
<span class="me-1">({{item.matchRating | translocoPercent}})</span>
</div>
</ng-container>

View file

@ -15,6 +15,7 @@ import {TranslocoPercentPipe} from "@jsverse/transloco-locale";
import {ReadMoreComponent} from "../../shared/read-more/read-more.component";
import {TranslocoDirective} from "@jsverse/transloco";
import {PlusMediaFormatPipe} from "../../_pipes/plus-media-format.pipe";
import {LoadingComponent} from "../../shared/loading/loading.component";
@Component({
selector: 'app-match-series-result-item',
@ -24,7 +25,8 @@ import {PlusMediaFormatPipe} from "../../_pipes/plus-media-format.pipe";
TranslocoPercentPipe,
ReadMoreComponent,
TranslocoDirective,
PlusMediaFormatPipe
PlusMediaFormatPipe,
LoadingComponent
],
templateUrl: './match-series-result-item.component.html',
styleUrl: './match-series-result-item.component.scss',
@ -37,7 +39,13 @@ export class MatchSeriesResultItemComponent {
@Input({required: true}) item!: ExternalSeriesMatch;
@Output() selected: EventEmitter<ExternalSeriesMatch> = new EventEmitter();
isSelected = false;
selectItem() {
if (this.isSelected) return;
this.isSelected = true;
this.cdRef.markForCheck();
this.selected.emit(this.item);
}

View file

@ -8,6 +8,8 @@
@if (tokenExpired) {
<p class="alert alert-warning">{{t('token-expired')}}</p>
} @else if (!(accountService.currentUser$ | async)!.preferences.aniListScrobblingEnabled) {
<p class="alert alert-warning">{{t('scrobbling-disabled')}}</p>
}
<p>{{t('description')}}</p>

View file

@ -18,6 +18,8 @@ import {ToastrService} from "ngx-toastr";
import {LooseLeafOrDefaultNumber, SpecialVolumeNumber} from "../../_models/chapter";
import {ColumnMode, NgxDatatableModule} from "@siemens/ngx-datatable";
import {CardActionablesComponent} from "../card-actionables/card-actionables.component";
import {AsyncPipe} from "@angular/common";
import {AccountService} from "../../_services/account.service";
export interface DataTablePage {
pageNumber: number,
@ -29,8 +31,8 @@ export interface DataTablePage {
@Component({
selector: 'app-user-scrobble-history',
standalone: true,
imports: [ScrobbleEventTypePipe, ReactiveFormsModule, TranslocoModule,
DefaultValuePipe, TranslocoLocaleModule, UtcToLocalTimePipe, NgbTooltip, NgxDatatableModule, CardActionablesComponent],
imports: [ScrobbleEventTypePipe, ReactiveFormsModule, TranslocoModule,
DefaultValuePipe, TranslocoLocaleModule, UtcToLocalTimePipe, NgbTooltip, NgxDatatableModule, CardActionablesComponent, AsyncPipe],
templateUrl: './user-scrobble-history.component.html',
styleUrls: ['./user-scrobble-history.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
@ -40,12 +42,14 @@ export class UserScrobbleHistoryComponent implements OnInit {
protected readonly SpecialVolumeNumber = SpecialVolumeNumber;
protected readonly LooseLeafOrDefaultNumber = LooseLeafOrDefaultNumber;
protected readonly ColumnMode = ColumnMode;
protected readonly ScrobbleEventType = ScrobbleEventType;
private readonly scrobblingService = inject(ScrobblingService);
private readonly cdRef = inject(ChangeDetectorRef);
private readonly destroyRef = inject(DestroyRef);
private readonly toastr = inject(ToastrService);
protected readonly ScrobbleEventType = ScrobbleEventType;
protected readonly accountService = inject(AccountService);
tokenExpired = false;

View file

@ -0,0 +1,34 @@
import {AgeRating} from "../../_models/metadata/age-rating";
import {PersonRole} from "../../_models/metadata/person";
export enum MetadataFieldType {
Genre = 0,
Tag = 1
}
export interface MetadataFieldMapping {
id: number;
sourceType: MetadataFieldType;
destinationType: MetadataFieldType;
sourceValue: string;
destinationValue: string;
excludeFromSource: boolean;
}
export interface MetadataSettings {
enabled: boolean;
enableSummary: boolean;
enablePublicationStatus: boolean;
enableRelationships: boolean;
enablePeople: boolean;
enableStartDate: boolean;
enableLocalizedName: boolean;
enableGenres: boolean;
enableTags: boolean;
firstLastPeopleNaming: boolean;
ageRatingMappings: Map<string, AgeRating>;
fieldMappings: Array<MetadataFieldMapping>;
blacklist: Array<string>;
whitelist: Array<string>;
personRoles: Array<PersonRole>;
}

View file

@ -6,166 +6,165 @@
<div class="container-fluid">
<p>{{t('kavita+-desc-part-1')}} <a [href]="WikiLink.KavitaPlus" target="_blank" rel="noreferrer nofollow">{{t('kavita+-desc-part-2')}}</a> {{t('kavita+-desc-part-3')}}</p>
</div>
<form [formGroup]="formGroup">
<div class="mt-2">
<app-setting-item [title]="t('title')" (editMode)="updateEditMode($event)" [isEditMode]="!isViewMode" [showEdit]="hasLicense">
<ng-template #titleExtra>
<button class="btn btn-icon btn-sm" (click)="loadLicenseInfo(true)">
@if (isChecking) {
<app-loading [loading]="isChecking" size="spinner-border-sm"></app-loading>
} @else if (hasLicense) {
<span>
<form [formGroup]="formGroup">
<div class="mt-2">
<app-setting-item [title]="t('title')" (editMode)="updateEditMode($event)" [isEditMode]="!isViewMode" [showEdit]="hasLicense">
<ng-template #titleExtra>
<button class="btn btn-icon btn-sm" (click)="loadLicenseInfo(true)">
@if (isChecking) {
<app-loading [loading]="isChecking" size="spinner-border-sm"></app-loading>
} @else if (hasLicense) {
<span>
<i class="fa-solid fa-refresh" tabindex="0" [ngbTooltip]="t('check')"></i>
</span>
}
</button>
</ng-template>
<ng-template #view>
@if (hasLicense) {
<span class="me-1">*********</span>
}
</button>
</ng-template>
<ng-template #view>
@if (hasLicense) {
<span class="me-1">*********</span>
@if (isChecking) {
<div class="spinner-border spinner-border-sm text-primary" role="status">
<span class="visually-hidden">{{t('loading')}}</span>
</div>
} @else {
@if (licenseInfo?.isActive) {
<i [ngbTooltip]="t('license-valid')" class="fa-solid fa-check-circle successful-validation ms-1">
<span class="visually-hidden">{{t('license-valid')}}</span>
</i>
@if (isChecking) {
<div class="spinner-border spinner-border-sm text-primary" role="status">
<span class="visually-hidden">{{t('loading')}}</span>
</div>
} @else {
<i class="error fa-solid fa-exclamation-circle ms-1" [ngbTooltip]="t('license-not-valid')">
<span class="visually-hidden">{{t('license-not-valid')}}</span>
@if (licenseInfo?.isActive) {
<i [ngbTooltip]="t('license-valid')" class="fa-solid fa-check-circle successful-validation ms-1">
<span class="visually-hidden">{{t('license-valid')}}</span>
</i>
} @else {
<i class="error fa-solid fa-exclamation-circle ms-1" [ngbTooltip]="t('license-not-valid')">
<span class="visually-hidden">{{t('license-not-valid')}}</span>
</i>
}
}
@if (!isChecking && hasLicense && !licenseInfo) {
<div><span class="error">{{t('license-mismatch')}}</span></div>
}
} @else {
{{t('no-license-key')}}
}
</ng-template>
<ng-template #edit>
<div class="form-group mb-3">
<label for="license-key">{{t('activate-license-label')}}</label>
<input id="license-key" type="text" class="form-control" formControlName="licenseKey" autocomplete="off"/>
</div>
<div class="form-group mb-3">
<label for="email">{{t('activate-email-label')}}</label>
<input id="email" type="email" class="form-control" formControlName="email" autocomplete="off"/>
</div>
<div class="form-group mb-3">
<label for="discordId">{{t('activate-discordId-label')}}</label>
<i class="fa fa-circle-info ms-1" aria-hidden="true" [ngbTooltip]="t('activate-discordId-tooltip')"></i>
<a class="ms-1" [href]="WikiLink.KavitaPlusDiscordId" target="_blank" rel="noopener noreferrer">{{t('help-label')}}</a>
<input id="discordId" type="text" class="form-control" formControlName="discordId" autocomplete="off" [class.is-invalid]="formGroup.get('discordId')?.invalid && formGroup.get('discordId')?.touched"/>
@if (formGroup.dirty || !formGroup.untouched) {
<div id="inviteForm-validations" class="invalid-feedback">
@if (formGroup.get('discordId')?.errors?.pattern) {
<div>
{{t('discord-validation')}}
</div>
}
</div>
}
</div>
<div class="col-auto d-flex d-md-block justify-content-sm-center text-md-end mb-3">
<!-- <button type="button" class="flex-fill btn btn-danger me-1" aria-describedby="license-key-header"-->
<!-- (click)="deleteLicense()">-->
<!-- {{t('activate-delete')}}-->
<!-- </button>-->
<button type="button" class="flex-fill btn btn-danger me-1" aria-describedby="license-key-header"
[ngbTooltip]="t('activate-reset-tooltip')"
[disabled]="!formGroup.get('email')?.value || !formGroup.get('licenseKey')?.value" (click)="resetLicense()">
{{t('activate-reset')}}
</button>
<button type="submit" class="flex-fill btn btn-primary" aria-describedby="license-key-header"
[disabled]="!formGroup.get('email')?.value || !formGroup.get('licenseKey')?.value" (click)="saveForm()">
@if (!isSaving) {
<span>{{t('activate-save')}}</span>
}
<app-loading [loading]="isSaving" size="spinner-border-sm"></app-loading>
</button>
</div>
</ng-template>
<ng-template #titleActions>
@if (hasLicense) {
@if (licenseInfo?.isActive) {
<a class="btn btn-primary-outline btn-sm me-1" [href]="manageLink" target="_blank" rel="noreferrer nofollow">{{t('manage')}}</a>
} @else {
<a class="btn btn-primary-outline btn-sm me-1"
[ngbTooltip]="t('invalid-license-tooltip')"
href="mailto:kavitareader@gmail.com?subject=Kavita+Subscription+Renewal&body=Description%3A%0D%0A%0D%0ALicense%20Key%3A%0D%0A%0D%0AYour%20Email%3A"
>{{t('renew')}}</a>
}
} @else {
<a class="btn btn-secondary btn-sm me-1" [href]="buyLink" target="_blank" rel="noreferrer nofollow">{{t('buy')}}</a>
<button class="btn btn-primary btn-sm" (click)="toggleViewMode()">{{isViewMode ? t('activate') : t('cancel')}}</button>
}
</ng-template>
</app-setting-item>
</div>
</form>
@if (hasLicense && licenseInfo) {
<div class="setting-section-break"></div>
<div class="row g-0 mt-3">
<h3>{{t('info-title')}}</h3>
<div class="mb-2 col-md-6 col-sm-12">
<app-setting-item [canEdit]="false" [showEdit]="false" [title]="t('license-active-label')">
<ng-template #view>
@if (isChecking) {
{{null | defaultValue}}
} @else {
<i class="fas {{licenseInfo.isActive ? 'fa-check-circle' : 'fa-circle-xmark error'}}">
<span class="visually-hidden">{{licenseInfo.isActive ? t('valid') : t('invalid')}]</span>
</i>
}
}
@if (!isChecking && hasLicense && !licenseInfo) {
<div><span class="error">{{t('license-mismatch')}}</span></div>
}
</ng-template>
</app-setting-item>
</div>
} @else {
{{t('no-license-key')}}
}
</ng-template>
<ng-template #edit>
<div class="form-group mb-3">
<label for="license-key">{{t('activate-license-label')}}</label>
<input id="license-key" type="text" class="form-control" formControlName="licenseKey" autocomplete="off"/>
</div>
<div class="form-group mb-3">
<label for="email">{{t('activate-email-label')}}</label>
<input id="email" type="email" class="form-control" formControlName="email" autocomplete="off"/>
</div>
<div class="form-group mb-3">
<label for="discordId">{{t('activate-discordId-label')}}</label>
<i class="fa fa-circle-info ms-1" aria-hidden="true" [ngbTooltip]="t('activate-discordId-tooltip')"></i>
<a class="ms-1" [href]="WikiLink.KavitaPlusDiscordId" target="_blank" rel="noopener noreferrer">{{t('help-label')}}</a>
<input id="discordId" type="text" class="form-control" formControlName="discordId" autocomplete="off" [class.is-invalid]="formGroup.get('discordId')?.invalid && formGroup.get('discordId')?.touched"/>
@if (formGroup.dirty || !formGroup.untouched) {
<div id="inviteForm-validations" class="invalid-feedback">
@if (formGroup.get('discordId')?.errors?.pattern) {
<div>
{{t('discord-validation')}}
</div>
}
</div>
}
</div>
<div class="col-auto d-flex d-md-block justify-content-sm-center text-md-end mb-3">
<!-- <button type="button" class="flex-fill btn btn-danger me-1" aria-describedby="license-key-header"-->
<!-- (click)="deleteLicense()">-->
<!-- {{t('activate-delete')}}-->
<!-- </button>-->
<button type="button" class="flex-fill btn btn-danger me-1" aria-describedby="license-key-header"
[ngbTooltip]="t('activate-reset-tooltip')"
[disabled]="!formGroup.get('email')?.value || !formGroup.get('licenseKey')?.value" (click)="resetLicense()">
{{t('activate-reset')}}
</button>
<button type="submit" class="flex-fill btn btn-primary" aria-describedby="license-key-header"
[disabled]="!formGroup.get('email')?.value || !formGroup.get('licenseKey')?.value" (click)="saveForm()">
@if (!isSaving) {
<span>{{t('activate-save')}}</span>
}
<app-loading [loading]="isSaving" size="spinner-border-sm"></app-loading>
</button>
</div>
</ng-template>
<ng-template #titleActions>
@if (hasLicense) {
@if (licenseInfo?.isActive) {
<a class="btn btn-primary-outline btn-sm me-1" [href]="manageLink" target="_blank" rel="noreferrer nofollow">{{t('manage')}}</a>
} @else {
<a class="btn btn-primary-outline btn-sm me-1"
[ngbTooltip]="t('invalid-license-tooltip')"
href="mailto:kavitareader@gmail.com?subject=Kavita+Subscription+Renewal&body=Description%3A%0D%0A%0D%0ALicense%20Key%3A%0D%0A%0D%0AYour%20Email%3A"
>{{t('renew')}}</a>
}
} @else {
<a class="btn btn-secondary btn-sm me-1" [href]="buyLink" target="_blank" rel="noreferrer nofollow">{{t('buy')}}</a>
<button class="btn btn-primary btn-sm" (click)="toggleViewMode()">{{isViewMode ? t('activate') : t('cancel')}}</button>
}
</ng-template>
</app-setting-item>
</div>
</form>
@if (hasLicense && licenseInfo) {
<div class="setting-section-break"></div>
<div class="row g-0 mt-3">
<h3 class="container-fluid">{{t('info-title')}}</h3>
<div class="mb-2 col-md-6 col-sm-12">
<app-setting-item [canEdit]="false" [showEdit]="false" [title]="t('license-active-label')">
<ng-template #view>
@if (isChecking) {
{{null | defaultValue}}
} @else {
<i class="fas {{licenseInfo.isActive ? 'fa-check-circle' : 'fa-circle-xmark error'}}">
<span class="visually-hidden">{{licenseInfo.isActive ? t('valid') : t('invalid')}]</span>
<div class="mb-2 col-md-6 col-sm-12">
<app-setting-item [canEdit]="false" [showEdit]="false" [title]="t('supported-version-label')">
<ng-template #view>
<i class="fas {{licenseInfo.isValidVersion ? 'fa-check-circle' : 'fa-circle-xmark error'}}">
<span class="visually-hidden">{{isValidVersion ? t('valid') : t('invalid')}]</span>
</i>
}
</ng-template>
</app-setting-item>
</div>
</ng-template>
</app-setting-item>
</div>
<div class="mb-2 col-md-6 col-sm-12">
<app-setting-item [canEdit]="false" [showEdit]="false" [title]="t('supported-version-label')">
<ng-template #view>
<i class="fas {{licenseInfo.isValidVersion ? 'fa-check-circle' : 'fa-circle-xmark error'}}">
<span class="visually-hidden">{{isValidVersion ? t('valid') : t('invalid')}]</span>
</i>
</ng-template>
</app-setting-item>
</div>
<div class="mb-2 col-md-6 col-sm-12">
<app-setting-item [canEdit]="false" [showEdit]="false" [title]="t('expiration-label')">
<ng-template #view>
{{licenseInfo.expirationDate | utcToLocalTime | defaultValue}}
</ng-template>
</app-setting-item>
</div>
<div class="mb-2 col-md-6 col-sm-12">
<app-setting-item [canEdit]="false" [showEdit]="false" [title]="t('expiration-label')">
<ng-template #view>
{{licenseInfo.expirationDate | utcToLocalTime | defaultValue}}
</ng-template>
</app-setting-item>
</div>
<div class="mb-2 col-md-6 col-sm-12">
<app-setting-item [canEdit]="false" [showEdit]="false" [title]="t('total-subbed-months-label')">
<ng-template #view>
{{licenseInfo.totalMonthsSubbed | number}}
</ng-template>
</app-setting-item>
</div>
<div class="mb-2 col-md-6 col-sm-12">
<app-setting-item [canEdit]="false" [showEdit]="false" [title]="t('total-subbed-months-label')">
<ng-template #view>
{{licenseInfo.totalMonthsSubbed | number}}
</ng-template>
</app-setting-item>
</div>
<div class="col-md-6 col-sm-12">
<app-setting-item [canEdit]="false" [showEdit]="false" [title]="t('email-label')">
<ng-template #view>
<div class="col-md-6 col-sm-12">
<app-setting-item [canEdit]="false" [showEdit]="false" [title]="t('email-label')">
<ng-template #view>
<span (click)="toggleEmailShow()" class="col-12 clickable">
@if (showEmail) {
{{licenseInfo.registeredEmail}}
@ -173,28 +172,31 @@
***************
}
</span>
</ng-template>
</app-setting-item>
</ng-template>
</app-setting-item>
</div>
<div class="setting-section-break"></div>
<!-- Actions around license -->
<h3>{{t('actions-title')}}</h3>
<div class="mt-2 mb-2">
<app-setting-button [subtitle]="t('delete-tooltip')">
<button type="button" class="flex-fill btn btn-danger mt-1" aria-describedby="license-key-header" (click)="deleteLicense()">
{{t('activate-delete')}}
</button>
</app-setting-button>
</div>
<div class="mt-2 mb-2">
<app-setting-button [subtitle]="t('manage-tooltip')">
<a class="btn btn-primary btn-sm mt-1" [href]="manageLink" target="_blank" rel="noreferrer nofollow">{{t('manage')}}</a>
</app-setting-button>
</div>
</div>
}
</div>
<div class="setting-section-break"></div>
<!-- Actions around license -->
<h3 class="container-fluid">{{t('actions-title')}}</h3>
<div class="mt-2 mb-2">
<app-setting-button [subtitle]="t('delete-tooltip')">
<button type="button" class="flex-fill btn btn-danger mt-1" aria-describedby="license-key-header" (click)="deleteLicense()">
{{t('activate-delete')}}
</button>
</app-setting-button>
</div>
<div class="mt-2 mb-2">
<app-setting-button [subtitle]="t('manage-tooltip')">
<a class="btn btn-primary btn-sm mt-1" [href]="manageLink" target="_blank" rel="noreferrer nofollow">{{t('manage')}}</a>
</app-setting-button>
</div>
</div>
}
</ng-container>

View file

@ -0,0 +1,266 @@
<ng-container *transloco="let t; read:'manage-metadata-settings'">
<p>{{t('description')}}</p>
@if (isLoaded) {
<form [formGroup]="settingsForm">
<div class="row g-0 mt-4 mb-4">
@if(settingsForm.get('enabled'); as formControl) {
<app-setting-switch [title]="t('enabled-label')" [subtitle]="t('enabled-tooltip')">
<ng-template #switch>
<div class="form-check form-switch float-end">
<input id="enabled" type="checkbox" class="form-check-input" formControlName="enabled">
</div>
</ng-template>
</app-setting-switch>
}
</div>
<div class="row g-0 mt-4 mb-4">
@if(settingsForm.get('enableSummary'); as formControl) {
<app-setting-switch [title]="t('summary-label')" [subtitle]="t('summary-tooltip')">
<ng-template #switch>
<div class="form-check form-switch float-end">
<input id="summary" type="checkbox" class="form-check-input" formControlName="enableSummary">
</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')">
<ng-template #switch>
<div class="form-check form-switch float-end">
<input id="derive-publication-status" type="checkbox" class="form-check-input" formControlName="enablePublicationStatus">
</div>
</ng-template>
</app-setting-switch>
}
</div>
<div class="row g-0 mt-4 mb-4">
@if(settingsForm.get('enableRelationships'); as formControl) {
<app-setting-switch [title]="t('enable-relations-label')" [subtitle]="t('enable-relations-tooltip')">
<ng-template #switch>
<div class="form-check form-switch float-end">
<input id="enable-relations-status" type="checkbox" class="form-check-input" formControlName="enableRelationships">
</div>
</ng-template>
</app-setting-switch>
}
</div>
<div class="row g-0 mt-4 mb-4">
@if(settingsForm.get('enableStartDate'); as formControl) {
<app-setting-switch [title]="t('enable-start-date-label')" [subtitle]="t('enable-start-date-tooltip')">
<ng-template #switch>
<div class="form-check form-switch float-end">
<input id="enable-start-date-status" type="checkbox" class="form-check-input" formControlName="enableStartDate">
</div>
</ng-template>
</app-setting-switch>
}
</div>
@if(settingsForm.get('enablePeople'); as formControl) {
<div class="setting-section-break"></div>
<div class="row g-0 mt-4 mb-4">
<div class="col-6">
<app-setting-switch [title]="t('enable-people-label')" [subtitle]="t('enable-people-tooltip')">
<ng-template #switch>
<div class="form-check form-switch float-end">
<input id="enable-people-status" type="checkbox" class="form-check-input" formControlName="enablePeople">
</div>
</ng-template>
</app-setting-switch>
</div>
<div class="col-6">
<app-setting-switch [title]="t('first-last-name-label')" [subtitle]="t('first-last-name-tooltip')">
<ng-template #switch>
<div class="form-check form-switch float-end">
<input id="enable-first-last-name" type="checkbox" class="form-check-input" formControlName="firstLastPeopleNaming">
</div>
</ng-template>
</app-setting-switch>
</div>
</div>
@if (settingsForm.get('personRoles')) {
<h5>{{t('person-roles-label')}}</h5>
<div class="row g-0 mt-4 mb-4" formArrayName="personRoles">
@for(role of personRoles; track role; let i = $index) {
<div class="col-md-3">
<div class="form-check">
<input type="checkbox" class="form-check-input" [formControlName]="'personRole_' + i" [id]="'role-' + role">
<label class="form-check-label" [for]="'role-' + role">{{ role | personRole }}</label>
</div>
</div>
}
</div>
}
}
<div class="setting-section-break"></div>
<div class="row g-0 mt-4 mb-4">
<div class="col-md-6">
@if(settingsForm.get('enableGenres'); as formControl) {
<app-setting-switch [title]="t('enable-genres-label')" [subtitle]="t('enable-genres-tooltip')">
<ng-template #switch>
<div class="form-check form-switch float-end">
<input id="enable-genres-status" type="checkbox" class="form-check-input" formControlName="enableGenres">
</div>
</ng-template>
</app-setting-switch>
}
</div>
<div class="col-md-6">
@if(settingsForm.get('enableTags'); as formControl) {
<app-setting-switch [title]="t('enable-tags-label')" [subtitle]="t('enable-tags-tooltip')">
<ng-template #switch>
<div class="form-check form-switch float-end">
<input id="enable-tags-status" type="checkbox" class="form-check-input" formControlName="enableTags">
</div>
</ng-template>
</app-setting-switch>
}
</div>
</div>
<div class="row g-0 mt-4 mb-4">
@if(settingsForm.get('blacklist'); as formControl) {
<app-setting-item [title]="t('blacklist-label')" [subtitle]="t('blacklist-tooltip')">
<ng-template #view>
@let val = (formControl.value || '').split(',');
@for(opt of val; track opt) {
<app-tag-badge>{{opt.trim()}}</app-tag-badge>
} @empty {
{{null | defaultValue}}
}
</ng-template>s
<ng-template #edit>
<textarea rows="3" id="blacklist" class="form-control" formControlName="blacklist"></textarea>
</ng-template>
</app-setting-item>
}
</div>
<div class="row g-0 mt-4 mb-4">
@if(settingsForm.get('whitelist'); as formControl) {
<app-setting-item [title]="t('whitelist-label')" [subtitle]="t('whitelist-tooltip')">
<ng-template #view>
@let val = (formControl.value || '').split(',');
@for(opt of val; track opt) {
<app-tag-badge>{{opt.trim()}}</app-tag-badge>
} @empty {
{{null | defaultValue}}
}
</ng-template>s
<ng-template #edit>
<textarea rows="3" id="whitelist" class="form-control" formControlName="whitelist"></textarea>
</ng-template>
</app-setting-item>
}
</div>
<div class="setting-section-break"></div>
<h4>{{t('age-rating-mapping-title')}}</h4>
<p>{{t('age-rating-mapping-description')}}</p>
<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">
<input type="text" class="form-control" formControlName="str" autocomplete="off" />
</div>
<div class="col-md-2">
<i class="fa fa-arrow-right" aria-hidden="true"></i>
</div>
<div class="col-md-4">
<select class="form-select" formControlName="rating">
@for (ageRating of ageRatings; track ageRating.value) {
<option [value]="ageRating.value">
{{ageRating.value | ageRating}}
</option>
}
</select>
</div>
<div class="col-md-2">
<button class="btn btn-icon" (click)="removeAgeRatingMappingRow(i)">
<i class="fa fa-trash-alt" aria-hidden="true"></i>
</button>
</div>
</div>
}
<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>
<!-- Field Mapping Table -->
<h4>{{t('field-mapping-title')}}</h4>
<p>{{t('field-mapping-description')}}</p>
<div formArrayName="fieldMappings">
@for (mapping of fieldMappings.controls; track mapping; let i = $index) {
<div [formGroupName]="i" class="row mb-2">
<div class="col-md-2">
<select class="form-select" formControlName="sourceType">
<option [value]="MetadataFieldType.Genre">{{t('genre')}}</option>
<option [value]="MetadataFieldType.Tag">{{t('tag')}}</option>
</select>
</div>
<div class="col-md-2">
<input type="text" class="form-control" formControlName="sourceValue"
placeholder="Source genre/tag" />
</div>
<div class="col-md-2">
<select class="form-select" formControlName="destinationType">
<option [value]="MetadataFieldType.Genre">{{t('genre')}}</option>
<option [value]="MetadataFieldType.Tag">{{t('tag')}}</option>
</select>
</div>
<div class="col-md-2">
<input type="text" class="form-control" formControlName="destinationValue"
placeholder="Destination genre/tag" />
</div>
<div class="col-md-2">
<div class="form-check">
<input id="remove-source-tag-{{i}}" type="checkbox" class="form-check-input"
formControlName="excludeFromSource">
<label [for]="'remove-source-tag-' + i" class="form-check-label">
{{t('remove-source-tag-label')}}
</label>
</div>
</div>
<div class="col-md-2">
<button class="btn btn-icon" (click)="removeFieldMappingRow(i)">
<i class="fa fa-trash-alt" aria-hidden="true"></i>
</button>
</div>
</div>
}
<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>
</form>
}
</ng-container>

View file

@ -0,0 +1,219 @@
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, inject, OnInit} from '@angular/core';
import {TranslocoDirective} from "@jsverse/transloco";
import {FormArray, FormBuilder, FormControl, FormGroup, ReactiveFormsModule, Validators} from "@angular/forms";
import {SettingSwitchComponent} from "../../settings/_components/setting-switch/setting-switch.component";
import {SettingItemComponent} from "../../settings/_components/setting-item/setting-item.component";
import {DefaultValuePipe} from "../../_pipes/default-value.pipe";
import {TagBadgeComponent} from "../../shared/tag-badge/tag-badge.component";
import {SettingsService} from "../settings.service";
import {debounceTime, switchMap} from "rxjs";
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import {filter, map} from "rxjs/operators";
import {AgeRatingPipe} from "../../_pipes/age-rating.pipe";
import {AgeRating} from "../../_models/metadata/age-rating";
import {MetadataService} from "../../_services/metadata.service";
import {AgeRatingDto} from "../../_models/metadata/age-rating-dto";
import {MetadataFieldMapping, MetadataFieldType} from "../_models/metadata-settings";
import {PersonRole} from "../../_models/metadata/person";
import {PersonRolePipe} from "../../_pipes/person-role.pipe";
import {NgClass} from "@angular/common";
@Component({
selector: 'app-manage-metadata-settings',
standalone: true,
imports: [
TranslocoDirective,
ReactiveFormsModule,
SettingSwitchComponent,
SettingItemComponent,
DefaultValuePipe,
TagBadgeComponent,
AgeRatingPipe,
PersonRolePipe,
],
templateUrl: './manage-metadata-settings.component.html',
styleUrl: './manage-metadata-settings.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ManageMetadataSettingsComponent implements OnInit {
protected readonly MetadataFieldType = MetadataFieldType;
private readonly settingService = inject(SettingsService);
private readonly metadataService = inject(MetadataService);
private readonly cdRef = inject(ChangeDetectorRef);
private readonly destroyRef = inject(DestroyRef);
private readonly fb = inject(FormBuilder);
settingsForm: FormGroup = new FormGroup({});
ageRatings: Array<AgeRatingDto> = [];
ageRatingMappings = this.fb.array([]);
fieldMappings = this.fb.array([]);
personRoles: PersonRole[] = [PersonRole.Writer, PersonRole.CoverArtist, PersonRole.Character];
isLoaded = false;
ngOnInit(): void {
this.metadataService.getAllAgeRatings().subscribe(ratings => {
this.ageRatings = ratings;
this.cdRef.markForCheck();
});
this.settingsForm.addControl('ageRatingMappings', this.ageRatingMappings);
this.settingsForm.addControl('fieldMappings', this.fieldMappings);
this.settingService.getMetadataSettings().subscribe(settings => {
this.settingsForm.addControl('enabled', new FormControl(settings.enabled, []));
this.settingsForm.addControl('enableSummary', new FormControl(settings.enableSummary, []));
this.settingsForm.addControl('enablePublicationStatus', new FormControl(settings.enablePublicationStatus, []));
this.settingsForm.addControl('enableRelations', new FormControl(settings.enableRelationships, []));
this.settingsForm.addControl('enableGenres', new FormControl(settings.enableGenres, []));
this.settingsForm.addControl('enableTags', new FormControl(settings.enableTags, []));
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('blacklist', new FormControl((settings.blacklist || '').join(','), []));
this.settingsForm.addControl('whitelist', new FormControl((settings.whitelist || '').join(','), []));
this.settingsForm.addControl('firstLastPeopleNaming', new FormControl((settings.firstLastPeopleNaming), []));
this.settingsForm.addControl('personRoles', this.fb.group(
Object.fromEntries(
this.personRoles.map((role, index) => [
`personRole_${index}`,
this.fb.control((settings.personRoles || this.personRoles).includes(role)),
])
)
));
if (settings.ageRatingMappings) {
Object.entries(settings.ageRatingMappings).forEach(([str, rating]) => {
this.addAgeRatingMapping(str, rating);
});
}
if (settings.fieldMappings) {
settings.fieldMappings.forEach(mapping => {
this.addFieldMapping(mapping);
});
}
this.settingsForm.get('enablePeople')?.valueChanges.subscribe(enabled => {
const firstLastControl = this.settingsForm.get('firstLastPeopleNaming');
if (enabled) {
firstLastControl?.enable();
} else {
firstLastControl?.disable();
}
});
this.settingsForm.get('enablePeople')?.updateValueAndValidity();
// Disable personRoles checkboxes based on enablePeople state
this.settingsForm.get('enablePeople')?.valueChanges.subscribe(enabled => {
const personRolesArray = this.settingsForm.get('personRoles') as FormArray;
if (enabled) {
personRolesArray.enable();
} else {
personRolesArray.disable();
}
});
this.isLoaded = true;
this.cdRef.markForCheck();
this.settingsForm.valueChanges.pipe(
debounceTime(300),
takeUntilDestroyed(this.destroyRef),
map(_ => this.packData()),
switchMap((data) => this.settingService.updateMetadataSettings(data)),
).subscribe();
});
}
packData(withFieldMappings: boolean = true) {
const model = this.settingsForm.value;
// Convert FormArray to dictionary
const ageRatingMappings = this.ageRatingMappings.controls.reduce((acc, control) => {
// @ts-ignore
const { str, rating } = control.value;
if (str && rating) {
// @ts-ignore
acc[str] = parseInt(rating + '', 10) as AgeRating;
}
return acc;
}, {});
const fieldMappings = this.fieldMappings.controls.map((control) => {
const value = control.value as MetadataFieldMapping;
return {
id: value.id,
sourceType: parseInt(value.sourceType + '', 10),
destinationType: parseInt(value.destinationType + '', 10),
sourceValue: value.sourceValue,
destinationValue: value.destinationValue,
excludeFromSource: value.excludeFromSource
}
}).filter(m => m.sourceValue.length > 0);
// Translate blacklist string -> Array<string>
return {
...model,
ageRatingMappings,
fieldMappings: withFieldMappings ? fieldMappings : [],
blacklist: (model.blacklist || '').split(',').map((item: string) => item.trim()),
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)])
}
}
addAgeRatingMapping(str: string = '', rating: AgeRating = AgeRating.Unknown) {
const mappingGroup = this.fb.group({
str: [str, Validators.required],
rating: [rating, Validators.required]
});
// @ts-ignore
this.ageRatingMappings.push(mappingGroup);
}
removeAgeRatingMappingRow(index: number) {
this.ageRatingMappings.removeAt(index);
}
addFieldMapping(mapping: MetadataFieldMapping | null = null) {
const mappingGroup = this.fb.group({
id: [mapping?.id || 0],
sourceType: [mapping?.sourceType || MetadataFieldType.Genre, Validators.required],
destinationType: [mapping?.destinationType || MetadataFieldType.Genre, Validators.required],
sourceValue: [mapping?.sourceValue || '', Validators.required],
destinationValue: [mapping?.destinationValue || ''],
excludeFromSource: [mapping?.excludeFromSource || false]
});
// Autofill destination value if empty when source value loses focus
mappingGroup.get('sourceValue')?.valueChanges
.pipe(
filter(() => !mappingGroup.get('destinationValue')?.value)
)
.subscribe(sourceValue => {
mappingGroup.get('destinationValue')?.setValue(sourceValue);
});
//@ts-ignore
this.fieldMappings.push(mappingGroup);
}
removeFieldMappingRow(index: number) {
this.fieldMappings.removeAt(index);
}
}

View file

@ -4,6 +4,7 @@ import {map, of} from 'rxjs';
import { environment } from 'src/environments/environment';
import { TextResonse } from '../_types/text-response';
import { ServerSettings } from './_models/server-settings';
import {MetadataSettings} from "./_models/metadata-settings";
/**
* Used only for the Test Email Service call
@ -27,6 +28,13 @@ export class SettingsService {
return this.http.get<ServerSettings>(this.baseUrl + 'settings');
}
getMetadataSettings() {
return this.http.get<MetadataSettings>(this.baseUrl + 'settings/metadata-settings');
}
updateMetadataSettings(model: MetadataSettings) {
return this.http.post<MetadataSettings>(this.baseUrl + 'settings/metadata-settings', model);
}
updateServerSettings(model: ServerSettings) {
return this.http.post<ServerSettings>(this.baseUrl + 'settings', model);
}

View file

@ -72,8 +72,8 @@ function deepClone(obj: any): any {
@Component({
selector: 'app-series-card',
standalone: true,
imports: [CardItemComponent, RelationshipPipe, CardActionablesComponent, DefaultValuePipe, DownloadIndicatorComponent,
EntityTitleComponent, FormsModule, ImageComponent, NgbProgressbar, NgbTooltip, RouterLink, TranslocoDirective,
imports: [RelationshipPipe, CardActionablesComponent, DefaultValuePipe, DownloadIndicatorComponent,
FormsModule, ImageComponent, NgbProgressbar, NgbTooltip, RouterLink, TranslocoDirective,
SeriesFormatComponent, DecimalPipe],
templateUrl: './series-card.component.html',
styleUrls: ['./series-card.component.scss'],
@ -245,6 +245,13 @@ export class SeriesCardComponent implements OnInit, OnChanges {
case(Action.Edit):
this.openEditModal(series);
break;
case Action.Match:
this.actionService.matchSeries(this.series, (refreshNeeded) => {
if (refreshNeeded) {
this.reload.emit(series.id);
}
});
break;
case(Action.AddToReadingList):
this.actionService.addSeriesToReadingList(series);
break;

View file

@ -480,7 +480,6 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
} else if (event.event === EVENTS.ScanSeries) {
const seriesScanEvent = event.payload as ScanSeriesEvent;
if (seriesScanEvent.seriesId === this.seriesId) {
//this.loadSeries(this.seriesId);
this.loadPageSource.next(false);
}
} else if (event.event === EVENTS.CoverUpdate) {
@ -491,7 +490,6 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
} else if (event.event === EVENTS.ChapterRemoved) {
const removedEvent = event.payload as ChapterRemovedEvent;
if (removedEvent.seriesId !== this.seriesId) return;
//this.loadSeries(this.seriesId, false);
this.loadPageSource.next(false);
}
});
@ -751,6 +749,13 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
this.seriesActions = this.actionFactoryService.getSeriesActions(this.handleSeriesActionCallback.bind(this))
.filter(action => action.action !== Action.Edit);
this.licenseService.hasValidLicense$.subscribe(hasLic => {
if (!hasLic) {
this.seriesActions = this.seriesActions.filter(action => action.action !== Action.Match);
this.cdRef.markForCheck();
}
});
this.seriesService.getRelatedForSeries(this.seriesId).subscribe((relations: RelatedSeries) => {
this.relationShips = relations;

View file

@ -1,5 +1,5 @@
<ng-container *transloco="let t;">
<div class="container-fluid">
<div>
<ng-content></ng-content>
@if (subtitle) {

View file

@ -1,5 +1,5 @@
<ng-container *transloco="let t;">
<div class="container-fluid">
<div>
<div class="settings-row g-0 row">
<div class="col-10 setting-title">
<h6 class="section-title">

View file

@ -1,5 +1,5 @@
<ng-container *transloco="let t;">
<div class="container-fluid">
<div>
<div class="row g-0 mb-2">
<div class="col-11">
<h6 class="section-title" [id]="id || title">{{title}}</h6>

View file

@ -122,6 +122,14 @@
}
}
@defer (when fragment === SettingsTabId.Metadata; prefetch on idle) {
@if(hasActiveLicense && fragment === SettingsTabId.Metadata) {
<div class="scale col-md-12">
<app-manage-metadata-settings></app-manage-metadata-settings>
</div>
}
}
@defer (when fragment === SettingsTabId.ScrobblingHolds; prefetch on idle) {
@if(hasActiveLicense && fragment === SettingsTabId.ScrobblingHolds) {
<div class="scale col-md-12">

View file

@ -49,6 +49,9 @@ import {ManageMatchedMetadataComponent} from "../../../admin/manage-matched-meta
import {ManageUserTokensComponent} from "../../../admin/manage-user-tokens/manage-user-tokens.component";
import {EmailHistoryComponent} from "../../../admin/email-history/email-history.component";
import {ScrobblingHoldsComponent} from "../../../user-settings/user-holds/scrobbling-holds.component";
import {
ManageMetadataSettingsComponent
} from "../../../admin/manage-metadata-settings/manage-metadata-settings.component";
@Component({
selector: 'app-settings',
@ -84,7 +87,8 @@ import {ScrobblingHoldsComponent} from "../../../user-settings/user-holds/scrobb
ManageMatchedMetadataComponent,
ManageUserTokensComponent,
EmailHistoryComponent,
ScrobblingHoldsComponent
ScrobblingHoldsComponent,
ManageMetadataSettingsComponent
],
templateUrl: './settings.component.html',
styleUrl: './settings.component.scss',

View file

@ -157,6 +157,16 @@
</app-setting-switch>
</div>
<div class="row g-0 mt-4 mb-4">
<app-setting-switch [title]="t('allow-metadata-matching-label')" [subtitle]="t('allow-metadata-matching-tooltip')">
<ng-template #switch>
<div class="form-check form-switch float-end">
<input type="checkbox" id="metadata-matching" role="switch" formControlName="allowMetadataMatching" 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('folder-watching-label')" [subtitle]="t('folder-watching-tooltip')">
<ng-template #switch>

View file

@ -9,9 +9,6 @@ import {
} from '@angular/core';
import {FormControl, FormGroup, ReactiveFormsModule, Validators} from '@angular/forms';
import {
NgbAccordionBody,
NgbAccordionButton, NgbAccordionCollapse,
NgbAccordionDirective, NgbAccordionHeader, NgbAccordionItem,
NgbActiveModal,
NgbModal,
NgbModalModule,
@ -71,7 +68,7 @@ enum StepID {
standalone: true,
imports: [CommonModule, NgbModalModule, NgbNavLink, NgbNavItem, NgbNavContent, ReactiveFormsModule, NgbTooltip,
SentenceCasePipe, NgbNav, NgbNavOutlet, CoverImageChooserComponent, TranslocoModule, DefaultDatePipe,
FileTypeGroupPipe, NgbAccordionDirective, NgbAccordionItem, NgbAccordionHeader, NgbAccordionButton, NgbAccordionCollapse, NgbAccordionBody, EditListComponent, SettingItemComponent, SettingSwitchComponent, SettingButtonComponent],
FileTypeGroupPipe, EditListComponent, SettingItemComponent, SettingSwitchComponent, SettingButtonComponent],
templateUrl: './library-settings-modal.component.html',
styleUrls: ['./library-settings-modal.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
@ -116,6 +113,7 @@ export class LibrarySettingsModalComponent implements OnInit {
manageCollections: new FormControl<boolean>(true, { nonNullable: true, validators: [Validators.required] }),
manageReadingLists: new FormControl<boolean>(true, { 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] }),
});
@ -153,7 +151,9 @@ export class LibrarySettingsModalComponent implements OnInit {
if (this.library && !(this.library.type === LibraryType.Manga || this.library.type === LibraryType.LightNovel) ) {
this.libraryForm.get('allowScrobbling')?.setValue(false);
this.libraryForm.get('allowMetadataMatching')?.setValue(false);
this.libraryForm.get('allowScrobbling')?.disable();
this.libraryForm.get('allowMetadataMatching')?.disable();
}
this.libraryForm.get('name')?.valueChanges.pipe(
@ -216,8 +216,10 @@ export class LibrarySettingsModalComponent implements OnInit {
this.libraryForm.get('allowScrobbling')?.setValue(this.IsKavitaPlusEligible);
if (!this.IsKavitaPlusEligible) {
this.libraryForm.get('allowScrobbling')?.disable();
this.libraryForm.get('allowMetadataMatching')?.disable();
} else {
this.libraryForm.get('allowScrobbling')?.enable();
this.libraryForm.get('allowMetadataMatching')?.enable();
}
this.cdRef.markForCheck();
}),
@ -237,6 +239,7 @@ export class LibrarySettingsModalComponent implements OnInit {
this.libraryForm.get('manageReadingLists')?.setValue(this.library.manageReadingLists);
this.libraryForm.get('collapseSeriesRelationships')?.setValue(this.library.collapseSeriesRelationships);
this.libraryForm.get('allowScrobbling')?.setValue(this.library.allowScrobbling);
this.libraryForm.get('allowMetadataMatching')?.setValue(this.library.allowMetadataMatching);
this.selectedFolders = this.library.folders;
this.madeChanges = false;
for(let fileTypeGroup of allFileTypeGroup) {

View file

@ -36,6 +36,7 @@ export enum SettingsTabId {
MALStackImport = 'mal-stack-import',
MatchedMetadata = 'admin-matched-metadata',
ManageUserTokens = 'admin-manage-tokens',
Metadata = 'admin-metadata',
// Non-Admin
Account = 'account',
@ -233,6 +234,7 @@ export class PreferenceNavComponent implements AfterViewInit {
this.matchedMetadataBadgeCount$
));
kavitaPlusSection.children.push(new SideNavItem(SettingsTabId.ManageUserTokens, [Role.Admin]));
kavitaPlusSection.children.push(new SideNavItem(SettingsTabId.Metadata, [Role.Admin]));
// Scrobbling History needs to be per-user and allow admin to view all
kavitaPlusSection.children.push(new SideNavItem(SettingsTabId.ScrobblingHolds, []));

View file

@ -87,6 +87,37 @@
<div class="setting-section-break"></div>
@if (licenseService.hasValidLicense$ | async) {
<h4 id="kavitaplus-heading" class="mt-3">{{t('kavitaplus-settings-title')}}</h4>
<div class="row g-0 mt-4 mb-4">
@if(settingsForm.get('aniListScrobblingEnabled'); as formControl) {
<app-setting-switch [title]="t('anilist-scrobbling-label')" [subtitle]="t('anilist-scrobbling-tooltip')">
<ng-template #switch>
<div class="form-check form-switch float-end">
<input id="setting-anilist-scrobbling" type="checkbox" class="form-check-input" formControlName="aniListScrobblingEnabled">
</div>
</ng-template>
</app-setting-switch>
}
</div>
<div class="row g-0 mt-4 mb-4">
@if(settingsForm.get('wantToReadSync'); as formControl) {
<app-setting-switch [title]="t('want-to-read-sync-label')" [subtitle]="t('want-to-read-sync-tooltip')">
<ng-template #switch>
<div class="form-check form-switch float-end">
<input id="setting-want-to-read-sync" type="checkbox" class="form-check-input" formControlName="wantToReadSync">
</div>
</ng-template>
</app-setting-switch>
}
</div>
<div class="setting-section-break"></div>
}
<h4 id="image-reader-heading" class="mt-3">{{t('image-reader-settings-title')}}</h4>
<ng-container>
<div class="row g-0 mt-4 mb-4">

View file

@ -35,7 +35,7 @@ import {
NgbAccordionDirective, NgbAccordionHeader,
NgbAccordionItem, NgbTooltip
} from "@ng-bootstrap/ng-bootstrap";
import {NgStyle, NgTemplateOutlet, TitleCasePipe} from "@angular/common";
import {AsyncPipe, NgStyle, NgTemplateOutlet, TitleCasePipe} from "@angular/common";
import {ColorPickerModule} from "ngx-color-picker";
import {SettingTitleComponent} from "../../settings/_components/setting-title/setting-title.component";
import {SettingItemComponent} from "../../settings/_components/setting-item/setting-item.component";
@ -53,26 +53,17 @@ import {PdfSpreadModePipe} from "../../_pipes/pdf-spread-mode.pipe";
import {PdfThemePipe} from "../../_pipes/pdf-theme.pipe";
import {PdfScrollModeTypePipe} from "../../pdf-reader/_pipe/pdf-scroll-mode.pipe";
import {PdfScrollModePipe} from "../../_pipes/pdf-scroll-mode.pipe";
import {LicenseService} from "../../_services/license.service";
@Component({
selector: 'app-manga-user-preferences',
standalone: true,
imports: [
TranslocoDirective,
NgbAccordionDirective,
ReactiveFormsModule,
NgbAccordionItem,
NgbAccordionCollapse,
NgbAccordionBody,
NgbAccordionHeader,
NgbAccordionButton,
NgbTooltip,
NgTemplateOutlet,
TitleCasePipe,
ColorPickerModule,
SettingTitleComponent,
SettingItemComponent,
PageLayoutModePipe,
SettingSwitchComponent,
ReadingDirectionPipe,
ScalingOptionPipe,
@ -82,12 +73,10 @@ import {PdfScrollModePipe} from "../../_pipes/pdf-scroll-mode.pipe";
NgStyle,
WritingStylePipe,
BookPageLayoutModePipe,
PdfSpreadTypePipe,
PdfSpreadTypePipe,
PdfSpreadModePipe,
PdfThemePipe,
PdfScrollModeTypePipe,
PdfScrollModePipe
PdfScrollModePipe,
AsyncPipe
],
templateUrl: './manage-user-preferences.component.html',
styleUrl: './manage-user-preferences.component.scss',
@ -102,6 +91,7 @@ export class ManageUserPreferencesComponent implements OnInit {
private readonly router = inject(Router);
private readonly cdRef = inject(ChangeDetectorRef);
private readonly localizationService = inject(LocalizationService);
protected readonly licenseService = inject(LicenseService);
protected readonly readingDirections = readingDirections;
protected readonly scalingOptions = scalingOptions;
@ -199,6 +189,9 @@ export class ManageUserPreferencesComponent implements OnInit {
this.settingsForm.addControl('shareReviews', new FormControl(this.user.preferences.shareReviews, []));
this.settingsForm.addControl('locale', new FormControl(this.user.preferences.locale || 'en', []));
this.settingsForm.addControl('aniListScrobblingEnabled', new FormControl(this.user.preferences.aniListScrobblingEnabled || false, []));
this.settingsForm.addControl('wantToReadSync', new FormControl(this.user.preferences.wantToReadSync || false, []));
// Automatically save settings as we edit them
this.settingsForm.valueChanges.pipe(
@ -267,6 +260,9 @@ export class ManageUserPreferencesComponent implements OnInit {
this.settingsForm.get('collapseSeriesRelationships')?.setValue(this.user.preferences.collapseSeriesRelationships, {onlySelf: true, emitEvent: false});
this.settingsForm.get('shareReviews')?.setValue(this.user.preferences.shareReviews, {onlySelf: true, emitEvent: false});
this.settingsForm.get('locale')?.setValue(this.user.preferences.locale || 'en', {onlySelf: true, emitEvent: false});
this.settingsForm.get('aniListScrobblingEnabled')?.setValue(this.user.preferences.aniListScrobblingEnabled || false, {onlySelf: true, emitEvent: false});
this.settingsForm.get('wantToReadSync')?.setValue(this.user.preferences.wantToReadSync || false, {onlySelf: true, emitEvent: false});
}
packSettings(): Preferences {
@ -303,6 +299,8 @@ export class ManageUserPreferencesComponent implements OnInit {
pdfTheme: parseInt(modelSettings.pdfTheme, 10),
pdfScrollMode: parseInt(modelSettings.pdfScrollMode, 10),
pdfSpreadMode: parseInt(modelSettings.pdfSpreadMode, 10),
aniListScrobblingEnabled: modelSettings.aniListScrobblingEnabled,
wantToReadSync: modelSettings.wantToReadSync
};
}