diff --git a/.gitignore b/.gitignore
index 7e048bb66..21ba16774 100644
--- a/.gitignore
+++ b/.gitignore
@@ -54,6 +54,22 @@ application-oauth2.yml
/benchmark
/release_notes
+### 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
+CHARACTERS_IMPLEMENTATION_SUMMARY.md
+
### Conveyor
output/
secret/
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') }}
+
+
+
+ {{ form.charactersLock ? 'mdi-lock' : 'mdi-lock-open' }}
+
+
+
+
+
@@ -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') }}
+
+
+
+ {{ form.book.charactersLock ? 'mdi-lock' : 'mdi-lock-open' }}
+
+
+
+
+
@@ -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') }}
+
+
+
+ {{ form.charactersLock ? 'mdi-lock' : 'mdi-lock-open' }}
+
+
+
+
+
@@ -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') }}
+
+
+
+
+ mdi-chevron-left
+
+
+
+
+
+ mdi-chevron-right
+
+
+ {{ 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') }}
+
+
+
+
+ mdi-chevron-left
+
+
+
+
+
+ mdi-chevron-right
+
+
+ {{ 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') }}
+
+
+
+
+ mdi-chevron-left
+
+
+
+
+
+ mdi-chevron-right
+
+
+ {{ 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/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/"]
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 =