From 87792b10065102e469a40e60ddf142bac9177f87 Mon Sep 17 00:00:00 2001 From: Irodzuita <38263702+Irodzuita@users.noreply.github.com> Date: Sun, 12 Oct 2025 16:50:40 -0400 Subject: [PATCH 1/2] feature: Add support for field from ComicInfo.xml Implements full support for the field from ComicInfo.xml specification: Backend changes: - Added BOOK_METADATA_CHARACTER and BOOK_METADATA_AGGREGATION_CHARACTER database tables - Created Flyway migration V20251012120000__book_metadata_characters.sql - Extended BookMetadata, BookMetadataPatch, and SeriesMetadataAggregation models - Updated ComicInfoProvider to parse field - Added characters to MetadataApplier and MetadataAggregator - Implemented SearchCondition.Character for filtering - Added /api/v1/characters REST endpoint Frontend changes: - Added characters field to TypeScript types (komga-books.ts, komga-series.ts, komga-search.ts) - Implemented character filtering in BrowseBooks, BrowseLibraries, and BrowseSeries views - Added character chips display in BrowseBook, BrowseSeries, and BrowseOneshot views - Extended EditBooksDialog, EditSeriesDialog, and EditOneshotDialog with characters editing - Added characters service methods to komga-referential.service.ts - Added English translations for characters UI labels Testing: - Updated TransientBookLifecycleTest and ComicInfoProviderTest This feature allows users to: - Import characters from ComicInfo.xml files - View characters on book/series detail pages - Filter books and series by characters - Edit characters metadata manually - Search for characters across libraries --- .gitignore | 11 +++++++++++ komga/Dockerfile.local | 24 ++++++++++++++++++++++++ 2 files changed, 35 insertions(+) create mode 100644 komga/Dockerfile.local diff --git a/.gitignore b/.gitignore index 7e048bb66..f15ccaa9a 100644 --- a/.gitignore +++ b/.gitignore @@ -54,6 +54,17 @@ application-oauth2.yml /benchmark /release_notes +# Local Testing +CHARACTERS_IMPLEMENTATION_SUMMARY.md +docker-compose.test.yml +start-test.ps1 +stop-test.ps1 +test-api.ps1 + +# Local configs +test-config/ +test-data/ + ### Conveyor output/ secret/ diff --git a/komga/Dockerfile.local b/komga/Dockerfile.local new file mode 100644 index 000000000..8f0d2bda8 --- /dev/null +++ b/komga/Dockerfile.local @@ -0,0 +1,24 @@ +FROM eclipse-temurin:17-jre + +# Install dependencies +RUN apt-get update && \ + apt-get install -y wget && \ + wget "https://github.com/pgaskin/kepubify/releases/latest/download/kepubify-linux-64bit" -O /usr/bin/kepubify && \ + chmod +x /usr/bin/kepubify && \ + apt-get clean && \ + rm -rf /var/lib/apt/lists/* + +VOLUME /tmp +VOLUME /config +VOLUME /data +WORKDIR /app + +# Copy the JAR file +COPY build/libs/*.jar app.jar + +ENV KOMGA_CONFIGDIR="/config" +ENV LANG='en_US.UTF-8' LANGUAGE='en_US:en' LC_ALL='en_US.UTF-8' + +EXPOSE 25600 + +ENTRYPOINT ["java", "-Dspring.profiles.include=docker", "-jar", "app.jar", "--spring.config.additional-location=file:/config/"] From ed9a8d7813667cb27ad5a9b79e0dab0979b7806d Mon Sep 17 00:00:00 2001 From: Irodzuita <38263702+Irodzuita@users.noreply.github.com> Date: Sun, 12 Oct 2025 22:59:23 -0400 Subject: [PATCH 2/2] Fully working character integration --- .gitignore | 17 ++++--- .vscode/settings.json | 3 ++ .../components/dialogs/EditBooksDialog.vue | 45 +++++++++++++++++++ .../components/dialogs/EditOneshotDialog.vue | 40 +++++++++++++++++ .../components/dialogs/EditSeriesDialog.vue | 39 ++++++++++++++++ komga-webui/src/locales/en.json | 3 ++ .../src/services/komga-referential.service.ts | 31 +++++++++++++ komga-webui/src/types/komga-books.ts | 4 ++ komga-webui/src/types/komga-search.ts | 8 ++++ komga-webui/src/types/komga-series.ts | 5 +++ komga-webui/src/views/BrowseBook.vue | 34 +++++++++++++- komga-webui/src/views/BrowseBooks.vue | 23 +++++++++- komga-webui/src/views/BrowseLibraries.vue | 23 +++++++++- komga-webui/src/views/BrowseOneshot.vue | 33 ++++++++++++++ komga-webui/src/views/BrowseSeries.vue | 35 +++++++++++++++ ...251012120000__book_metadata_characters.sql | 28 ++++++++++++ .../gotson/komga/domain/model/BookMetadata.kt | 9 +++- .../domain/model/BookMetadataAggregation.kt | 1 + .../komga/domain/model/BookMetadataPatch.kt | 2 + .../komga/domain/model/SearchCondition.kt | 6 +++ .../persistence/ReferentialRepository.kt | 12 +++++ .../domain/service/MetadataAggregator.kt | 3 +- .../komga/domain/service/MetadataApplier.kt | 1 + .../infrastructure/jooq/BookSearchHelper.kt | 27 +++++++++++ .../infrastructure/jooq/SeriesSearchHelper.kt | 27 +++++++++++ .../infrastructure/jooq/main/BookDtoDao.kt | 13 +++++- .../jooq/main/BookMetadataAggregationDao.kt | 37 ++++++++++++++- .../jooq/main/BookMetadataDao.kt | 45 ++++++++++++++++++- .../jooq/main/ReferentialDao.kt | 44 ++++++++++++++++++ .../infrastructure/jooq/main/SeriesDtoDao.kt | 12 ++++- .../metadata/comicrack/ComicInfoProvider.kt | 2 + .../api/rest/ReferentialController.kt | 13 ++++++ .../komga/interfaces/api/rest/dto/BookDto.kt | 2 + .../api/rest/dto/BookMetadataUpdateDto.kt | 14 ++++++ .../interfaces/api/rest/dto/SeriesDto.kt | 1 + .../service/TransientBookLifecycleTest.kt | 6 +-- .../comicrack/ComicInfoProviderTest.kt | 18 ++++++++ 37 files changed, 645 insertions(+), 21 deletions(-) create mode 100644 .vscode/settings.json create mode 100644 komga/src/flyway/resources/db/migration/sqlite/V20251012120000__book_metadata_characters.sql diff --git a/.gitignore b/.gitignore index f15ccaa9a..21ba16774 100644 --- a/.gitignore +++ b/.gitignore @@ -54,16 +54,21 @@ application-oauth2.yml /benchmark /release_notes -# Local Testing -CHARACTERS_IMPLEMENTATION_SUMMARY.md +### Local Testing +test-config/ +test-data/ +Dockerfile.local docker-compose.test.yml +TESTING_LOCALLY.md +QUICK_START.md +README_TESTING.md +WEBUI_BUILD_NOTE.md +WHAT_WENT_WRONG.md +FRONTEND_IMPLEMENTATION_NEEDED.md start-test.ps1 stop-test.ps1 test-api.ps1 - -# Local configs -test-config/ -test-data/ +CHARACTERS_IMPLEMENTATION_SUMMARY.md ### Conveyor output/ diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 000000000..8f2b7113d --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "java.compile.nullAnalysis.mode": "disabled" +} \ No newline at end of file diff --git a/komga-webui/src/components/dialogs/EditBooksDialog.vue b/komga-webui/src/components/dialogs/EditBooksDialog.vue index e596642ed..8f9074dc5 100644 --- a/komga-webui/src/components/dialogs/EditBooksDialog.vue +++ b/komga-webui/src/components/dialogs/EditBooksDialog.vue @@ -306,6 +306,32 @@ + + + + + {{ $t('common.characters') }} + + + + + @@ -468,6 +494,8 @@ export default Vue.extend({ authorsLock: false, tags: [] as string[], tagsLock: false, + characters: [] as string[], + charactersLock: false, isbn: '', isbnLock: false, links: [], @@ -482,6 +510,7 @@ export default Vue.extend({ authorSearch: [], authorSearchResults: [] as string[], tagsAvailable: [] as string[], + charactersAvailable: [] as string[], } }, props: { @@ -499,6 +528,7 @@ export default Vue.extend({ if (val) { this.getThumbnails(this.books) this.loadAvailableTags() + this.loadAvailableCharacters() } else { this.dialogCancel() } @@ -535,6 +565,7 @@ export default Vue.extend({ }), }, tags: {}, + characters: {}, releaseDate: {validDate}, summary: {}, authors: {}, @@ -591,6 +622,9 @@ export default Vue.extend({ async loadAvailableTags() { this.tagsAvailable = await this.$komgaReferential.getTags() }, + async loadAvailableCharacters() { + this.charactersAvailable = await this.$komgaReferential.getCharacters() + }, linksLabelRules(label: string): boolean | string { if (!!this.$_.trim(label)) return true return this.$t('common.required').toString() @@ -637,8 +671,14 @@ export default Vue.extend({ const tagsLock = this.$_.uniq(books.map(x => x.metadata.tagsLock)) this.form.tagsLock = tagsLock.length > 1 ? false : tagsLock[0] + + this.form.characters = [] + + const charactersLock = this.$_.uniq(books.map(x => x.metadata.charactersLock)) + this.form.charactersLock = charactersLock.length > 1 ? false : charactersLock[0] } else { this.form.tags = [] + this.form.characters = [] this.form.links = [] const book = books as BookDto this.$_.merge(this.form, book.metadata) @@ -663,6 +703,7 @@ export default Vue.extend({ const metadata = { authorsLock: this.form.authorsLock, tagsLock: this.form.tagsLock, + charactersLock: this.form.charactersLock, } if (this.$v.form?.authors?.$dirty) { @@ -677,6 +718,10 @@ export default Vue.extend({ this.$_.merge(metadata, {tags: this.form.tags}) } + if (this.$v.form?.characters?.$dirty) { + this.$_.merge(metadata, {characters: this.form.characters}) + } + if (this.single) { this.$_.merge(metadata, { titleLock: this.form.titleLock, diff --git a/komga-webui/src/components/dialogs/EditOneshotDialog.vue b/komga-webui/src/components/dialogs/EditOneshotDialog.vue index c8b364c86..37f23e429 100644 --- a/komga-webui/src/components/dialogs/EditOneshotDialog.vue +++ b/komga-webui/src/components/dialogs/EditOneshotDialog.vue @@ -402,6 +402,32 @@ + + + + + {{ $t('dialog.edit_books.field_characters') }} + + + + + @@ -621,6 +647,8 @@ export default Vue.extend({ authorsLock: false, tags: [], tagsLock: false, + characters: [], + charactersLock: false, isbn: '', isbnLock: false, links: [], @@ -643,6 +671,7 @@ export default Vue.extend({ authorSearchResults: [] as string[], genresAvailable: [] as string[], tagsAvailable: [] as string[], + charactersAvailable: [] as string[], sharingLabelsAvailable: [] as string[], } }, @@ -661,6 +690,7 @@ export default Vue.extend({ if (val) { this.getThumbnails(this.books) this.loadAvailableTags() + this.loadAvailableCharacters() this.loadAvailableGenres() this.loadAvailableSharingLabels() } else { @@ -706,6 +736,7 @@ export default Vue.extend({ }, summary: {}, tags: {}, + characters: {}, releaseDate: {validDate}, links: {}, authors: {}, @@ -807,6 +838,9 @@ export default Vue.extend({ async loadAvailableTags() { this.tagsAvailable = await this.$komgaReferential.getTags() }, + async loadAvailableCharacters() { + this.charactersAvailable = await this.$komgaReferential.getCharacters() + }, async loadAvailableGenres() { this.genresAvailable = await this.$komgaReferential.getGenres() }, @@ -874,6 +908,11 @@ export default Vue.extend({ const tagsLock = this.$_.uniq(oneshots.map(x => x.book.metadata.tagsLock)) this.form.book.tagsLock = tagsLock.length > 1 ? false : tagsLock[0] + this.form.book.characters = [] + + const charactersLock = this.$_.uniq(oneshots.map(x => x.book.metadata.charactersLock)) + this.form.book.charactersLock = charactersLock.length > 1 ? false : charactersLock[0] + this.form.series.sharingLabels = [] const sharingLabelsLock = this.$_.uniq(oneshots.map(x => x.series.metadata.sharingLabelsLock)) @@ -888,6 +927,7 @@ export default Vue.extend({ this.form.series.genres = [] this.form.series.sharingLabels = [] this.form.book.tags = [] + this.form.book.characters = [] this.form.book.links = [] const oneshot = oneshots as Oneshot this.$_.merge(this.form.series, oneshot.series.metadata) diff --git a/komga-webui/src/components/dialogs/EditSeriesDialog.vue b/komga-webui/src/components/dialogs/EditSeriesDialog.vue index e19264dc3..5d29f5e6d 100644 --- a/komga-webui/src/components/dialogs/EditSeriesDialog.vue +++ b/komga-webui/src/components/dialogs/EditSeriesDialog.vue @@ -395,6 +395,32 @@ + + + + + {{ $t('dialog.edit_series.field_characters') }} + + + + + @@ -597,6 +623,8 @@ export default Vue.extend({ genresLock: false, tags: [], tagsLock: false, + characters: [], + charactersLock: false, totalBookCount: undefined as number | undefined, totalBookCountLock: false, sharingLabels: [], @@ -621,6 +649,7 @@ export default Vue.extend({ }, genresAvailable: [] as string[], tagsAvailable: [] as string[], + charactersAvailable: [] as string[], sharingLabelsAvailable: [] as string[], } }, @@ -639,6 +668,7 @@ export default Vue.extend({ if(val) { this.getThumbnails(this.series) this.loadAvailableTags() + this.loadAvailableCharacters() this.loadAvailableGenres() this.loadAvailableSharingLabels() } else { @@ -744,6 +774,9 @@ export default Vue.extend({ async loadAvailableTags() { this.tagsAvailable = await this.$komgaReferential.getTags() }, + async loadAvailableCharacters() { + this.charactersAvailable = await this.$komgaReferential.getCharacters() + }, async loadAvailableGenres() { this.genresAvailable = await this.$komgaReferential.getGenres() }, @@ -809,6 +842,11 @@ export default Vue.extend({ const tagsLock = this.$_.uniq(series.map(x => x.metadata.tagsLock)) this.form.tagsLock = tagsLock.length > 1 ? false : tagsLock[0] + this.form.characters = [] + + const charactersLock = this.$_.uniq(series.map(x => x.metadata.charactersLock)) + this.form.charactersLock = charactersLock.length > 1 ? false : charactersLock[0] + this.form.sharingLabels = [] const sharingLabelsLock = this.$_.uniq(series.map(x => x.metadata.sharingLabelsLock)) @@ -819,6 +857,7 @@ export default Vue.extend({ } else { this.form.genres = [] this.form.tags = [] + this.form.characters = [] this.form.sharingLabels = [] this.form.links = [] this.form.alternateTitles = [] diff --git a/komga-webui/src/locales/en.json b/komga-webui/src/locales/en.json index e29d92c4c..c0390b558 100644 --- a/komga-webui/src/locales/en.json +++ b/komga-webui/src/locales/en.json @@ -278,6 +278,7 @@ "settings": "Settings", "sidecars": "Sidecars", "tags": "Tags", + "characters": "Characters", "ui": "User Interface", "unavailable": "Unavailable", "unlock_all": "Unlock all", @@ -434,6 +435,7 @@ "field_release_date_error": "Must be a valid date in YYYY-MM-DD format", "field_summary": "Summary", "field_tags": "Tags", + "field_characters": "Characters", "field_title": "Title", "number_sort_decrement": "Decrement all by 1", "number_sort_increment": "Increment all by 1", @@ -838,6 +840,7 @@ "age_rating": "age rating", "age_rating_none": "None", "any": "Any", + "character": "character", "complete": "Complete", "genre": "genre", "in_progress": "In Progress", diff --git a/komga-webui/src/services/komga-referential.service.ts b/komga-webui/src/services/komga-referential.service.ts index 4078fe004..6121a45f7 100644 --- a/komga-webui/src/services/komga-referential.service.ts +++ b/komga-webui/src/services/komga-referential.service.ts @@ -85,6 +85,18 @@ export default class KomgaReferentialService { } } + async getCharacters(): Promise { + try { + return (await this.http.get('/api/v1/characters')).data + } catch (e) { + let msg = 'An error occurred while trying to retrieve characters' + if (e.response.data.message) { + msg += `: ${e.response.data.message}` + } + throw new Error(msg) + } + } + async getSharingLabels(libraryIds?: string[], collectionId?: string): Promise { try { const params = {} as any @@ -123,6 +135,25 @@ export default class KomgaReferentialService { } } + async getSeriesAndBookCharacters(libraryIds?: string[], collectionId?: string): Promise { + try { + const params = {} as any + if (libraryIds) params.library_id = libraryIds + if (collectionId) params.collection_id = collectionId + + return (await this.http.get('/api/v1/characters', { + params: params, + paramsSerializer: params => qs.stringify(params, {indices: false}), + })).data + } catch (e) { + let msg = 'An error occurred while trying to retrieve series and book characters' + if (e.response.data.message) { + msg += `: ${e.response.data.message}` + } + throw new Error(msg) + } + } + async getBookTags(seriesId?: string, readListId?: string, libraryIds?: string[]): Promise { try { const params = {} as any diff --git a/komga-webui/src/types/komga-books.ts b/komga-webui/src/types/komga-books.ts index 4f9145215..de5f2c4b4 100644 --- a/komga-webui/src/types/komga-books.ts +++ b/komga-webui/src/types/komga-books.ts @@ -71,6 +71,8 @@ export interface BookMetadataDto { authorsLock: boolean, tags: string[], tagsLock: boolean, + characters: string[], + charactersLock: boolean, isbn: string, isbnLock: boolean links?: WebLinkDto[], @@ -100,6 +102,8 @@ export interface BookMetadataUpdateDto { authorsLock?: boolean, tags?: string[], tagsLock?: boolean + characters?: string[], + charactersLock?: boolean, isbn?: string, isbnLock?: boolean, links?: WebLinkDto[], diff --git a/komga-webui/src/types/komga-search.ts b/komga-webui/src/types/komga-search.ts index 804d315fa..ac02e1418 100644 --- a/komga-webui/src/types/komga-search.ts +++ b/komga-webui/src/types/komga-search.ts @@ -110,6 +110,14 @@ export class SearchConditionTag implements SearchConditionBook, SearchConditionS } } +export class SearchConditionCharacter implements SearchConditionBook, SearchConditionSeries { + character: SearchOperatorEquality + + constructor(op: SearchOperatorEquality) { + this.character = op + } +} + export class SearchConditionLanguage implements SearchConditionSeries { language: SearchOperatorEquality diff --git a/komga-webui/src/types/komga-series.ts b/komga-webui/src/types/komga-series.ts index 94052b9e9..b9d56883b 100644 --- a/komga-webui/src/types/komga-series.ts +++ b/komga-webui/src/types/komga-series.ts @@ -44,6 +44,8 @@ export interface SeriesMetadataDto { genresLock: boolean, tags: string[], tagsLock: boolean, + characters: string[], + charactersLock: boolean, totalBookCount?: number, totalBookCountLock: boolean, sharingLabels: string[], @@ -59,6 +61,7 @@ export interface SeriesBooksMetadataDto { lastModified: string authors: AuthorDto[], tags: string[], + characters: string[], releaseDate: string, summary: string, summaryNumber: string, @@ -85,6 +88,8 @@ export interface SeriesMetadataUpdateDto { genresLock?: boolean, tags?: string[], tagsLock?: boolean, + characters?: string[], + charactersLock?: boolean, totalBookCount?: number, totalBookCountLock: boolean, sharingLabels?: string[], diff --git a/komga-webui/src/views/BrowseBook.vue b/komga-webui/src/views/BrowseBook.vue index b351eb32c..8e6f7e036 100644 --- a/komga-webui/src/views/BrowseBook.vue +++ b/komga-webui/src/views/BrowseBook.vue @@ -342,6 +342,37 @@ + + + {{ $i18n.t('common.characters') }} + + + + + + {{ c }} + + + + + @@ -470,7 +501,7 @@ import RtlIcon from '@/components/RtlIcon.vue' import {BookSseDto, LibrarySseDto, ReadListSseDto, ReadProgressSseDto} from '@/types/komga-sse' import {RawLocation} from 'vue-router/types/router' import {ReadListDto} from '@/types/komga-readlists' -import {BookSearch, SearchConditionSeriesId, SearchConditionTag, SearchOperatorIs} from '@/types/komga-search' +import {BookSearch, SearchConditionCharacter, SearchConditionSeriesId, SearchConditionTag, SearchOperatorIs} from '@/types/komga-search' export default Vue.extend({ name: 'BrowseBook', @@ -479,6 +510,7 @@ export default Vue.extend({ return { MediaStatus, SearchConditionTag, + SearchConditionCharacter, SearchOperatorIs, book: {} as BookDto, context: {} as Context, diff --git a/komga-webui/src/views/BrowseBooks.vue b/komga-webui/src/views/BrowseBooks.vue index df0008dad..af5e6b7dc 100644 --- a/komga-webui/src/views/BrowseBooks.vue +++ b/komga-webui/src/views/BrowseBooks.vue @@ -158,6 +158,7 @@ import { SearchConditionAllOfBook, SearchConditionAnyOfBook, SearchConditionAuthor, + SearchConditionCharacter, SearchConditionDeleted, SearchConditionLibraryId, SearchConditionMediaProfile, @@ -222,6 +223,7 @@ export default Vue.extend({ drawer: false, filterOptions: { tag: [] as NameValue[], + character: [] as NameValue[], }, } }, @@ -375,6 +377,18 @@ export default Vue.extend({ ], anyAllSelector: true, }, + character: { + name: this.$t('filter.character').toString(), + values: [ + { + name: this.$t('filter.any').toString(), + value: new SearchConditionCharacter(new SearchOperatorIsNotNull()), + nValue: new SearchConditionCharacter(new SearchOperatorIsNull()), + }, + ...this.filterOptions.character, + ], + anyAllSelector: true, + }, mediaProfile: { name: this.$t('filter.media_profile').toString(), values: Object.values(MediaProfile).map(x => ({ name: i18n.t(`enums.media_profile.${x}`), @@ -447,17 +461,20 @@ export default Vue.extend({ const requestLibraryIds = libraryId !== LIBRARIES_ALL ? [libraryId] : this.$store.getters.getLibrariesPinned.map((it: LibraryDto) => it.id) // load dynamic filters - const [tags] = await Promise.all([ + const [tags, characters] = await Promise.all([ this.$komgaReferential.getBookTags(undefined, undefined, requestLibraryIds), + this.$komgaReferential.getCharacters(undefined, undefined, requestLibraryIds), ]) this.$set(this.filterOptions, 'tag', toNameValueCondition(tags, x => new SearchConditionTag(new SearchOperatorIs(x)), x => new SearchConditionTag(new SearchOperatorIsNot(x)))) + this.$set(this.filterOptions, 'character', toNameValueCondition(characters, x => new SearchConditionCharacter(new SearchOperatorIs(x)), x => new SearchConditionCharacter(new SearchOperatorIsNot(x)))) // get filter from query params or local storage and validate with available filter values let activeFilters: any - if (route.query.readStatus || route.query.tag || authorRoles.some(role => role in route.query) || route.query.oneshot || route.query.deleted || route.query.mediaProfile || route.query.mediaStatus) { + if (route.query.readStatus || route.query.tag || route.query.character || authorRoles.some(role => role in route.query) || route.query.oneshot || route.query.deleted || route.query.mediaProfile || route.query.mediaStatus) { activeFilters = { readStatus: route.query.readStatus || [], tag: route.query.tag || [], + character: route.query.character || [], oneshot: route.query.oneshot || [], deleted: route.query.deleted || [], mediaProfile: route.query.mediaProfile || [], @@ -491,6 +508,7 @@ export default Vue.extend({ const validFilter = { readStatus: this.$_.intersectionWith(filters.readStatus, extractFilterOptionsValues(this.filterOptionsList.readStatus.values), objIsEqual) || [], tag: this.$_.intersectionWith(filters.tag, extractFilterOptionsValues(this.filterOptions.tag), objIsEqual) || [], + character: this.$_.intersectionWith(filters.character, extractFilterOptionsValues(this.filterOptions.character), objIsEqual) || [], oneshot: this.$_.intersectionWith(filters.oneshot, extractFilterOptionsValues(this.filterOptionsList.oneshot.values), objIsEqual) || [], deleted: this.$_.intersectionWith(filters.deleted, extractFilterOptionsValues(this.filterOptionsList.deleted.values), objIsEqual) || [], mediaProfile: this.$_.intersectionWith(filters.mediaProfile, extractFilterOptionsValues(this.filterOptionsPanel.mediaProfile.values), objIsEqual) || [], @@ -603,6 +621,7 @@ export default Vue.extend({ } if (this.filters.readStatus && this.filters.readStatus.length > 0) conditions.push(new SearchConditionAnyOfBook(this.filters.readStatus)) if (this.filters.tag && this.filters.tag.length > 0) this.filtersMode?.tag?.allOf ? conditions.push(new SearchConditionAllOfBook(this.filters.tag)) : conditions.push(new SearchConditionAnyOfBook(this.filters.tag)) + if (this.filters.character && this.filters.character.length > 0) this.filtersMode?.character?.allOf ? conditions.push(new SearchConditionAllOfBook(this.filters.character)) : conditions.push(new SearchConditionAnyOfBook(this.filters.character)) if (this.filters.oneshot && this.filters.oneshot.length > 0) conditions.push(...this.filters.oneshot) if (this.filters.mediaProfile && this.filters.mediaProfile.length > 0) this.filtersMode?.mediaProfile?.allOf ? conditions.push(new SearchConditionAllOfBook(this.filters.mediaProfile)) : conditions.push(new SearchConditionAnyOfBook(this.filters.mediaProfile)) if (this.filters.deleted && this.filters.deleted.length > 0) conditions.push(...this.filters.deleted) diff --git a/komga-webui/src/views/BrowseLibraries.vue b/komga-webui/src/views/BrowseLibraries.vue index 33f5d0e71..e826e95d2 100644 --- a/komga-webui/src/views/BrowseLibraries.vue +++ b/komga-webui/src/views/BrowseLibraries.vue @@ -171,6 +171,7 @@ import { SearchConditionAnyOfBook, SearchConditionAnyOfSeries, SearchConditionAuthor, + SearchConditionCharacter, SearchConditionComplete, SearchConditionDeleted, SearchConditionGenre, @@ -250,6 +251,7 @@ export default Vue.extend({ filterOptions: { genre: [] as NameValue[], tag: [] as NameValue[], + character: [] as NameValue[], publisher: [] as NameValue[], language: [] as NameValue[], ageRating: [] as NameValue[], @@ -459,6 +461,18 @@ export default Vue.extend({ ], anyAllSelector: true, }, + character: { + name: this.$t('filter.character').toString(), + values: [ + { + name: this.$t('filter.any').toString(), + value: new SearchConditionCharacter(new SearchOperatorIsNotNull()), + nValue: new SearchConditionCharacter(new SearchOperatorIsNull()), + }, + ...this.filterOptions.character, + ], + anyAllSelector: true, + }, publisher: { name: this.$t('filter.publisher').toString(), values: [ @@ -579,9 +593,10 @@ export default Vue.extend({ const requestLibraryIds = libraryId !== LIBRARIES_ALL ? [libraryId] : this.$store.getters.getLibrariesPinned.map((it: LibraryDto) => it.id) // load dynamic filters - const [genres, tags, publishers, languages, ageRatings, releaseDates, sharingLabels] = await Promise.all([ + const [genres, tags, characters, publishers, languages, ageRatings, releaseDates, sharingLabels] = await Promise.all([ this.$komgaReferential.getGenres(requestLibraryIds), this.$komgaReferential.getSeriesAndBookTags(requestLibraryIds), + this.$komgaReferential.getSeriesAndBookCharacters(requestLibraryIds), this.$komgaReferential.getPublishers(requestLibraryIds), this.$komgaReferential.getLanguages(requestLibraryIds), this.$komgaReferential.getAgeRatings(requestLibraryIds), @@ -590,6 +605,7 @@ export default Vue.extend({ ]) this.$set(this.filterOptions, 'genre', toNameValueCondition(genres, x => new SearchConditionGenre(new SearchOperatorIs(x)), x => new SearchConditionGenre(new SearchOperatorIsNot(x)))) this.$set(this.filterOptions, 'tag', toNameValueCondition(tags, x => new SearchConditionTag(new SearchOperatorIs(x)), x => new SearchConditionTag(new SearchOperatorIsNot(x)))) + this.$set(this.filterOptions, 'character', toNameValueCondition(characters, x => new SearchConditionCharacter(new SearchOperatorIs(x)), x => new SearchConditionCharacter(new SearchOperatorIsNot(x)))) this.$set(this.filterOptions, 'publisher', toNameValueCondition(publishers, x => new SearchConditionPublisher(new SearchOperatorIs(x)), x => new SearchConditionPublisher(new SearchOperatorIsNot(x)))) this.$set(this.filterOptions, 'language', languages.map((x: NameValue) => { return { @@ -620,12 +636,13 @@ export default Vue.extend({ // get filter from query params or local storage and validate with available filter values let activeFilters: any - if (route.query.status || route.query.readStatus || route.query.genre || route.query.tag || route.query.language || route.query.ageRating || route.query.publisher || authorRoles.some(role => role in route.query) || route.query.complete || route.query.oneshot || route.query.sharingLabel || route.query.deleted) { + if (route.query.status || route.query.readStatus || route.query.genre || route.query.tag || route.query.character || route.query.language || route.query.ageRating || route.query.publisher || authorRoles.some(role => role in route.query) || route.query.complete || route.query.oneshot || route.query.sharingLabel || route.query.deleted) { activeFilters = { status: route.query.status || [], readStatus: route.query.readStatus || [], genre: route.query.genre || [], tag: route.query.tag || [], + character: route.query.character || [], publisher: route.query.publisher || [], language: route.query.language || [], ageRating: route.query.ageRating || [], @@ -665,6 +682,7 @@ export default Vue.extend({ readStatus: this.$_.intersectionWith(filters.readStatus, extractFilterOptionsValues(this.filterOptionsList.readStatus.values), objIsEqual) || [], genre: this.$_.intersectionWith(filters.genre, extractFilterOptionsValues(this.filterOptions.genre), objIsEqual) || [], tag: this.$_.intersectionWith(filters.tag, extractFilterOptionsValues(this.filterOptions.tag), objIsEqual) || [], + character: this.$_.intersectionWith(filters.character, extractFilterOptionsValues(this.filterOptions.character), objIsEqual) || [], publisher: this.$_.intersectionWith(filters.publisher, extractFilterOptionsValues(this.filterOptions.publisher), objIsEqual) || [], language: this.$_.intersectionWith(filters.language, extractFilterOptionsValues(this.filterOptions.language), objIsEqual) || [], ageRating: this.$_.intersectionWith(filters.ageRating, extractFilterOptionsValues(this.filterOptions.ageRating), objIsEqual) || [], @@ -786,6 +804,7 @@ export default Vue.extend({ if (this.filters.readStatus && this.filters.readStatus.length > 0) conditions.push(new SearchConditionAnyOfSeries(this.filters.readStatus)) if (this.filters.genre && this.filters.genre.length > 0) this.filtersMode?.genre?.allOf ? conditions.push(new SearchConditionAllOfSeries(this.filters.genre)) : conditions.push(new SearchConditionAnyOfSeries(this.filters.genre)) if (this.filters.tag && this.filters.tag.length > 0) this.filtersMode?.tag?.allOf ? conditions.push(new SearchConditionAllOfSeries(this.filters.tag)) : conditions.push(new SearchConditionAnyOfSeries(this.filters.tag)) + if (this.filters.character && this.filters.character.length > 0) this.filtersMode?.character?.allOf ? conditions.push(new SearchConditionAllOfSeries(this.filters.character)) : conditions.push(new SearchConditionAnyOfSeries(this.filters.character)) if (this.filters.language && this.filters.language.length > 0) this.filtersMode?.language?.allOf ? conditions.push(new SearchConditionAllOfSeries(this.filters.language)) : conditions.push(new SearchConditionAnyOfSeries(this.filters.language)) if (this.filters.publisher && this.filters.publisher.length > 0) this.filtersMode?.publisher?.allOf ? conditions.push(new SearchConditionAllOfSeries(this.filters.publisher)) : conditions.push(new SearchConditionAnyOfSeries(this.filters.publisher)) if (this.filters.ageRating && this.filters.ageRating.length > 0) this.filtersMode?.ageRating?.allOf ? conditions.push(new SearchConditionAllOfSeries(this.filters.ageRating)) : conditions.push(new SearchConditionAnyOfSeries(this.filters.ageRating)) diff --git a/komga-webui/src/views/BrowseOneshot.vue b/komga-webui/src/views/BrowseOneshot.vue index ac2b11851..140158924 100644 --- a/komga-webui/src/views/BrowseOneshot.vue +++ b/komga-webui/src/views/BrowseOneshot.vue @@ -413,6 +413,37 @@ + + + {{ $t('common.characters') }} + + + + + + {{ c }} + + + + + @@ -573,6 +604,7 @@ import OneshotActionsMenu from '@/components/menus/OneshotActionsMenu.vue' import { BookSearch, SearchConditionAgeRating, + SearchConditionCharacter, SearchConditionGenre, SearchConditionLanguage, SearchConditionPublisher, @@ -595,6 +627,7 @@ export default Vue.extend({ SearchConditionPublisher, SearchConditionGenre, SearchConditionTag, + SearchConditionCharacter, SearchConditionLanguage, SearchConditionAgeRating, SearchOperatorIs, diff --git a/komga-webui/src/views/BrowseSeries.vue b/komga-webui/src/views/BrowseSeries.vue index 198c51228..405283873 100644 --- a/komga-webui/src/views/BrowseSeries.vue +++ b/komga-webui/src/views/BrowseSeries.vue @@ -381,6 +381,39 @@ + + + {{ $t('common.characters') }} + + + + + + {{ c }} + + + + + {{ $t('browse_book.links') }} @@ -555,6 +588,7 @@ import { SearchConditionAnyOfBook, SearchConditionAuthor, SearchConditionBook, + SearchConditionCharacter, SearchConditionDeleted, SearchConditionGenre, SearchConditionLanguage, @@ -610,6 +644,7 @@ export default Vue.extend({ SearchConditionPublisher, SearchConditionGenre, SearchConditionTag, + SearchConditionCharacter, SearchConditionLanguage, SearchConditionAgeRating, SearchOperatorIs, diff --git a/komga/src/flyway/resources/db/migration/sqlite/V20251012120000__book_metadata_characters.sql b/komga/src/flyway/resources/db/migration/sqlite/V20251012120000__book_metadata_characters.sql new file mode 100644 index 000000000..0629a6dd5 --- /dev/null +++ b/komga/src/flyway/resources/db/migration/sqlite/V20251012120000__book_metadata_characters.sql @@ -0,0 +1,28 @@ +-- Add characters support to book metadata +-- Following the same pattern as tags (BOOK_METADATA_TAG and BOOK_METADATA_AGGREGATION_TAG) + +-- Table for book-level characters +CREATE TABLE BOOK_METADATA_CHARACTER +( + CHARACTER varchar NOT NULL, + BOOK_ID varchar NOT NULL, + FOREIGN KEY (BOOK_ID) REFERENCES BOOK (ID) +); + +-- Index for performance +CREATE INDEX idx__book_metadata_character__book_id on BOOK_METADATA_CHARACTER (BOOK_ID); + +-- Add characters lock column to book metadata +alter table BOOK_METADATA + add column CHARACTERS_LOCK boolean NOT NULL DEFAULT 0; + +-- Table for series-level aggregated characters +CREATE TABLE BOOK_METADATA_AGGREGATION_CHARACTER +( + CHARACTER varchar NOT NULL, + SERIES_ID varchar NOT NULL, + FOREIGN KEY (SERIES_ID) REFERENCES SERIES (ID) +); + +-- Index for performance +CREATE INDEX idx__book_metadata_aggregation_character__series_id on BOOK_METADATA_AGGREGATION_CHARACTER (SERIES_ID); diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/model/BookMetadata.kt b/komga/src/main/kotlin/org/gotson/komga/domain/model/BookMetadata.kt index 07e84f003..cffda3d49 100644 --- a/komga/src/main/kotlin/org/gotson/komga/domain/model/BookMetadata.kt +++ b/komga/src/main/kotlin/org/gotson/komga/domain/model/BookMetadata.kt @@ -12,6 +12,7 @@ class BookMetadata( val releaseDate: LocalDate? = null, val authors: List = emptyList(), tags: Set = emptySet(), + characters: Set = emptySet(), val isbn: String = "", val links: List = emptyList(), val titleLock: Boolean = false, @@ -21,6 +22,7 @@ class BookMetadata( val releaseDateLock: Boolean = false, val authorsLock: Boolean = false, val tagsLock: Boolean = false, + val charactersLock: Boolean = false, val isbnLock: Boolean = false, val linksLock: Boolean = false, val bookId: String = "", @@ -31,6 +33,7 @@ class BookMetadata( val summary = summary.trim() val number = number.trim() val tags = tags.lowerNotBlank().toSet() + val characters = characters.lowerNotBlank().toSet() fun copy( title: String = this.title, @@ -40,6 +43,7 @@ class BookMetadata( releaseDate: LocalDate? = this.releaseDate, authors: List = this.authors.toList(), tags: Set = this.tags, + characters: Set = this.characters, isbn: String = this.isbn, links: List = this.links, titleLock: Boolean = this.titleLock, @@ -49,6 +53,7 @@ class BookMetadata( releaseDateLock: Boolean = this.releaseDateLock, authorsLock: Boolean = this.authorsLock, tagsLock: Boolean = this.tagsLock, + charactersLock: Boolean = this.charactersLock, isbnLock: Boolean = this.isbnLock, linksLock: Boolean = this.linksLock, bookId: String = this.bookId, @@ -62,6 +67,7 @@ class BookMetadata( releaseDate = releaseDate, authors = authors, tags = tags, + characters = characters, isbn = isbn, links = links, titleLock = titleLock, @@ -71,6 +77,7 @@ class BookMetadata( releaseDateLock = releaseDateLock, authorsLock = authorsLock, tagsLock = tagsLock, + charactersLock = charactersLock, isbnLock = isbnLock, linksLock = linksLock, bookId = bookId, @@ -78,5 +85,5 @@ class BookMetadata( lastModifiedDate = lastModifiedDate, ) - override fun toString(): String = "BookMetadata(numberSort=$numberSort, releaseDate=$releaseDate, authors=$authors, isbn='$isbn', links=$links, titleLock=$titleLock, summaryLock=$summaryLock, numberLock=$numberLock, numberSortLock=$numberSortLock, releaseDateLock=$releaseDateLock, authorsLock=$authorsLock, tagsLock=$tagsLock, isbnLock=$isbnLock, linksLock=$linksLock, bookId='$bookId', createdDate=$createdDate, lastModifiedDate=$lastModifiedDate, title='$title', summary='$summary', number='$number', tags=$tags)" + override fun toString(): String = "BookMetadata(numberSort=$numberSort, releaseDate=$releaseDate, authors=$authors, isbn='$isbn', links=$links, titleLock=$titleLock, summaryLock=$summaryLock, numberLock=$numberLock, numberSortLock=$numberSortLock, releaseDateLock=$releaseDateLock, authorsLock=$authorsLock, tagsLock=$tagsLock, charactersLock=$charactersLock, isbnLock=$isbnLock, linksLock=$linksLock, bookId='$bookId', createdDate=$createdDate, lastModifiedDate=$lastModifiedDate, title='$title', summary='$summary', number='$number', tags=$tags, characters=$characters)" } diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/model/BookMetadataAggregation.kt b/komga/src/main/kotlin/org/gotson/komga/domain/model/BookMetadataAggregation.kt index 4763ea276..39db7567b 100644 --- a/komga/src/main/kotlin/org/gotson/komga/domain/model/BookMetadataAggregation.kt +++ b/komga/src/main/kotlin/org/gotson/komga/domain/model/BookMetadataAggregation.kt @@ -6,6 +6,7 @@ import java.time.LocalDateTime data class BookMetadataAggregation( val authors: List = emptyList(), val tags: Set = emptySet(), + val characters: Set = emptySet(), val releaseDate: LocalDate? = null, val summary: String = "", val summaryNumber: String = "", diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/model/BookMetadataPatch.kt b/komga/src/main/kotlin/org/gotson/komga/domain/model/BookMetadataPatch.kt index 8c902773f..5ce06c486 100644 --- a/komga/src/main/kotlin/org/gotson/komga/domain/model/BookMetadataPatch.kt +++ b/komga/src/main/kotlin/org/gotson/komga/domain/model/BookMetadataPatch.kt @@ -12,6 +12,7 @@ data class BookMetadataPatch( val isbn: String? = null, val links: List? = null, val tags: Set? = null, + val characters: Set? = null, val readLists: List = emptyList(), ) { data class ReadListEntry( @@ -28,6 +29,7 @@ enum class BookMetadataPatchCapability { RELEASE_DATE, AUTHORS, TAGS, + CHARACTERS, ISBN, READ_LISTS, THUMBNAILS, diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/model/SearchCondition.kt b/komga/src/main/kotlin/org/gotson/komga/domain/model/SearchCondition.kt index fcccf3ad2..6e9d1207d 100644 --- a/komga/src/main/kotlin/org/gotson/komga/domain/model/SearchCondition.kt +++ b/komga/src/main/kotlin/org/gotson/komga/domain/model/SearchCondition.kt @@ -105,6 +105,12 @@ class SearchCondition { ) : Book, Series + data class Character( + @JsonProperty("character") + val operator: SearchOperator.EqualityNullable, + ) : Book, + Series + data class SharingLabel( @JsonProperty("sharingLabel") val operator: SearchOperator.EqualityNullable, diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/persistence/ReferentialRepository.kt b/komga/src/main/kotlin/org/gotson/komga/domain/persistence/ReferentialRepository.kt index 898291281..a6648022c 100644 --- a/komga/src/main/kotlin/org/gotson/komga/domain/persistence/ReferentialRepository.kt +++ b/komga/src/main/kotlin/org/gotson/komga/domain/persistence/ReferentialRepository.kt @@ -99,6 +99,18 @@ interface ReferentialRepository { filterOnLibraryIds: Collection?, ): Set + fun findAllSeriesAndBookCharacters(filterOnLibraryIds: Collection?): Set + + fun findAllSeriesAndBookCharactersByLibraries( + libraryIds: Set, + filterOnLibraryIds: Collection?, + ): Set + + fun findAllSeriesAndBookCharactersByCollection( + collectionId: String, + filterOnLibraryIds: Collection?, + ): Set + fun findAllSeriesTags(filterOnLibraryIds: Collection?): Set fun findAllSeriesTagsByLibrary( diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/service/MetadataAggregator.kt b/komga/src/main/kotlin/org/gotson/komga/domain/service/MetadataAggregator.kt index eb970a7a7..4a8aacfe5 100644 --- a/komga/src/main/kotlin/org/gotson/komga/domain/service/MetadataAggregator.kt +++ b/komga/src/main/kotlin/org/gotson/komga/domain/service/MetadataAggregator.kt @@ -9,6 +9,7 @@ class MetadataAggregator { fun aggregate(metadatas: Collection): BookMetadataAggregation { val authors = metadatas.flatMap { it.authors }.distinctBy { "${it.role}__${it.name}" } val tags = metadatas.flatMap { it.tags }.toSet() + val characters = metadatas.flatMap { it.characters }.toSet() val (summary, summaryNumber) = metadatas .sortedBy { it.numberSort } @@ -18,6 +19,6 @@ class MetadataAggregator { } ?: ("" to "") val releaseDate = metadatas.mapNotNull { it.releaseDate }.minOrNull() - return BookMetadataAggregation(authors = authors, tags = tags, releaseDate = releaseDate, summary = summary, summaryNumber = summaryNumber) + return BookMetadataAggregation(authors = authors, tags = tags, characters = characters, releaseDate = releaseDate, summary = summary, summaryNumber = summaryNumber) } } diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/service/MetadataApplier.kt b/komga/src/main/kotlin/org/gotson/komga/domain/service/MetadataApplier.kt index 9168a607e..9aed552f7 100644 --- a/komga/src/main/kotlin/org/gotson/komga/domain/service/MetadataApplier.kt +++ b/komga/src/main/kotlin/org/gotson/komga/domain/service/MetadataApplier.kt @@ -33,6 +33,7 @@ class MetadataApplier { isbn = getIfNotLocked(isbn, patch.isbn, isbnLock), links = getIfNotLocked(links, patch.links, linksLock), tags = getIfNotLocked(tags, patch.tags, tagsLock), + characters = getIfNotLocked(characters, patch.characters, charactersLock), ) } diff --git a/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/BookSearchHelper.kt b/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/BookSearchHelper.kt index 232c547ac..ae1781da0 100644 --- a/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/BookSearchHelper.kt +++ b/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/BookSearchHelper.kt @@ -168,6 +168,33 @@ class BookSearchHelper( } } to emptySet() + is SearchCondition.Character -> + Tables.BOOK.ID.let { field -> + val innerEquals = { character: String -> + DSL + .select(Tables.BOOK_METADATA_CHARACTER.BOOK_ID) + .from(Tables.BOOK_METADATA_CHARACTER) + .where( + Tables.BOOK_METADATA_CHARACTER.CHARACTER + .collate(SqliteUdfDataSource.COLLATION_UNICODE_3) + .equalIgnoreCase(character), + ) + } + val innerAny = { + DSL + .select(Tables.BOOK_METADATA_CHARACTER.BOOK_ID) + .from(Tables.BOOK_METADATA_CHARACTER) + .where(Tables.BOOK_METADATA_CHARACTER.CHARACTER.isNotNull) + } + + when (searchCondition.operator) { + is SearchOperator.Is -> field.`in`(innerEquals(searchCondition.operator.value)) + is SearchOperator.IsNot -> field.notIn(innerEquals(searchCondition.operator.value)) + is SearchOperator.IsNullT<*> -> field.notIn(innerAny()) + is SearchOperator.IsNotNullT<*> -> field.`in`(innerAny()) + } + } to emptySet() + is SearchCondition.Author -> Tables.BOOK.ID.let { field -> val inner = { name: String?, role: String? -> diff --git a/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/SeriesSearchHelper.kt b/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/SeriesSearchHelper.kt index 9de826df3..347685403 100644 --- a/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/SeriesSearchHelper.kt +++ b/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/SeriesSearchHelper.kt @@ -137,6 +137,33 @@ class SeriesSearchHelper( } } to emptySet() + is SearchCondition.Character -> + Tables.SERIES.ID.let { field -> + val innerEquals = { character: String -> + DSL + .select(Tables.BOOK_METADATA_AGGREGATION_CHARACTER.SERIES_ID) + .from(Tables.BOOK_METADATA_AGGREGATION_CHARACTER) + .where( + Tables.BOOK_METADATA_AGGREGATION_CHARACTER.CHARACTER + .collate(SqliteUdfDataSource.COLLATION_UNICODE_3) + .equalIgnoreCase(character), + ) + } + val innerAny = { + DSL + .select(Tables.BOOK_METADATA_AGGREGATION_CHARACTER.SERIES_ID) + .from(Tables.BOOK_METADATA_AGGREGATION_CHARACTER) + .where(Tables.BOOK_METADATA_AGGREGATION_CHARACTER.CHARACTER.isNotNull) + } + + when (searchCondition.operator) { + is SearchOperator.Is -> field.`in`(innerEquals(searchCondition.operator.value)) + is SearchOperator.IsNot -> field.notIn(innerEquals(searchCondition.operator.value)) + is SearchOperator.IsNullT<*> -> field.notIn(innerAny()) + is SearchOperator.IsNotNullT<*> -> field.`in`(innerAny()) + } + } to emptySet() + is SearchCondition.Author -> Tables.SERIES.ID.let { field -> val inner = { name: String?, role: String? -> diff --git a/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/main/BookDtoDao.kt b/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/main/BookDtoDao.kt index 7231899da..5925a7d58 100644 --- a/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/main/BookDtoDao.kt +++ b/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/main/BookDtoDao.kt @@ -67,6 +67,7 @@ class BookDtoDao( private val sd = Tables.SERIES_METADATA private val rlb = Tables.READLIST_BOOK private val bt = Tables.BOOK_METADATA_TAG + private val bc = Tables.BOOK_METADATA_CHARACTER private val bl = Tables.BOOK_METADATA_LINK private val onDeckFields = b.fields() + m.fields() + d.fields() + r.fields() + sd.TITLE @@ -445,6 +446,7 @@ class BookDtoDao( lateinit var authors: Map> lateinit var tags: Map> + lateinit var characters: Map> lateinit var links: Map> dsl.withTempTable(batchSize, bookIds).use { tempTable -> authors = @@ -460,6 +462,12 @@ class BookDtoDao( .where(bt.BOOK_ID.`in`(tempTable.selectTempStrings())) .groupBy({ it.bookId }, { it.tag }) + characters = + dsl + .selectFrom(bc) + .where(bc.BOOK_ID.`in`(tempTable.selectTempStrings())) + .groupBy({ it.bookId }, { it.character }) + links = dsl .selectFrom(bl) @@ -475,7 +483,7 @@ class BookDtoDao( val rr = rec.into(r) val seriesTitle = rec.into(sd.TITLE).component1() - br.toDto(mr.toDto(), dr.toDto(authors[br.id].orEmpty(), tags[br.id].orEmpty().toSet(), links[br.id].orEmpty()), if (rr.userId != null) rr.toDto() else null, seriesTitle) + br.toDto(mr.toDto(), dr.toDto(authors[br.id].orEmpty(), tags[br.id].orEmpty().toSet(), characters[br.id].orEmpty().toSet(), links[br.id].orEmpty()), if (rr.userId != null) rr.toDto() else null, seriesTitle) } } @@ -517,6 +525,7 @@ class BookDtoDao( private fun BookMetadataRecord.toDto( authors: List, tags: Set, + characters: Set, links: List, ) = BookMetadataDto( title = title, @@ -533,6 +542,8 @@ class BookDtoDao( authorsLock = authorsLock, tags = tags, tagsLock = tagsLock, + characters = characters, + charactersLock = charactersLock, isbn = isbn, isbnLock = isbnLock, links = links, diff --git a/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/main/BookMetadataAggregationDao.kt b/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/main/BookMetadataAggregationDao.kt index d0167e00e..83a9f5f47 100644 --- a/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/main/BookMetadataAggregationDao.kt +++ b/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/main/BookMetadataAggregationDao.kt @@ -27,6 +27,7 @@ class BookMetadataAggregationDao( private val d = Tables.BOOK_METADATA_AGGREGATION private val a = Tables.BOOK_METADATA_AGGREGATION_AUTHOR private val t = Tables.BOOK_METADATA_AGGREGATION_TAG + private val c = Tables.BOOK_METADATA_AGGREGATION_CHARACTER override fun findById(seriesId: String): BookMetadataAggregation = dslRO.findOne(listOf(seriesId)).first() @@ -43,7 +44,7 @@ class BookMetadataAggregationDao( { it.into(d) }, { it.into(a) }, ).map { (dr, ar) -> - dr.toDomain(ar.filterNot { it.name == null }.map { it.toDomain() }, this.findTags(dr.seriesId)) + dr.toDomain(ar.filterNot { it.name == null }.map { it.toDomain() }, this.findTags(dr.seriesId), this.findCharacters(dr.seriesId)) } private fun DSLContext.findTags(seriesId: String) = @@ -53,6 +54,13 @@ class BookMetadataAggregationDao( .where(t.SERIES_ID.eq(seriesId)) .fetchSet(t.TAG) + private fun DSLContext.findCharacters(seriesId: String) = + this + .select(c.CHARACTER) + .from(c) + .where(c.SERIES_ID.eq(seriesId)) + .fetchSet(c.CHARACTER) + @Transactional override fun insert(metadata: BookMetadataAggregation) { dslRW @@ -88,8 +96,14 @@ class BookMetadataAggregationDao( .where(t.SERIES_ID.eq(metadata.seriesId)) .execute() + dslRW + .deleteFrom(c) + .where(c.SERIES_ID.eq(metadata.seriesId)) + .execute() + dslRW.insertAuthors(metadata) dslRW.insertTags(metadata) + dslRW.insertCharacters(metadata) } private fun DSLContext.insertAuthors(metadata: BookMetadataAggregation) { @@ -126,10 +140,28 @@ class BookMetadataAggregationDao( } } + private fun DSLContext.insertCharacters(metadata: BookMetadataAggregation) { + if (metadata.characters.isNotEmpty()) { + metadata.characters.chunked(batchSize).forEach { chunk -> + this + .batch( + this + .insertInto(c, c.SERIES_ID, c.CHARACTER) + .values(null as String?, null), + ).also { step -> + chunk.forEach { + step.bind(metadata.seriesId, it) + } + }.execute() + } + } + } + @Transactional override fun delete(seriesId: String) { dslRW.deleteFrom(a).where(a.SERIES_ID.eq(seriesId)).execute() dslRW.deleteFrom(t).where(t.SERIES_ID.eq(seriesId)).execute() + dslRW.deleteFrom(c).where(c.SERIES_ID.eq(seriesId)).execute() dslRW.deleteFrom(d).where(d.SERIES_ID.eq(seriesId)).execute() } @@ -138,6 +170,7 @@ class BookMetadataAggregationDao( dslRW.withTempTable(batchSize, seriesIds).use { dslRW.deleteFrom(a).where(a.SERIES_ID.`in`(it.selectTempStrings())).execute() dslRW.deleteFrom(t).where(t.SERIES_ID.`in`(it.selectTempStrings())).execute() + dslRW.deleteFrom(c).where(c.SERIES_ID.`in`(it.selectTempStrings())).execute() dslRW.deleteFrom(d).where(d.SERIES_ID.`in`(it.selectTempStrings())).execute() } } @@ -147,9 +180,11 @@ class BookMetadataAggregationDao( private fun BookMetadataAggregationRecord.toDomain( authors: List, tags: Set, + characters: Set, ) = BookMetadataAggregation( authors = authors, tags = tags, + characters = characters, releaseDate = releaseDate, summary = summary, summaryNumber = summaryNumber, diff --git a/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/main/BookMetadataDao.kt b/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/main/BookMetadataDao.kt index bbbcf299b..c1d31fc38 100644 --- a/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/main/BookMetadataDao.kt +++ b/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/main/BookMetadataDao.kt @@ -29,6 +29,7 @@ class BookMetadataDao( private val d = Tables.BOOK_METADATA private val a = Tables.BOOK_METADATA_AUTHOR private val bt = Tables.BOOK_METADATA_TAG + private val bc = Tables.BOOK_METADATA_CHARACTER private val bl = Tables.BOOK_METADATA_LINK private val groupFields = arrayOf(*d.fields(), *a.fields()) @@ -52,7 +53,7 @@ class BookMetadataDao( { it.into(d) }, { it.into(a) }, ).map { (dr, ar) -> - dr.toDomain(ar.filterNot { it.name == null }.map { it.toDomain() }, this.findTags(dr.bookId), this.findLinks(dr.bookId)) + dr.toDomain(ar.filterNot { it.name == null }.map { it.toDomain() }, this.findTags(dr.bookId), this.findCharacters(dr.bookId), this.findLinks(dr.bookId)) } private fun DSLContext.findTags(bookId: String) = @@ -62,6 +63,13 @@ class BookMetadataDao( .where(bt.BOOK_ID.eq(bookId)) .fetchSet(bt.TAG) + private fun DSLContext.findCharacters(bookId: String) = + this + .select(bc.CHARACTER) + .from(bc) + .where(bc.BOOK_ID.eq(bookId)) + .fetchSet(bc.CHARACTER) + private fun DSLContext.findLinks(bookId: String) = this .select(bl.LABEL, bl.URL) @@ -97,10 +105,11 @@ class BookMetadataDao( d.RELEASE_DATE_LOCK, d.AUTHORS_LOCK, d.TAGS_LOCK, + d.CHARACTERS_LOCK, d.ISBN, d.ISBN_LOCK, d.LINKS_LOCK, - ).values(null as String?, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null), + ).values(null as String?, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null), ).also { step -> chunk.forEach { step.bind( @@ -117,6 +126,7 @@ class BookMetadataDao( it.releaseDateLock, it.authorsLock, it.tagsLock, + it.charactersLock, it.isbn, it.isbnLock, it.linksLock, @@ -127,6 +137,7 @@ class BookMetadataDao( dslRW.insertAuthors(metadatas) dslRW.insertTags(metadatas) + dslRW.insertCharacters(metadatas) dslRW.insertLinks(metadatas) } } @@ -156,6 +167,7 @@ class BookMetadataDao( .set(d.RELEASE_DATE_LOCK, metadata.releaseDateLock) .set(d.AUTHORS_LOCK, metadata.authorsLock) .set(d.TAGS_LOCK, metadata.tagsLock) + .set(d.CHARACTERS_LOCK, metadata.charactersLock) .set(d.ISBN, metadata.isbn) .set(d.ISBN_LOCK, metadata.isbnLock) .set(d.LINKS_LOCK, metadata.linksLock) @@ -171,6 +183,10 @@ class BookMetadataDao( .deleteFrom(bt) .where(bt.BOOK_ID.eq(metadata.bookId)) .execute() + dslRW + .deleteFrom(bc) + .where(bc.BOOK_ID.eq(metadata.bookId)) + .execute() dslRW .deleteFrom(bl) .where(bl.BOOK_ID.eq(metadata.bookId)) @@ -178,6 +194,7 @@ class BookMetadataDao( dslRW.insertAuthors(listOf(metadata)) dslRW.insertTags(listOf(metadata)) + dslRW.insertCharacters(listOf(metadata)) dslRW.insertLinks(listOf(metadata)) } @@ -219,6 +236,25 @@ class BookMetadataDao( } } + private fun DSLContext.insertCharacters(metadatas: Collection) { + if (metadatas.any { it.characters.isNotEmpty() }) { + metadatas.chunked(batchSize).forEach { chunk -> + this + .batch( + this + .insertInto(bc, bc.BOOK_ID, bc.CHARACTER) + .values(null as String?, null), + ).also { step -> + chunk.forEach { metadata -> + metadata.characters.forEach { + step.bind(metadata.bookId, it) + } + } + }.execute() + } + } + } + private fun DSLContext.insertLinks(metadatas: Collection) { if (metadatas.any { it.links.isNotEmpty() }) { metadatas.chunked(batchSize).forEach { chunk -> @@ -242,6 +278,7 @@ class BookMetadataDao( override fun delete(bookId: String) { dslRW.deleteFrom(a).where(a.BOOK_ID.eq(bookId)).execute() dslRW.deleteFrom(bt).where(bt.BOOK_ID.eq(bookId)).execute() + dslRW.deleteFrom(bc).where(bc.BOOK_ID.eq(bookId)).execute() dslRW.deleteFrom(bl).where(bl.BOOK_ID.eq(bookId)).execute() dslRW.deleteFrom(d).where(d.BOOK_ID.eq(bookId)).execute() } @@ -251,6 +288,7 @@ class BookMetadataDao( dslRW.withTempTable(batchSize, bookIds).use { dslRW.deleteFrom(a).where(a.BOOK_ID.`in`(it.selectTempStrings())).execute() dslRW.deleteFrom(bt).where(bt.BOOK_ID.`in`(it.selectTempStrings())).execute() + dslRW.deleteFrom(bc).where(bc.BOOK_ID.`in`(it.selectTempStrings())).execute() dslRW.deleteFrom(bl).where(bl.BOOK_ID.`in`(it.selectTempStrings())).execute() dslRW.deleteFrom(d).where(d.BOOK_ID.`in`(it.selectTempStrings())).execute() } @@ -261,6 +299,7 @@ class BookMetadataDao( private fun BookMetadataRecord.toDomain( authors: List, tags: Set, + characters: Set, links: List, ) = BookMetadata( title = title, @@ -270,6 +309,7 @@ class BookMetadataDao( releaseDate = releaseDate, authors = authors, tags = tags, + characters = characters, isbn = isbn, links = links, bookId = bookId, @@ -282,6 +322,7 @@ class BookMetadataDao( releaseDateLock = releaseDateLock, authorsLock = authorsLock, tagsLock = tagsLock, + charactersLock = charactersLock, isbnLock = isbnLock, linksLock = linksLock, ) diff --git a/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/main/ReferentialDao.kt b/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/main/ReferentialDao.kt index 35ad984b7..572b75fb4 100644 --- a/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/main/ReferentialDao.kt +++ b/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/main/ReferentialDao.kt @@ -37,6 +37,7 @@ class ReferentialDao( private val g = Tables.SERIES_METADATA_GENRE private val bt = Tables.BOOK_METADATA_TAG private val st = Tables.SERIES_METADATA_TAG + private val bc = Tables.BOOK_METADATA_CHARACTER private val cs = Tables.COLLECTION_SERIES private val rb = Tables.READLIST_BOOK private val sl = Tables.SERIES_METADATA_SHARING @@ -341,6 +342,49 @@ class ReferentialDao( .sortedBy { it.stripAccents().lowercase() } .toSet() + override fun findAllSeriesAndBookCharacters(filterOnLibraryIds: Collection?): Set = + dslRO + .select(bc.CHARACTER.`as`("character")) + .from(bc) + .apply { filterOnLibraryIds?.let { leftJoin(b).on(bc.BOOK_ID.eq(b.ID)).where(b.LIBRARY_ID.`in`(it)) } } + .fetchSet(0, String::class.java) + .sortedBy { it.stripAccents().lowercase() } + .toSet() + + override fun findAllSeriesAndBookCharactersByLibraries( + libraryIds: Set, + filterOnLibraryIds: Collection?, + ): Set = + dslRO + .select(bc.CHARACTER.`as`("character")) + .from(bc) + .leftJoin(b) + .on(bc.BOOK_ID.eq(b.ID)) + .where(b.LIBRARY_ID.`in`(libraryIds)) + .apply { filterOnLibraryIds?.let { and(b.LIBRARY_ID.`in`(it)) } } + .fetchSet(0, String::class.java) + .sortedBy { it.stripAccents().lowercase() } + .toSet() + + override fun findAllSeriesAndBookCharactersByCollection( + collectionId: String, + filterOnLibraryIds: Collection?, + ): Set = + dslRO + .select(bc.CHARACTER.`as`("character")) + .from(bc) + .leftJoin(b) + .on(bc.BOOK_ID.eq(b.ID)) + .leftJoin(s) + .on(b.SERIES_ID.eq(s.ID)) + .leftJoin(cs) + .on(s.ID.eq(cs.SERIES_ID)) + .where(cs.COLLECTION_ID.eq(collectionId)) + .apply { filterOnLibraryIds?.let { and(s.LIBRARY_ID.`in`(it)) } } + .fetchSet(0, String::class.java) + .sortedBy { it.stripAccents().lowercase() } + .toSet() + override fun findAllSeriesTags(filterOnLibraryIds: Collection?): Set = dslRO .select(st.TAG) diff --git a/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/main/SeriesDtoDao.kt b/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/main/SeriesDtoDao.kt index 7157afaad..5b047d021 100644 --- a/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/main/SeriesDtoDao.kt +++ b/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/main/SeriesDtoDao.kt @@ -71,6 +71,7 @@ class SeriesDtoDao( private val bma = Tables.BOOK_METADATA_AGGREGATION private val bmaa = Tables.BOOK_METADATA_AGGREGATION_AUTHOR private val bmat = Tables.BOOK_METADATA_AGGREGATION_TAG + private val bmac = Tables.BOOK_METADATA_AGGREGATION_CHARACTER private val groupFields = arrayOf( @@ -310,6 +311,7 @@ class SeriesDtoDao( lateinit var alternateTitles: Map> lateinit var aggregatedAuthors: Map> lateinit var aggregatedTags: Map> + lateinit var aggregatedCharacters: Map> dsl.withTempTable(batchSize, seriesIds).use { tempTable -> genres = @@ -354,6 +356,12 @@ class SeriesDtoDao( .selectFrom(bmat) .where(bmat.SERIES_ID.`in`(tempTable.selectTempStrings())) .groupBy({ it.seriesId }, { it.tag }) + + aggregatedCharacters = + dsl + .selectFrom(bmac) + .where(bmac.SERIES_ID.`in`(tempTable.selectTempStrings())) + .groupBy({ it.seriesId }, { it.character }) } return records @@ -372,7 +380,7 @@ class SeriesDtoDao( booksUnreadCount, booksInProgressCount, dr.toDto(genres[sr.id].orEmpty().toSet(), tags[sr.id].orEmpty().toSet(), sharingLabels[sr.id].orEmpty().toSet(), links[sr.id].orEmpty(), alternateTitles[sr.id].orEmpty()), - bmar.toDto(aggregatedAuthors[sr.id].orEmpty(), aggregatedTags[sr.id].orEmpty().toSet()), + bmar.toDto(aggregatedAuthors[sr.id].orEmpty(), aggregatedTags[sr.id].orEmpty().toSet(), aggregatedCharacters[sr.id].orEmpty().toSet()), ) } } @@ -450,9 +458,11 @@ class SeriesDtoDao( private fun BookMetadataAggregationRecord.toDto( authors: List, tags: Set, + characters: Set, ) = BookMetadataAggregationDto( authors = authors, tags = tags, + characters = characters, releaseDate = releaseDate, summary = summary, summaryNumber = summaryNumber, diff --git a/komga/src/main/kotlin/org/gotson/komga/infrastructure/metadata/comicrack/ComicInfoProvider.kt b/komga/src/main/kotlin/org/gotson/komga/infrastructure/metadata/comicrack/ComicInfoProvider.kt index 1262a1958..563954175 100644 --- a/komga/src/main/kotlin/org/gotson/komga/infrastructure/metadata/comicrack/ComicInfoProvider.kt +++ b/komga/src/main/kotlin/org/gotson/komga/infrastructure/metadata/comicrack/ComicInfoProvider.kt @@ -103,6 +103,7 @@ class ComicInfoProvider( } val tags = comicInfo.tags?.split(',')?.mapNotNull { it.trim().lowercase().ifBlank { null } } + val characters = comicInfo.characters?.split(',')?.mapNotNull { it.trim().lowercase().ifBlank { null } } val isbn = comicInfo.gtin?.let { isbnValidator.validate(it) } @@ -116,6 +117,7 @@ class ComicInfoProvider( readLists = readLists, links = links?.ifEmpty { null }, tags = if (!tags.isNullOrEmpty()) tags.toSet() else null, + characters = if (!characters.isNullOrEmpty()) characters.toSet() else null, isbn = isbn, ) } diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/ReferentialController.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/ReferentialController.kt index f0c3cb99e..14b4b7fa6 100644 --- a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/ReferentialController.kt +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/ReferentialController.kt @@ -142,6 +142,19 @@ class ReferentialController( else -> referentialRepository.findAllBookTags(principal.user.getAuthorizedLibraryIds(null)) } + @GetMapping("v1/characters") + @Operation(summary = "List characters", description = "Can be filtered by various criteria") + fun getCharacters( + @AuthenticationPrincipal principal: KomgaPrincipal, + @RequestParam(name = "library_id", required = false) libraryIds: Set = emptySet(), + @RequestParam(name = "collection_id", required = false) collectionId: String?, + ): Set = + when { + libraryIds.isNotEmpty() -> referentialRepository.findAllSeriesAndBookCharactersByLibraries(libraryIds, principal.user.getAuthorizedLibraryIds(null)) + collectionId != null -> referentialRepository.findAllSeriesAndBookCharactersByCollection(collectionId, principal.user.getAuthorizedLibraryIds(null)) + else -> referentialRepository.findAllSeriesAndBookCharacters(principal.user.getAuthorizedLibraryIds(null)) + } + @GetMapping("v1/tags/series") @Operation(summary = "List series tags", description = "Can be filtered by various criteria") fun getSeriesTags( diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/BookDto.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/BookDto.kt index 1b451e701..7a68ed139 100644 --- a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/BookDto.kt +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/BookDto.kt @@ -60,6 +60,8 @@ data class BookMetadataDto( val authorsLock: Boolean, val tags: Set, val tagsLock: Boolean, + val characters: Set, + val charactersLock: Boolean, val isbn: String, val isbnLock: Boolean, val links: List, diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/BookMetadataUpdateDto.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/BookMetadataUpdateDto.kt index cc17ef2ac..4a6ae0e00 100644 --- a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/BookMetadataUpdateDto.kt +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/BookMetadataUpdateDto.kt @@ -60,6 +60,13 @@ class BookMetadataUpdateDto { var tagsLock: Boolean? = null + var characters: Set? + by Delegates.observable(null) { prop, _, _ -> + isSet[prop.name] = true + } + + var charactersLock: Boolean? = null + @get:NullOrBlankOrISBN var isbn: String? by Delegates.observable(null) { prop, _, _ -> @@ -120,6 +127,13 @@ fun BookMetadata.patch(patch: BookMetadataUpdateDto) = this.tags }, tagsLock = patch.tagsLock ?: this.tagsLock, + characters = + if (patch.isSet("characters")) { + if (patch.characters != null) patch.characters!! else emptySet() + } else { + this.characters + }, + charactersLock = patch.charactersLock ?: this.charactersLock, isbn = if (patch.isSet("isbn")) patch.isbn?.filter { it.isDigit() } ?: "" else this.isbn, isbnLock = patch.isbnLock ?: this.isbnLock, links = diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/SeriesDto.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/SeriesDto.kt index 1f3cdf125..12f94779d 100644 --- a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/SeriesDto.kt +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/SeriesDto.kt @@ -65,6 +65,7 @@ data class SeriesMetadataDto( data class BookMetadataAggregationDto( val authors: List = emptyList(), val tags: Set = emptySet(), + val characters: Set = emptySet(), @JsonFormat(pattern = "yyyy-MM-dd") val releaseDate: LocalDate?, val summary: String, diff --git a/komga/src/test/kotlin/org/gotson/komga/domain/service/TransientBookLifecycleTest.kt b/komga/src/test/kotlin/org/gotson/komga/domain/service/TransientBookLifecycleTest.kt index 371da1d98..f317eb9af 100644 --- a/komga/src/test/kotlin/org/gotson/komga/domain/service/TransientBookLifecycleTest.kt +++ b/komga/src/test/kotlin/org/gotson/komga/domain/service/TransientBookLifecycleTest.kt @@ -60,7 +60,7 @@ class TransientBookLifecycleTest( Media(), ) - every { mockProvider.getBookMetadataFromBook(any()) } returns BookMetadataPatch(null, null, null, 15F, null, null, null, null, null, emptyList()) + every { mockProvider.getBookMetadataFromBook(any()) } returns BookMetadataPatch(null, null, null, 15F, null, null, null, null, null, emptySet()) every { mockProvider.getSeriesMetadataFromBook(any(), any()) } returns SeriesMetadataPatch("BATMAN", null, null, null, null, null, null, null, null, null, emptySet()) val (seriesId, number) = transientBookLifecycle.getMetadata(book) @@ -82,7 +82,7 @@ class TransientBookLifecycleTest( Media(), ) - every { mockProvider.getBookMetadataFromBook(any()) } returns BookMetadataPatch(null, null, null, null, null, null, null, null, null, emptyList()) + every { mockProvider.getBookMetadataFromBook(any()) } returns BookMetadataPatch(null, null, null, null, null, null, null, null, null, emptySet()) every { mockProvider.getSeriesMetadataFromBook(any(), any()) } returns SeriesMetadataPatch(null, null, null, null, null, null, null, null, null, null, emptySet()) val (seriesId, number) = transientBookLifecycle.getMetadata(book) @@ -104,7 +104,7 @@ class TransientBookLifecycleTest( Media(), ) - every { mockProvider.getBookMetadataFromBook(any()) } returns BookMetadataPatch(null, null, null, null, null, null, null, null, null, emptyList()) + every { mockProvider.getBookMetadataFromBook(any()) } returns BookMetadataPatch(null, null, null, null, null, null, null, null, null, emptySet()) every { mockProvider.getSeriesMetadataFromBook(any(), any()) } returns SeriesMetadataPatch(" ", null, null, null, null, null, null, null, null, null, emptySet()) val (seriesId, number) = transientBookLifecycle.getMetadata(book) diff --git a/komga/src/test/kotlin/org/gotson/komga/infrastructure/metadata/comicrack/ComicInfoProviderTest.kt b/komga/src/test/kotlin/org/gotson/komga/infrastructure/metadata/comicrack/ComicInfoProviderTest.kt index 129c8e9ed..7fb07f1b0 100644 --- a/komga/src/test/kotlin/org/gotson/komga/infrastructure/metadata/comicrack/ComicInfoProviderTest.kt +++ b/komga/src/test/kotlin/org/gotson/komga/infrastructure/metadata/comicrack/ComicInfoProviderTest.kt @@ -95,6 +95,24 @@ class ComicInfoProviderTest { } } + @Test + fun `given comicInfo with characters when getting book metadata then characters are parsed correctly`() { + val comicInfo = + ComicInfo().apply { + characters = "Spider-Man, Mary Jane Watson, Green Goblin" + } + + every { mockMapper.readValue(any(), ComicInfo::class.java) } returns comicInfo + + val patch = comicInfoProvider.getBookMetadataFromBook(BookWithMedia(book, media)) + + with(patch!!) { + assertThat(characters as Iterable) + .hasSize(3) + .containsExactlyInAnyOrder("spider-man", "mary jane watson", "green goblin") + } + } + @Test fun `given comicInfo with single link when getting book metadata then metadata patch is valid`() { val comicInfo =