Kavita+ Comic Metadata Matching (#3740)

This commit is contained in:
Joe Milazzo 2025-04-25 07:26:48 -06:00 committed by GitHub
parent 4521965315
commit ed154e4768
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
46 changed files with 4207 additions and 98 deletions

View file

@ -27,8 +27,9 @@ export interface MetadataTagDto {
export interface ExternalSeriesDetail {
name: string;
aniListId?: number;
malId?: number;
aniListId?: number | null;
malId?: number | null;
cbrId?: number | null;
synonyms: Array<string>;
plusMediaFormat: PlusMediaFormat;
siteUrl?: string;

View file

@ -1,4 +1,4 @@
import { Pipe, PipeTransform } from '@angular/core';
import {Pipe, PipeTransform} from '@angular/core';
import {MetadataSettingField} from "../admin/_models/metadata-setting-field";
import {translate} from "@jsverse/transloco";
@ -10,9 +10,19 @@ export class MetadataSettingFiledPipe implements PipeTransform {
transform(value: MetadataSettingField): string {
switch (value) {
case MetadataSettingField.ChapterTitle:
return translate('metadata-setting-field-pipe.chapter-title');
case MetadataSettingField.ChapterSummary:
return translate('metadata-setting-field-pipe.chapter-summary');
case MetadataSettingField.ChapterReleaseDate:
return translate('metadata-setting-field-pipe.chapter-release-date');
case MetadataSettingField.ChapterPublisher:
return translate('metadata-setting-field-pipe.chapter-publisher');
case MetadataSettingField.ChapterCovers:
return translate('metadata-setting-field-pipe.chapter-covers');
case MetadataSettingField.AgeRating:
return translate('metadata-setting-field-pipe.age-rating');
case MetadataSettingField.People:
case MetadataSettingField.People:
return translate('metadata-setting-field-pipe.people');
case MetadataSettingField.Covers:
return translate('metadata-setting-field-pipe.covers');

View file

@ -1,4 +1,4 @@
import { Pipe, PipeTransform } from '@angular/core';
import {Pipe, PipeTransform} from '@angular/core';
import {PlusMediaFormat} from "../_models/series-detail/external-series-detail";
import {translate} from "@jsverse/transloco";
@ -13,7 +13,7 @@ export class PlusMediaFormatPipe implements PipeTransform {
case PlusMediaFormat.Manga:
return translate('library-type-pipe.manga');
case PlusMediaFormat.Comic:
return translate('library-type-pipe.comic');
return translate('library-type-pipe.comicVine');
case PlusMediaFormat.LightNovel:
return translate('library-type-pipe.lightNovel');
case PlusMediaFormat.Book:

View file

@ -17,6 +17,8 @@ export class ProviderImagePipe implements PipeTransform {
return `assets/images/ExternalServices/GoogleBooks${large ? '-lg' : ''}.png`;
case ScrobbleProvider.Kavita:
return `assets/images/logo-${large ? '64' : '32'}.png`;
case ScrobbleProvider.Cbr:
return `assets/images/ExternalServices/ComicBookRoundup.png`;
}
}

View file

@ -1,23 +0,0 @@
import { Pipe, PipeTransform } from '@angular/core';
import {ScrobbleProvider} from "../_services/scrobbling.service";
@Pipe({
name: 'providerName',
standalone: true
})
export class ProviderNamePipe implements PipeTransform {
transform(value: ScrobbleProvider): string {
switch (value) {
case ScrobbleProvider.AniList:
return 'AniList';
case ScrobbleProvider.Mal:
return 'MAL';
case ScrobbleProvider.Kavita:
return 'Kavita';
case ScrobbleProvider.GoogleBooks:
return 'Google Books';
}
}
}

View file

@ -12,6 +12,7 @@ export class ScrobbleProviderNamePipe implements PipeTransform {
case ScrobbleProvider.AniList: return 'AniList';
case ScrobbleProvider.Mal: return 'MAL';
case ScrobbleProvider.Kavita: return 'Kavita';
case ScrobbleProvider.Cbr: return 'Comicbook Roundup';
case ScrobbleProvider.GoogleBooks: return 'Google Books';
}
}

View file

@ -12,9 +12,10 @@ import {UtilityService} from "../shared/_services/utility.service";
export enum ScrobbleProvider {
Kavita = 0,
AniList= 1,
AniList = 1,
Mal = 2,
GoogleBooks = 3
GoogleBooks = 3,
Cbr = 4
}
@Injectable({

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}&aniListId=${series.aniListId}${series.malId ? '&malId=' + series.malId : ''}`, {}, TextResonse);
return this.httpClient.post<string>(this.baseUrl + `series/update-match?seriesId=${seriesId}&aniListId=${series.aniListId || 0}&malId=${series.malId || 0}&cbrId=${series.cbrId || 0}`, {}, TextResonse);
}
updateDontMatch(seriesId: number, dontMatch: boolean) {

View file

@ -91,7 +91,7 @@ export class MatchSeriesModalComponent implements OnInit {
data.tags = data.tags || [];
data.genres = data.genres || [];
this.seriesService.updateMatch(this.series.id, data).subscribe(_ => {
this.seriesService.updateMatch(this.series.id, item.series).subscribe(_ => {
this.save();
});
}

View file

@ -7,7 +7,14 @@ export enum MetadataSettingField {
LocalizedName = 6,
Covers = 7,
AgeRating = 8,
People = 9
People = 9,
// Chapter fields
ChapterTitle = 10,
ChapterSummary = 11,
ChapterReleaseDate = 12,
ChapterPublisher = 13,
ChapterCovers = 14,
}
export const allMetadataSettingField = Object.keys(MetadataSettingField)

View file

@ -25,6 +25,14 @@ export interface MetadataSettings {
enableStartDate: boolean;
enableCoverImage: boolean;
enableLocalizedName: boolean;
enableChapterSummary: boolean;
enableChapterReleaseDate: boolean;
enableChapterTitle: boolean;
enableChapterPublisher: boolean;
enableChapterCoverImage: boolean;
enableGenres: boolean;
enableTags: boolean;
firstLastPeopleNaming: boolean;

View file

@ -89,6 +89,70 @@
}
</div>
<div class="setting-section-break"></div>
<!-- Chapter-based fields -->
<h5>{{t('chapter-header')}}</h5>
<div class="row g-0 mt-4 mb-4">
@if(settingsForm.get('enableChapterTitle'); as formControl) {
<app-setting-switch [title]="t('enable-chapter-title-label')" [subtitle]="t('enable-chapter-title-tooltip')">
<ng-template #switch>
<div class="form-check form-switch float-end">
<input id="enable-chapter-title" type="checkbox" class="form-check-input" formControlName="enableChapterTitle">
</div>
</ng-template>
</app-setting-switch>
}
</div>
<div class="row g-0 mt-4 mb-4">
@if(settingsForm.get('enableChapterSummary'); as formControl) {
<app-setting-switch [title]="t('enable-chapter-summary-label')" [subtitle]="t('enable-chapter-summary-tooltip')">
<ng-template #switch>
<div class="form-check form-switch float-end">
<input id="enable-chapter-summary" type="checkbox" class="form-check-input" formControlName="enableChapterSummary">
</div>
</ng-template>
</app-setting-switch>
}
</div>
<div class="row g-0 mt-4 mb-4">
@if(settingsForm.get('enableChapterReleaseDate'); as formControl) {
<app-setting-switch [title]="t('enable-chapter-release-date-label')" [subtitle]="t('enable-chapter-release-date-tooltip')">
<ng-template #switch>
<div class="form-check form-switch float-end">
<input id="enable-chapter-release-date" type="checkbox" class="form-check-input" formControlName="enableChapterReleaseDate">
</div>
</ng-template>
</app-setting-switch>
}
</div>
<div class="row g-0 mt-4 mb-4">
@if(settingsForm.get('enableChapterPublisher'); as formControl) {
<app-setting-switch [title]="t('enable-chapter-publisher-label')" [subtitle]="t('enable-chapter-publisher-tooltip')">
<ng-template #switch>
<div class="form-check form-switch float-end">
<input id="enable-chapter-publisher" type="checkbox" class="form-check-input" formControlName="enableChapterPublisher">
</div>
</ng-template>
</app-setting-switch>
}
</div>
<div class="row g-0 mt-4 mb-4">
@if(settingsForm.get('enableChapterCoverImage'); as formControl) {
<app-setting-switch [title]="t('enable-chapter-cover-label')" [subtitle]="t('enable-chapter-cover-tooltip')">
<ng-template #switch>
<div class="form-check form-switch float-end">
<input id="enable-chapter-cover" type="checkbox" class="form-check-input" formControlName="enableChapterCoverImage">
</div>
</ng-template>
</app-setting-switch>
}
</div>
@if(settingsForm.get('enablePeople'); as formControl) {
<div class="setting-section-break"></div>
@ -133,6 +197,7 @@
<div class="setting-section-break"></div>

View file

@ -79,6 +79,13 @@ export class ManageMetadataSettingsComponent implements OnInit {
this.settingsForm.addControl('enableStartDate', new FormControl(settings.enableStartDate, []));
this.settingsForm.addControl('enableCoverImage', new FormControl(settings.enableCoverImage, []));
this.settingsForm.addControl('enableChapterTitle', new FormControl(settings.enableChapterTitle, []));
this.settingsForm.addControl('enableChapterSummary', new FormControl(settings.enableChapterSummary, []));
this.settingsForm.addControl('enableChapterReleaseDate', new FormControl(settings.enableChapterReleaseDate, []));
this.settingsForm.addControl('enableChapterPublisher', new FormControl(settings.enableChapterPublisher, []));
this.settingsForm.addControl('enableChapterCoverImage', new FormControl(settings.enableChapterCoverImage, []));
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), []));

View file

@ -43,6 +43,7 @@ import {DefaultModalOptions} from "../../../_models/default-modal-options";
templateUrl: './all-collections.component.html',
styleUrls: ['./all-collections.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [SideNavCompanionBarComponent, CardDetailLayoutComponent, CardItemComponent, AsyncPipe, DecimalPipe,
TranslocoDirective, CollectionOwnerComponent, BulkOperationsComponent]
})

View file

@ -32,7 +32,7 @@
<div class="under-image">
<app-image [imageUrl]="collectionTag.source | providerImage"
width="16px" height="16px"
[ngbTooltip]="collectionTag.source | providerName" tabindex="0"></app-image>
[ngbTooltip]="collectionTag.source | scrobbleProviderName" tabindex="0"></app-image>
<span class="ms-2 me-2">{{t('sync-progress', {title: series.length + ' / ' + collectionTag.totalSourceCount})}}</span>
<i class="fa-solid fa-question-circle" aria-hidden="true" [ngbTooltip]="t('last-sync', {date: collectionTag.lastSyncUtc | date: 'short' | defaultDate })"></i>
</div>

View file

@ -56,20 +56,20 @@ import {User} from "../../../_models/user";
import {ScrobbleProvider} from "../../../_services/scrobbling.service";
import {DefaultDatePipe} from "../../../_pipes/default-date.pipe";
import {ProviderImagePipe} from "../../../_pipes/provider-image.pipe";
import {ProviderNamePipe} from "../../../_pipes/provider-name.pipe";
import {
SmartCollectionDrawerComponent
} from "../../../_single-module/smart-collection-drawer/smart-collection-drawer.component";
import {DefaultModalOptions} from "../../../_models/default-modal-options";
import {ScrobbleProviderNamePipe} from "../../../_pipes/scrobble-provider-name.pipe";
@Component({
selector: 'app-collection-detail',
templateUrl: './collection-detail.component.html',
styleUrls: ['./collection-detail.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'app-collection-detail',
templateUrl: './collection-detail.component.html',
styleUrls: ['./collection-detail.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [SideNavCompanionBarComponent, CardActionablesComponent, ImageComponent, ReadMoreComponent,
BulkOperationsComponent, CardDetailLayoutComponent, SeriesCardComponent, TranslocoDirective, NgbTooltip,
DatePipe, DefaultDatePipe, ProviderImagePipe, ProviderNamePipe, AsyncPipe]
DatePipe, DefaultDatePipe, ProviderImagePipe, AsyncPipe, ScrobbleProviderNamePipe]
})
export class CollectionDetailComponent implements OnInit, AfterContentChecked {

View file

@ -6,8 +6,8 @@
{{t('collection-via-label')}}
<app-image [imageUrl]="collection.source | providerImage"
width="16px" height="16px"
[ngbTooltip]="collection.source | providerName"
[attr.aria-label]="collection.source | providerName"></app-image>
[ngbTooltip]="collection.source | scrobbleProviderName"
[attr.aria-label]="collection.source | scrobbleProviderName"></app-image>
}
</div>
}

View file

@ -1,23 +1,23 @@
import {ChangeDetectionStrategy, Component, inject, Input} from '@angular/core';
import {ScrobbleProvider} from "../../../_services/scrobbling.service";
import {ProviderImagePipe} from "../../../_pipes/provider-image.pipe";
import {ProviderNamePipe} from "../../../_pipes/provider-name.pipe";
import {UserCollection} from "../../../_models/collection-tag";
import {TranslocoDirective} from "@jsverse/transloco";
import {AsyncPipe} from "@angular/common";
import {AccountService} from "../../../_services/account.service";
import {ImageComponent} from "../../../shared/image/image.component";
import {NgbTooltip} from "@ng-bootstrap/ng-bootstrap";
import {ScrobbleProviderNamePipe} from "../../../_pipes/scrobble-provider-name.pipe";
@Component({
selector: 'app-collection-owner',
imports: [
ProviderImagePipe,
ProviderNamePipe,
TranslocoDirective,
AsyncPipe,
ImageComponent,
NgbTooltip
NgbTooltip,
ScrobbleProviderNamePipe
],
templateUrl: './collection-owner.component.html',
styleUrl: './collection-owner.component.scss',

View file

@ -17,7 +17,7 @@
@for (rating of ratings; track rating.provider + rating.averageScore) {
<div class="col-auto custom-col clickable" [ngbPopover]="externalPopContent" [popoverContext]="{rating: rating}"
[popoverTitle]="rating.provider | providerName" popoverClass="sm-popover">
[popoverTitle]="rating.provider | scrobbleProviderName" popoverClass="sm-popover">
<span class="badge rounded-pill me-1">
<img class="me-1" [ngSrc]="rating.provider | providerImage:true" width="24" height="24" alt="" aria-hidden="true">
{{rating.averageScore}}%
@ -64,9 +64,11 @@
</ng-template>
<ng-template #externalPopContent let-rating="rating">
<div>
<i class="fa-solid fa-heart" aria-hidden="true"></i> {{rating.favoriteCount}}
</div>
@if (rating.favoriteCount > 0) {
<div>
<i class="fa-solid fa-heart" aria-hidden="true"></i> {{rating.favoriteCount}}
</div>
}
@if (rating.providerUrl) {
<a [href]="rating.providerUrl" target="_blank" rel="noreferrer nofollow">{{t('entry-label')}}</a>

View file

@ -1,7 +1,8 @@
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component, DestroyRef,
Component,
DestroyRef,
inject,
Input,
OnInit,
@ -13,7 +14,6 @@ import {ProviderImagePipe} from "../../../_pipes/provider-image.pipe";
import {NgbModal, NgbPopover} from "@ng-bootstrap/ng-bootstrap";
import {LoadingComponent} from "../../../shared/loading/loading.component";
import {LibraryType} from "../../../_models/library/library";
import {ProviderNamePipe} from "../../../_pipes/provider-name.pipe";
import {NgxStarsModule} from "ngx-stars";
import {ThemeService} from "../../../_services/theme.service";
import {Breakpoint, UtilityService} from "../../../shared/_services/utility.service";
@ -23,15 +23,16 @@ import {SafeHtmlPipe} from "../../../_pipes/safe-html.pipe";
import {ImageService} from "../../../_services/image.service";
import {AsyncPipe, NgOptimizedImage, NgTemplateOutlet} from "@angular/common";
import {RatingModalComponent} from "../rating-modal/rating-modal.component";
import {ScrobbleProviderNamePipe} from "../../../_pipes/scrobble-provider-name.pipe";
@Component({
selector: 'app-external-rating',
imports: [ProviderImagePipe, NgbPopover, LoadingComponent, ProviderNamePipe, NgxStarsModule, ImageComponent,
TranslocoDirective, SafeHtmlPipe, NgOptimizedImage, AsyncPipe, NgTemplateOutlet],
templateUrl: './external-rating.component.html',
styleUrls: ['./external-rating.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None
selector: 'app-external-rating',
imports: [ProviderImagePipe, NgbPopover, LoadingComponent, NgxStarsModule, ImageComponent,
TranslocoDirective, SafeHtmlPipe, NgOptimizedImage, AsyncPipe, NgTemplateOutlet, ScrobbleProviderNamePipe],
templateUrl: './external-rating.component.html',
styleUrls: ['./external-rating.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None
})
export class ExternalRatingComponent implements OnInit {

View file

@ -128,6 +128,11 @@ export class LibrarySettingsModalComponent implements OnInit {
return libType === LibraryType.Manga || libType === LibraryType.LightNovel;
}
get IsMetadataDownloadEligible() {
const libType = parseInt(this.libraryForm.get('type')?.value + '', 10) as LibraryType;
return libType === LibraryType.Manga || libType === LibraryType.LightNovel || libType === LibraryType.ComicVine;
}
ngOnInit(): void {
if (this.library === undefined) {
this.isAddLibrary = true;
@ -141,11 +146,19 @@ 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();
if (this.IsMetadataDownloadEligible) {
this.libraryForm.get('allowMetadataMatching')?.setValue(this.library.allowMetadataMatching);
this.libraryForm.get('allowMetadataMatching')?.enable();
} else {
this.libraryForm.get('allowMetadataMatching')?.setValue(false);
this.libraryForm.get('allowMetadataMatching')?.disable();
}
}
this.libraryForm.get('name')?.valueChanges.pipe(
debounceTime(100),
distinctUntilChanged(),
@ -208,11 +221,16 @@ export class LibrarySettingsModalComponent implements OnInit {
if (!this.IsKavitaPlusEligible) {
this.libraryForm.get('allowScrobbling')?.disable();
this.libraryForm.get('allowMetadataMatching')?.disable();
} else {
this.libraryForm.get('allowScrobbling')?.enable();
this.libraryForm.get('allowMetadataMatching')?.enable();
}
if (this.IsMetadataDownloadEligible) {
this.libraryForm.get('allowMetadataMatching')?.enable();
} else {
this.libraryForm.get('allowMetadataMatching')?.disable();
}
this.cdRef.markForCheck();
}),
takeUntilDestroyed(this.destroyRef)
@ -231,7 +249,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.IsKavitaPlusEligible ? this.library.allowScrobbling : false);
this.libraryForm.get('allowMetadataMatching')?.setValue(this.IsKavitaPlusEligible ? this.library.allowMetadataMatching : false);
this.libraryForm.get('allowMetadataMatching')?.setValue(this.IsMetadataDownloadEligible ? this.library.allowMetadataMatching : false);
this.selectedFolders = this.library.folders;
this.madeChanges = false;

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

View file

@ -806,6 +806,17 @@
"enable-cover-image-tooltip": "Allow Kavita to write the cover image for the Series",
"enable-start-date-label": "Start Date",
"enable-start-date-tooltip": "Allow Start Date of Series to be written to the Series",
"enable-chapter-title-label": "Title",
"enable-chapter-title-tooltip": "Allow Title of Chapter/Issue to be written",
"enable-chapter-summary-label": "{{manage-metadata-settings.summary-label}}",
"enable-chapter-summary-tooltip": "{{manage-metadata-settings.summary-tooltip}}",
"enable-chapter-release-date-label": "Release Date",
"enable-chapter-release-date-tooltip": "Allow Release Date of Chapter/Issue to be written",
"enable-chapter-publisher-label": "Publisher",
"enable-chapter-publisher-tooltip": "Allow Publisher of Chapter/Issue to be written",
"enable-chapter-cover-label": "Chapter Cover",
"enable-chapter-cover-tooltip": "Allow Cover of Chapter/Issue to be set",
"enable-genres-label": "Genres",
"enable-genres-tooltip": "Allow Series Genres to be written.",
"enable-tags-label": "Tags",
@ -827,7 +838,8 @@
"first-last-name-tooltip": "Ensure People's names are written First then Last",
"person-roles-label": "Roles",
"overrides-label": "Overrides",
"overrides-description": "Allow Kavita to write over locked fields."
"overrides-description": "Allow Kavita to write over locked fields.",
"chapter-header": "Chapter Fields"
},
"book-line-overlay": {
@ -2686,7 +2698,12 @@
"start-date": "{{manage-metadata-settings.enable-start-date-label}}",
"genres": "{{metadata-fields.genres-title}}",
"tags": "{{metadata-fields.tags-title}}",
"localized-name": "{{edit-series-modal.localized-name-label}}"
"localized-name": "{{edit-series-modal.localized-name-label}}",
"chapter-release-date": "Release Date (Chapter)",
"chapter-summary": "Summary (Chapter)",
"chapter-covers": "Covers (Chapter)",
"chapter-publisher": "{{person-role-pipe.publisher}} (Chapter)",
"chapter-title": "Title (Chapter)"
},