diff --git a/.github/stale.yml b/.github/stale.yml deleted file mode 100644 index b2741c349..000000000 --- a/.github/stale.yml +++ /dev/null @@ -1,18 +0,0 @@ -# Number of days of inactivity before an issue becomes stale -daysUntilStale: 60 -# Number of days of inactivity before a stale issue is closed -daysUntilClose: 7 -# Issues with these labels will never be considered stale -exemptLabels: - - pinned - - security - - tech debt -# Label to use when marking an issue as stale -staleLabel: stale -# Comment to post when marking an issue as stale. Set to `false` to disable -markComment: > - This issue has been automatically marked as stale because it has not had - recent activity. It will be closed if no further activity occurs. Thank you - for your contributions. -# Comment to post when closing a stale issue. Set to `false` to disable -closeComment: false diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 564cc608c..292181bca 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -67,6 +67,9 @@ jobs: restore-keys: ${{ runner.os }}-gradle - name: npmBuild run: ./gradlew npmBuild + - name: npmTest + run: npm run test:unit + working-directory: komga-webui release: name: Semantic Release diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml new file mode 100644 index 000000000..3399368f4 --- /dev/null +++ b/.github/workflows/stale.yml @@ -0,0 +1,23 @@ +name: 'Close stale issues and PRs' +on: + schedule: + - cron: '30 1 * * *' + +jobs: + stale: + runs-on: ubuntu-latest + steps: + - uses: actions/stale@v3 + with: + stale-issue-message: > + This issue has been automatically marked as stale because it has not had + recent activity. It will be closed if no further activity occurs. Thank you + for your contributions. + stale-pr-message: > + This pull request has been automatically marked as stale because it has not had + recent activity. It will be closed if no further activity occurs. Thank you + for your contributions. + days-before-stale: 90 + days-before-close: 14 + exempt-issue-labels: 'pinned,security,tech debt' + exempt-all-assignees: true diff --git a/.husky/pre-commit b/.husky/pre-commit index fe1f396a5..801ff5eb8 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -2,4 +2,4 @@ . "$(dirname "$0")/_/husky.sh" npm run --prefix komga-webui lint -./gradlew ktlintFormat +./gradlew ktlintCheck diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml index 2acbb5bd6..ea881c68b 100644 --- a/.idea/codeStyles/Project.xml +++ b/.idea/codeStyles/Project.xml @@ -2,31 +2,15 @@ - - \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 9def350be..a1ab5c8bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,85 @@ +## [0.88.1](https://github.com/gotson/komga/compare/v0.88.0...v0.88.1) (2021-04-26) + + +### Bug Fixes + +* replace java webp library ([f658f9a](https://github.com/gotson/komga/commit/f658f9abe04896cbce6590bae0783836f4e59f74)) + +# [0.88.0](https://github.com/gotson/komga/compare/v0.87.5...v0.88.0) (2021-04-26) + + +### Features + +* **webui:** ability to read incognito ([dc0cc13](https://github.com/gotson/komga/commit/dc0cc1380793bf870e11444debf1872a6cefc95c)) + +## [0.87.5](https://github.com/gotson/komga/compare/v0.87.4...v0.87.5) (2021-04-26) + + +### Bug Fixes + +* **webui:** double pages could show duplicate pages ([76ba55a](https://github.com/gotson/komga/commit/76ba55a1242cfc90bceaddef8520719ac1165854)) +* **webui:** mark read progress instantly ([97cc3e0](https://github.com/gotson/komga/commit/97cc3e043d15255bc53641bdf0b5cd53cb5c14c4)), closes [#475](https://github.com/gotson/komga/issues/475) +* **webui:** scroll continuous reader to top on book change ([d27828d](https://github.com/gotson/komga/commit/d27828de13fdbe348055fe4c23d129a29ad974e8)), closes [#475](https://github.com/gotson/komga/issues/475) + +## [0.87.4](https://github.com/gotson/komga/compare/v0.87.3...v0.87.4) (2021-04-21) + + +### Bug Fixes + +* **api:** filter referential data by access rights ([09fa5e9](https://github.com/gotson/komga/commit/09fa5e95a5a59f2136a7992468ca77a257a267a5)), closes [#492](https://github.com/gotson/komga/issues/492) + +## [0.87.3](https://github.com/gotson/komga/compare/v0.87.2...v0.87.3) (2021-04-21) + + +### Bug Fixes + +* error importing reading list with non-numerical number ([3ef0240](https://github.com/gotson/komga/commit/3ef02409bac07ee64c0136e34aac6afcf7c1aba4)), closes [#499](https://github.com/gotson/komga/issues/499) +* **webui:** stored filters are not validated ([fbb4171](https://github.com/gotson/komga/commit/fbb4171d9bd3727d3826e650fe97822ec146bd4d)), closes [#504](https://github.com/gotson/komga/issues/504) + +## [0.87.2](https://github.com/gotson/komga/compare/v0.87.1...v0.87.2) (2021-04-20) + + +### Bug Fixes + +* translated using Weblate (Esperanto) ([19ff72f](https://github.com/gotson/komga/commit/19ff72f9e34fce599b3f9691dcaa78b8a21909ef)) +* **importer:** keep metadata when upgrading book ([a3b8866](https://github.com/gotson/komga/commit/a3b88667d0364edf68f5a906053af0977c176135)) +* prevent transient scanning of directories that are part of existing libraries ([8a92b84](https://github.com/gotson/komga/commit/8a92b84fd06c7b6b8ae3ab6975f7802708598de3)) +* **importer:** prevent import of files that are in an existing library ([b0170c7](https://github.com/gotson/komga/commit/b0170c7c8840089811108e275e76cf4bb0350d8b)) + +## [0.87.1](https://github.com/gotson/komga/compare/v0.87.0...v0.87.1) (2021-04-20) + + +### Bug Fixes + +* **importer:** hardlink fails inside docker ([24cf181](https://github.com/gotson/komga/commit/24cf1819b02dd2625d7a6327a17ef2081cf52e64)) + +# [0.87.0](https://github.com/gotson/komga/compare/v0.86.0...v0.87.0) (2021-04-19) + + +### Bug Fixes + +* translated using Weblate (Esperanto) ([#506](https://github.com/gotson/komga/issues/506)) ([248f474](https://github.com/gotson/komga/commit/248f47458cb82d777e0f5823ddbe044ca76092c7)) + + +### Features + +* **api:** import books ([d41dcef](https://github.com/gotson/komga/commit/d41dcefd3efd4f9844d5b3b1d336a246c320a1ec)) +* **api:** support for transient books ([02b0893](https://github.com/gotson/komga/commit/02b08932babd27b5b309b3038279885ac65d0821)) +* **webui:** import books ([13b304d](https://github.com/gotson/komga/commit/13b304dd147f3102345c2edb85d41f87ccae1871)) +* added translation using Weblate (Esperanto, Polish) ([f3cc6f6](https://github.com/gotson/komga/commit/f3cc6f6e916862741cd7ff3aafa98a4c587653c6)) + +# [0.86.0](https://github.com/gotson/komga/compare/v0.85.1...v0.86.0) (2021-04-07) + + +### Bug Fixes + +* **webui:** series year incorrectly formatted ([d166207](https://github.com/gotson/komga/commit/d16620791243201f2e2eb0910201f73e2c2975f7)) + + +### Features + +* added translation using Weblate (Finnish) ([81142ab](https://github.com/gotson/komga/commit/81142ab570ea9ce1cfd964e7c3205d0c1a9ead7a)) + ## [0.85.1](https://github.com/gotson/komga/compare/v0.85.0...v0.85.1) (2021-03-31) diff --git a/ERRORCODES.md b/ERRORCODES.md index a8906cccb..dd2a9d9a0 100644 --- a/ERRORCODES.md +++ b/ERRORCODES.md @@ -1,3 +1,4 @@ + # Error codes Code | Description @@ -18,3 +19,5 @@ ERR_1012 | No match for series ERR_1013 | No unique match for book number within series ERR_1014 | No match for book number within series ERR_1015 | Error while deserializing ComicRack ReadingList +ERR_1016 | Directory not accessible or not a directory +ERR_1017 | Cannot scan folder that is part of an existing library diff --git a/build.gradle.kts b/build.gradle.kts index b24c50565..a9b10d450 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,3 +1,5 @@ +import com.github.benmanes.gradle.versions.updates.DependencyUpdatesTask + plugins { run { val kotlinVersion = "1.4.31" @@ -9,12 +11,29 @@ plugins { id("com.github.ben-manes.versions") version "0.38.0" } +fun isNonStable(version: String): Boolean { + val stableKeyword = listOf("RELEASE", "FINAL", "GA").any { version.toUpperCase().contains(it) } + val unstableKeyword = listOf("ALPHA", "RC").any { version.toUpperCase().contains(it) } + val regex = "^[0-9,.v-]+(-r)?$".toRegex() + val isStable = stableKeyword || regex.matches(version) + return unstableKeyword || !isStable +} + allprojects { repositories { mavenCentral() } apply(plugin = "org.jlleitschuh.gradle.ktlint") apply(plugin = "com.github.ben-manes.versions") + + tasks.named("dependencyUpdates").configure { + // disallow release candidates as upgradable versions from stable versions + rejectVersionIf { + isNonStable(candidate.version) && !isNonStable(currentVersion) + } + gradleReleaseChannel = "current" + checkConstraints = true + } } tasks.wrapper { diff --git a/gradle.properties b/gradle.properties index 72f901d97..51e4a5077 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1 +1 @@ -version=0.85.1 +version=0.88.1 diff --git a/komga-webui/src/components/FileImportRow.vue b/komga-webui/src/components/FileImportRow.vue new file mode 100644 index 000000000..85db4d905 --- /dev/null +++ b/komga-webui/src/components/FileImportRow.vue @@ -0,0 +1,262 @@ + + + + + diff --git a/komga-webui/src/components/HorizontalScroller.vue b/komga-webui/src/components/HorizontalScroller.vue index 3c7ea455d..2069e0fb2 100644 --- a/komga-webui/src/components/HorizontalScroller.vue +++ b/komga-webui/src/components/HorizontalScroller.vue @@ -7,14 +7,12 @@ - mdi-chevron-right - mdi-chevron-left + - mdi-chevron-left - mdi-chevron-right + @@ -31,9 +29,11 @@ + + diff --git a/komga-webui/src/components/RtlIcon.vue b/komga-webui/src/components/RtlIcon.vue new file mode 100644 index 000000000..5df7d5122 --- /dev/null +++ b/komga-webui/src/components/RtlIcon.vue @@ -0,0 +1,26 @@ + + + + + diff --git a/komga-webui/src/components/dialogs/FileNameChooserDialog.vue b/komga-webui/src/components/dialogs/FileNameChooserDialog.vue new file mode 100644 index 000000000..de9e4453e --- /dev/null +++ b/komga-webui/src/components/dialogs/FileNameChooserDialog.vue @@ -0,0 +1,134 @@ + + + + + diff --git a/komga-webui/src/components/dialogs/SeriesPickerDialog.vue b/komga-webui/src/components/dialogs/SeriesPickerDialog.vue new file mode 100644 index 000000000..21ed89a37 --- /dev/null +++ b/komga-webui/src/components/dialogs/SeriesPickerDialog.vue @@ -0,0 +1,127 @@ + + + + + diff --git a/komga-webui/src/components/dialogs/TransientBookDetailsDialog.vue b/komga-webui/src/components/dialogs/TransientBookDetailsDialog.vue new file mode 100644 index 000000000..bd76887b9 --- /dev/null +++ b/komga-webui/src/components/dialogs/TransientBookDetailsDialog.vue @@ -0,0 +1,117 @@ + + + + + diff --git a/komga-webui/src/components/dialogs/TransientBookViewerDialog.vue b/komga-webui/src/components/dialogs/TransientBookViewerDialog.vue new file mode 100644 index 000000000..824d5d0d0 --- /dev/null +++ b/komga-webui/src/components/dialogs/TransientBookViewerDialog.vue @@ -0,0 +1,228 @@ + + + + + diff --git a/komga-webui/src/components/readers/ContinuousReader.vue b/komga-webui/src/components/readers/ContinuousReader.vue index e64a96607..97cf52bb9 100644 --- a/komga-webui/src/components/readers/ContinuousReader.vue +++ b/komga-webui/src/components/readers/ContinuousReader.vue @@ -37,8 +37,8 @@ + + diff --git a/komga-webui/src/views/BookReader.vue b/komga-webui/src/views/BookReader.vue index f5557d0e3..95023cbad 100644 --- a/komga-webui/src/views/BookReader.vue +++ b/komga-webui/src/views/BookReader.vue @@ -20,6 +20,13 @@ {{ bookTitle }} + + + {{ $t('bookreader.tooltip_incognito') }} + + @@ -324,6 +331,7 @@ export default Vue.extend({ series: {} as SeriesDto, context: {} as Context, contextName: '', + incognito: false, siblingPrevious: {} as BookDto, siblingNext: {} as BookDto, jumpToNextBook: false, @@ -427,7 +435,7 @@ export default Vue.extend({ page(val) { this.updateRoute() this.goToPage = val - this.markProgress(val) + // this.markProgress(val) }, }, computed: { @@ -587,6 +595,9 @@ export default Vue.extend({ document.title = `Komga - ${getBookTitleCompact(this.book.metadata.title, this.series.metadata.title)}` } + // parse query params to get incognito mode + this.incognito = !!(this.$route.query.incognito && this.$route.query.incognito.toString().toLowerCase() === 'true'); + const pageDtos = (await this.$komgaBooks.getBookPages(bookId)) pageDtos.forEach((p: any) => p['url'] = this.getPageUrl(p)) this.pages = pageDtos as PageDtoWithUrl[] @@ -654,7 +665,7 @@ export default Vue.extend({ this.$router.push({ name: 'read-book', params: {bookId: this.siblingPrevious.id.toString()}, - query: {context: this.context.origin, contextId: this.context.id}, + query: {context: this.context.origin, contextId: this.context.id, incognito: this.incognito.toString()}, }) } }, @@ -666,12 +677,13 @@ export default Vue.extend({ this.$router.push({ name: 'read-book', params: {bookId: this.siblingNext.id.toString()}, - query: {context: this.context.origin, contextId: this.context.id}, + query: {context: this.context.origin, contextId: this.context.id, incognito: this.incognito.toString()}, }) } }, goTo(page: number) { this.page = page + this.markProgress(page) }, goToFirst() { this.goTo(1) @@ -687,6 +699,7 @@ export default Vue.extend({ page: this.page.toString(), context: this.context.origin, contextId: this.context.id, + incognito: this.incognito.toString(), }, } as Location) }, @@ -770,7 +783,8 @@ export default Vue.extend({ this.notification.enabled = true }, async markProgress(page: number) { - await this.$komgaBooks.updateReadProgress(this.bookId, {page: page}) + if (!this.incognito) + await this.$komgaBooks.updateReadProgress(this.bookId, {page: page}) }, downloadCurrentPage() { new jsFileDownloader({ diff --git a/komga-webui/src/views/BrowseBook.vue b/komga-webui/src/views/BrowseBook.vue index 869c85749..d6f0a4ab9 100644 --- a/komga-webui/src/views/BrowseBook.vue +++ b/komga-webui/src/views/BrowseBook.vue @@ -29,8 +29,7 @@ :disabled="$_.isEmpty(siblingPrevious)" :to="{ name: 'browse-book', params: { bookId: previousId }, query: { context: context.origin, contextId: context.id} }" > - mdi-chevron-right - mdi-chevron-left + @@ -67,8 +66,7 @@ :disabled="$_.isEmpty(siblingNext)" :to="{ name: 'browse-book', params: { bookId: nextId }, query: { context: context.origin, contextId: context.id} }" > - mdi-chevron-left - mdi-chevron-right + @@ -104,7 +102,9 @@ {{ book.metadata.number }} · {{ book.media.pagesCount }} {{ $t('common.pages') }} - {{ new Intl.DateTimeFormat($i18n.locale, { dateStyle: 'long' }).format(new Date(book.metadata.releaseDate)) }} + {{ + new Intl.DateTimeFormat($i18n.locale, {dateStyle: 'long'}).format(new Date(book.metadata.releaseDate)) + }} @@ -122,6 +122,18 @@ {{ $t('common.read') }} + + + + mdi-incognito + {{ $t('common.read') }} + + + + + + + mdi-incognito + {{ $t('common.read') }} + + + { return { authorRoles, diff --git a/komga-webui/src/views/BrowseCollection.vue b/komga-webui/src/views/BrowseCollection.vue index b6d477d4d..4c3e8ae66 100644 --- a/komga-webui/src/views/BrowseCollection.vue +++ b/komga-webui/src/views/BrowseCollection.vue @@ -121,7 +121,7 @@ import FilterPanels from '@/components/FilterPanels.vue' import FilterList from '@/components/FilterList.vue' import {Location} from 'vue-router' import EmptyState from '@/components/EmptyState.vue' -import {parseQueryFilter} from '@/functions/query-params' +import {parseQueryParam} from '@/functions/query-params' import {SeriesDto} from "@/types/komga-series"; import {authorRoles} from "@/types/author-roles"; import {groupAuthorsByRole} from "@/functions/authors"; @@ -258,7 +258,7 @@ export default Vue.extend({ value: x.id, }))) this.$set(this.filterOptions, 'genre', toNameValue(await this.$komgaReferential.getGenres(undefined, collectionId))) - this.$set(this.filterOptions, 'tag', toNameValue(await this.$komgaReferential.getTags(undefined, undefined, collectionId))) + this.$set(this.filterOptions, 'tag', toNameValue(await this.$komgaReferential.getSeriesTags(undefined, collectionId))) this.$set(this.filterOptions, 'publisher', toNameValue(await this.$komgaReferential.getPublishers(undefined, collectionId))) this.$set(this.filterOptions, 'language', (await this.$komgaReferential.getLanguages(undefined, collectionId))) this.$set(this.filterOptions, 'ageRating', toNameValue(await this.$komgaReferential.getAgeRatings(undefined, collectionId))) @@ -268,24 +268,44 @@ export default Vue.extend({ this.$set(this.filterOptions, role, role in grouped ? toNameValue(grouped[role]) : []) }) - // filter query params with available filter values + // 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.library || route.query.publisher || authorRoles.some(role => role in route.query)) { - this.$set(this.filters, 'status', parseQueryFilter(route.query.status, Object.keys(SeriesStatus))) - this.$set(this.filters, 'readStatus', parseQueryFilter(route.query.readStatus, Object.keys(ReadStatus))) - this.$set(this.filters, 'library', parseQueryFilter(route.query.library, this.filterOptions.library.map(x => x.value))) - this.$set(this.filters, 'genre', parseQueryFilter(route.query.genre, this.filterOptions.genre.map(x => x.value))) - this.$set(this.filters, 'tag', parseQueryFilter(route.query.tag, this.filterOptions.tag.map(x => x.value))) - this.$set(this.filters, 'publisher', parseQueryFilter(route.query.publisher, this.filterOptions.publisher.map(x => x.value))) - this.$set(this.filters, 'language', parseQueryFilter(route.query.language, this.filterOptions.language.map(x => x.value))) - this.$set(this.filters, 'ageRating', parseQueryFilter(route.query.ageRating, this.filterOptions.ageRating.map(x => x.value))) - this.$set(this.filters, 'releaseDate', parseQueryFilter(route.query.releaseDate, this.filterOptions.releaseDate.map(x => x.value))) + activeFilters = { + status: parseQueryParam(route.query.status), + readStatus: parseQueryParam(route.query.readStatus), + library: parseQueryParam(route.query.library), + genre: parseQueryParam(route.query.genre), + tag: parseQueryParam(route.query.tag), + publisher: parseQueryParam(route.query.publisher), + language: parseQueryParam(route.query.language), + ageRating: parseQueryParam(route.query.ageRating), + releaseDate: parseQueryParam(route.query.releaseDate), + } authorRoles.forEach((role: string) => { - //@ts-ignore - this.$set(this.filters, role, parseQueryFilter(route.query[role], this.filterOptions[role].map((x: NameValue) => x.value))) + activeFilters[role] = parseQueryParam(route.query[role]) }) } else { - this.filters = this.$store.getters.getCollectionFilter(route.params.collectionId) || {} as FiltersActive + activeFilters = this.$store.getters.getCollectionFilter(route.params.collectionId) || {} as FiltersActive } + this.filters = this.validateFilters(activeFilters) + }, + validateFilters(filters: FiltersActive): FiltersActive { + const validFilter = { + status: filters.status?.filter(x => Object.keys(SeriesStatus).includes(x)) || [], + readStatus: filters.readStatus?.filter(x => Object.keys(ReadStatus).includes(x)) || [], + library: filters.library?.filter(x => this.filterOptions.library.map(n => n.value).includes(x)) || [], + genre: filters.genre?.filter(x => this.filterOptions.genre.map(n => n.value).includes(x)) || [], + tag: filters.tag?.filter(x => this.filterOptions.tag.map(n => n.value).includes(x)) || [], + publisher: filters.publisher?.filter(x => this.filterOptions.publisher.map(n => n.value).includes(x)) || [], + language: filters.language?.filter(x => this.filterOptions.language.map(n => n.value).includes(x)) || [], + ageRating: filters.ageRating?.filter(x => this.filterOptions.ageRating.map(n => n.value).includes(x)) || [], + releaseDate: filters.releaseDate?.filter(x => this.filterOptions.releaseDate.map(n => n.value).includes(x)) || [], + } as any + authorRoles.forEach((role: string) => { + validFilter[role] = filters[role]?.filter(x => ((this.filterOptions as any)[role] as NameValue[]).map(n => n.value).includes(x)) || [] + }) + return validFilter }, setWatches() { this.filterUnwatch = this.$watch('filters', (val) => { diff --git a/komga-webui/src/views/BrowseLibraries.vue b/komga-webui/src/views/BrowseLibraries.vue index e9a253735..cfe67526a 100644 --- a/komga-webui/src/views/BrowseLibraries.vue +++ b/komga-webui/src/views/BrowseLibraries.vue @@ -104,7 +104,7 @@ import ItemBrowser from '@/components/ItemBrowser.vue' import LibraryNavigation from '@/components/LibraryNavigation.vue' import LibraryActionsMenu from '@/components/menus/LibraryActionsMenu.vue' import PageSizeSelect from '@/components/PageSizeSelect.vue' -import {parseQueryFilter, parseQuerySort} from '@/functions/query-params' +import {parseQueryParam, parseQuerySort} from '@/functions/query-params' import {ReadStatus} from '@/types/enum-books' import {SeriesStatus, SeriesStatusKeyValue} from '@/types/enum-series' import {LIBRARY_CHANGED, LIBRARY_DELETED, SERIES_CHANGED} from '@/types/events' @@ -288,7 +288,7 @@ export default Vue.extend({ // load dynamic filters this.$set(this.filterOptions, 'genre', toNameValue(await this.$komgaReferential.getGenres(requestLibraryId))) - this.$set(this.filterOptions, 'tag', toNameValue(await this.$komgaReferential.getTags(requestLibraryId))) + this.$set(this.filterOptions, 'tag', toNameValue(await this.$komgaReferential.getSeriesTags(requestLibraryId))) this.$set(this.filterOptions, 'publisher', toNameValue(await this.$komgaReferential.getPublishers(requestLibraryId))) this.$set(this.filterOptions, 'language', (await this.$komgaReferential.getLanguages(requestLibraryId))) this.$set(this.filterOptions, 'ageRating', toNameValue(await this.$komgaReferential.getAgeRatings(requestLibraryId))) @@ -298,23 +298,42 @@ export default Vue.extend({ this.$set(this.filterOptions, role, role in grouped ? toNameValue(grouped[role]) : []) }) - // filter query params with available filter values + // 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)) { - this.$set(this.filters, 'status', parseQueryFilter(route.query.status, Object.keys(SeriesStatus))) - this.$set(this.filters, 'readStatus', parseQueryFilter(route.query.readStatus, Object.keys(ReadStatus))) - this.$set(this.filters, 'genre', parseQueryFilter(route.query.genre, this.filterOptions.genre.map(x => x.value))) - this.$set(this.filters, 'tag', parseQueryFilter(route.query.tag, this.filterOptions.tag.map(x => x.value))) - this.$set(this.filters, 'publisher', parseQueryFilter(route.query.publisher, this.filterOptions.publisher.map(x => x.value))) - this.$set(this.filters, 'language', parseQueryFilter(route.query.language, this.filterOptions.language.map(x => x.value))) - this.$set(this.filters, 'ageRating', parseQueryFilter(route.query.ageRating, this.filterOptions.ageRating.map(x => x.value))) - this.$set(this.filters, 'releaseDate', parseQueryFilter(route.query.releaseDate, this.filterOptions.releaseDate.map(x => x.value))) + activeFilters = { + status: parseQueryParam(route.query.status), + readStatus: parseQueryParam(route.query.readStatus), + genre: parseQueryParam(route.query.genre), + tag: parseQueryParam(route.query.tag), + publisher: parseQueryParam(route.query.publisher), + language: parseQueryParam(route.query.language), + ageRating: parseQueryParam(route.query.ageRating), + releaseDate: parseQueryParam(route.query.releaseDate), + } authorRoles.forEach((role: string) => { - //@ts-ignore - this.$set(this.filters, role, parseQueryFilter(route.query[role], this.filterOptions[role].map((x: NameValue) => x.value))) + activeFilters[role] = parseQueryParam(route.query[role]) }) } else { - this.filters = this.$store.getters.getLibraryFilter(route.params.libraryId) || {} as FiltersActive + activeFilters = this.$store.getters.getLibraryFilter(route.params.libraryId) || {} as FiltersActive } + this.filters = this.validateFilters(activeFilters) + }, + validateFilters(filters: FiltersActive): FiltersActive { + const validFilter = { + status: filters.status?.filter(x => Object.keys(SeriesStatus).includes(x)) || [], + readStatus: filters.readStatus?.filter(x => Object.keys(ReadStatus).includes(x)) || [], + genre: filters.genre?.filter(x => this.filterOptions.genre.map(n => n.value).includes(x)) || [], + tag: filters.tag?.filter(x => this.filterOptions.tag.map(n => n.value).includes(x)) || [], + publisher: filters.publisher?.filter(x => this.filterOptions.publisher.map(n => n.value).includes(x)) || [], + language: filters.language?.filter(x => this.filterOptions.language.map(n => n.value).includes(x)) || [], + ageRating: filters.ageRating?.filter(x => this.filterOptions.ageRating.map(n => n.value).includes(x)) || [], + releaseDate: filters.releaseDate?.filter(x => this.filterOptions.releaseDate.map(n => n.value).includes(x)) || [], + } as any + authorRoles.forEach((role: string) => { + validFilter[role] = filters[role]?.filter(x => ((this.filterOptions as any)[role] as NameValue[]).map(n => n.value).includes(x)) || [] + }) + return validFilter }, libraryDeleted(event: EventLibraryDeleted) { if (event.id === this.libraryId) { diff --git a/komga-webui/src/views/BrowseSeries.vue b/komga-webui/src/views/BrowseSeries.vue index 598f70070..027adc8f0 100644 --- a/komga-webui/src/views/BrowseSeries.vue +++ b/komga-webui/src/views/BrowseSeries.vue @@ -6,8 +6,7 @@ :title="$t('common.go_to_library')" :to="{name:'browse-libraries', params: {libraryId: series.libraryId }}" > - mdi-arrow-right - mdi-arrow-left + {{ $t('browse_series.earliest_year_from_release_dates') }} @@ -371,7 +370,7 @@ import ItemBrowser from '@/components/ItemBrowser.vue' import ItemCard from '@/components/ItemCard.vue' import SeriesActionsMenu from '@/components/menus/SeriesActionsMenu.vue' import PageSizeSelect from '@/components/PageSizeSelect.vue' -import {parseQueryFilter, parseQuerySort} from '@/functions/query-params' +import {parseQueryParamAndFilter, parseQuerySort} from '@/functions/query-params' import {seriesFileUrl, seriesThumbnailUrl} from '@/functions/urls' import {ReadStatus} from '@/types/enum-books' import {BOOK_CHANGED, LIBRARY_DELETED, READLIST_CHANGED, SERIES_CHANGED} from '@/types/events' @@ -389,6 +388,7 @@ import {groupAuthorsByRole} from "@/functions/authors"; import ReadMore from "@/components/ReadMore.vue"; import {authorRoles, authorRolesSeries} from "@/types/author-roles"; import VueHorizontal from "vue-horizontal"; +import RtlIcon from "@/components/RtlIcon.vue"; const tags = require('language-tags') @@ -409,6 +409,7 @@ export default Vue.extend({ SortList, ReadMore, VueHorizontal, + RtlIcon, }, data: function () { return { @@ -566,18 +567,18 @@ export default Vue.extend({ this.sortActive = this.parseQuerySortOrDefault(route.query.sort) // load dynamic filters - this.$set(this.filterOptions, 'tag', toNameValue(await this.$komgaReferential.getTags(undefined, seriesId))) + this.$set(this.filterOptions, 'tag', toNameValue(await this.$komgaReferential.getBookTags(seriesId))) const grouped = groupAuthorsByRole(await this.$komgaReferential.getAuthors(undefined, undefined, undefined, seriesId)) authorRoles.forEach((role: string) => { this.$set(this.filterOptions, role, role in grouped ? toNameValue(grouped[role]) : []) }) // filter query params with available filter values - this.$set(this.filters, 'readStatus', parseQueryFilter(this.$route.query.readStatus, Object.keys(ReadStatus))) - this.$set(this.filters, 'tag', parseQueryFilter(this.$route.query.tag, this.filterOptions.tag.map(x => x.value))) + this.$set(this.filters, 'readStatus', parseQueryParamAndFilter(this.$route.query.readStatus, Object.keys(ReadStatus))) + this.$set(this.filters, 'tag', parseQueryParamAndFilter(this.$route.query.tag, this.filterOptions.tag.map(x => x.value))) authorRoles.forEach((role: string) => { //@ts-ignore - this.$set(this.filters, role, parseQueryFilter(route.query[role], this.filterOptions[role].map((x: NameValue) => x.value))) + this.$set(this.filters, role, parseQueryParamAndFilter(route.query[role], this.filterOptions[role].map((x: NameValue) => x.value))) }) }, setWatches() { diff --git a/komga-webui/src/views/Home.vue b/komga-webui/src/views/Home.vue index c378e5a6e..3b0a31429 100644 --- a/komga-webui/src/views/Home.vue +++ b/komga-webui/src/views/Home.vue @@ -69,6 +69,15 @@ + + + mdi-import + + + {{ $t('book_import.title') }} + + + mdi-cog diff --git a/komga-webui/tests/unit/.eslintrc.js b/komga-webui/tests/unit/.eslintrc.js index 958d51ba2..c529c260c 100644 --- a/komga-webui/tests/unit/.eslintrc.js +++ b/komga-webui/tests/unit/.eslintrc.js @@ -1,5 +1,5 @@ module.exports = { env: { - jest: true - } + jest: true, + }, } diff --git a/komga-webui/tests/unit/functions/book-spreads.spec.ts b/komga-webui/tests/unit/functions/book-spreads.spec.ts new file mode 100644 index 000000000..be8caae45 --- /dev/null +++ b/komga-webui/tests/unit/functions/book-spreads.spec.ts @@ -0,0 +1,81 @@ +import {buildSpreads} from "@/functions/book-spreads"; +import {PagedReaderLayout} from "@/types/enum-reader"; +import {PageDtoWithUrl} from "@/types/komga-books"; + +describe("Single Page", () => { + const pageLayout = PagedReaderLayout.SINGLE_PAGE + + test("given no pages then it should return no spreads", () => { + const pages = [] as PageDtoWithUrl[] + + const spreads = buildSpreads(pages, pageLayout) + + expect(spreads.length).toEqual(0) + }) + + test("given single page then it should return single spread with single page", () => { + const pages = [ + { + number: 1, + } as PageDtoWithUrl, + ] as PageDtoWithUrl[] + + const spreads = buildSpreads(pages, pageLayout) + + expect(spreads.length).toEqual(1) + expect(spreads[0].length).toEqual(1) + expect(spreads[0][0].number).toEqual(1) + }) +}) + +describe("Double Pages", () => { + const pageLayout = PagedReaderLayout.DOUBLE_PAGES + + test("given no pages then it should return no spreads", () => { + const pages = [] as PageDtoWithUrl[] + + const spreads = buildSpreads(pages, pageLayout) + + expect(spreads.length).toEqual(0) + }) + + test("given single page then it should return single spread with single page", () => { + const pages = [ + { + number: 1, + } as PageDtoWithUrl, + ] as PageDtoWithUrl[] + + const spreads = buildSpreads(pages, pageLayout) + + expect(spreads.length).toEqual(1) + expect(spreads[0].length).toEqual(1) + expect(spreads[0][0].number).toEqual(1) + }) +}) + +describe("Double Pages No Cover", () => { + const pageLayout = PagedReaderLayout.DOUBLE_NO_COVER + + test("given no pages then it should return no spreads", () => { + const pages = [] as PageDtoWithUrl[] + + const spreads = buildSpreads(pages, pageLayout) + + expect(spreads.length).toEqual(0) + }) + + test("given single page then it should return single spread with single page", () => { + const pages = [ + { + number: 1, + } as PageDtoWithUrl, + ] as PageDtoWithUrl[] + + const spreads = buildSpreads(pages, pageLayout) + + expect(spreads.length).toEqual(1) + expect(spreads[0].length).toEqual(1) + expect(spreads[0][0].number).toEqual(1) + }) +}) diff --git a/komga/build.gradle.kts b/komga/build.gradle.kts index c0c6a98d0..6baeaf145 100644 --- a/komga/build.gradle.kts +++ b/komga/build.gradle.kts @@ -1,4 +1,3 @@ -import com.github.benmanes.gradle.versions.updates.DependencyUpdatesTask import org.apache.tools.ant.taskdefs.condition.Os import org.jetbrains.kotlin.gradle.tasks.KotlinCompile @@ -68,9 +67,9 @@ dependencies { implementation("org.jsoup:jsoup:1.13.1") implementation("net.coobird:thumbnailator:0.4.13") - runtimeOnly("com.twelvemonkeys.imageio:imageio-jpeg:3.6.4") - runtimeOnly("com.twelvemonkeys.imageio:imageio-tiff:3.6.4") - runtimeOnly(files("$projectDir/libs/webp-imageio-decoder-plugin-0.2.jar")) + runtimeOnly("com.twelvemonkeys.imageio:imageio-jpeg:3.7.0") + runtimeOnly("com.twelvemonkeys.imageio:imageio-tiff:3.7.0") + runtimeOnly("com.twelvemonkeys.imageio:imageio-webp:3.7.0") implementation("com.github.gotson:webp-imageio:0.2.0") // support for jpeg2000 runtimeOnly("com.github.jai-imageio:jai-imageio-jpeg2000:1.4.0") @@ -83,6 +82,8 @@ dependencies { implementation("com.github.f4b6a3:tsid-creator:3.0.1") + implementation("com.github.ben-manes.caffeine:caffeine:2.9.0") + // While waiting for https://github.com/xerial/sqlite-jdbc/pull/491 and https://github.com/xerial/sqlite-jdbc/pull/494 // runtimeOnly("org.xerial:sqlite-jdbc:3.32.3.2") // jooqGenerator("org.xerial:sqlite-jdbc:3.32.3.2") @@ -107,7 +108,11 @@ tasks { withType { kotlinOptions { jvmTarget = "1.8" - freeCompilerArgs = listOf("-Xjsr305=strict", "-Xopt-in=kotlin.time.ExperimentalTime") + freeCompilerArgs = listOf( + "-Xjsr305=strict", + "-Xopt-in=kotlin.time.ExperimentalTime", + "-Xopt-in=kotlin.io.path.ExperimentalPathApi" + ) } } @@ -251,20 +256,6 @@ openApi { forkProperties.set("-Dspring.profiles.active=claim") } -fun isNonStable(version: String): Boolean { - val stableKeyword = listOf("RELEASE", "FINAL", "GA").any { version.toUpperCase().contains(it) } - val regex = "^[0-9,.v-]+(-r)?$".toRegex() - val isStable = stableKeyword || regex.matches(version) - return isStable.not() -} -tasks.named("dependencyUpdates", DependencyUpdatesTask::class.java).configure { - // disallow release candidates as upgradable versions from stable versions - rejectVersionIf { - isNonStable(candidate.version) && !isNonStable(currentVersion) - } - gradleReleaseChannel = "current" -} - configure { version.set("0.40.0") filter { diff --git a/komga/docs/openapi.json b/komga/docs/openapi.json index ada7f3db0..bd85a7e2e 100644 --- a/komga/docs/openapi.json +++ b/komga/docs/openapi.json @@ -303,6 +303,65 @@ } } }, + "TransientBookDto": { + "type": "object", + "required": [ + "comment", + "fileLastModified", + "files", + "id", + "mediaType", + "name", + "pages", + "size", + "sizeBytes", + "status", + "url" + ], + "properties": { + "pages": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PageDto" + } + }, + "size": { + "type": "string" + }, + "fileLastModified": { + "format": "date-time", + "type": "string" + }, + "name": { + "type": "string" + }, + "files": { + "type": "array", + "items": { + "type": "string" + } + }, + "mediaType": { + "type": "string" + }, + "comment": { + "type": "string" + }, + "id": { + "type": "string" + }, + "url": { + "type": "string" + }, + "sizeBytes": { + "format": "int64", + "type": "integer" + }, + "status": { + "type": "string" + } + } + }, "Pageable": { "type": "object", "properties": { @@ -728,8 +787,7 @@ ], "properties": { "number": { - "format": "int32", - "type": "integer" + "type": "string" }, "series": { "type": "string" @@ -797,6 +855,29 @@ } } }, + "BookImportBatchDto": { + "type": "object", + "required": [ + "books", + "copyMode" + ], + "properties": { + "books": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BookImportDto" + } + }, + "copyMode": { + "type": "string", + "enum": [ + "MOVE", + "COPY", + "HARDLINK" + ] + } + } + }, "OpdsEntry": { "type": "object", "required": [ @@ -1055,6 +1136,27 @@ } } }, + "BookImportDto": { + "type": "object", + "required": [ + "seriesId", + "sourceFile" + ], + "properties": { + "destinationName": { + "type": "string" + }, + "upgradeBookId": { + "type": "string" + }, + "sourceFile": { + "type": "string" + }, + "seriesId": { + "type": "string" + } + } + }, "OpdsFeed": { "type": "object", "required": [ @@ -1536,6 +1638,17 @@ } } }, + "ScanRequestDto": { + "type": "object", + "required": [ + "path" + ], + "properties": { + "path": { + "type": "string" + } + } + }, "BookMetadataAggregationDto": { "type": "object", "required": [ @@ -1863,6 +1976,59 @@ ] } }, + "/api/v1/transient-books/{id}/pages/{pageNumber}": { + "get": { + "operationId": "getSourcePage", + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "type": "array", + "items": { + "format": "byte", + "type": "string" + } + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + } + } + }, + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "id", + "required": true + }, + { + "schema": { + "format": "int32", + "type": "integer" + }, + "in": "path", + "name": "pageNumber", + "required": true + } + ], + "tags": [ + "transient-books-controller" + ] + } + }, "/api/v1/books/latest": { "get": { "description": "Return newly added or updated books.", @@ -2356,6 +2522,50 @@ ] } }, + "/api/v1/tags/book": { + "get": { + "operationId": "getBookTags", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "uniqueItems": true, + "type": "array", + "items": { + "type": "string" + } + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + } + } + }, + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "query", + "name": "series_id", + "required": false + } + ], + "tags": [ + "referential-controller" + ] + } + }, "/opds/v1.2/books/latest": { "get": { "operationId": "getLatestBooks", @@ -2477,7 +2687,7 @@ }, "/api/v1/books/{bookId}/analyze": { "post": { - "operationId": "analyze_2", + "operationId": "analyze_3", "responses": { "400": { "description": "Bad Request", @@ -2508,6 +2718,39 @@ ] } }, + "/api/v1/books/import": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BookImportBatchDto" + } + } + }, + "required": true + }, + "operationId": "importBooks", + "responses": { + "400": { + "description": "Bad Request", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + } + }, + "202": { + "description": "Accepted" + } + }, + "tags": [ + "book-controller" + ] + } + }, "/api/v1/books/{bookId}/file/*": { "get": { "description": "Download the book file.", @@ -2740,7 +2983,7 @@ }, "/api/v1/series/{seriesId}/analyze": { "post": { - "operationId": "analyze", + "operationId": "analyze_1", "responses": { "400": { "description": "Bad Request", @@ -4014,6 +4257,58 @@ ] } }, + "/api/v1/tags/series": { + "get": { + "operationId": "getSeriesTags", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "uniqueItems": true, + "type": "array", + "items": { + "type": "string" + } + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + } + } + }, + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "query", + "name": "library_id", + "required": false + }, + { + "schema": { + "type": "string" + }, + "in": "query", + "name": "collection_id", + "required": false + } + ], + "tags": [ + "referential-controller" + ] + } + }, "/api/v1/age-ratings": { "get": { "operationId": "getAgeRatings", @@ -4959,7 +5254,7 @@ }, "/api/v1/libraries/{libraryId}/analyze": { "post": { - "operationId": "analyze_1", + "operationId": "analyze_2", "responses": { "400": { "description": "Bad Request", @@ -5081,6 +5376,46 @@ ] } }, + "/api/v1/transient-books/{id}/analyze": { + "post": { + "operationId": "analyze", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TransientBookDto" + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + } + } + }, + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "id", + "required": true + } + ], + "tags": [ + "transient-books-controller" + ] + } + }, "/opds/v1.2/catalog": { "get": { "operationId": "getCatalog", @@ -5570,6 +5905,49 @@ ] } }, + "/api/v1/transient-books": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ScanRequestDto" + } + } + }, + "required": true + }, + "operationId": "scanForTransientBooks", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TransientBookDto" + } + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + } + } + }, + "tags": [ + "transient-books-controller" + ] + } + }, "/api/v1/books/{bookId}/pages": { "get": { "operationId": "getBookPages", diff --git a/komga/libs/webp-imageio-decoder-plugin-0.2.jar b/komga/libs/webp-imageio-decoder-plugin-0.2.jar deleted file mode 100644 index 9fa29e38d..000000000 Binary files a/komga/libs/webp-imageio-decoder-plugin-0.2.jar and /dev/null differ diff --git a/komga/src/main/kotlin/org/gotson/komga/application/tasks/Task.kt b/komga/src/main/kotlin/org/gotson/komga/application/tasks/Task.kt index e9e0e1059..6885b0b4b 100644 --- a/komga/src/main/kotlin/org/gotson/komga/application/tasks/Task.kt +++ b/komga/src/main/kotlin/org/gotson/komga/application/tasks/Task.kt @@ -1,6 +1,7 @@ package org.gotson.komga.application.tasks import org.gotson.komga.domain.model.BookMetadataPatchCapability +import org.gotson.komga.domain.model.CopyMode import java.io.Serializable sealed class Task : Serializable { @@ -29,4 +30,8 @@ sealed class Task : Serializable { data class AggregateSeriesMetadata(val seriesId: String) : Task() { override fun uniqueId() = "AGGREGATE_SERIES_METADATA_$seriesId" } + + data class ImportBook(val sourceFile: String, val seriesId: String, val copyMode: CopyMode, val destinationName: String?, val upgradeBookId: String?) : Task() { + override fun uniqueId(): String = "IMPORT_BOOK_${seriesId}_$sourceFile" + } } diff --git a/komga/src/main/kotlin/org/gotson/komga/application/tasks/TaskHandler.kt b/komga/src/main/kotlin/org/gotson/komga/application/tasks/TaskHandler.kt index 016c63734..bff748aab 100644 --- a/komga/src/main/kotlin/org/gotson/komga/application/tasks/TaskHandler.kt +++ b/komga/src/main/kotlin/org/gotson/komga/application/tasks/TaskHandler.kt @@ -4,6 +4,7 @@ import mu.KotlinLogging import org.gotson.komga.domain.persistence.BookRepository import org.gotson.komga.domain.persistence.LibraryRepository import org.gotson.komga.domain.persistence.SeriesRepository +import org.gotson.komga.domain.service.BookImporter import org.gotson.komga.domain.service.BookLifecycle import org.gotson.komga.domain.service.LibraryContentLifecycle import org.gotson.komga.domain.service.MetadataLifecycle @@ -11,6 +12,7 @@ import org.gotson.komga.infrastructure.jms.QUEUE_TASKS import org.gotson.komga.infrastructure.jms.QUEUE_TASKS_SELECTOR import org.springframework.jms.annotation.JmsListener import org.springframework.stereotype.Service +import java.nio.file.Paths import kotlin.time.measureTime private val logger = KotlinLogging.logger {} @@ -23,7 +25,8 @@ class TaskHandler( private val seriesRepository: SeriesRepository, private val libraryContentLifecycle: LibraryContentLifecycle, private val bookLifecycle: BookLifecycle, - private val metadataLifecycle: MetadataLifecycle + private val metadataLifecycle: MetadataLifecycle, + private val bookImporter: BookImporter, ) { @JmsListener(destination = QUEUE_TASKS, selector = QUEUE_TASKS_SELECTOR) @@ -68,6 +71,11 @@ class TaskHandler( seriesRepository.findByIdOrNull(task.seriesId)?.let { metadataLifecycle.aggregateMetadata(it) } ?: logger.warn { "Cannot execute task $task: Series does not exist" } + + is Task.ImportBook -> + seriesRepository.findByIdOrNull(task.seriesId)?.let { series -> + bookImporter.importBook(Paths.get(task.sourceFile), series, task.copyMode, task.destinationName, task.upgradeBookId) + } ?: logger.warn { "Cannot execute task $task: Series does not exist" } } }.also { logger.info { "Task $task executed in $it" } diff --git a/komga/src/main/kotlin/org/gotson/komga/application/tasks/TaskReceiver.kt b/komga/src/main/kotlin/org/gotson/komga/application/tasks/TaskReceiver.kt index 0b5d6d2f9..5928fdae4 100644 --- a/komga/src/main/kotlin/org/gotson/komga/application/tasks/TaskReceiver.kt +++ b/komga/src/main/kotlin/org/gotson/komga/application/tasks/TaskReceiver.kt @@ -4,6 +4,7 @@ import mu.KotlinLogging import org.gotson.komga.domain.model.Book import org.gotson.komga.domain.model.BookMetadataPatchCapability import org.gotson.komga.domain.model.BookSearch +import org.gotson.komga.domain.model.CopyMode import org.gotson.komga.domain.model.Library import org.gotson.komga.domain.model.Media import org.gotson.komga.domain.persistence.BookRepository @@ -77,6 +78,10 @@ class TaskReceiver( submitTask(Task.AggregateSeriesMetadata(seriesId)) } + fun importBook(sourceFile: String, seriesId: String, copyMode: CopyMode, destinationName: String?, upgradeBookId: String?) { + submitTask(Task.ImportBook(sourceFile, seriesId, copyMode, destinationName, upgradeBookId)) + } + private fun submitTask(task: Task) { logger.info { "Sending task: $task" } jmsTemplate.convertAndSend(QUEUE_TASKS, task) { diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/model/BookWithMedia.kt b/komga/src/main/kotlin/org/gotson/komga/domain/model/BookWithMedia.kt new file mode 100644 index 000000000..e4185d029 --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/domain/model/BookWithMedia.kt @@ -0,0 +1,6 @@ +package org.gotson.komga.domain.model + +data class BookWithMedia( + val book: Book, + val media: Media, +) diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/model/CopyMode.kt b/komga/src/main/kotlin/org/gotson/komga/domain/model/CopyMode.kt new file mode 100644 index 000000000..f885b9de8 --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/domain/model/CopyMode.kt @@ -0,0 +1,7 @@ +package org.gotson.komga.domain.model + +enum class CopyMode { + MOVE, + COPY, + HARDLINK, +} diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/model/Media.kt b/komga/src/main/kotlin/org/gotson/komga/domain/model/Media.kt index ba7b18755..11576b64e 100644 --- a/komga/src/main/kotlin/org/gotson/komga/domain/model/Media.kt +++ b/komga/src/main/kotlin/org/gotson/komga/domain/model/Media.kt @@ -16,4 +16,8 @@ data class Media( enum class Status { UNKNOWN, ERROR, READY, UNSUPPORTED, OUTDATED } + + override fun toString(): String { + return "Media(status=$status, mediaType=$mediaType, comment=$comment, bookId='$bookId', createdDate=$createdDate, lastModifiedDate=$lastModifiedDate)" + } } diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/model/ReadListRequest.kt b/komga/src/main/kotlin/org/gotson/komga/domain/model/ReadListRequest.kt index fa8bbcef1..dc525408c 100644 --- a/komga/src/main/kotlin/org/gotson/komga/domain/model/ReadListRequest.kt +++ b/komga/src/main/kotlin/org/gotson/komga/domain/model/ReadListRequest.kt @@ -10,7 +10,7 @@ data class ReadListRequest( data class ReadListRequestBook( val series: String, - val number: Int, + val number: String, ) data class ReadListRequestResult( diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/persistence/ReadProgressRepository.kt b/komga/src/main/kotlin/org/gotson/komga/domain/persistence/ReadProgressRepository.kt index fb7b6cb2e..69fed2ccd 100644 --- a/komga/src/main/kotlin/org/gotson/komga/domain/persistence/ReadProgressRepository.kt +++ b/komga/src/main/kotlin/org/gotson/komga/domain/persistence/ReadProgressRepository.kt @@ -6,6 +6,7 @@ interface ReadProgressRepository { fun findAll(): Collection fun findByBookIdAndUserId(bookId: String, userId: String): ReadProgress? fun findByUserId(userId: String): Collection + fun findByBookId(bookId: String): Collection fun save(readProgress: ReadProgress) 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 37d5505cf..bb67e3296 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 @@ -4,36 +4,37 @@ import org.gotson.komga.domain.model.Author import java.time.LocalDate interface ReferentialRepository { - fun findAuthorsByName(search: String): List - fun findAuthorsByNameAndLibrary(search: String, libraryId: String): List - fun findAuthorsByNameAndCollection(search: String, collectionId: String): List - fun findAuthorsByNameAndSeries(search: String, seriesId: String): List - fun findAuthorsNamesByName(search: String): List - fun findAuthorsRoles(): List + fun findAuthorsByName(search: String, filterOnLibraryIds: Collection?): List + fun findAuthorsByNameAndLibrary(search: String, libraryId: String, filterOnLibraryIds: Collection?): List + fun findAuthorsByNameAndCollection(search: String, collectionId: String, filterOnLibraryIds: Collection?): List + fun findAuthorsByNameAndSeries(search: String, seriesId: String, filterOnLibraryIds: Collection?): List + fun findAuthorsNamesByName(search: String, filterOnLibraryIds: Collection?): List + fun findAuthorsRoles(filterOnLibraryIds: Collection?): List - fun findAllGenres(): Set - fun findAllGenresByLibrary(libraryId: String): Set - fun findAllGenresByCollection(collectionId: String): Set + fun findAllGenres(filterOnLibraryIds: Collection?): Set + fun findAllGenresByLibrary(libraryId: String, filterOnLibraryIds: Collection?): Set + fun findAllGenresByCollection(collectionId: String, filterOnLibraryIds: Collection?): Set - fun findAllTags(): Set - fun findAllTagsByLibrary(libraryId: String): Set - fun findAllTagsBySeries(seriesId: String): Set - fun findAllTagsByCollection(collectionId: String): Set + fun findAllSeriesAndBookTags(filterOnLibraryIds: Collection?): Set + fun findAllSeriesTags(filterOnLibraryIds: Collection?): Set + fun findAllSeriesTagsByLibrary(libraryId: String, filterOnLibraryIds: Collection?): Set + fun findAllSeriesTagsByCollection(collectionId: String, filterOnLibraryIds: Collection?): Set + fun findAllBookTags(filterOnLibraryIds: Collection?): Set + fun findAllBookTagsBySeries(seriesId: String, filterOnLibraryIds: Collection?): Set - fun findAllLanguages(): Set - fun findAllLanguagesByLibrary(libraryId: String): Set - fun findAllLanguagesByCollection(collectionId: String): Set + fun findAllLanguages(filterOnLibraryIds: Collection?): Set + fun findAllLanguagesByLibrary(libraryId: String, filterOnLibraryIds: Collection?): Set + fun findAllLanguagesByCollection(collectionId: String, filterOnLibraryIds: Collection?): Set - fun findAllPublishers(): Set - fun findAllPublishersByLibrary(libraryId: String): Set - fun findAllPublishersByLibraries(libraryIds: Set): Set - fun findAllPublishersByCollection(collectionId: String): Set + fun findAllPublishers(filterOnLibraryIds: Collection?): Set + fun findAllPublishersByLibrary(libraryId: String, filterOnLibraryIds: Collection?): Set + fun findAllPublishersByCollection(collectionId: String, filterOnLibraryIds: Collection?): Set - fun findAllAgeRatings(): Set - fun findAllAgeRatingsByLibrary(libraryId: String): Set - fun findAllAgeRatingsByCollection(collectionId: String): Set + fun findAllAgeRatings(filterOnLibraryIds: Collection?): Set + fun findAllAgeRatingsByLibrary(libraryId: String, filterOnLibraryIds: Collection?): Set + fun findAllAgeRatingsByCollection(collectionId: String, filterOnLibraryIds: Collection?): Set - fun findAllSeriesReleaseDates(): Set - fun findAllSeriesReleaseDatesByLibrary(libraryId: String): Set - fun findAllSeriesReleaseDatesByCollection(collectionId: String): Set + fun findAllSeriesReleaseDates(filterOnLibraryIds: Collection?): Set + fun findAllSeriesReleaseDatesByLibrary(libraryId: String, filterOnLibraryIds: Collection?): Set + fun findAllSeriesReleaseDatesByCollection(collectionId: String, filterOnLibraryIds: Collection?): Set } diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/persistence/TransientBookRepository.kt b/komga/src/main/kotlin/org/gotson/komga/domain/persistence/TransientBookRepository.kt new file mode 100644 index 000000000..92a376a8f --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/domain/persistence/TransientBookRepository.kt @@ -0,0 +1,9 @@ +package org.gotson.komga.domain.persistence + +import org.gotson.komga.domain.model.BookWithMedia + +interface TransientBookRepository { + fun findById(transientBookId: String): BookWithMedia? + fun save(transientBook: BookWithMedia) + fun saveAll(transientBooks: Collection) +} diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/service/BookAnalyzer.kt b/komga/src/main/kotlin/org/gotson/komga/domain/service/BookAnalyzer.kt index bd7a0bd80..046f43184 100644 --- a/komga/src/main/kotlin/org/gotson/komga/domain/service/BookAnalyzer.kt +++ b/komga/src/main/kotlin/org/gotson/komga/domain/service/BookAnalyzer.kt @@ -3,15 +3,16 @@ package org.gotson.komga.domain.service import mu.KotlinLogging import org.gotson.komga.domain.model.Book import org.gotson.komga.domain.model.BookPage +import org.gotson.komga.domain.model.BookWithMedia import org.gotson.komga.domain.model.Media import org.gotson.komga.domain.model.MediaNotReadyException import org.gotson.komga.domain.model.MediaUnsupportedException import org.gotson.komga.domain.model.ThumbnailBook -import org.gotson.komga.domain.persistence.MediaRepository import org.gotson.komga.infrastructure.image.ImageConverter import org.gotson.komga.infrastructure.mediacontainer.ContentDetector import org.gotson.komga.infrastructure.mediacontainer.MediaContainerExtractor import org.springframework.stereotype.Service +import java.nio.file.AccessDeniedException private val logger = KotlinLogging.logger {} @@ -19,8 +20,7 @@ private val logger = KotlinLogging.logger {} class BookAnalyzer( private val contentDetector: ContentDetector, extractors: List, - private val imageConverter: ImageConverter, - private val mediaRepository: MediaRepository + private val imageConverter: ImageConverter ) { val supportedMediaTypes = extractors @@ -32,61 +32,66 @@ class BookAnalyzer( fun analyze(book: Book): Media { logger.info { "Trying to analyze book: $book" } + try { + val mediaType = contentDetector.detectMediaType(book.path()) + logger.info { "Detected media type: $mediaType" } + if (!supportedMediaTypes.containsKey(mediaType)) + return Media(mediaType = mediaType, status = Media.Status.UNSUPPORTED, comment = "ERR_1001", bookId = book.id) - val mediaType = contentDetector.detectMediaType(book.path()) - logger.info { "Detected media type: $mediaType" } - if (!supportedMediaTypes.containsKey(mediaType)) - return Media(mediaType = mediaType, status = Media.Status.UNSUPPORTED, comment = "ERR_1001") - - val entries = try { - supportedMediaTypes.getValue(mediaType).getEntries(book.path()) - } catch (ex: MediaUnsupportedException) { - return Media(mediaType = mediaType, status = Media.Status.UNSUPPORTED, comment = ex.code) - } catch (ex: Exception) { - logger.error(ex) { "Error while analyzing book: $book" } - return Media(mediaType = mediaType, status = Media.Status.ERROR, comment = "ERR_1008") - } - - val (pages, others) = entries - .partition { entry -> - entry.mediaType?.let { contentDetector.isImage(it) } ?: false - }.let { (images, others) -> - Pair( - images.map { BookPage(it.name, it.mediaType!!, it.dimension) }, - others - ) + val entries = try { + supportedMediaTypes.getValue(mediaType).getEntries(book.path()) + } catch (ex: MediaUnsupportedException) { + return Media(mediaType = mediaType, status = Media.Status.UNSUPPORTED, comment = ex.code, bookId = book.id) + } catch (ex: Exception) { + logger.error(ex) { "Error while analyzing book: $book" } + return Media(mediaType = mediaType, status = Media.Status.ERROR, comment = "ERR_1008", bookId = book.id) } - val entriesErrorSummary = others - .filter { it.mediaType.isNullOrBlank() } - .map { it.name } - .ifEmpty { null } - ?.joinToString(prefix = "ERR_1007 [", postfix = "]") { it } + val (pages, others) = entries + .partition { entry -> + entry.mediaType?.let { contentDetector.isImage(it) } ?: false + }.let { (images, others) -> + Pair( + images.map { BookPage(it.name, it.mediaType!!, it.dimension) }, + others + ) + } - if (pages.isEmpty()) { - logger.warn { "Book $book does not contain any pages" } - return Media(mediaType = mediaType, status = Media.Status.ERROR, comment = "ERR_1006") + val entriesErrorSummary = others + .filter { it.mediaType.isNullOrBlank() } + .map { it.name } + .ifEmpty { null } + ?.joinToString(prefix = "ERR_1007 [", postfix = "]") { it } + + if (pages.isEmpty()) { + logger.warn { "Book $book does not contain any pages" } + return Media(mediaType = mediaType, status = Media.Status.ERROR, comment = "ERR_1006", bookId = book.id) + } + logger.info { "Book has ${pages.size} pages" } + + val files = others.map { it.name } + + return Media(mediaType = mediaType, status = Media.Status.READY, pages = pages, files = files, comment = entriesErrorSummary, bookId = book.id) + } catch (ade: AccessDeniedException) { + logger.error(ade) { "Error while analyzing book: $book" } + return Media(status = Media.Status.ERROR, comment = "ERR_1000", bookId = book.id) + } catch (ex: Exception) { + logger.error(ex) { "Error while analyzing book: $book" } + return Media(status = Media.Status.ERROR, comment = "ERR_1005", bookId = book.id) } - logger.info { "Book has ${pages.size} pages" } - - val files = others.map { it.name } - - return Media(mediaType = mediaType, status = Media.Status.READY, pages = pages, files = files, comment = entriesErrorSummary) } @Throws(MediaNotReadyException::class) - fun generateThumbnail(book: Book): ThumbnailBook { + fun generateThumbnail(book: BookWithMedia): ThumbnailBook { logger.info { "Generate thumbnail for book: $book" } - val media = mediaRepository.findById(book.id) - - if (media.status != Media.Status.READY) { + if (book.media.status != Media.Status.READY) { logger.warn { "Book media is not ready, cannot generate thumbnail. Book: $book" } throw MediaNotReadyException() } val thumbnail = try { - supportedMediaTypes.getValue(media.mediaType!!).getEntryStream(book.path(), media.pages.first().fileName).let { cover -> + supportedMediaTypes.getValue(book.media.mediaType!!).getEntryStream(book.book.path(), book.media.pages.first().fileName).let { cover -> imageConverter.resizeImage(cover, thumbnailFormat, thumbnailSize) } } catch (ex: Exception) { @@ -97,7 +102,7 @@ class BookAnalyzer( return ThumbnailBook( thumbnail = thumbnail, type = ThumbnailBook.Type.GENERATED, - bookId = book.id + bookId = book.book.id ) } @@ -105,37 +110,33 @@ class BookAnalyzer( MediaNotReadyException::class, IndexOutOfBoundsException::class ) - fun getPageContent(book: Book, number: Int): ByteArray { + fun getPageContent(book: BookWithMedia, number: Int): ByteArray { logger.info { "Get page #$number for book: $book" } - val media = mediaRepository.findById(book.id) - - if (media.status != Media.Status.READY) { + if (book.media.status != Media.Status.READY) { logger.warn { "Book media is not ready, cannot get pages" } throw MediaNotReadyException() } - if (number > media.pages.size || number <= 0) { - logger.error { "Page number #$number is out of bounds. Book has ${media.pages.size} pages" } + if (number > book.media.pages.size || number <= 0) { + logger.error { "Page number #$number is out of bounds. Book has ${book.media.pages.size} pages" } throw IndexOutOfBoundsException("Page $number does not exist") } - return supportedMediaTypes.getValue(media.mediaType!!).getEntryStream(book.path(), media.pages[number - 1].fileName) + return supportedMediaTypes.getValue(book.media.mediaType!!).getEntryStream(book.book.path(), book.media.pages[number - 1].fileName) } @Throws( MediaNotReadyException::class ) - fun getFileContent(book: Book, fileName: String): ByteArray { + fun getFileContent(book: BookWithMedia, fileName: String): ByteArray { logger.info { "Get file $fileName for book: $book" } - val media = mediaRepository.findById(book.id) - - if (media.status != Media.Status.READY) { + if (book.media.status != Media.Status.READY) { logger.warn { "Book media is not ready, cannot get files" } throw MediaNotReadyException() } - return supportedMediaTypes.getValue(media.mediaType!!).getEntryStream(book.path(), fileName) + return supportedMediaTypes.getValue(book.media.mediaType!!).getEntryStream(book.book.path(), fileName) } } diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/service/BookImporter.kt b/komga/src/main/kotlin/org/gotson/komga/domain/service/BookImporter.kt new file mode 100644 index 000000000..6d7e554d6 --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/domain/service/BookImporter.kt @@ -0,0 +1,152 @@ +package org.gotson.komga.domain.service + +import mu.KotlinLogging +import org.gotson.komga.application.tasks.TaskReceiver +import org.gotson.komga.domain.model.CopyMode +import org.gotson.komga.domain.model.Media +import org.gotson.komga.domain.model.PathContainedInPath +import org.gotson.komga.domain.model.Series +import org.gotson.komga.domain.persistence.BookMetadataRepository +import org.gotson.komga.domain.persistence.BookRepository +import org.gotson.komga.domain.persistence.LibraryRepository +import org.gotson.komga.domain.persistence.MediaRepository +import org.gotson.komga.domain.persistence.ReadListRepository +import org.gotson.komga.domain.persistence.ReadProgressRepository +import org.gotson.komga.infrastructure.language.toIndexedMap +import org.springframework.stereotype.Service +import java.io.FileNotFoundException +import java.nio.file.FileAlreadyExistsException +import java.nio.file.Files +import java.nio.file.NoSuchFileException +import java.nio.file.Path +import java.nio.file.Paths +import kotlin.io.path.copyTo +import kotlin.io.path.deleteExisting +import kotlin.io.path.deleteIfExists +import kotlin.io.path.exists +import kotlin.io.path.extension +import kotlin.io.path.moveTo +import kotlin.io.path.notExists + +private val logger = KotlinLogging.logger {} + +@Service +class BookImporter( + private val bookLifecycle: BookLifecycle, + private val fileSystemScanner: FileSystemScanner, + private val seriesLifecycle: SeriesLifecycle, + private val bookRepository: BookRepository, + private val mediaRepository: MediaRepository, + private val metadataRepository: BookMetadataRepository, + private val readProgressRepository: ReadProgressRepository, + private val readListRepository: ReadListRepository, + private val taskReceiver: TaskReceiver, + private val libraryRepository: LibraryRepository, +) { + + fun importBook(sourceFile: Path, series: Series, copyMode: CopyMode, destinationName: String? = null, upgradeBookId: String? = null) { + if (sourceFile.notExists()) throw FileNotFoundException("File not found: $sourceFile") + + libraryRepository.findAll().forEach { library -> + if (sourceFile.startsWith(library.path())) throw PathContainedInPath("Cannot import file that is part of an existing library") + } + + val destFile = series.path().resolve( + if (destinationName != null) Paths.get("$destinationName.${sourceFile.extension}").fileName.toString() + else sourceFile.fileName.toString() + ) + + val upgradedBookId = + if (upgradeBookId != null) { + bookRepository.findByIdOrNull(upgradeBookId)?.let { + if (it.seriesId != series.id) throw IllegalArgumentException("Book to upgrade ($upgradeBookId) does not belong to series: $series") + it.id + } + } else null + val upgradedBookPath = + if (upgradedBookId != null) + bookRepository.findByIdOrNull(upgradedBookId)?.path() + else null + + var deletedUpgradedFile = false + when { + upgradedBookPath != null && destFile == upgradedBookPath -> { + logger.info { "Deleting existing file: $upgradedBookPath" } + try { + upgradedBookPath.deleteExisting() + deletedUpgradedFile = true + } catch (e: NoSuchFileException) { + logger.warn { "Could not delete upgraded book: $upgradedBookPath" } + } + } + destFile.exists() -> throw FileAlreadyExistsException("Destination file already exists: $destFile") + } + + when (copyMode) { + CopyMode.MOVE -> { + logger.info { "Moving file $sourceFile to $destFile" } + sourceFile.moveTo(destFile) + } + CopyMode.COPY -> { + logger.info { "Copying file $sourceFile to $destFile" } + sourceFile.copyTo(destFile) + } + CopyMode.HARDLINK -> try { + logger.info { "Hardlink file $sourceFile to $destFile" } + Files.createLink(destFile, sourceFile) + } catch (e: Exception) { + logger.warn(e) { "Filesystem does not support hardlinks, copying instead" } + sourceFile.copyTo(destFile) + } + } + + val importedBook = fileSystemScanner.scanFile(destFile) + ?.copy(libraryId = series.libraryId) + ?: throw IllegalStateException("Newly imported book could not be scanned: $destFile") + + seriesLifecycle.addBooks(series, listOf(importedBook)) + + if (upgradedBookId != null) { + // copy media and mark it as outdated + mediaRepository.findById(upgradedBookId).let { + mediaRepository.update( + it.copy( + bookId = importedBook.id, + status = Media.Status.OUTDATED, + ) + ) + } + + // copy metadata + metadataRepository.findById(upgradedBookId).let { + metadataRepository.update(it.copy(bookId = importedBook.id)) + } + + // copy read progress + readProgressRepository.findByBookId(upgradedBookId) + .map { it.copy(bookId = importedBook.id) } + .forEach { readProgressRepository.save(it) } + + // replace upgraded book by imported book in read lists + readListRepository.findAllByBook(upgradedBookId, filterOnLibraryIds = null) + .forEach { rl -> + readListRepository.update( + rl.copy( + bookIds = rl.bookIds.values.map { if (it == upgradedBookId) importedBook.id else it }.toIndexedMap() + ) + ) + } + + // delete upgraded book file on disk if it has not been replaced earlier + if (upgradedBookPath != null && !deletedUpgradedFile && upgradedBookPath.deleteIfExists()) + logger.info { "Deleted existing file: $upgradedBookPath" } + + // delete upgraded book + bookLifecycle.deleteOne(upgradedBookId) + } + + seriesLifecycle.sortBooks(series) + + taskReceiver.analyzeBook(importedBook) + } +} diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/service/BookLifecycle.kt b/komga/src/main/kotlin/org/gotson/komga/domain/service/BookLifecycle.kt index ca7e0216a..74a90cc2c 100644 --- a/komga/src/main/kotlin/org/gotson/komga/domain/service/BookLifecycle.kt +++ b/komga/src/main/kotlin/org/gotson/komga/domain/service/BookLifecycle.kt @@ -3,6 +3,7 @@ package org.gotson.komga.domain.service import mu.KotlinLogging import org.gotson.komga.domain.model.Book import org.gotson.komga.domain.model.BookPageContent +import org.gotson.komga.domain.model.BookWithMedia import org.gotson.komga.domain.model.ImageConversionException import org.gotson.komga.domain.model.KomgaUser import org.gotson.komga.domain.model.Media @@ -19,7 +20,6 @@ import org.gotson.komga.infrastructure.image.ImageConverter import org.gotson.komga.infrastructure.image.ImageType import org.springframework.stereotype.Service import java.io.File -import java.nio.file.AccessDeniedException import java.nio.file.Files import java.nio.file.Paths @@ -39,15 +39,7 @@ class BookLifecycle( fun analyzeAndPersist(book: Book): Boolean { logger.info { "Analyze and persist book: $book" } - val media = try { - bookAnalyzer.analyze(book) - } catch (ade: AccessDeniedException) { - logger.error(ade) { "Error while analyzing book: $book" } - Media(status = Media.Status.ERROR, comment = "ERR_1000") - } catch (ex: Exception) { - logger.error(ex) { "Error while analyzing book: $book" } - Media(status = Media.Status.ERROR, comment = "ERR_1005") - }.copy(bookId = book.id) + val media = bookAnalyzer.analyze(book) // if the number of pages has changed, delete all read progress for that book mediaRepository.findById(book.id).let { previous -> @@ -63,7 +55,7 @@ class BookLifecycle( fun generateThumbnailAndPersist(book: Book) { logger.info { "Generate thumbnail and persist for book: $book" } try { - addThumbnailForBook(bookAnalyzer.generateThumbnail(book)) + addThumbnailForBook(bookAnalyzer.generateThumbnail(BookWithMedia(book, mediaRepository.findById(book.id)))) } catch (ex: Exception) { logger.error(ex) { "Error while creating thumbnail" } } @@ -155,7 +147,7 @@ class BookLifecycle( ) fun getBookPage(book: Book, number: Int, convertTo: ImageType? = null, resizeTo: Int? = null): BookPageContent { val media = mediaRepository.findById(book.id) - val pageContent = bookAnalyzer.getPageContent(book, number) + val pageContent = bookAnalyzer.getPageContent(BookWithMedia(book, mediaRepository.findById(book.id)), number) val pageMediaType = media.pages[number - 1].mediaType if (resizeTo != null) { diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/service/FileSystemScanner.kt b/komga/src/main/kotlin/org/gotson/komga/domain/service/FileSystemScanner.kt index 26ce70eb6..77f2c2ffb 100644 --- a/komga/src/main/kotlin/org/gotson/komga/domain/service/FileSystemScanner.kt +++ b/komga/src/main/kotlin/org/gotson/komga/domain/service/FileSystemScanner.kt @@ -17,6 +17,8 @@ import java.nio.file.attribute.BasicFileAttributes import java.nio.file.attribute.FileTime import java.time.LocalDateTime import java.time.ZoneId +import kotlin.io.path.exists +import kotlin.io.path.readAttributes import kotlin.time.measureTime private val logger = KotlinLogging.logger {} @@ -35,7 +37,7 @@ class FileSystemScanner( logger.info { "Force directory modified time: $forceDirectoryModifiedTime" } if (!(Files.isDirectory(root) && Files.isReadable(root))) - throw DirectoryNotFoundException("Library root is not accessible: $root") + throw DirectoryNotFoundException("Folder is not accessible: $root", "ERR_1016") val scannedSeries = mutableMapOf>() @@ -69,12 +71,7 @@ class FileSystemScanner( supportedExtensions.contains(FilenameUtils.getExtension(file.fileName.toString()).toLowerCase()) && !file.fileName.toString().startsWith(".") ) { - val book = Book( - name = FilenameUtils.getBaseName(file.fileName.toString()), - url = file.toUri().toURL(), - fileLastModified = attrs.getUpdatedTime(), - fileSize = attrs.size() - ) + val book = pathToBook(file, attrs) file.parent.let { key -> if (pathToBooks.containsKey(key)) pathToBooks[key]!!.add(book) else pathToBooks[key] = mutableListOf(book) @@ -118,6 +115,20 @@ class FileSystemScanner( return scannedSeries } + + fun scanFile(path: Path): Book? { + if (!path.exists()) return null + + return pathToBook(path, path.readAttributes()) + } + + private fun pathToBook(path: Path, attrs: BasicFileAttributes): Book = + Book( + name = FilenameUtils.getBaseName(path.fileName.toString()), + url = path.toUri().toURL(), + fileLastModified = attrs.getUpdatedTime(), + fileSize = attrs.size() + ) } fun BasicFileAttributes.getUpdatedTime(): LocalDateTime = diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/service/MetadataLifecycle.kt b/komga/src/main/kotlin/org/gotson/komga/domain/service/MetadataLifecycle.kt index ae2590e2d..3eea94d0b 100644 --- a/komga/src/main/kotlin/org/gotson/komga/domain/service/MetadataLifecycle.kt +++ b/komga/src/main/kotlin/org/gotson/komga/domain/service/MetadataLifecycle.kt @@ -4,6 +4,7 @@ import mu.KotlinLogging import org.gotson.komga.domain.model.Book import org.gotson.komga.domain.model.BookMetadataPatch import org.gotson.komga.domain.model.BookMetadataPatchCapability +import org.gotson.komga.domain.model.BookWithMedia import org.gotson.komga.domain.model.ReadList import org.gotson.komga.domain.model.Series import org.gotson.komga.domain.model.SeriesCollection @@ -65,7 +66,7 @@ class MetadataLifecycle( logger.info { "Library is not set to import book metadata from Barcode ISBN, skipping" } else -> { logger.debug { "Provider: $provider" } - val patch = provider.getBookMetadataFromBook(book, media) + val patch = provider.getBookMetadataFromBook(BookWithMedia(book, media)) if ( (provider is ComicInfoProvider && library.importComicInfoBook) || @@ -156,7 +157,7 @@ class MetadataLifecycle( else -> { logger.debug { "Provider: $provider" } val patches = bookRepository.findBySeriesId(series.id) - .mapNotNull { provider.getSeriesMetadataFromBook(it, mediaRepository.findById(it.id)) } + .mapNotNull { provider.getSeriesMetadataFromBook(BookWithMedia(it, mediaRepository.findById(it.id))) } if ( (provider is ComicInfoProvider && library.importComicInfoSeries) || diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/service/ReadListMatcher.kt b/komga/src/main/kotlin/org/gotson/komga/domain/service/ReadListMatcher.kt index 3285ed524..5a5bd2bbd 100644 --- a/komga/src/main/kotlin/org/gotson/komga/domain/service/ReadListMatcher.kt +++ b/komga/src/main/kotlin/org/gotson/komga/domain/service/ReadListMatcher.kt @@ -40,7 +40,7 @@ class ReadListMatcher( val seriesId = seriesMatches.first().id val seriesBooks = bookRepository.findBySeriesId(seriesId) val bookMatches = bookMetadataRepository.findByIds(seriesBooks.map { it.id }) - .filter { it.number.toIntOrNull()?.equals(book.number) ?: false } + .filter { (it.number.trimStart('0') == book.number.trimStart('0')) } .map { it.bookId } when { bookMatches.size > 1 -> unmatchedBooks += ReadListRequestResultBook(book, "ERR_1013") diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/service/TransientBookLifecycle.kt b/komga/src/main/kotlin/org/gotson/komga/domain/service/TransientBookLifecycle.kt new file mode 100644 index 000000000..4bc466810 --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/domain/service/TransientBookLifecycle.kt @@ -0,0 +1,54 @@ +package org.gotson.komga.domain.service + +import org.gotson.komga.domain.model.BookPageContent +import org.gotson.komga.domain.model.BookWithMedia +import org.gotson.komga.domain.model.Media +import org.gotson.komga.domain.model.MediaNotReadyException +import org.gotson.komga.domain.model.PathContainedInPath +import org.gotson.komga.domain.persistence.LibraryRepository +import org.gotson.komga.domain.persistence.TransientBookRepository +import org.springframework.stereotype.Service +import java.nio.file.Paths + +@Service +class TransientBookLifecycle( + private val transientBookRepository: TransientBookRepository, + private val bookAnalyzer: BookAnalyzer, + private val fileSystemScanner: FileSystemScanner, + private val libraryRepository: LibraryRepository, +) { + + fun scanAndPersist(filePath: String): List { + val folderToScan = Paths.get(filePath) + + libraryRepository.findAll().forEach { library -> + if (folderToScan.startsWith(library.path())) throw PathContainedInPath("Cannot scan folder that is part of an existing library", "ERR_1017") + } + + val books = fileSystemScanner.scanRootFolder(folderToScan).values.flatten().map { BookWithMedia(it, Media()) } + + transientBookRepository.saveAll(books) + + return books + } + + fun analyzeAndPersist(transientBook: BookWithMedia): BookWithMedia { + val media = bookAnalyzer.analyze(transientBook.book) + + val updated = transientBook.copy(media = media) + transientBookRepository.save(updated) + + return updated + } + + @Throws( + MediaNotReadyException::class, + IndexOutOfBoundsException::class + ) + fun getBookPage(transientBook: BookWithMedia, number: Int): BookPageContent { + val pageContent = bookAnalyzer.getPageContent(transientBook, number) + val pageMediaType = transientBook.media.pages[number - 1].mediaType + + return BookPageContent(number, pageContent, pageMediaType) + } +} diff --git a/komga/src/main/kotlin/org/gotson/komga/infrastructure/cache/TransientBookCache.kt b/komga/src/main/kotlin/org/gotson/komga/infrastructure/cache/TransientBookCache.kt new file mode 100644 index 000000000..9eac7da77 --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/infrastructure/cache/TransientBookCache.kt @@ -0,0 +1,27 @@ +package org.gotson.komga.infrastructure.cache + +import com.github.benmanes.caffeine.cache.Caffeine +import mu.KotlinLogging +import org.gotson.komga.domain.model.BookWithMedia +import org.gotson.komga.domain.persistence.TransientBookRepository +import org.springframework.stereotype.Service +import java.util.concurrent.TimeUnit + +private val logger = KotlinLogging.logger {} + +@Service +class TransientBookCache : TransientBookRepository { + private val cache = Caffeine.newBuilder() + .expireAfterAccess(1, TimeUnit.HOURS) + .build() + + override fun findById(transientBookId: String): BookWithMedia? = cache.getIfPresent(transientBookId) + + override fun save(transientBook: BookWithMedia) { + cache.put(transientBook.book.id, transientBook) + } + + override fun saveAll(transientBooks: Collection) { + cache.putAll(transientBooks.associateBy { it.book.id }) + } +} diff --git a/komga/src/main/kotlin/org/gotson/komga/infrastructure/image/ImageConverter.kt b/komga/src/main/kotlin/org/gotson/komga/infrastructure/image/ImageConverter.kt index 6c8cdc80a..27f95a614 100644 --- a/komga/src/main/kotlin/org/gotson/komga/infrastructure/image/ImageConverter.kt +++ b/komga/src/main/kotlin/org/gotson/komga/infrastructure/image/ImageConverter.kt @@ -23,8 +23,16 @@ class ImageConverter { init { val registry = IIORegistry.getDefaultInstance() - val nativeWebp = registry.getServiceProviderByClass(Class.forName("com.luciad.imageio.webp.WebPImageReaderSpi")) as ImageReaderSpi? - val javaWebp = registry.getServiceProviderByClass(Class.forName("net.sf.javavp8decoder.imageio.WebPImageReaderSpi")) as ImageReaderSpi? + val nativeWebp = try { + registry.getServiceProviderByClass(Class.forName("com.luciad.imageio.webp.WebPImageReaderSpi")) + } catch (e: Exception) { + null + } as ImageReaderSpi? + val javaWebp = try { + registry.getServiceProviderByClass(Class.forName("com.twelvemonkeys.imageio.plugins.webp.WebPImageReaderSpi")) + } catch (e: Exception) { + null + } as ImageReaderSpi? if (nativeWebp != null) { if (!WebP.loadNativeLibrary()) { diff --git a/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/ReadProgressDao.kt b/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/ReadProgressDao.kt index 16bda71d1..f7463870f 100644 --- a/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/ReadProgressDao.kt +++ b/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/ReadProgressDao.kt @@ -33,6 +33,12 @@ class ReadProgressDao( .fetchInto(r) .map { it.toDomain() } + override fun findByBookId(bookId: String): Collection = + dsl.selectFrom(r) + .where(r.BOOK_ID.eq(bookId)) + .fetchInto(r) + .map { it.toDomain() } + override fun save(readProgress: ReadProgress) { dsl.insertInto(r, r.BOOK_ID, r.USER_ID, r.PAGE, r.COMPLETED) .values(readProgress.bookId, readProgress.userId, readProgress.page, readProgress.completed) diff --git a/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/ReferentialDao.kt b/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/ReferentialDao.kt index 8340b7af6..2208ddd02 100644 --- a/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/ReferentialDao.kt +++ b/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/ReferentialDao.kt @@ -27,215 +27,295 @@ class ReferentialDao( private val st = Tables.SERIES_METADATA_TAG private val cs = Tables.COLLECTION_SERIES - override fun findAuthorsByName(search: String): List = + override fun findAuthorsByName(search: String, filterOnLibraryIds: Collection?): List = dsl.selectDistinct(a.NAME, a.ROLE) .from(a) + .apply { filterOnLibraryIds?.let { leftJoin(b).on(a.BOOK_ID.eq(b.ID)) } } .where(a.NAME.containsIgnoreCase(search)) + .apply { filterOnLibraryIds?.let { and(b.LIBRARY_ID.`in`(it)) } } .orderBy(a.NAME, a.ROLE) .fetchInto(a) .map { it.toDomain() } - override fun findAuthorsByNameAndLibrary(search: String, libraryId: String): List = + override fun findAuthorsByNameAndLibrary(search: String, libraryId: String, filterOnLibraryIds: Collection?): List = dsl.selectDistinct(bmaa.NAME, bmaa.ROLE) .from(bmaa) .leftJoin(s).on(bmaa.SERIES_ID.eq(s.ID)) .where(bmaa.NAME.containsIgnoreCase(search)) .and(s.LIBRARY_ID.eq(libraryId)) + .apply { filterOnLibraryIds?.let { and(s.LIBRARY_ID.`in`(it)) } } .orderBy(bmaa.NAME, bmaa.ROLE) .fetchInto(bmaa) .map { it.toDomain() } - override fun findAuthorsByNameAndCollection(search: String, collectionId: String): List = + override fun findAuthorsByNameAndCollection(search: String, collectionId: String, filterOnLibraryIds: Collection?): List = dsl.selectDistinct(bmaa.NAME, bmaa.ROLE) .from(bmaa) .leftJoin(cs).on(bmaa.SERIES_ID.eq(cs.SERIES_ID)) + .apply { filterOnLibraryIds?.let { leftJoin(s).on(bmaa.SERIES_ID.eq(s.ID)) } } .where(bmaa.NAME.containsIgnoreCase(search)) .and(cs.COLLECTION_ID.eq(collectionId)) + .apply { filterOnLibraryIds?.let { and(s.LIBRARY_ID.`in`(it)) } } .orderBy(bmaa.NAME, bmaa.ROLE) .fetchInto(bmaa) .map { it.toDomain() } - override fun findAuthorsByNameAndSeries(search: String, seriesId: String): List = + override fun findAuthorsByNameAndSeries(search: String, seriesId: String, filterOnLibraryIds: Collection?): List = dsl.selectDistinct(bmaa.NAME, bmaa.ROLE) .from(bmaa) + .apply { filterOnLibraryIds?.let { leftJoin(s).on(bmaa.SERIES_ID.eq(s.ID)) } } .where(bmaa.NAME.containsIgnoreCase(search)) .and(bmaa.SERIES_ID.eq(seriesId)) + .apply { filterOnLibraryIds?.let { and(s.LIBRARY_ID.`in`(it)) } } .orderBy(bmaa.NAME, bmaa.ROLE) .fetchInto(bmaa) .map { it.toDomain() } - override fun findAuthorsNamesByName(search: String): List = + override fun findAuthorsNamesByName(search: String, filterOnLibraryIds: Collection?): List = dsl.selectDistinct(a.NAME) .from(a) + .apply { filterOnLibraryIds?.let { leftJoin(b).on(a.BOOK_ID.eq(b.ID)) } } .where(a.NAME.containsIgnoreCase(search)) + .apply { filterOnLibraryIds?.let { and(b.LIBRARY_ID.`in`(it)) } } .orderBy(a.NAME) .fetch(a.NAME) - override fun findAuthorsRoles(): List = + override fun findAuthorsRoles(filterOnLibraryIds: Collection?): List = dsl.selectDistinct(a.ROLE) .from(a) + .apply { + filterOnLibraryIds?.let { + leftJoin(b).on(a.BOOK_ID.eq(b.ID)) + .where(b.LIBRARY_ID.`in`(it)) + } + } .orderBy(a.ROLE) .fetch(a.ROLE) - override fun findAllGenres(): Set = + override fun findAllGenres(filterOnLibraryIds: Collection?): Set = dsl.selectDistinct(g.GENRE) .from(g) + .apply { + filterOnLibraryIds?.let { + leftJoin(s).on(g.SERIES_ID.eq(s.ID)) + .where(s.LIBRARY_ID.`in`(it)) + } + } .orderBy(lower(g.GENRE)) .fetchSet(g.GENRE) - override fun findAllGenresByLibrary(libraryId: String): Set = + override fun findAllGenresByLibrary(libraryId: String, filterOnLibraryIds: Collection?): Set = dsl.selectDistinct(g.GENRE) .from(g) .leftJoin(s).on(g.SERIES_ID.eq(s.ID)) .where(s.LIBRARY_ID.eq(libraryId)) + .apply { filterOnLibraryIds?.let { and(s.LIBRARY_ID.`in`(it)) } } .orderBy(lower(g.GENRE)) .fetchSet(g.GENRE) - override fun findAllGenresByCollection(collectionId: String): Set = + override fun findAllGenresByCollection(collectionId: String, filterOnLibraryIds: Collection?): Set = dsl.selectDistinct(g.GENRE) .from(g) .leftJoin(cs).on(g.SERIES_ID.eq(cs.SERIES_ID)) + .apply { filterOnLibraryIds?.let { leftJoin(s).on(g.SERIES_ID.eq(s.ID)) } } .where(cs.COLLECTION_ID.eq(collectionId)) + .apply { filterOnLibraryIds?.let { and(s.LIBRARY_ID.`in`(it)) } } .orderBy(lower(g.GENRE)) .fetchSet(g.GENRE) - override fun findAllTags(): Set = + override fun findAllSeriesAndBookTags(filterOnLibraryIds: Collection?): Set = dsl.select(bt.TAG.`as`("tag")) .from(bt) + .apply { + filterOnLibraryIds?.let { + leftJoin(b).on(bt.BOOK_ID.eq(b.ID)) + .where(b.LIBRARY_ID.`in`(it)) + } + } .union( - select(st.TAG.`as`("tag")).from(st) + select(st.TAG.`as`("tag")) + .from(st) + .apply { + filterOnLibraryIds?.let { + leftJoin(s).on(st.SERIES_ID.eq(s.ID)) + .where(s.LIBRARY_ID.`in`(it)) + } + } ) .fetchSet(0, String::class.java) .sortedBy { it.toLowerCase() } .toSet() - override fun findAllTagsByLibrary(libraryId: String): Set = + override fun findAllSeriesTags(filterOnLibraryIds: Collection?): Set = + dsl.select(st.TAG) + .from(st) + .apply { + filterOnLibraryIds?.let { + leftJoin(s).on(st.SERIES_ID.eq(s.ID)) + .where(s.LIBRARY_ID.`in`(it)) + } + } + .orderBy(lower(st.TAG)) + .fetchSet(st.TAG) + + override fun findAllSeriesTagsByLibrary(libraryId: String, filterOnLibraryIds: Collection?): Set = dsl.select(st.TAG) .from(st) .leftJoin(s).on(st.SERIES_ID.eq(s.ID)) .where(s.LIBRARY_ID.eq(libraryId)) + .apply { filterOnLibraryIds?.let { and(s.LIBRARY_ID.`in`(it)) } } .orderBy(lower(st.TAG)) .fetchSet(st.TAG) - override fun findAllTagsBySeries(seriesId: String): Set = + override fun findAllBookTagsBySeries(seriesId: String, filterOnLibraryIds: Collection?): Set = dsl.select(bt.TAG) .from(bt) .leftJoin(b).on(bt.BOOK_ID.eq(b.ID)) .where(b.SERIES_ID.eq(seriesId)) + .apply { filterOnLibraryIds?.let { and(b.LIBRARY_ID.`in`(it)) } } .orderBy(lower(bt.TAG)) .fetchSet(bt.TAG) - override fun findAllTagsByCollection(collectionId: String): Set = + override fun findAllSeriesTagsByCollection(collectionId: String, filterOnLibraryIds: Collection?): Set = dsl.select(st.TAG) .from(st) .leftJoin(cs).on(st.SERIES_ID.eq(cs.SERIES_ID)) + .apply { filterOnLibraryIds?.let { leftJoin(s).on(st.SERIES_ID.eq(s.ID)) } } .where(cs.COLLECTION_ID.eq(collectionId)) + .apply { filterOnLibraryIds?.let { and(s.LIBRARY_ID.`in`(it)) } } .orderBy(lower(st.TAG)) .fetchSet(st.TAG) - override fun findAllLanguages(): Set = + override fun findAllBookTags(filterOnLibraryIds: Collection?): Set = + dsl.select(bt.TAG) + .from(bt) + .apply { + filterOnLibraryIds?.let { + leftJoin(b).on(bt.BOOK_ID.eq(b.ID)) + .where(b.LIBRARY_ID.`in`(it)) + } + } + .orderBy(lower(st.TAG)) + .fetchSet(st.TAG) + + override fun findAllLanguages(filterOnLibraryIds: Collection?): Set = dsl.selectDistinct(sd.LANGUAGE) .from(sd) + .apply { filterOnLibraryIds?.let { leftJoin(s).on(sd.SERIES_ID.eq(s.ID)) } } .where(sd.LANGUAGE.ne("")) + .apply { filterOnLibraryIds?.let { and(s.LIBRARY_ID.`in`(it)) } } .orderBy(sd.LANGUAGE) .fetchSet(sd.LANGUAGE) - override fun findAllLanguagesByLibrary(libraryId: String): Set = + override fun findAllLanguagesByLibrary(libraryId: String, filterOnLibraryIds: Collection?): Set = dsl.selectDistinct(sd.LANGUAGE) .from(sd) .leftJoin(s).on(sd.SERIES_ID.eq(s.ID)) .where(sd.LANGUAGE.ne("")) .and(s.LIBRARY_ID.eq(libraryId)) + .apply { filterOnLibraryIds?.let { and(s.LIBRARY_ID.`in`(it)) } } .orderBy(sd.LANGUAGE) .fetchSet(sd.LANGUAGE) - override fun findAllLanguagesByCollection(collectionId: String): Set = + override fun findAllLanguagesByCollection(collectionId: String, filterOnLibraryIds: Collection?): Set = dsl.selectDistinct(sd.LANGUAGE) .from(sd) .leftJoin(cs).on(sd.SERIES_ID.eq(cs.SERIES_ID)) + .apply { filterOnLibraryIds?.let { leftJoin(s).on(sd.SERIES_ID.eq(s.ID)) } } .where(sd.LANGUAGE.ne("")) .and(cs.COLLECTION_ID.eq(collectionId)) + .apply { filterOnLibraryIds?.let { and(s.LIBRARY_ID.`in`(it)) } } .orderBy(sd.LANGUAGE) .fetchSet(sd.LANGUAGE) - override fun findAllPublishers(): Set = + override fun findAllPublishers(filterOnLibraryIds: Collection?): Set = dsl.selectDistinct(sd.PUBLISHER) .from(sd) + .apply { filterOnLibraryIds?.let { leftJoin(s).on(sd.SERIES_ID.eq(s.ID)) } } .where(sd.PUBLISHER.ne("")) + .apply { filterOnLibraryIds?.let { and(s.LIBRARY_ID.`in`(it)) } } .orderBy(sd.PUBLISHER) .fetchSet(sd.PUBLISHER) - override fun findAllPublishersByLibrary(libraryId: String): Set = + override fun findAllPublishersByLibrary(libraryId: String, filterOnLibraryIds: Collection?): Set = dsl.selectDistinct(sd.PUBLISHER) .from(sd) .leftJoin(s).on(sd.SERIES_ID.eq(s.ID)) .where(sd.PUBLISHER.ne("")) .and(s.LIBRARY_ID.eq(libraryId)) + .apply { filterOnLibraryIds?.let { and(s.LIBRARY_ID.`in`(it)) } } .orderBy(sd.PUBLISHER) .fetchSet(sd.PUBLISHER) - override fun findAllPublishersByLibraries(libraryIds: Set): Set = - dsl.selectDistinct(sd.PUBLISHER) - .from(sd) - .leftJoin(s).on(sd.SERIES_ID.eq(s.ID)) - .where(sd.PUBLISHER.ne("")) - .and(s.LIBRARY_ID.`in`(libraryIds)) - .orderBy(sd.PUBLISHER) - .fetchSet(sd.PUBLISHER) - - override fun findAllPublishersByCollection(collectionId: String): Set = + override fun findAllPublishersByCollection(collectionId: String, filterOnLibraryIds: Collection?): Set = dsl.selectDistinct(sd.PUBLISHER) .from(sd) .leftJoin(cs).on(sd.SERIES_ID.eq(cs.SERIES_ID)) + .apply { filterOnLibraryIds?.let { leftJoin(s).on(sd.SERIES_ID.eq(s.ID)) } } .where(sd.PUBLISHER.ne("")) .and(cs.COLLECTION_ID.eq(collectionId)) + .apply { filterOnLibraryIds?.let { and(s.LIBRARY_ID.`in`(it)) } } .orderBy(sd.PUBLISHER) .fetchSet(sd.PUBLISHER) - override fun findAllAgeRatings(): Set = + override fun findAllAgeRatings(filterOnLibraryIds: Collection?): Set = dsl.selectDistinct(sd.AGE_RATING) .from(sd) + .apply { + filterOnLibraryIds?.let { + leftJoin(s).on(sd.SERIES_ID.eq(s.ID)) + .where(s.LIBRARY_ID.`in`(it)) + } + } .orderBy(sd.AGE_RATING) .fetchSet(sd.AGE_RATING) - override fun findAllAgeRatingsByLibrary(libraryId: String): Set = + override fun findAllAgeRatingsByLibrary(libraryId: String, filterOnLibraryIds: Collection?): Set = dsl.selectDistinct(sd.AGE_RATING) .from(sd) .leftJoin(s).on(sd.SERIES_ID.eq(s.ID)) .where(s.LIBRARY_ID.eq(libraryId)) + .apply { filterOnLibraryIds?.let { and(s.LIBRARY_ID.`in`(it)) } } .orderBy(sd.AGE_RATING) .fetchSet(sd.AGE_RATING) - override fun findAllAgeRatingsByCollection(collectionId: String): Set = + override fun findAllAgeRatingsByCollection(collectionId: String, filterOnLibraryIds: Collection?): Set = dsl.selectDistinct(sd.AGE_RATING) .from(sd) .leftJoin(cs).on(sd.SERIES_ID.eq(cs.SERIES_ID)) + .apply { filterOnLibraryIds?.let { leftJoin(s).on(sd.SERIES_ID.eq(s.ID)) } } .where(cs.COLLECTION_ID.eq(collectionId)) + .apply { filterOnLibraryIds?.let { and(s.LIBRARY_ID.`in`(it)) } } .orderBy(sd.AGE_RATING) .fetchSet(sd.AGE_RATING) - override fun findAllSeriesReleaseDates(): Set = + override fun findAllSeriesReleaseDates(filterOnLibraryIds: Collection?): Set = dsl.selectDistinct(bma.RELEASE_DATE) .from(bma) + .apply { filterOnLibraryIds?.let { leftJoin(s).on(bma.SERIES_ID.eq(s.ID)) } } .where(bma.RELEASE_DATE.isNotNull) + .apply { filterOnLibraryIds?.let { and(s.LIBRARY_ID.`in`(it)) } } .orderBy(bma.RELEASE_DATE.desc()) .fetchSet(bma.RELEASE_DATE) - override fun findAllSeriesReleaseDatesByLibrary(libraryId: String): Set = + override fun findAllSeriesReleaseDatesByLibrary(libraryId: String, filterOnLibraryIds: Collection?): Set = dsl.selectDistinct(bma.RELEASE_DATE) .from(bma) .leftJoin(s).on(bma.SERIES_ID.eq(s.ID)) .where(s.LIBRARY_ID.eq(libraryId)) .and(bma.RELEASE_DATE.isNotNull) + .apply { filterOnLibraryIds?.let { and(s.LIBRARY_ID.`in`(it)) } } .orderBy(bma.RELEASE_DATE.desc()) .fetchSet(bma.RELEASE_DATE) - override fun findAllSeriesReleaseDatesByCollection(collectionId: String): Set = + override fun findAllSeriesReleaseDatesByCollection(collectionId: String, filterOnLibraryIds: Collection?): Set = dsl.selectDistinct(bma.RELEASE_DATE) .from(bma) .leftJoin(cs).on(bma.SERIES_ID.eq(cs.SERIES_ID)) + .apply { filterOnLibraryIds?.let { leftJoin(s).on(bma.SERIES_ID.eq(s.ID)) } } .where(cs.COLLECTION_ID.eq(collectionId)) .and(bma.RELEASE_DATE.isNotNull) + .apply { filterOnLibraryIds?.let { and(s.LIBRARY_ID.`in`(it)) } } .orderBy(bma.RELEASE_DATE.desc()) .fetchSet(bma.RELEASE_DATE) diff --git a/komga/src/main/kotlin/org/gotson/komga/infrastructure/metadata/BookMetadataProvider.kt b/komga/src/main/kotlin/org/gotson/komga/infrastructure/metadata/BookMetadataProvider.kt index 085e9c112..723281db0 100644 --- a/komga/src/main/kotlin/org/gotson/komga/infrastructure/metadata/BookMetadataProvider.kt +++ b/komga/src/main/kotlin/org/gotson/komga/infrastructure/metadata/BookMetadataProvider.kt @@ -1,11 +1,10 @@ package org.gotson.komga.infrastructure.metadata -import org.gotson.komga.domain.model.Book import org.gotson.komga.domain.model.BookMetadataPatch import org.gotson.komga.domain.model.BookMetadataPatchCapability -import org.gotson.komga.domain.model.Media +import org.gotson.komga.domain.model.BookWithMedia interface BookMetadataProvider { fun getCapabilities(): List - fun getBookMetadataFromBook(book: Book, media: Media): BookMetadataPatch? + fun getBookMetadataFromBook(book: BookWithMedia): BookMetadataPatch? } diff --git a/komga/src/main/kotlin/org/gotson/komga/infrastructure/metadata/SeriesMetadataProvider.kt b/komga/src/main/kotlin/org/gotson/komga/infrastructure/metadata/SeriesMetadataProvider.kt index 4abbda685..446a7be20 100644 --- a/komga/src/main/kotlin/org/gotson/komga/infrastructure/metadata/SeriesMetadataProvider.kt +++ b/komga/src/main/kotlin/org/gotson/komga/infrastructure/metadata/SeriesMetadataProvider.kt @@ -1,9 +1,8 @@ package org.gotson.komga.infrastructure.metadata -import org.gotson.komga.domain.model.Book -import org.gotson.komga.domain.model.Media +import org.gotson.komga.domain.model.BookWithMedia import org.gotson.komga.domain.model.SeriesMetadataPatch interface SeriesMetadataProvider { - fun getSeriesMetadataFromBook(book: Book, media: Media): SeriesMetadataPatch? + fun getSeriesMetadataFromBook(book: BookWithMedia): SeriesMetadataPatch? } diff --git a/komga/src/main/kotlin/org/gotson/komga/infrastructure/metadata/barcode/IsbnBarcodeProvider.kt b/komga/src/main/kotlin/org/gotson/komga/infrastructure/metadata/barcode/IsbnBarcodeProvider.kt index bb92076c8..5d00058f8 100644 --- a/komga/src/main/kotlin/org/gotson/komga/infrastructure/metadata/barcode/IsbnBarcodeProvider.kt +++ b/komga/src/main/kotlin/org/gotson/komga/infrastructure/metadata/barcode/IsbnBarcodeProvider.kt @@ -8,10 +8,9 @@ import com.google.zxing.RGBLuminanceSource import com.google.zxing.common.HybridBinarizer import mu.KotlinLogging import org.apache.commons.validator.routines.ISBNValidator -import org.gotson.komga.domain.model.Book import org.gotson.komga.domain.model.BookMetadataPatch import org.gotson.komga.domain.model.BookMetadataPatchCapability -import org.gotson.komga.domain.model.Media +import org.gotson.komga.domain.model.BookWithMedia import org.gotson.komga.domain.service.BookAnalyzer import org.gotson.komga.infrastructure.metadata.BookMetadataProvider import org.springframework.stereotype.Service @@ -37,8 +36,8 @@ class IsbnBarcodeProvider( override fun getCapabilities(): List = listOf(BookMetadataPatchCapability.ISBN) - override fun getBookMetadataFromBook(book: Book, media: Media): BookMetadataPatch? { - val pagesToTry = (1..media.pages.size).toList().let { + override fun getBookMetadataFromBook(book: BookWithMedia): BookMetadataPatch? { + val pagesToTry = (1..book.media.pages.size).toList().let { (it.takeLast(PAGES_LAST).reversed() + it.take(PAGES_FIRST)).distinct() } 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 882eb48cf..7ceb2628c 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 @@ -3,10 +3,9 @@ package org.gotson.komga.infrastructure.metadata.comicrack import com.fasterxml.jackson.dataformat.xml.XmlMapper import mu.KotlinLogging import org.gotson.komga.domain.model.Author -import org.gotson.komga.domain.model.Book import org.gotson.komga.domain.model.BookMetadataPatch import org.gotson.komga.domain.model.BookMetadataPatchCapability -import org.gotson.komga.domain.model.Media +import org.gotson.komga.domain.model.BookWithMedia import org.gotson.komga.domain.model.SeriesMetadata import org.gotson.komga.domain.model.SeriesMetadataPatch import org.gotson.komga.domain.service.BookAnalyzer @@ -40,8 +39,8 @@ class ComicInfoProvider( BookMetadataPatchCapability.READ_LISTS, ) - override fun getBookMetadataFromBook(book: Book, media: Media): BookMetadataPatch? { - getComicInfo(book, media)?.let { comicInfo -> + override fun getBookMetadataFromBook(book: BookWithMedia): BookMetadataPatch? { + getComicInfo(book)?.let { comicInfo -> val releaseDate = comicInfo.year?.let { LocalDate.of(comicInfo.year!!, comicInfo.month ?: 1, comicInfo.day ?: 1) } @@ -83,8 +82,8 @@ class ComicInfoProvider( return null } - override fun getSeriesMetadataFromBook(book: Book, media: Media): SeriesMetadataPatch? { - getComicInfo(book, media)?.let { comicInfo -> + override fun getSeriesMetadataFromBook(book: BookWithMedia): SeriesMetadataPatch? { + getComicInfo(book)?.let { comicInfo -> val readingDirection = when (comicInfo.manga) { Manga.NO -> SeriesMetadata.ReadingDirection.LEFT_TO_RIGHT Manga.YES_AND_RIGHT_TO_LEFT -> SeriesMetadata.ReadingDirection.RIGHT_TO_LEFT @@ -110,9 +109,9 @@ class ComicInfoProvider( return null } - private fun getComicInfo(book: Book, media: Media): ComicInfo? { + private fun getComicInfo(book: BookWithMedia): ComicInfo? { try { - if (media.files.none { it == COMIC_INFO }) { + if (book.media.files.none { it == COMIC_INFO }) { logger.debug { "Book does not contain any $COMIC_INFO file: $book" } return null } diff --git a/komga/src/main/kotlin/org/gotson/komga/infrastructure/metadata/comicrack/ReadListProvider.kt b/komga/src/main/kotlin/org/gotson/komga/infrastructure/metadata/comicrack/ReadListProvider.kt index b4a13018a..05b20d6e2 100644 --- a/komga/src/main/kotlin/org/gotson/komga/infrastructure/metadata/comicrack/ReadListProvider.kt +++ b/komga/src/main/kotlin/org/gotson/komga/infrastructure/metadata/comicrack/ReadListProvider.kt @@ -28,7 +28,7 @@ class ReadListProvider( val books = readingList.books.mapNotNull { val series = computeSeriesFromSeriesAndVolume(it.series, it.volume) if (!series.isNullOrBlank() && it.number != null) - ReadListRequestBook(series, it.number!!) + ReadListRequestBook(series, it.number!!.trim()) else { logger.warn { "Book is missing series or number, skipping: $it" } null diff --git a/komga/src/main/kotlin/org/gotson/komga/infrastructure/metadata/comicrack/dto/Book.kt b/komga/src/main/kotlin/org/gotson/komga/infrastructure/metadata/comicrack/dto/Book.kt index 26f4eb0dd..946daefbe 100644 --- a/komga/src/main/kotlin/org/gotson/komga/infrastructure/metadata/comicrack/dto/Book.kt +++ b/komga/src/main/kotlin/org/gotson/komga/infrastructure/metadata/comicrack/dto/Book.kt @@ -9,7 +9,7 @@ class Book { var series: String? = null @JsonProperty(value = "Number") - var number: Int? = null + var number: String? = null @JsonProperty(value = "Volume") var volume: Int? = null diff --git a/komga/src/main/kotlin/org/gotson/komga/infrastructure/metadata/epub/EpubMetadataProvider.kt b/komga/src/main/kotlin/org/gotson/komga/infrastructure/metadata/epub/EpubMetadataProvider.kt index cf81dd997..fd647c45a 100644 --- a/komga/src/main/kotlin/org/gotson/komga/infrastructure/metadata/epub/EpubMetadataProvider.kt +++ b/komga/src/main/kotlin/org/gotson/komga/infrastructure/metadata/epub/EpubMetadataProvider.kt @@ -2,10 +2,9 @@ package org.gotson.komga.infrastructure.metadata.epub import org.apache.commons.validator.routines.ISBNValidator import org.gotson.komga.domain.model.Author -import org.gotson.komga.domain.model.Book import org.gotson.komga.domain.model.BookMetadataPatch import org.gotson.komga.domain.model.BookMetadataPatchCapability -import org.gotson.komga.domain.model.Media +import org.gotson.komga.domain.model.BookWithMedia import org.gotson.komga.domain.model.SeriesMetadata import org.gotson.komga.domain.model.SeriesMetadataPatch import org.gotson.komga.infrastructure.mediacontainer.EpubExtractor @@ -41,9 +40,9 @@ class EpubMetadataProvider( BookMetadataPatchCapability.ISBN, ) - override fun getBookMetadataFromBook(book: Book, media: Media): BookMetadataPatch? { - if (media.mediaType != "application/epub+zip") return null - epubExtractor.getPackageFile(book.path())?.let { packageFile -> + override fun getBookMetadataFromBook(book: BookWithMedia): BookMetadataPatch? { + if (book.media.mediaType != "application/epub+zip") return null + epubExtractor.getPackageFile(book.book.path())?.let { packageFile -> val opf = Jsoup.parse(packageFile) val title = opf.selectFirst("metadata > dc|title")?.text()?.ifBlank { null } @@ -80,9 +79,9 @@ class EpubMetadataProvider( return null } - override fun getSeriesMetadataFromBook(book: Book, media: Media): SeriesMetadataPatch? { - if (media.mediaType != "application/epub+zip") return null - epubExtractor.getPackageFile(book.path())?.let { packageFile -> + override fun getSeriesMetadataFromBook(book: BookWithMedia): SeriesMetadataPatch? { + if (book.media.mediaType != "application/epub+zip") return null + epubExtractor.getPackageFile(book.book.path())?.let { packageFile -> val opf = Jsoup.parse(packageFile) val series = opf.selectFirst("metadata > meta[property=belongs-to-collection]")?.text()?.ifBlank { null } diff --git a/komga/src/main/kotlin/org/gotson/komga/infrastructure/web/Utils.kt b/komga/src/main/kotlin/org/gotson/komga/infrastructure/web/Utils.kt index dbe271e54..73e45ec08 100644 --- a/komga/src/main/kotlin/org/gotson/komga/infrastructure/web/Utils.kt +++ b/komga/src/main/kotlin/org/gotson/komga/infrastructure/web/Utils.kt @@ -1,6 +1,7 @@ package org.gotson.komga.infrastructure.web import org.springframework.http.CacheControl +import org.springframework.http.MediaType import org.springframework.http.ResponseEntity import java.net.URL import java.nio.file.Paths @@ -20,3 +21,13 @@ val cachePrivate = CacheControl .noTransform() .cachePrivate() .mustRevalidate() + +fun getMediaTypeOrDefault(mediaTypeString: String?): MediaType { + mediaTypeString?.let { + try { + return MediaType.parseMediaType(mediaTypeString) + } catch (ex: Exception) { + } + } + return MediaType.APPLICATION_OCTET_STREAM +} diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/opds/OpdsController.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/opds/OpdsController.kt index c48329956..a84709673 100644 --- a/komga/src/main/kotlin/org/gotson/komga/interfaces/opds/OpdsController.kt +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/opds/OpdsController.kt @@ -338,9 +338,7 @@ class OpdsController( fun getPublishers( @AuthenticationPrincipal principal: KomgaPrincipal ): OpdsFeed { - val publishers = - if (principal.user.sharedAllLibraries) referentialRepository.findAllPublishers() - else referentialRepository.findAllPublishersByLibraries(principal.user.sharedLibrariesIds) + val publishers = referentialRepository.findAllPublishers(principal.user.getAuthorizedLibraryIds(null)) return OpdsFeedNavigation( id = ID_PUBLISHERS_ALL, diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/BookController.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/BookController.kt index 389670c18..ad003eb81 100644 --- a/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/BookController.kt +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/BookController.kt @@ -28,8 +28,10 @@ import org.gotson.komga.infrastructure.mediacontainer.ContentDetector import org.gotson.komga.infrastructure.security.KomgaPrincipal import org.gotson.komga.infrastructure.swagger.PageableAsQueryParam import org.gotson.komga.infrastructure.swagger.PageableWithoutSortAsQueryParam +import org.gotson.komga.infrastructure.web.getMediaTypeOrDefault import org.gotson.komga.infrastructure.web.setCachePrivate import org.gotson.komga.interfaces.rest.dto.BookDto +import org.gotson.komga.interfaces.rest.dto.BookImportBatchDto import org.gotson.komga.interfaces.rest.dto.BookMetadataUpdateDto import org.gotson.komga.interfaces.rest.dto.PageDto import org.gotson.komga.interfaces.rest.dto.ReadListDto @@ -502,19 +504,30 @@ class BookController( } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) } + @PostMapping("api/v1/books/import") + @PreAuthorize("hasRole('$ROLE_ADMIN')") + @ResponseStatus(HttpStatus.ACCEPTED) + fun importBooks( + @RequestBody bookImportBatch: BookImportBatchDto, + ) { + bookImportBatch.books.forEach { + try { + taskReceiver.importBook( + sourceFile = it.sourceFile, + seriesId = it.seriesId, + copyMode = bookImportBatch.copyMode, + destinationName = it.destinationName, + upgradeBookId = it.upgradeBookId, + ) + } catch (e: Exception) { + logger.error(e) { "Error while creating import task for: $it" } + } + } + } + private fun ResponseEntity.BodyBuilder.setNotModified(media: Media) = this.setCachePrivate().lastModified(getBookLastModified(media)) private fun getBookLastModified(media: Media) = media.lastModifiedDate.toInstant(ZoneOffset.UTC).toEpochMilli() - - private fun getMediaTypeOrDefault(mediaTypeString: String?): MediaType { - mediaTypeString?.let { - try { - return MediaType.parseMediaType(mediaTypeString) - } catch (ex: Exception) { - } - } - return MediaType.APPLICATION_OCTET_STREAM - } } diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/ReferentialController.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/ReferentialController.kt index f035d4896..1f764a671 100644 --- a/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/ReferentialController.kt +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/ReferentialController.kt @@ -1,9 +1,11 @@ package org.gotson.komga.interfaces.rest import org.gotson.komga.domain.persistence.ReferentialRepository +import org.gotson.komga.infrastructure.security.KomgaPrincipal import org.gotson.komga.interfaces.rest.dto.AuthorDto import org.gotson.komga.interfaces.rest.dto.toDto import org.springframework.http.MediaType +import org.springframework.security.core.annotation.AuthenticationPrincipal import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RequestParam @@ -17,6 +19,7 @@ class ReferentialController( @GetMapping("/authors") fun getAuthors( + @AuthenticationPrincipal principal: KomgaPrincipal, @RequestParam(name = "search", defaultValue = "") search: String, @RequestParam(name = "library_id", required = false) libraryId: String?, @RequestParam(name = "collection_id", required = false) collectionId: String?, @@ -24,87 +27,119 @@ class ReferentialController( ): List = when { - libraryId != null -> referentialRepository.findAuthorsByNameAndLibrary(search, libraryId) - collectionId != null -> referentialRepository.findAuthorsByNameAndCollection(search, collectionId) - seriesId != null -> referentialRepository.findAuthorsByNameAndSeries(search, seriesId) - else -> referentialRepository.findAuthorsByName(search) + libraryId != null -> referentialRepository.findAuthorsByNameAndLibrary(search, libraryId, principal.user.getAuthorizedLibraryIds(null)) + collectionId != null -> referentialRepository.findAuthorsByNameAndCollection(search, collectionId, principal.user.getAuthorizedLibraryIds(null)) + seriesId != null -> referentialRepository.findAuthorsByNameAndSeries(search, seriesId, principal.user.getAuthorizedLibraryIds(null)) + else -> referentialRepository.findAuthorsByName(search, principal.user.getAuthorizedLibraryIds(null)) }.map { it.toDto() } @GetMapping("/authors/names") fun getAuthorsNames( + @AuthenticationPrincipal principal: KomgaPrincipal, @RequestParam(name = "search", defaultValue = "") search: String ): List = - referentialRepository.findAuthorsNamesByName(search) + referentialRepository.findAuthorsNamesByName(search, principal.user.getAuthorizedLibraryIds(null)) @GetMapping("/authors/roles") - fun getAuthorsRoles(): List = - referentialRepository.findAuthorsRoles() + fun getAuthorsRoles( + @AuthenticationPrincipal principal: KomgaPrincipal, + ): List = + referentialRepository.findAuthorsRoles(principal.user.getAuthorizedLibraryIds(null)) @GetMapping("/genres") fun getGenres( + @AuthenticationPrincipal principal: KomgaPrincipal, @RequestParam(name = "library_id", required = false) libraryId: String?, @RequestParam(name = "collection_id", required = false) collectionId: String? ): Set = when { - libraryId != null -> referentialRepository.findAllGenresByLibrary(libraryId) - collectionId != null -> referentialRepository.findAllGenresByCollection(collectionId) - else -> referentialRepository.findAllGenres() + libraryId != null -> referentialRepository.findAllGenresByLibrary(libraryId, principal.user.getAuthorizedLibraryIds(null)) + collectionId != null -> referentialRepository.findAllGenresByCollection(collectionId, principal.user.getAuthorizedLibraryIds(null)) + else -> referentialRepository.findAllGenres(principal.user.getAuthorizedLibraryIds(null)) } @GetMapping("/tags") fun getTags( + @AuthenticationPrincipal principal: KomgaPrincipal, + // TODO: remove those parameters once Tachiyomi Extension is using the new /tags/series endpoint @RequestParam(name = "library_id", required = false) libraryId: String?, @RequestParam(name = "series_id", required = false) seriesId: String?, @RequestParam(name = "collection_id", required = false) collectionId: String? ): Set = when { - libraryId != null -> referentialRepository.findAllTagsByLibrary(libraryId) - seriesId != null -> referentialRepository.findAllTagsBySeries(seriesId) - collectionId != null -> referentialRepository.findAllTagsByCollection(collectionId) - else -> referentialRepository.findAllTags() + libraryId != null -> referentialRepository.findAllSeriesTagsByLibrary(libraryId, principal.user.getAuthorizedLibraryIds(null)) + seriesId != null -> referentialRepository.findAllBookTagsBySeries(seriesId, principal.user.getAuthorizedLibraryIds(null)) + collectionId != null -> referentialRepository.findAllSeriesTagsByCollection(collectionId, principal.user.getAuthorizedLibraryIds(null)) + else -> referentialRepository.findAllSeriesAndBookTags(principal.user.getAuthorizedLibraryIds(null)) + } + + @GetMapping("/tags/book") + fun getBookTags( + @AuthenticationPrincipal principal: KomgaPrincipal, + @RequestParam(name = "series_id", required = false) seriesId: String?, + ): Set = + when { + seriesId != null -> referentialRepository.findAllBookTagsBySeries(seriesId, principal.user.getAuthorizedLibraryIds(null)) + else -> referentialRepository.findAllBookTags(principal.user.getAuthorizedLibraryIds(null)) + } + + @GetMapping("/tags/series") + fun getSeriesTags( + @AuthenticationPrincipal principal: KomgaPrincipal, + @RequestParam(name = "library_id", required = false) libraryId: String?, + @RequestParam(name = "collection_id", required = false) collectionId: String? + ): Set = + when { + libraryId != null -> referentialRepository.findAllSeriesTagsByLibrary(libraryId, principal.user.getAuthorizedLibraryIds(null)) + collectionId != null -> referentialRepository.findAllSeriesTagsByCollection(collectionId, principal.user.getAuthorizedLibraryIds(null)) + else -> referentialRepository.findAllSeriesTags(principal.user.getAuthorizedLibraryIds(null)) } @GetMapping("/languages") fun getLanguages( + @AuthenticationPrincipal principal: KomgaPrincipal, @RequestParam(name = "library_id", required = false) libraryId: String?, @RequestParam(name = "collection_id", required = false) collectionId: String? ): Set = when { - libraryId != null -> referentialRepository.findAllLanguagesByLibrary(libraryId) - collectionId != null -> referentialRepository.findAllLanguagesByCollection(collectionId) - else -> referentialRepository.findAllLanguages() + libraryId != null -> referentialRepository.findAllLanguagesByLibrary(libraryId, principal.user.getAuthorizedLibraryIds(null)) + collectionId != null -> referentialRepository.findAllLanguagesByCollection(collectionId, principal.user.getAuthorizedLibraryIds(null)) + else -> referentialRepository.findAllLanguages(principal.user.getAuthorizedLibraryIds(null)) } @GetMapping("/publishers") fun getPublishers( + @AuthenticationPrincipal principal: KomgaPrincipal, @RequestParam(name = "library_id", required = false) libraryId: String?, @RequestParam(name = "collection_id", required = false) collectionId: String? ): Set = when { - libraryId != null -> referentialRepository.findAllPublishersByLibrary(libraryId) - collectionId != null -> referentialRepository.findAllPublishersByCollection(collectionId) - else -> referentialRepository.findAllPublishers() + libraryId != null -> referentialRepository.findAllPublishersByLibrary(libraryId, principal.user.getAuthorizedLibraryIds(null)) + collectionId != null -> referentialRepository.findAllPublishersByCollection(collectionId, principal.user.getAuthorizedLibraryIds(null)) + else -> referentialRepository.findAllPublishers(principal.user.getAuthorizedLibraryIds(null)) } @GetMapping("/age-ratings") fun getAgeRatings( + @AuthenticationPrincipal principal: KomgaPrincipal, @RequestParam(name = "library_id", required = false) libraryId: String?, @RequestParam(name = "collection_id", required = false) collectionId: String? ): Set = when { - libraryId != null -> referentialRepository.findAllAgeRatingsByLibrary(libraryId) - collectionId != null -> referentialRepository.findAllAgeRatingsByCollection(collectionId) - else -> referentialRepository.findAllAgeRatings() + libraryId != null -> referentialRepository.findAllAgeRatingsByLibrary(libraryId, principal.user.getAuthorizedLibraryIds(null)) + collectionId != null -> referentialRepository.findAllAgeRatingsByCollection(collectionId, principal.user.getAuthorizedLibraryIds(null)) + else -> referentialRepository.findAllAgeRatings(principal.user.getAuthorizedLibraryIds(null)) }.map { it?.toString() ?: "None" }.toSet() @GetMapping("/series/release-dates") fun getSeriesReleaseDates( + @AuthenticationPrincipal principal: KomgaPrincipal, @RequestParam(name = "library_id", required = false) libraryId: String?, @RequestParam(name = "collection_id", required = false) collectionId: String? ): Set = when { - libraryId != null -> referentialRepository.findAllSeriesReleaseDatesByLibrary(libraryId) - collectionId != null -> referentialRepository.findAllSeriesReleaseDatesByCollection(collectionId) - else -> referentialRepository.findAllSeriesReleaseDates() + libraryId != null -> referentialRepository.findAllSeriesReleaseDatesByLibrary(libraryId, principal.user.getAuthorizedLibraryIds(null)) + collectionId != null -> referentialRepository.findAllSeriesReleaseDatesByCollection(collectionId, principal.user.getAuthorizedLibraryIds(null)) + else -> referentialRepository.findAllSeriesReleaseDates(principal.user.getAuthorizedLibraryIds(null)) }.map { it.year.toString() }.toSet() } diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/TransientBooksController.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/TransientBooksController.kt new file mode 100644 index 000000000..f88ab239b --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/TransientBooksController.kt @@ -0,0 +1,121 @@ +package org.gotson.komga.interfaces.rest + +import com.jakewharton.byteunits.BinaryByteUnit +import mu.KotlinLogging +import org.gotson.komga.domain.model.BookWithMedia +import org.gotson.komga.domain.model.CodedException +import org.gotson.komga.domain.model.MediaNotReadyException +import org.gotson.komga.domain.model.ROLE_ADMIN +import org.gotson.komga.domain.persistence.TransientBookRepository +import org.gotson.komga.domain.service.TransientBookLifecycle +import org.gotson.komga.infrastructure.web.getMediaTypeOrDefault +import org.gotson.komga.infrastructure.web.toFilePath +import org.gotson.komga.interfaces.rest.dto.PageDto +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType +import org.springframework.http.ResponseEntity +import org.springframework.security.access.prepost.PreAuthorize +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController +import org.springframework.web.server.ResponseStatusException +import java.nio.file.NoSuchFileException +import java.time.LocalDateTime + +private val logger = KotlinLogging.logger {} + +@RestController +@RequestMapping("api/v1/transient-books", produces = [MediaType.APPLICATION_JSON_VALUE]) +@PreAuthorize("hasRole('$ROLE_ADMIN')") +class TransientBooksController( + private val transientBookLifecycle: TransientBookLifecycle, + private val transientBookRepository: TransientBookRepository, +) { + + @PostMapping + fun scanForTransientBooks( + @RequestBody request: ScanRequestDto + ): List = + try { + transientBookLifecycle.scanAndPersist(request.path) + .sortedBy { it.book.path() } + .map { it.toDto() } + } catch (e: CodedException) { + throw ResponseStatusException(HttpStatus.BAD_REQUEST, e.code) + } + + @PostMapping("{id}/analyze") + fun analyze( + @PathVariable id: String, + ): TransientBookDto = transientBookRepository.findById(id)?.let { + transientBookLifecycle.analyzeAndPersist(it).toDto() + } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + + @GetMapping( + value = ["{id}/pages/{pageNumber}"], + produces = [MediaType.ALL_VALUE] + ) + fun getSourcePage( + @PathVariable id: String, + @PathVariable pageNumber: Int, + ): ResponseEntity = + transientBookRepository.findById(id)?.let { + try { + val pageContent = transientBookLifecycle.getBookPage(it, pageNumber) + + ResponseEntity.ok() + .contentType(getMediaTypeOrDefault(pageContent.mediaType)) + .body(pageContent.content) + } catch (ex: IndexOutOfBoundsException) { + throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Page number does not exist") + } catch (ex: MediaNotReadyException) { + throw ResponseStatusException(HttpStatus.NOT_FOUND, "Book analysis failed") + } catch (ex: NoSuchFileException) { + logger.warn(ex) { "File not found}" } + throw ResponseStatusException(HttpStatus.NOT_FOUND, "File not found, it may have moved") + } + } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) +} + +private fun BookWithMedia.toDto() = + TransientBookDto( + id = book.id, + name = book.name, + url = book.url.toFilePath(), + fileLastModified = book.fileLastModified, + sizeBytes = book.fileSize, + status = media.status.toString(), + mediaType = media.mediaType ?: "", + pages = media.pages.mapIndexed { index, bookPage -> + PageDto( + number = index + 1, + fileName = bookPage.fileName, + mediaType = bookPage.mediaType, + width = bookPage.dimension?.width, + height = bookPage.dimension?.height, + ) + }, + files = media.files, + comment = media.comment ?: "", + ) + +data class ScanRequestDto( + val path: String, +) + +data class TransientBookDto( + val id: String, + val name: String, + val url: String, + val fileLastModified: LocalDateTime, + val sizeBytes: Long, + val size: String = BinaryByteUnit.format(sizeBytes), + val status: String, + val mediaType: String, + val pages: List, + val files: List, + val comment: String, +) diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/dto/BookImportBatchDto.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/dto/BookImportBatchDto.kt new file mode 100644 index 000000000..1b5c48fa2 --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/dto/BookImportBatchDto.kt @@ -0,0 +1,15 @@ +package org.gotson.komga.interfaces.rest.dto + +import org.gotson.komga.domain.model.CopyMode + +data class BookImportBatchDto( + val books: List = emptyList(), + val copyMode: CopyMode, +) + +data class BookImportDto( + val sourceFile: String, + val seriesId: String, + val upgradeBookId: String? = null, + val destinationName: String? = null, +) diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/dto/ReadListRequestResultDto.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/dto/ReadListRequestResultDto.kt index d2aff6733..dc6ce93e9 100644 --- a/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/dto/ReadListRequestResultDto.kt +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/dto/ReadListRequestResultDto.kt @@ -6,7 +6,7 @@ import org.gotson.komga.domain.model.ReadListRequestResultBook data class ReadListRequestBookDto( val series: String, - val number: Int, + val number: String, ) data class ReadListRequestResultDto( diff --git a/komga/src/test/kotlin/org/gotson/komga/domain/model/Utils.kt b/komga/src/test/kotlin/org/gotson/komga/domain/model/Utils.kt index 4c9ba58a9..9f0bec77a 100644 --- a/komga/src/test/kotlin/org/gotson/komga/domain/model/Utils.kt +++ b/komga/src/test/kotlin/org/gotson/komga/domain/model/Utils.kt @@ -4,29 +4,33 @@ import com.github.f4b6a3.tsid.TsidCreator import java.net.URL import java.time.LocalDateTime -fun makeBook(name: String, fileLastModified: LocalDateTime = LocalDateTime.now(), libraryId: String = "", seriesId: String = ""): Book { +fun makeBook(name: String, fileLastModified: LocalDateTime = LocalDateTime.now(), libraryId: String = "", seriesId: String = "", url: URL? = null): Book { Thread.sleep(5) return Book( name = name, - url = URL("file:/$name"), + url = url ?: URL("file:/$name"), fileLastModified = fileLastModified, libraryId = libraryId, seriesId = seriesId ) } -fun makeSeries(name: String, libraryId: String = ""): Series { +fun makeSeries(name: String, libraryId: String = "", url: URL? = null): Series { Thread.sleep(5) return Series( name = name, - url = URL("file:/$name"), + url = url ?: URL("file:/$name"), fileLastModified = LocalDateTime.now(), libraryId = libraryId ) } -fun makeLibrary(name: String = "default", url: String = "file:/$name", id: String = TsidCreator.getTsid256().toString()): Library { - return Library(name, URL(url), id = id) +fun makeLibrary(name: String = "default", path: String = "file:/$name", id: String = TsidCreator.getTsid256().toString(), url: URL? = null): Library { + return Library( + name = name, + root = url ?: URL(path), + id = id + ) } fun makeBookPage(name: String) = diff --git a/komga/src/test/kotlin/org/gotson/komga/domain/service/BookImporterTest.kt b/komga/src/test/kotlin/org/gotson/komga/domain/service/BookImporterTest.kt new file mode 100644 index 000000000..3462a6824 --- /dev/null +++ b/komga/src/test/kotlin/org/gotson/komga/domain/service/BookImporterTest.kt @@ -0,0 +1,456 @@ +package org.gotson.komga.domain.service + +import com.google.common.jimfs.Configuration +import com.google.common.jimfs.Jimfs +import com.ninjasquad.springmockk.MockkBean +import io.mockk.Runs +import io.mockk.every +import io.mockk.just +import io.mockk.verify +import org.assertj.core.api.Assertions +import org.assertj.core.api.Assertions.assertThat +import org.gotson.komga.application.tasks.TaskReceiver +import org.gotson.komga.domain.model.Book +import org.gotson.komga.domain.model.BookPage +import org.gotson.komga.domain.model.CopyMode +import org.gotson.komga.domain.model.KomgaUser +import org.gotson.komga.domain.model.Media +import org.gotson.komga.domain.model.PathContainedInPath +import org.gotson.komga.domain.model.ReadList +import org.gotson.komga.domain.model.makeBook +import org.gotson.komga.domain.model.makeLibrary +import org.gotson.komga.domain.model.makeSeries +import org.gotson.komga.domain.persistence.BookMetadataRepository +import org.gotson.komga.domain.persistence.BookRepository +import org.gotson.komga.domain.persistence.KomgaUserRepository +import org.gotson.komga.domain.persistence.LibraryRepository +import org.gotson.komga.domain.persistence.MediaRepository +import org.gotson.komga.domain.persistence.ReadListRepository +import org.gotson.komga.domain.persistence.ReadProgressRepository +import org.gotson.komga.domain.persistence.SeriesRepository +import org.gotson.komga.infrastructure.language.toIndexedMap +import org.junit.jupiter.api.AfterAll +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.test.context.junit.jupiter.SpringExtension +import java.io.FileNotFoundException +import java.nio.file.FileAlreadyExistsException +import java.nio.file.Files +import java.nio.file.Paths +import kotlin.io.path.createDirectories +import kotlin.io.path.createDirectory +import kotlin.io.path.createFile + +@ExtendWith(SpringExtension::class) +@SpringBootTest +class BookImporterTest( + @Autowired private val bookImporter: BookImporter, + @Autowired private val bookRepository: BookRepository, + @Autowired private val bookLifecycle: BookLifecycle, + @Autowired private val readProgressRepository: ReadProgressRepository, + @Autowired private val libraryRepository: LibraryRepository, + @Autowired private val seriesRepository: SeriesRepository, + @Autowired private val seriesLifecycle: SeriesLifecycle, + @Autowired private val mediaRepository: MediaRepository, + @Autowired private val metadataRepository: BookMetadataRepository, + @Autowired private val userRepository: KomgaUserRepository, + @Autowired private val readListRepository: ReadListRepository, + @Autowired private val readListLifecycle: ReadListLifecycle, +) { + + @MockkBean + private lateinit var mockTaskReceiver: TaskReceiver + + private val library = makeLibrary("lib", "file:/library") + private val user1 = KomgaUser("user1@example.org", "", false) + private val user2 = KomgaUser("user2@example.org", "", false) + + @BeforeAll + fun init() { + libraryRepository.insert(library) + + userRepository.insert(user1) + userRepository.insert(user2) + } + + @BeforeEach + fun beforeEach() { + every { mockTaskReceiver.analyzeBook(any()) } just Runs + every { mockTaskReceiver.refreshBookMetadata(any(), any()) } just Runs + } + + @AfterAll + fun teardown() { + libraryRepository.deleteAll() + userRepository.deleteAll() + } + + @AfterEach + fun `clear repository`() { + seriesLifecycle.deleteMany(seriesRepository.findAll().map { it.id }) + } + + @Test + fun `given non-existent source file when importing then exception is thrown`() { + // given + val sourceFile = Paths.get("/non-existent") + + // when + val thrown = Assertions.catchThrowable { + bookImporter.importBook(sourceFile, makeSeries("a series"), CopyMode.COPY) + } + + // then + assertThat(thrown).isInstanceOf(FileNotFoundException::class.java) + } + + @Test + fun `given existing target when importing then exception is thrown`() { + Jimfs.newFileSystem(Configuration.unix()).use { fs -> + // given + val sourceDir = fs.getPath("/source").createDirectory() + val sourceFile = sourceDir.resolve("source.cbz").createFile() + val destDir = fs.getPath("/dest").createDirectory() + destDir.resolve("source.cbz").createFile() + + val series = makeSeries("dest", url = destDir.toUri().toURL()) + + // when + val thrown = Assertions.catchThrowable { + bookImporter.importBook(sourceFile, series, CopyMode.COPY) + } + + // then + assertThat(thrown).isInstanceOf(FileAlreadyExistsException::class.java) + } + } + + @Test + fun `given existing target when importing with destination name then exception is thrown`() { + Jimfs.newFileSystem(Configuration.unix()).use { fs -> + // given + val sourceDir = fs.getPath("/source").createDirectory() + val sourceFile = sourceDir.resolve("source.cbz").createFile() + val destDir = fs.getPath("/dest").createDirectory() + destDir.resolve("dest.cbz").createFile() + + val series = makeSeries("dest").copy(url = destDir.toUri().toURL()) + + // when + val thrown = Assertions.catchThrowable { + bookImporter.importBook(sourceFile, series, CopyMode.COPY, destinationName = "dest") + } + + // then + assertThat(thrown).isInstanceOf(FileAlreadyExistsException::class.java) + } + } + + @Test + fun `given source file parf of a Komga library when importing then exception is thrown`() { + Jimfs.newFileSystem(Configuration.unix()).use { fs -> + // given + val sourceDir = fs.getPath("/source").createDirectory() + val sourceFile = sourceDir.resolve("source.cbz").createFile() + val destDir = fs.getPath("/dest").createDirectory() + + val series = makeSeries("dest").copy(url = destDir.toUri().toURL()) + + val libraryJimfs = makeLibrary("jimfs", url = sourceDir.toUri().toURL()) + libraryRepository.insert(libraryJimfs) + + // when + val thrown = Assertions.catchThrowable { + bookImporter.importBook(sourceFile, series, CopyMode.COPY) + } + + // then + assertThat(thrown).isInstanceOf(PathContainedInPath::class.java) + + libraryRepository.delete(libraryJimfs.id) + } + } + + @Test + fun `given book when importing then book is imported and series is sorted`() { + Jimfs.newFileSystem(Configuration.unix()).use { fs -> + // given + val sourceDir = fs.getPath("/source").createDirectory() + val sourceFile = sourceDir.resolve("2.cbz").createFile() + val destDir = fs.getPath("/library/series").createDirectories() + + val existingBooks = listOf( + makeBook("1", libraryId = library.id), + makeBook("3", libraryId = library.id), + ) + val series = makeSeries("series", url = destDir.toUri().toURL(), libraryId = library.id) + .also { series -> + seriesLifecycle.createSeries(series) + seriesLifecycle.addBooks(series, existingBooks) + seriesLifecycle.sortBooks(series) + } + + // when + bookImporter.importBook(sourceFile, series, CopyMode.COPY) + + // then + val books = bookRepository.findBySeriesId(series.id).sortedBy { it.number } + assertThat(books).hasSize(3) + assertThat(books[0].id).isEqualTo(existingBooks[0].id) + assertThat(books[2].id).isEqualTo(existingBooks[1].id) + + with(books[1]) { + assertThat(id) + .isNotEqualTo(existingBooks[0].id) + .isNotEqualTo(existingBooks[1].id) + assertThat(number).isEqualTo(2) + assertThat(name).isEqualTo("2") + + val newMedia = mediaRepository.findById(id) + assertThat(newMedia.status).isEqualTo(Media.Status.UNKNOWN) + } + + verify(exactly = 1) { mockTaskReceiver.analyzeBook(any()) } + } + } + + @Test + fun `given existing book when importing with upgrade then existing book is deleted`() { + Jimfs.newFileSystem(Configuration.unix()).use { fs -> + // given + val sourceDir = fs.getPath("/source").createDirectory() + val sourceFile = sourceDir.resolve("source.cbz").createFile() + val destDir = fs.getPath("/library/series").createDirectories() + val existingFile = destDir.resolve("4.cbz").createFile() + + val bookToUpgrade = makeBook("2", libraryId = library.id, url = existingFile.toUri().toURL()) + val otherBooks = listOf( + makeBook("1", libraryId = library.id), + makeBook("3", libraryId = library.id), + ) + val series = makeSeries("series", url = destDir.toUri().toURL(), libraryId = library.id) + .also { series -> + seriesLifecycle.createSeries(series) + seriesLifecycle.addBooks(series, listOf(bookToUpgrade) + otherBooks) + seriesLifecycle.sortBooks(series) + } + + // when + bookImporter.importBook(sourceFile, series, CopyMode.MOVE, upgradeBookId = bookToUpgrade.id) + + // then + val books = bookRepository.findBySeriesId(series.id).sortedBy { it.number } + assertThat(books).hasSize(3) + assertThat(books[0].id).isEqualTo(otherBooks[0].id) + assertThat(books[1].id).isEqualTo(otherBooks[1].id) + assertThat(books[2].id).isNotEqualTo(bookToUpgrade.id) + + assertThat(bookRepository.findByIdOrNull(bookToUpgrade.id)).isNull() + + val upgradedMedia = mediaRepository.findById(books[2].id) + assertThat(upgradedMedia.status).isEqualTo(Media.Status.OUTDATED) + + assertThat(Files.notExists(sourceFile)).isTrue + + verify(exactly = 1) { mockTaskReceiver.analyzeBook(any()) } + } + } + + @Test + fun `given existing book with metadata when importing with upgrade then metadata is kept`() { + Jimfs.newFileSystem(Configuration.unix()).use { fs -> + // given + val sourceDir = fs.getPath("/source").createDirectory() + val sourceFile = sourceDir.resolve("source.cbz").createFile() + val destDir = fs.getPath("/library/series").createDirectories() + val existingFile = destDir.resolve("4.cbz").createFile() + + val bookToUpgrade = makeBook("2", libraryId = library.id, url = existingFile.toUri().toURL()) + val otherBooks = listOf( + makeBook("1", libraryId = library.id), + makeBook("3", libraryId = library.id), + ) + val series = makeSeries("series", url = destDir.toUri().toURL(), libraryId = library.id) + .also { series -> + seriesLifecycle.createSeries(series) + seriesLifecycle.addBooks(series, listOf(bookToUpgrade) + otherBooks) + seriesLifecycle.sortBooks(series) + } + + metadataRepository.findById(bookToUpgrade.id).let { + metadataRepository.update( + it.copy( + summary = "a summary", + number = "HS", + numberLock = true, + numberSort = 100F, + numberSortLock = true, + ) + ) + } + + // when + bookImporter.importBook(sourceFile, series, CopyMode.MOVE, upgradeBookId = bookToUpgrade.id) + + // then + val books = bookRepository.findBySeriesId(series.id).sortedBy { it.number } + assertThat(books).hasSize(3) + assertThat(books[0].id).isEqualTo(otherBooks[0].id) + assertThat(books[1].id).isEqualTo(otherBooks[1].id) + assertThat(books[2].id).isNotEqualTo(bookToUpgrade.id) + + assertThat(bookRepository.findByIdOrNull(bookToUpgrade.id)).isNull() + + val upgradedMedia = mediaRepository.findById(books[2].id) + assertThat(upgradedMedia.status).isEqualTo(Media.Status.OUTDATED) + + with(metadataRepository.findById(books[2].id)) { + assertThat(summary).isEqualTo("a summary") + assertThat(number).isEqualTo("HS") + assertThat(numberLock).isTrue + assertThat(numberSort).isEqualTo(100F) + assertThat(numberSortLock).isTrue + } + + assertThat(Files.notExists(sourceFile)).isTrue + + verify(exactly = 1) { mockTaskReceiver.analyzeBook(any()) } + } + } + + @Test + fun `given existing book when importing with upgrade and same name then existing book is replaced`() { + Jimfs.newFileSystem(Configuration.unix()).use { fs -> + // given + val sourceDir = fs.getPath("/source").createDirectory() + val sourceFile = sourceDir.resolve("source.cbz").createFile() + val destDir = fs.getPath("/library/series").createDirectories() + val existingFile = destDir.resolve("2.cbz").createFile() + + val bookToUpgrade = makeBook("2", libraryId = library.id, url = existingFile.toUri().toURL()) + val otherBooks = listOf( + makeBook("1", libraryId = library.id), + makeBook("3", libraryId = library.id), + ) + val series = makeSeries("series", url = destDir.toUri().toURL(), libraryId = library.id) + .also { series -> + seriesLifecycle.createSeries(series) + seriesLifecycle.addBooks(series, listOf(bookToUpgrade) + otherBooks) + seriesLifecycle.sortBooks(series) + } + + // when + bookImporter.importBook(sourceFile, series, CopyMode.COPY, destinationName = "2", upgradeBookId = bookToUpgrade.id) + + // then + val books = bookRepository.findBySeriesId(series.id).sortedBy { it.number } + assertThat(books).hasSize(3) + assertThat(books[0].id).isEqualTo(otherBooks[0].id) + assertThat(books[1].id).isNotEqualTo(bookToUpgrade.id) + assertThat(books[2].id).isEqualTo(otherBooks[1].id) + + assertThat(bookRepository.findByIdOrNull(bookToUpgrade.id)).isNull() + + val upgradedMedia = mediaRepository.findById(books[1].id) + assertThat(upgradedMedia.status).isEqualTo(Media.Status.OUTDATED) + + assertThat(Files.exists(sourceFile)).isTrue + + verify(exactly = 1) { mockTaskReceiver.analyzeBook(any()) } + } + } + + @Test + fun `given book with read progress when importing with upgrade then read progress is kept`() { + Jimfs.newFileSystem(Configuration.unix()).use { fs -> + // given + val sourceDir = fs.getPath("/source").createDirectory() + val sourceFile = sourceDir.resolve("source.cbz").createFile() + val destDir = fs.getPath("/library/series").createDirectories() + val existingFile = destDir.resolve("1.cbz").createFile() + + val bookToUpgrade = makeBook("1", libraryId = library.id, url = existingFile.toUri().toURL()) + val series = makeSeries("series", url = destDir.toUri().toURL(), libraryId = library.id) + .also { series -> + seriesLifecycle.createSeries(series) + seriesLifecycle.addBooks(series, listOf(bookToUpgrade)) + seriesLifecycle.sortBooks(series) + } + + mediaRepository.findById(bookToUpgrade.id).let { media -> + mediaRepository.update( + media.copy( + status = Media.Status.READY, + pages = (1..10).map { BookPage("$it", "image/jpeg") } + ) + ) + } + + bookLifecycle.markReadProgressCompleted(bookToUpgrade.id, user1) + bookLifecycle.markReadProgress(bookToUpgrade, user2, 4) + + // when + bookImporter.importBook(sourceFile, series, CopyMode.MOVE, upgradeBookId = bookToUpgrade.id) + + // then + val books = bookRepository.findBySeriesId(series.id).sortedBy { it.number } + assertThat(books).hasSize(1) + + val progress = readProgressRepository.findByBookId(books[0].id) + assertThat(progress).hasSize(2) + with(progress.find { it.userId == user1.id }!!) { + assertThat(completed).isTrue + } + with(progress.find { it.userId == user2.id }!!) { + assertThat(completed).isFalse + assertThat(page).isEqualTo(4) + } + + verify(exactly = 1) { mockTaskReceiver.analyzeBook(any()) } + } + } + + @Test + fun `given book part of a read list when importing with upgrade then imported book replaces upgraded book in the read list`() { + Jimfs.newFileSystem(Configuration.unix()).use { fs -> + // given + val sourceDir = fs.getPath("/source").createDirectory() + val sourceFile = sourceDir.resolve("source.cbz").createFile() + val destDir = fs.getPath("/library/series").createDirectories() + val existingFile = destDir.resolve("1.cbz").createFile() + + val bookToUpgrade = makeBook("1", libraryId = library.id, url = existingFile.toUri().toURL()) + val series = makeSeries("series", url = destDir.toUri().toURL(), libraryId = library.id) + .also { series -> + seriesLifecycle.createSeries(series) + seriesLifecycle.addBooks(series, listOf(bookToUpgrade)) + seriesLifecycle.sortBooks(series) + } + + val readList = ReadList( + name = "readlist", + bookIds = listOf(bookToUpgrade.id).toIndexedMap(), + ) + readListLifecycle.addReadList(readList) + + // when + bookImporter.importBook(sourceFile, series, CopyMode.MOVE, upgradeBookId = bookToUpgrade.id) + + // then + val books = bookRepository.findBySeriesId(series.id).sortedBy { it.number } + assertThat(books).hasSize(1) + + with(readListRepository.findByIdOrNull(readList.id)!!) { + assertThat(bookIds).hasSize(1) + assertThat(bookIds[0]).isEqualTo(books[0].id) + } + + verify(exactly = 1) { mockTaskReceiver.analyzeBook(any()) } + } + } +} diff --git a/komga/src/test/kotlin/org/gotson/komga/domain/service/BookLifecycleTest.kt b/komga/src/test/kotlin/org/gotson/komga/domain/service/BookLifecycleTest.kt index 531f8bf02..89c457dbb 100644 --- a/komga/src/test/kotlin/org/gotson/komga/domain/service/BookLifecycleTest.kt +++ b/komga/src/test/kotlin/org/gotson/komga/domain/service/BookLifecycleTest.kt @@ -35,7 +35,7 @@ class BookLifecycleTest( @Autowired private val seriesLifecycle: SeriesLifecycle, @Autowired private val readProgressRepository: ReadProgressRepository, @Autowired private val mediaRepository: MediaRepository, - @Autowired private val userRepository: KomgaUserRepository + @Autowired private val userRepository: KomgaUserRepository, ) { @MockkBean @@ -90,7 +90,7 @@ class BookLifecycleTest( assertThat(readProgressRepository.findAll()).hasSize(2) // when - every { mockAnalyzer.analyze(any()) } returns Media(status = Media.Status.READY, mediaType = "application/zip", pages = mutableListOf(makeBookPage("1.jpg"), makeBookPage("2.jpg"))) + every { mockAnalyzer.analyze(any()) } returns Media(status = Media.Status.READY, mediaType = "application/zip", pages = mutableListOf(makeBookPage("1.jpg"), makeBookPage("2.jpg")), bookId = book.id) bookLifecycle.analyzeAndPersist(book) // then @@ -123,7 +123,7 @@ class BookLifecycleTest( assertThat(readProgressRepository.findAll()).hasSize(2) // when - every { mockAnalyzer.analyze(any()) } returns Media(status = Media.Status.READY, mediaType = "application/zip", pages = (1..10).map { BookPage("$it", "image/jpeg") }) + every { mockAnalyzer.analyze(any()) } returns Media(status = Media.Status.READY, mediaType = "application/zip", pages = (1..10).map { BookPage("$it", "image/jpeg") }, bookId = book.id) bookLifecycle.analyzeAndPersist(book) // then diff --git a/komga/src/test/kotlin/org/gotson/komga/domain/service/LibraryContentLifecycleTest.kt b/komga/src/test/kotlin/org/gotson/komga/domain/service/LibraryContentLifecycleTest.kt index 19acb7628..c8379a930 100644 --- a/komga/src/test/kotlin/org/gotson/komga/domain/service/LibraryContentLifecycleTest.kt +++ b/komga/src/test/kotlin/org/gotson/komga/domain/service/LibraryContentLifecycleTest.kt @@ -200,7 +200,7 @@ class LibraryContentLifecycleTest( ) libraryContentLifecycle.scanRootFolder(library) - every { mockAnalyzer.analyze(any()) } returns Media(status = Media.Status.READY, mediaType = "application/zip", pages = mutableListOf(makeBookPage("1.jpg"), makeBookPage("2.jpg"))) + every { mockAnalyzer.analyze(any()) } returns Media(status = Media.Status.READY, mediaType = "application/zip", pages = mutableListOf(makeBookPage("1.jpg"), makeBookPage("2.jpg")), bookId = book1.id) bookRepository.findAll().map { bookLifecycle.analyzeAndPersist(it) } // when @@ -236,7 +236,7 @@ class LibraryContentLifecycleTest( ) libraryContentLifecycle.scanRootFolder(library) - every { mockAnalyzer.analyze(any()) } returns Media(status = Media.Status.READY, mediaType = "application/zip", pages = mutableListOf(makeBookPage("1.jpg"), makeBookPage("2.jpg"))) + every { mockAnalyzer.analyze(any()) } returns Media(status = Media.Status.READY, mediaType = "application/zip", pages = mutableListOf(makeBookPage("1.jpg"), makeBookPage("2.jpg")), bookId = book1.id) bookRepository.findAll().map { bookLifecycle.analyzeAndPersist(it) } // when diff --git a/komga/src/test/kotlin/org/gotson/komga/domain/service/ReadListMatcherTest.kt b/komga/src/test/kotlin/org/gotson/komga/domain/service/ReadListMatcherTest.kt index 21d93542a..3348cb1e3 100644 --- a/komga/src/test/kotlin/org/gotson/komga/domain/service/ReadListMatcherTest.kt +++ b/komga/src/test/kotlin/org/gotson/komga/domain/service/ReadListMatcherTest.kt @@ -78,17 +78,17 @@ class ReadListMatcherTest( seriesLifecycle.sortBooks(s) bookMetadataRepository.findById(booksSeries2[0].id).let { - bookMetadataRepository.update(it.copy(number = "025")) + bookMetadataRepository.update(it.copy(number = "0025")) } } val request = ReadListRequest( name = "readlist", books = listOf( - ReadListRequestBook(series = "Batman: White Knight", number = 1), - ReadListRequestBook(series = "joker", number = 2), - ReadListRequestBook(series = "Batman: White Knight", number = 2), - ReadListRequestBook(series = "joker", number = 25), + ReadListRequestBook(series = "Batman: White Knight", number = "1"), + ReadListRequestBook(series = "joker", number = "02"), + ReadListRequestBook(series = "Batman: White Knight", number = "2"), + ReadListRequestBook(series = "joker", number = "25"), ) ) @@ -99,7 +99,7 @@ class ReadListMatcherTest( with(result) { assertThat(readList).isNotNull assertThat(unmatchedBooks).isEmpty() - assertThat(errorCode).isBlank() + assertThat(errorCode).isBlank with(readList!!) { assertThat(name).isEqualTo(request.name) assertThat(bookIds).hasSize(4) @@ -125,10 +125,10 @@ class ReadListMatcherTest( val request = ReadListRequest( name = "my readlist", books = listOf( - ReadListRequestBook(series = "batman: white knight", number = 1), - ReadListRequestBook(series = "joker", number = 2), - ReadListRequestBook(series = "BATMAN: WHITE KNIGHT", number = 2), - ReadListRequestBook(series = "joker", number = 25), + ReadListRequestBook(series = "batman: white knight", number = "1"), + ReadListRequestBook(series = "joker", number = "2"), + ReadListRequestBook(series = "BATMAN: WHITE KNIGHT", number = "2"), + ReadListRequestBook(series = "joker", number = "25"), ) ) @@ -176,10 +176,10 @@ class ReadListMatcherTest( val request = ReadListRequest( name = "readlist", books = listOf( - ReadListRequestBook(series = "tokyo ghost", number = 1), - ReadListRequestBook(series = "batman", number = 3), - ReadListRequestBook(series = "joker", number = 3), - ReadListRequestBook(series = "batman", number = 2), + ReadListRequestBook(series = "tokyo ghost", number = "1"), + ReadListRequestBook(series = "batman", number = "3"), + ReadListRequestBook(series = "joker", number = "3"), + ReadListRequestBook(series = "batman", number = "2"), ) ) diff --git a/komga/src/test/kotlin/org/gotson/komga/infrastructure/metadata/barcode/IsbnBarcodeProviderTest.kt b/komga/src/test/kotlin/org/gotson/komga/infrastructure/metadata/barcode/IsbnBarcodeProviderTest.kt index d2c416506..7d25e4f66 100644 --- a/komga/src/test/kotlin/org/gotson/komga/infrastructure/metadata/barcode/IsbnBarcodeProviderTest.kt +++ b/komga/src/test/kotlin/org/gotson/komga/infrastructure/metadata/barcode/IsbnBarcodeProviderTest.kt @@ -5,6 +5,7 @@ import io.mockk.mockk import org.apache.commons.validator.routines.ISBNValidator import org.assertj.core.api.Assertions.assertThat import org.gotson.komga.domain.model.BookPage +import org.gotson.komga.domain.model.BookWithMedia import org.gotson.komga.domain.model.Media import org.gotson.komga.domain.model.makeBook import org.gotson.komga.domain.service.BookAnalyzer @@ -25,7 +26,7 @@ class IsbnBarcodeProviderTest { val media = Media(pages = listOf(BookPage("page", "image/jpeg"))) // when - val patch = isbnBarcodeProvider.getBookMetadataFromBook(book, media) + val patch = isbnBarcodeProvider.getBookMetadataFromBook(BookWithMedia(book, media)) // then assertThat(patch?.isbn).isEqualTo("9782811632397") @@ -40,7 +41,7 @@ class IsbnBarcodeProviderTest { val media = Media(pages = listOf(BookPage("page", "image/jpeg"))) // when - val patch = isbnBarcodeProvider.getBookMetadataFromBook(book, media) + val patch = isbnBarcodeProvider.getBookMetadataFromBook(BookWithMedia(book, media)) // then assertThat(patch).isNull() @@ -56,7 +57,7 @@ class IsbnBarcodeProviderTest { val media = Media(pages = listOf(BookPage("page", "image/jpeg"))) // when - val patch = isbnBarcodeProvider.getBookMetadataFromBook(book, media) + val patch = isbnBarcodeProvider.getBookMetadataFromBook(BookWithMedia(book, media)) // then assertThat(patch).isNull() 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 7fa7c742f..0c09ddfd9 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 @@ -4,6 +4,7 @@ import com.fasterxml.jackson.dataformat.xml.XmlMapper import io.mockk.every import io.mockk.mockk import org.assertj.core.api.Assertions.assertThat +import org.gotson.komga.domain.model.BookWithMedia import org.gotson.komga.domain.model.Media import org.gotson.komga.domain.model.SeriesMetadata import org.gotson.komga.domain.model.makeBook @@ -53,7 +54,7 @@ class ComicInfoProviderTest { every { mockMapper.readValue(any(), ComicInfo::class.java) } returns comicInfo - val patch = comicInfoProvider.getBookMetadataFromBook(book, media) + val patch = comicInfoProvider.getBookMetadataFromBook(BookWithMedia(book, media)) with(patch!!) { assertThat(title).isEqualTo("title") @@ -86,7 +87,7 @@ class ComicInfoProviderTest { every { mockMapper.readValue(any(), ComicInfo::class.java) } returns comicInfo - val patch = comicInfoProvider.getBookMetadataFromBook(book, media) + val patch = comicInfoProvider.getBookMetadataFromBook(BookWithMedia(book, media)) with(patch!!) { assertThat(title).isNull() @@ -106,7 +107,7 @@ class ComicInfoProviderTest { every { mockMapper.readValue(any(), ComicInfo::class.java) } returns comicInfo - val patch = comicInfoProvider.getBookMetadataFromBook(book, media) + val patch = comicInfoProvider.getBookMetadataFromBook(BookWithMedia(book, media)) with(patch!!) { assertThat(releaseDate).isNull() @@ -121,7 +122,7 @@ class ComicInfoProviderTest { every { mockMapper.readValue(any(), ComicInfo::class.java) } returns comicInfo - val patch = comicInfoProvider.getBookMetadataFromBook(book, media) + val patch = comicInfoProvider.getBookMetadataFromBook(BookWithMedia(book, media)) with(patch!!) { assertThat(releaseDate).isEqualTo(LocalDate.of(2020, 1, 1)) @@ -142,7 +143,7 @@ class ComicInfoProviderTest { every { mockMapper.readValue(any(), ComicInfo::class.java) } returns comicInfo - val patch = comicInfoProvider.getBookMetadataFromBook(book, media) + val patch = comicInfoProvider.getBookMetadataFromBook(BookWithMedia(book, media)) with(patch!!) { assertThat(authors).hasSize(7) @@ -165,7 +166,7 @@ class ComicInfoProviderTest { every { mockMapper.readValue(any(), ComicInfo::class.java) } returns comicInfo - val patch = comicInfoProvider.getBookMetadataFromBook(book, media) + val patch = comicInfoProvider.getBookMetadataFromBook(BookWithMedia(book, media)) with(patch!!) { assertThat(authors).hasSize(14) @@ -179,7 +180,7 @@ class ComicInfoProviderTest { val book = makeBook("book") val media = Media(Media.Status.READY) - val patch = comicInfoProvider.getBookMetadataFromBook(book, media) + val patch = comicInfoProvider.getBookMetadataFromBook(BookWithMedia(book, media)) assertThat(patch).isNull() } @@ -201,7 +202,7 @@ class ComicInfoProviderTest { every { mockMapper.readValue(any(), ComicInfo::class.java) } returns comicInfo - val patch = comicInfoProvider.getSeriesMetadataFromBook(book, media)!! + val patch = comicInfoProvider.getSeriesMetadataFromBook(BookWithMedia(book, media))!! with(patch) { assertThat(title).isEqualTo("series") @@ -226,7 +227,7 @@ class ComicInfoProviderTest { every { mockMapper.readValue(any(), ComicInfo::class.java) } returns comicInfo - val patch = comicInfoProvider.getSeriesMetadataFromBook(book, media)!! + val patch = comicInfoProvider.getSeriesMetadataFromBook(BookWithMedia(book, media))!! with(patch) { assertThat(title).isEqualTo("series (2020)") @@ -242,7 +243,7 @@ class ComicInfoProviderTest { every { mockMapper.readValue(any(), ComicInfo::class.java) } returns comicInfo - val patch = comicInfoProvider.getSeriesMetadataFromBook(book, media)!! + val patch = comicInfoProvider.getSeriesMetadataFromBook(BookWithMedia(book, media))!! with(patch) { assertThat(title).isEqualTo("series") @@ -257,7 +258,7 @@ class ComicInfoProviderTest { every { mockMapper.readValue(any(), ComicInfo::class.java) } returns comicInfo - val patch = comicInfoProvider.getSeriesMetadataFromBook(book, media)!! + val patch = comicInfoProvider.getSeriesMetadataFromBook(BookWithMedia(book, media))!! with(patch) { assertThat(language).isNull() @@ -277,7 +278,7 @@ class ComicInfoProviderTest { every { mockMapper.readValue(any(), ComicInfo::class.java) } returns comicInfo - val patch = comicInfoProvider.getSeriesMetadataFromBook(book, media)!! + val patch = comicInfoProvider.getSeriesMetadataFromBook(BookWithMedia(book, media))!! with(patch) { assertThat(title).isNull() diff --git a/komga/src/test/kotlin/org/gotson/komga/infrastructure/metadata/comicrack/ReadListProviderTest.kt b/komga/src/test/kotlin/org/gotson/komga/infrastructure/metadata/comicrack/ReadListProviderTest.kt index a3f6b90cc..706aeec45 100644 --- a/komga/src/test/kotlin/org/gotson/komga/infrastructure/metadata/comicrack/ReadListProviderTest.kt +++ b/komga/src/test/kotlin/org/gotson/komga/infrastructure/metadata/comicrack/ReadListProviderTest.kt @@ -21,12 +21,12 @@ class ReadListProviderTest { books = listOf( Book().apply { series = "series 1" - number = 4 + number = " 4 " volume = 2005 }, Book().apply { series = "series 2" - number = 1 + number = "1" }, ) } @@ -44,12 +44,12 @@ class ReadListProviderTest { with(books[0]) { assertThat(series).isEqualTo("series 1 (2005)") - assertThat(number).isEqualTo(4) + assertThat(number).isEqualTo("4") } with(books[1]) { assertThat(series).isEqualTo("series 2") - assertThat(number).isEqualTo(1) + assertThat(number).isEqualTo("1") } } } @@ -62,12 +62,12 @@ class ReadListProviderTest { books = listOf( Book().apply { series = " " - number = 4 + number = "4" volume = 2005 }, Book().apply { series = null - number = 1 + number = "1" }, Book().apply { series = "Series" @@ -110,12 +110,12 @@ class ReadListProviderTest { books = listOf( Book().apply { series = "series 1" - number = 4 + number = "4" volume = 2005 }, Book().apply { series = "series 2" - number = 1 + number = "1" }, ) } @@ -137,12 +137,12 @@ class ReadListProviderTest { books = listOf( Book().apply { series = "series 1" - number = 4 + number = "4" volume = 2005 }, Book().apply { series = "series 2" - number = 1 + number = "1" }, ) } diff --git a/komga/src/test/kotlin/org/gotson/komga/infrastructure/metadata/comicrack/dto/ReadingListTest.kt b/komga/src/test/kotlin/org/gotson/komga/infrastructure/metadata/comicrack/dto/ReadingListTest.kt index 375140ee6..1584c9b08 100644 --- a/komga/src/test/kotlin/org/gotson/komga/infrastructure/metadata/comicrack/dto/ReadingListTest.kt +++ b/komga/src/test/kotlin/org/gotson/komga/infrastructure/metadata/comicrack/dto/ReadingListTest.kt @@ -21,7 +21,7 @@ class ReadingListTest { with(books[0]) { assertThat(series).isEqualTo("Civil War") - assertThat(number).isEqualTo(1) + assertThat(number).isEqualTo("1") assertThat(volume).isEqualTo(2006) assertThat(year).isEqualTo(2006) assertThat(fileName).isEqualTo("Civil War Vol.2006 #01 (July, 2006)") @@ -29,7 +29,7 @@ class ReadingListTest { with(books[1]) { assertThat(series).isEqualTo("Wolverine") - assertThat(number).isEqualTo(42) + assertThat(number).isEqualTo("42") assertThat(volume).isEqualTo(2003) assertThat(year).isEqualTo(2006) assertThat(fileName).isEqualTo("Wolverine Vol.2003 #42 (July, 2006)") @@ -37,7 +37,7 @@ class ReadingListTest { with(books[2]) { assertThat(series).isEqualTo("X-Factor") - assertThat(number).isEqualTo(8) + assertThat(number).isEqualTo("HS") assertThat(volume).isEqualTo(2006) assertThat(year).isEqualTo(2006) assertThat(fileName).isEqualTo("X-Factor Vol.2006 #08 (August, 2006)") diff --git a/komga/src/test/kotlin/org/gotson/komga/interfaces/rest/LibraryControllerTest.kt b/komga/src/test/kotlin/org/gotson/komga/interfaces/rest/LibraryControllerTest.kt index a8ae74f66..76cab10ad 100644 --- a/komga/src/test/kotlin/org/gotson/komga/interfaces/rest/LibraryControllerTest.kt +++ b/komga/src/test/kotlin/org/gotson/komga/interfaces/rest/LibraryControllerTest.kt @@ -31,7 +31,7 @@ class LibraryControllerTest( private val route = "/api/v1/libraries" - private val library = makeLibrary(url = "file:/library1", id = "1") + private val library = makeLibrary(path = "file:/library1", id = "1") @BeforeAll fun `setup library`() { diff --git a/komga/src/test/resources/comicrack/ReadingList.xml b/komga/src/test/resources/comicrack/ReadingList.xml index d24d2b0ee..6984ca9e7 100644 --- a/komga/src/test/resources/comicrack/ReadingList.xml +++ b/komga/src/test/resources/comicrack/ReadingList.xml @@ -1,5 +1,5 @@ - + Civil War @@ -10,7 +10,7 @@ 29a69cbf-af64-471d-889c-8fc0f0080f7c Wolverine Vol.2003 #42 (July, 2006) - + ec70e585-5a80-428c-a67e-fd22b668449b X-Factor Vol.2006 #08 (August, 2006)