diff --git a/komga/build.gradle.kts b/komga/build.gradle.kts index 20dc449f4..1c8cd885e 100644 --- a/komga/build.gradle.kts +++ b/komga/build.gradle.kts @@ -63,6 +63,7 @@ dependencies { implementation("commons-io:commons-io:2.8.0") implementation("org.apache.commons:commons-lang3:3.12.0") + implementation("commons-validator:commons-validator:1.7") implementation("com.ibm.icu:icu4j:68.2") @@ -82,6 +83,9 @@ dependencies { runtimeOnly("com.github.jai-imageio:jai-imageio-jpeg2000:1.4.0") runtimeOnly("org.apache.pdfbox:jbig2-imageio:3.0.3") + // barcode scanning + implementation("com.google.zxing:core:3.4.1") + implementation("com.jakewharton.byteunits:byteunits:0.9.1") implementation("com.github.f4b6a3:tsid-creator:3.0.1") diff --git a/komga/src/flyway/resources/db/migration/sqlite/V20210308163522__book_isbn.sql b/komga/src/flyway/resources/db/migration/sqlite/V20210308163522__book_isbn.sql new file mode 100644 index 000000000..55d1774ec --- /dev/null +++ b/komga/src/flyway/resources/db/migration/sqlite/V20210308163522__book_isbn.sql @@ -0,0 +1,7 @@ +alter table library + add column IMPORT_BARCODE_ISBN boolean NOT NULL DEFAULT 1; + +alter table book_metadata + add column ISBN varchar NOT NULL DEFAULT ''; +alter table book_metadata + add column ISBN_LOCK boolean NOT NULL DEFAULT 0; diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/model/BookMetadata.kt b/komga/src/main/kotlin/org/gotson/komga/domain/model/BookMetadata.kt index 20b4fee47..c086a8cc4 100644 --- a/komga/src/main/kotlin/org/gotson/komga/domain/model/BookMetadata.kt +++ b/komga/src/main/kotlin/org/gotson/komga/domain/model/BookMetadata.kt @@ -11,6 +11,7 @@ class BookMetadata( val releaseDate: LocalDate? = null, val authors: List = emptyList(), tags: Set = emptySet(), + val isbn: String = "", val titleLock: Boolean = false, val summaryLock: Boolean = false, @@ -19,6 +20,7 @@ class BookMetadata( val releaseDateLock: Boolean = false, val authorsLock: Boolean = false, val tagsLock: Boolean = false, + val isbnLock: Boolean = false, val bookId: String = "", @@ -39,6 +41,7 @@ class BookMetadata( releaseDate: LocalDate? = this.releaseDate, authors: List = this.authors.toList(), tags: Set = this.tags, + isbn: String = this.isbn, titleLock: Boolean = this.titleLock, summaryLock: Boolean = this.summaryLock, numberLock: Boolean = this.numberLock, @@ -46,6 +49,7 @@ class BookMetadata( releaseDateLock: Boolean = this.releaseDateLock, authorsLock: Boolean = this.authorsLock, tagsLock: Boolean = this.tagsLock, + isbnLock: Boolean = this.isbnLock, bookId: String = this.bookId, createdDate: LocalDateTime = this.createdDate, lastModifiedDate: LocalDateTime = this.lastModifiedDate @@ -58,6 +62,7 @@ class BookMetadata( releaseDate = releaseDate, authors = authors, tags = tags, + isbn = isbn, titleLock = titleLock, summaryLock = summaryLock, numberLock = numberLock, @@ -65,11 +70,12 @@ class BookMetadata( releaseDateLock = releaseDateLock, authorsLock = authorsLock, tagsLock = tagsLock, + isbnLock = isbnLock, bookId = bookId, createdDate = createdDate, lastModifiedDate = lastModifiedDate ) override fun toString(): String = - "BookMetadata(numberSort=$numberSort, releaseDate=$releaseDate, authors=$authors, titleLock=$titleLock, summaryLock=$summaryLock, numberLock=$numberLock, numberSortLock=$numberSortLock, releaseDateLock=$releaseDateLock, authorsLock=$authorsLock, bookId=$bookId, createdDate=$createdDate, lastModifiedDate=$lastModifiedDate, title='$title', summary='$summary', number='$number')" + "BookMetadata(numberSort=$numberSort, releaseDate=$releaseDate, authors=$authors, isbn='$isbn', titleLock=$titleLock, summaryLock=$summaryLock, numberLock=$numberLock, numberSortLock=$numberSortLock, releaseDateLock=$releaseDateLock, authorsLock=$authorsLock, tagsLock=$tagsLock, isbnLock=$isbnLock, bookId='$bookId', createdDate=$createdDate, lastModifiedDate=$lastModifiedDate, title='$title', summary='$summary', number='$number', tags=$tags)" } diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/model/BookMetadataPatch.kt b/komga/src/main/kotlin/org/gotson/komga/domain/model/BookMetadataPatch.kt index a9660bde4..b5f9480cf 100644 --- a/komga/src/main/kotlin/org/gotson/komga/domain/model/BookMetadataPatch.kt +++ b/komga/src/main/kotlin/org/gotson/komga/domain/model/BookMetadataPatch.kt @@ -3,12 +3,13 @@ package org.gotson.komga.domain.model import java.time.LocalDate data class BookMetadataPatch( - val title: String?, - val summary: String?, - val number: String?, - val numberSort: Float?, - val releaseDate: LocalDate?, - val authors: List?, + val title: String? = null, + val summary: String? = null, + val number: String? = null, + val numberSort: Float? = null, + val releaseDate: LocalDate? = null, + val authors: List? = null, + val isbn: String? = null, val readLists: List = emptyList() ) { diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/model/Library.kt b/komga/src/main/kotlin/org/gotson/komga/domain/model/Library.kt index 772815ad3..23d30911e 100644 --- a/komga/src/main/kotlin/org/gotson/komga/domain/model/Library.kt +++ b/komga/src/main/kotlin/org/gotson/komga/domain/model/Library.kt @@ -16,6 +16,7 @@ data class Library( val importEpubBook: Boolean = true, val importEpubSeries: Boolean = true, val importLocalArtwork: Boolean = true, + val importBarcodeIsbn: Boolean = true, val scanForceModifiedTime: Boolean = false, val scanDeep: Boolean = false, diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/service/MetadataApplier.kt b/komga/src/main/kotlin/org/gotson/komga/domain/service/MetadataApplier.kt index 1c6d0f608..dd6452574 100644 --- a/komga/src/main/kotlin/org/gotson/komga/domain/service/MetadataApplier.kt +++ b/komga/src/main/kotlin/org/gotson/komga/domain/service/MetadataApplier.kt @@ -24,7 +24,8 @@ class MetadataApplier { number = getIfNotLocked(number, patch.number, numberLock), numberSort = getIfNotLocked(numberSort, patch.numberSort, numberSortLock), releaseDate = getIfNotLocked(releaseDate, patch.releaseDate, releaseDateLock), - authors = getIfNotLocked(authors, patch.authors, authorsLock) + authors = getIfNotLocked(authors, patch.authors, authorsLock), + isbn = getIfNotLocked(isbn, patch.isbn, isbnLock), ) } 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 6e2d9a06e..77506b3fb 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 @@ -2,6 +2,7 @@ package org.gotson.komga.domain.service import mu.KotlinLogging import org.gotson.komga.domain.model.Book +import org.gotson.komga.domain.model.BookMetadataPatch import org.gotson.komga.domain.model.ReadList import org.gotson.komga.domain.model.Series import org.gotson.komga.domain.model.SeriesCollection @@ -16,6 +17,7 @@ import org.gotson.komga.domain.persistence.SeriesCollectionRepository import org.gotson.komga.domain.persistence.SeriesMetadataRepository import org.gotson.komga.infrastructure.metadata.BookMetadataProvider import org.gotson.komga.infrastructure.metadata.SeriesMetadataProvider +import org.gotson.komga.infrastructure.metadata.barcode.IsbnBarcodeProvider import org.gotson.komga.infrastructure.metadata.comicinfo.ComicInfoProvider import org.gotson.komga.infrastructure.metadata.epub.EpubMetadataProvider import org.gotson.komga.infrastructure.metadata.localartwork.LocalArtworkProvider @@ -58,63 +60,80 @@ class MetadataLifecycle( logger.debug { "Provider: $provider" } val patch = provider.getBookMetadataFromBook(book, media) - // handle book metadata - if ((provider is ComicInfoProvider && library.importComicInfoBook) || - (provider is EpubMetadataProvider && library.importEpubBook) + if ( + (provider is ComicInfoProvider && library.importComicInfoBook) || + (provider is EpubMetadataProvider && library.importEpubBook) || + (provider is IsbnBarcodeProvider && library.importBarcodeIsbn) ) { - patch?.let { bPatch -> - bookMetadataRepository.findById(book.id).let { - logger.debug { "Original metadata: $it" } - val patched = metadataApplier.apply(bPatch, it) - logger.debug { "Patched metadata: $patched" } - - bookMetadataRepository.update(patched) - } - } + handlePatchForBookMetadata(patch, book) } - // handle read lists if (provider is ComicInfoProvider && library.importComicInfoReadList) { - patch?.readLists?.forEach { readList -> - - readListRepository.findByNameOrNull(readList.name).let { existing -> - if (existing != null) { - if (existing.bookIds.containsValue(book.id)) - logger.debug { "Book is already in existing readlist '${existing.name}'" } - else { - val map = existing.bookIds.toSortedMap() - val key = if (readList.number != null && existing.bookIds.containsKey(readList.number)) { - logger.debug { "Existing readlist '${existing.name}' already contains a book at position ${readList.number}, adding book '${book.name}' at the end" } - existing.bookIds.lastKey() + 1 - } else { - logger.debug { "Adding book '${book.name}' to existing readlist '${existing.name}'" } - readList.number ?: existing.bookIds.lastKey() + 1 - } - map[key] = book.id - readListLifecycle.updateReadList( - existing.copy(bookIds = map) - ) - } - } else { - logger.debug { "Adding book '${book.name}' to new readlist '$readList'" } - readListLifecycle.addReadList( - ReadList( - name = readList.name, - bookIds = mapOf((readList.number ?: 0) to book.id).toSortedMap() - ) - ) - } - } - } + handlePatchForReadLists(patch, book) } } } } - if (library.importLocalArtwork) - localArtworkProvider.getBookThumbnails(book).forEach { - bookLifecycle.addThumbnailForBook(it) + if (library.importLocalArtwork) refreshMetadataLocalArtwork(book) + } + + private fun handlePatchForReadLists( + patch: BookMetadataPatch?, + book: Book + ) { + patch?.readLists?.forEach { readList -> + + readListRepository.findByNameOrNull(readList.name).let { existing -> + if (existing != null) { + if (existing.bookIds.containsValue(book.id)) + logger.debug { "Book is already in existing readlist '${existing.name}'" } + else { + val map = existing.bookIds.toSortedMap() + val key = if (readList.number != null && existing.bookIds.containsKey(readList.number)) { + logger.debug { "Existing readlist '${existing.name}' already contains a book at position ${readList.number}, adding book '${book.name}' at the end" } + existing.bookIds.lastKey() + 1 + } else { + logger.debug { "Adding book '${book.name}' to existing readlist '${existing.name}'" } + readList.number ?: existing.bookIds.lastKey() + 1 + } + map[key] = book.id + readListLifecycle.updateReadList( + existing.copy(bookIds = map) + ) + } + } else { + logger.debug { "Adding book '${book.name}' to new readlist '$readList'" } + readListLifecycle.addReadList( + ReadList( + name = readList.name, + bookIds = mapOf((readList.number ?: 0) to book.id).toSortedMap() + ) + ) + } } + } + } + + private fun handlePatchForBookMetadata( + patch: BookMetadataPatch?, + book: Book + ) { + patch?.let { bPatch -> + bookMetadataRepository.findById(book.id).let { + logger.debug { "Original metadata: $it" } + val patched = metadataApplier.apply(bPatch, it) + logger.debug { "Patched metadata: $patched" } + + bookMetadataRepository.update(patched) + } + } + } + + private fun refreshMetadataLocalArtwork(book: Book) { + localArtworkProvider.getBookThumbnails(book).forEach { + bookLifecycle.addThumbnailForBook(it) + } } fun refreshMetadata(series: Series) { @@ -131,68 +150,83 @@ class MetadataLifecycle( val patches = bookRepository.findBySeriesId(series.id) .mapNotNull { provider.getSeriesMetadataFromBook(it, mediaRepository.findById(it.id)) } - // handle series metadata - if ((provider is ComicInfoProvider && library.importComicInfoSeries) || + if ( + (provider is ComicInfoProvider && library.importComicInfoSeries) || (provider is EpubMetadataProvider && library.importEpubSeries) ) { - - val aggregatedPatch = SeriesMetadataPatch( - title = patches.mostFrequent { it.title }, - titleSort = patches.mostFrequent { it.titleSort }, - status = patches.mostFrequent { it.status }, - genres = patches.mapNotNull { it.genres }.flatten().toSet().ifEmpty { null }, - language = patches.mostFrequent { it.language }, - summary = null, - readingDirection = patches.mostFrequent { it.readingDirection }, - ageRating = patches.mapNotNull { it.ageRating }.maxOrNull(), - publisher = patches.mostFrequent { it.publisher }, - collections = emptyList() - ) - - seriesMetadataRepository.findById(series.id).let { - logger.debug { "Apply metadata for series: $series" } - - logger.debug { "Original metadata: $it" } - val patched = metadataApplier.apply(aggregatedPatch, it) - logger.debug { "Patched metadata: $patched" } - - seriesMetadataRepository.update(patched) - } + handlePatchForSeriesMetadata(patches, series) } - // add series to collections if (provider is ComicInfoProvider && library.importComicInfoCollection) { - patches.flatMap { it.collections }.distinct().forEach { collection -> - collectionRepository.findByNameOrNull(collection).let { existing -> - if (existing != null) { - if (existing.seriesIds.contains(series.id)) - logger.debug { "Series is already in existing collection '${existing.name}'" } - else { - logger.debug { "Adding series '${series.name}' to existing collection '${existing.name}'" } - collectionLifecycle.updateCollection( - existing.copy(seriesIds = existing.seriesIds + series.id) - ) - } - } else { - logger.debug { "Adding series '${series.name}' to new collection '$collection'" } - collectionLifecycle.addCollection( - SeriesCollection( - name = collection, - seriesIds = listOf(series.id) - ) - ) - } - } - } + handlePatchForCollections(patches, series) } } } } - if (library.importLocalArtwork) - localArtworkProvider.getSeriesThumbnails(series).forEach { - seriesLifecycle.addThumbnailForSeries(it) + if (library.importLocalArtwork) refreshMetadataLocalArtwork(series) + } + + private fun refreshMetadataLocalArtwork(series: Series) { + localArtworkProvider.getSeriesThumbnails(series).forEach { + seriesLifecycle.addThumbnailForSeries(it) + } + } + + private fun handlePatchForCollections( + patches: List, + series: Series + ) { + patches.flatMap { it.collections }.distinct().forEach { collection -> + collectionRepository.findByNameOrNull(collection).let { existing -> + if (existing != null) { + if (existing.seriesIds.contains(series.id)) + logger.debug { "Series is already in existing collection '${existing.name}'" } + else { + logger.debug { "Adding series '${series.name}' to existing collection '${existing.name}'" } + collectionLifecycle.updateCollection( + existing.copy(seriesIds = existing.seriesIds + series.id) + ) + } + } else { + logger.debug { "Adding series '${series.name}' to new collection '$collection'" } + collectionLifecycle.addCollection( + SeriesCollection( + name = collection, + seriesIds = listOf(series.id) + ) + ) + } } + } + } + + private fun handlePatchForSeriesMetadata( + patches: List, + series: Series + ) { + val aggregatedPatch = SeriesMetadataPatch( + title = patches.mostFrequent { it.title }, + titleSort = patches.mostFrequent { it.titleSort }, + status = patches.mostFrequent { it.status }, + genres = patches.mapNotNull { it.genres }.flatten().toSet().ifEmpty { null }, + language = patches.mostFrequent { it.language }, + summary = null, + readingDirection = patches.mostFrequent { it.readingDirection }, + ageRating = patches.mapNotNull { it.ageRating }.maxOrNull(), + publisher = patches.mostFrequent { it.publisher }, + collections = emptyList() + ) + + seriesMetadataRepository.findById(series.id).let { + logger.debug { "Apply metadata for series: $series" } + + logger.debug { "Original metadata: $it" } + val patched = metadataApplier.apply(aggregatedPatch, it) + logger.debug { "Patched metadata: $patched" } + + seriesMetadataRepository.update(patched) + } } fun aggregateMetadata(series: Series) { diff --git a/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/BookDtoDao.kt b/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/BookDtoDao.kt index 5612071e4..46b03cb35 100644 --- a/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/BookDtoDao.kt +++ b/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/BookDtoDao.kt @@ -346,6 +346,8 @@ class BookDtoDao( authorsLock = authorsLock, tags = tags, tagsLock = tagsLock, + isbn = isbn, + isbnLock = isbnLock, created = createdDate, lastModified = lastModifiedDate ) diff --git a/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/BookMetadataDao.kt b/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/BookMetadataDao.kt index fe7da6982..9bc41b068 100644 --- a/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/BookMetadataDao.kt +++ b/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/BookMetadataDao.kt @@ -73,8 +73,10 @@ class BookMetadataDao( d.RELEASE_DATE, d.RELEASE_DATE_LOCK, d.AUTHORS_LOCK, - d.TAGS_LOCK - ).values(null as String?, null, null, null, null, null, null, null, null, null, null, null, null) + d.TAGS_LOCK, + d.ISBN, + d.ISBN_LOCK + ).values(null as String?, null, null, null, null, null, null, null, null, null, null, null, null, null, null) ).also { step -> metadatas.forEach { step.bind( @@ -90,7 +92,9 @@ class BookMetadataDao( it.releaseDate, it.releaseDateLock, it.authorsLock, - it.tagsLock + it.tagsLock, + it.isbn, + it.isbnLock ) } }.execute() @@ -129,6 +133,8 @@ class BookMetadataDao( .set(d.RELEASE_DATE_LOCK, metadata.releaseDateLock) .set(d.AUTHORS_LOCK, metadata.authorsLock) .set(d.TAGS_LOCK, metadata.tagsLock) + .set(d.ISBN, metadata.isbn) + .set(d.ISBN_LOCK, metadata.isbnLock) .set(d.LAST_MODIFIED_DATE, LocalDateTime.now(ZoneId.of("Z"))) .where(d.BOOK_ID.eq(metadata.bookId)) .execute() @@ -205,6 +211,7 @@ class BookMetadataDao( releaseDate = releaseDate, authors = authors, tags = tags, + isbn = isbn, bookId = bookId, @@ -217,7 +224,8 @@ class BookMetadataDao( numberSortLock = numberSortLock, releaseDateLock = releaseDateLock, authorsLock = authorsLock, - tagsLock = tagsLock + tagsLock = tagsLock, + isbnLock = isbnLock, ) private fun BookMetadataAuthorRecord.toDomain() = diff --git a/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/LibraryDao.kt b/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/LibraryDao.kt index 67048e39e..e5ef38b5a 100644 --- a/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/LibraryDao.kt +++ b/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/LibraryDao.kt @@ -72,6 +72,7 @@ class LibraryDao( .set(l.IMPORT_EPUB_BOOK, library.importEpubBook) .set(l.IMPORT_EPUB_SERIES, library.importEpubSeries) .set(l.IMPORT_LOCAL_ARTWORK, library.importLocalArtwork) + .set(l.IMPORT_BARCODE_ISBN, library.importBarcodeIsbn) .set(l.SCAN_FORCE_MODIFIED_TIME, library.scanForceModifiedTime) .set(l.SCAN_DEEP, library.scanDeep) .execute() @@ -88,6 +89,7 @@ class LibraryDao( .set(l.IMPORT_EPUB_BOOK, library.importEpubBook) .set(l.IMPORT_EPUB_SERIES, library.importEpubSeries) .set(l.IMPORT_LOCAL_ARTWORK, library.importLocalArtwork) + .set(l.IMPORT_BARCODE_ISBN, library.importBarcodeIsbn) .set(l.SCAN_FORCE_MODIFIED_TIME, library.scanForceModifiedTime) .set(l.SCAN_DEEP, library.scanDeep) .set(l.LAST_MODIFIED_DATE, LocalDateTime.now(ZoneId.of("Z"))) @@ -108,6 +110,7 @@ class LibraryDao( importEpubBook = importEpubBook, importEpubSeries = importEpubSeries, importLocalArtwork = importLocalArtwork, + importBarcodeIsbn = importBarcodeIsbn, scanForceModifiedTime = scanForceModifiedTime, scanDeep = scanDeep, id = id, 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 new file mode 100644 index 000000000..94aabfe13 --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/infrastructure/metadata/barcode/IsbnBarcodeProvider.kt @@ -0,0 +1,69 @@ +package org.gotson.komga.infrastructure.metadata.barcode + +import com.google.zxing.BarcodeFormat +import com.google.zxing.BinaryBitmap +import com.google.zxing.DecodeHintType +import com.google.zxing.MultiFormatReader +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.Media +import org.gotson.komga.domain.service.BookAnalyzer +import org.gotson.komga.infrastructure.metadata.BookMetadataProvider +import org.springframework.stereotype.Service +import java.util.EnumSet +import javax.imageio.ImageIO + +private val logger = KotlinLogging.logger {} + +private const val PAGES_LAST = 3 +private const val PAGES_FIRST = 3 + +@Service +class IsbnBarcodeProvider( + private val bookAnalyzer: BookAnalyzer, + private val validator: ISBNValidator +) : BookMetadataProvider { + + private val hints = mapOf( + DecodeHintType.POSSIBLE_FORMATS to EnumSet.of(BarcodeFormat.EAN_13), + DecodeHintType.TRY_HARDER to true + ) + + override fun getBookMetadataFromBook(book: Book, media: Media): BookMetadataPatch? { + val pagesToTry = (1..media.pages.size).toList().let { + (it.takeLast(PAGES_LAST).reversed() + it.take(PAGES_FIRST)).distinct() + } + + for (p in pagesToTry) { + val imageBytes = bookAnalyzer.getPageContent(book, p) + ImageIO.read(imageBytes.inputStream())?.let { image -> + val pixels = image.getRGB(0, 0, image.width, image.height, null, 0, image.width) + val source = RGBLuminanceSource(image.width, image.height, pixels) + val bitmap = BinaryBitmap(HybridBinarizer(source)) + + val result = try { + MultiFormatReader().decode(bitmap, hints) + } catch (e: Exception) { + null + } + + if (result == null || result.text == null) { + logger.debug { "Book page $p does not contain a barcode: $book" } + } else { + if (validator.isValid(result.text)) { + logger.debug { "Book page $p contains barcode which is valid ISBN: '${result.text}'. $book" } + return BookMetadataPatch(isbn = validator.validate(result.text)) + } else { + logger.debug { "Book page $p contains barcode which is invalid ISBN: '${result.text}'. $book" } + } + } + } + } + + return null + } +} diff --git a/komga/src/main/kotlin/org/gotson/komga/infrastructure/metadata/barcode/IsbnConfiguration.kt b/komga/src/main/kotlin/org/gotson/komga/infrastructure/metadata/barcode/IsbnConfiguration.kt new file mode 100644 index 000000000..c52251866 --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/infrastructure/metadata/barcode/IsbnConfiguration.kt @@ -0,0 +1,12 @@ +package org.gotson.komga.infrastructure.metadata.barcode + +import org.apache.commons.validator.routines.ISBNValidator +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration + +@Configuration +class IsbnConfiguration { + + @Bean + fun isbnValidator() = ISBNValidator(true) +} 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 3919fc9ad..3c67d23f2 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 @@ -56,8 +56,6 @@ class EpubMetadataProvider( return BookMetadataPatch( title = title, summary = description, - number = null, - numberSort = null, releaseDate = date, authors = authors ) 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 fc9fff96f..5b7a65d58 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 @@ -443,7 +443,9 @@ class BookController( tags = if (isSet("tags")) { if (tags != null) tags!! else emptySet() } else existing.tags, - tagsLock = tagsLock ?: existing.tagsLock + tagsLock = tagsLock ?: existing.tagsLock, + isbn = isbn?.filter { it.isDigit() } ?: existing.isbn, + isbnLock = isbnLock ?: existing.isbnLock ) } bookMetadataRepository.update(updated) diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/LibraryController.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/LibraryController.kt index 79445b741..8d6e723ba 100644 --- a/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/LibraryController.kt +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/LibraryController.kt @@ -80,6 +80,7 @@ class LibraryController( importEpubBook = library.importEpubBook, importEpubSeries = library.importEpubSeries, importLocalArtwork = library.importLocalArtwork, + importBarcodeIsbn = library.importBarcodeIsbn, scanForceModifiedTime = library.scanForceModifiedTime, scanDeep = library.scanDeep ) @@ -114,6 +115,7 @@ class LibraryController( importEpubBook = library.importEpubBook, importEpubSeries = library.importEpubSeries, importLocalArtwork = library.importLocalArtwork, + importBarcodeIsbn = library.importBarcodeIsbn, scanForceModifiedTime = library.scanForceModifiedTime, scanDeep = library.scanDeep ) @@ -168,6 +170,7 @@ data class LibraryCreationDto( val importEpubBook: Boolean = true, val importEpubSeries: Boolean = true, val importLocalArtwork: Boolean = true, + val importBarcodeIsbn: Boolean = true, val scanForceModifiedTime: Boolean = false, val scanDeep: Boolean = false ) @@ -183,6 +186,7 @@ data class LibraryDto( val importEpubBook: Boolean, val importEpubSeries: Boolean, val importLocalArtwork: Boolean, + val importBarcodeIsbn: Boolean, val scanForceModifiedTime: Boolean, val scanDeep: Boolean ) @@ -197,6 +201,7 @@ data class LibraryUpdateDto( val importEpubBook: Boolean, val importEpubSeries: Boolean, val importLocalArtwork: Boolean, + val importBarcodeIsbn: Boolean, val scanForceModifiedTime: Boolean, val scanDeep: Boolean ) @@ -212,6 +217,7 @@ fun Library.toDto(includeRoot: Boolean) = LibraryDto( importEpubBook = importEpubBook, importEpubSeries = importEpubSeries, importLocalArtwork = importLocalArtwork, + importBarcodeIsbn = importBarcodeIsbn, scanForceModifiedTime = scanForceModifiedTime, scanDeep = scanDeep ) diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/dto/BookDto.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/dto/BookDto.kt index ffda7b31d..6ddae6eeb 100644 --- a/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/dto/BookDto.kt +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/dto/BookDto.kt @@ -52,6 +52,8 @@ data class BookMetadataDto( val authorsLock: Boolean, val tags: Set, val tagsLock: Boolean, + val isbn: String, + val isbnLock: Boolean, @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss") val created: LocalDateTime, diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/dto/BookMetadataUpdateDto.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/dto/BookMetadataUpdateDto.kt index 71b994ee5..36fd2c4ce 100644 --- a/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/dto/BookMetadataUpdateDto.kt +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/dto/BookMetadataUpdateDto.kt @@ -1,6 +1,7 @@ package org.gotson.komga.interfaces.rest.dto import org.gotson.komga.infrastructure.validation.NullOrNotBlank +import org.hibernate.validator.constraints.ISBN import java.time.LocalDate import javax.validation.Valid import javax.validation.constraints.NotBlank @@ -49,6 +50,11 @@ class BookMetadataUpdateDto { } var tagsLock: Boolean? = null + + @get:ISBN + var isbn: String? = null + + var isbnLock: Boolean? = null } class AuthorUpdateDto { diff --git a/komga/src/test/kotlin/org/gotson/komga/infrastructure/jooq/BookMetadataDaoTest.kt b/komga/src/test/kotlin/org/gotson/komga/infrastructure/jooq/BookMetadataDaoTest.kt index ecbb8c3c2..61e5f2c38 100644 --- a/komga/src/test/kotlin/org/gotson/komga/infrastructure/jooq/BookMetadataDaoTest.kt +++ b/komga/src/test/kotlin/org/gotson/komga/infrastructure/jooq/BookMetadataDaoTest.kt @@ -67,6 +67,7 @@ class BookMetadataDaoTest( releaseDate = LocalDate.now(), authors = listOf(Author("author", "role")), tags = setOf("tag", "another"), + isbn = "987654321", bookId = book.id, titleLock = true, summaryLock = true, @@ -74,7 +75,8 @@ class BookMetadataDaoTest( numberSortLock = true, releaseDateLock = true, authorsLock = true, - tagsLock = true + tagsLock = true, + isbnLock = true, ) bookMetadataDao.insert(metadata) @@ -95,6 +97,7 @@ class BookMetadataDaoTest( assertThat(role).isEqualTo(metadata.authors.first().role) } assertThat(created.tags).containsAll(metadata.tags) + assertThat(created.isbn).isEqualTo(metadata.isbn) assertThat(created.titleLock).isEqualTo(metadata.titleLock) assertThat(created.summaryLock).isEqualTo(metadata.summaryLock) @@ -103,6 +106,7 @@ class BookMetadataDaoTest( assertThat(created.releaseDateLock).isEqualTo(metadata.releaseDateLock) assertThat(created.authorsLock).isEqualTo(metadata.authorsLock) assertThat(created.tagsLock).isEqualTo(metadata.tagsLock) + assertThat(created.isbnLock).isEqualTo(metadata.isbnLock) } @Test @@ -120,20 +124,22 @@ class BookMetadataDaoTest( assertThat(created.bookId).isEqualTo(book.id) assertThat(created.title).isEqualTo(metadata.title) - assertThat(created.summary).isBlank() + assertThat(created.summary).isBlank assertThat(created.number).isEqualTo(metadata.number) assertThat(created.numberSort).isEqualTo(metadata.numberSort) assertThat(created.releaseDate).isNull() assertThat(created.authors).isEmpty() assertThat(created.tags).isEmpty() + assertThat(created.isbn).isBlank - assertThat(created.titleLock).isFalse() - assertThat(created.summaryLock).isFalse() - assertThat(created.numberLock).isFalse() - assertThat(created.numberSortLock).isFalse() - assertThat(created.releaseDateLock).isFalse() - assertThat(created.authorsLock).isFalse() - assertThat(created.tagsLock).isFalse() + assertThat(created.titleLock).isFalse + assertThat(created.summaryLock).isFalse + assertThat(created.numberLock).isFalse + assertThat(created.numberSortLock).isFalse + assertThat(created.releaseDateLock).isFalse + assertThat(created.authorsLock).isFalse + assertThat(created.tagsLock).isFalse + assertThat(created.isbnLock).isFalse } @Test @@ -160,13 +166,15 @@ class BookMetadataDaoTest( releaseDate = LocalDate.now(), authors = listOf(Author("author2", "role2")), tags = setOf("another"), + isbn = "987654321", titleLock = true, summaryLock = true, numberLock = true, numberSortLock = true, releaseDateLock = true, authorsLock = true, - tagsLock = true + tagsLock = true, + isbnLock = true, ) } @@ -183,6 +191,7 @@ class BookMetadataDaoTest( assertThat(modified.summary).isEqualTo(updated.summary) assertThat(modified.number).isEqualTo(updated.number) assertThat(modified.numberSort).isEqualTo(updated.numberSort) + assertThat(modified.isbn).isEqualTo(updated.isbn) assertThat(modified.titleLock).isEqualTo(updated.titleLock) assertThat(modified.summaryLock).isEqualTo(updated.summaryLock) @@ -191,6 +200,7 @@ class BookMetadataDaoTest( assertThat(modified.releaseDateLock).isEqualTo(updated.releaseDateLock) assertThat(modified.authorsLock).isEqualTo(updated.authorsLock) assertThat(modified.tagsLock).isEqualTo(updated.tagsLock) + assertThat(modified.isbnLock).isEqualTo(updated.isbnLock) assertThat(modified.tags).containsAll(updated.tags) assertThat(modified.authors.first().name).isEqualTo(updated.authors.first().name) diff --git a/komga/src/test/kotlin/org/gotson/komga/infrastructure/jooq/LibraryDaoTest.kt b/komga/src/test/kotlin/org/gotson/komga/infrastructure/jooq/LibraryDaoTest.kt index 5439bae5b..53ef1e520 100644 --- a/komga/src/test/kotlin/org/gotson/komga/infrastructure/jooq/LibraryDaoTest.kt +++ b/komga/src/test/kotlin/org/gotson/komga/infrastructure/jooq/LibraryDaoTest.kt @@ -59,7 +59,10 @@ class LibraryDaoTest( importEpubBook = false, importComicInfoCollection = false, importComicInfoSeries = false, - importComicInfoBook = false + importComicInfoBook = false, + importComicInfoReadList = false, + importBarcodeIsbn = false, + importLocalArtwork = false, ) } @@ -79,6 +82,9 @@ class LibraryDaoTest( assertThat(modified.importComicInfoCollection).isEqualTo(updated.importComicInfoCollection) assertThat(modified.importComicInfoSeries).isEqualTo(updated.importComicInfoSeries) assertThat(modified.importComicInfoBook).isEqualTo(updated.importComicInfoBook) + assertThat(modified.importComicInfoReadList).isEqualTo(updated.importComicInfoReadList) + assertThat(modified.importBarcodeIsbn).isEqualTo(updated.importBarcodeIsbn) + assertThat(modified.importLocalArtwork).isEqualTo(updated.importLocalArtwork) } @Test 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 new file mode 100644 index 000000000..d2c416506 --- /dev/null +++ b/komga/src/test/kotlin/org/gotson/komga/infrastructure/metadata/barcode/IsbnBarcodeProviderTest.kt @@ -0,0 +1,64 @@ +package org.gotson.komga.infrastructure.metadata.barcode + +import io.mockk.every +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.Media +import org.gotson.komga.domain.model.makeBook +import org.gotson.komga.domain.service.BookAnalyzer +import org.junit.jupiter.api.Test +import org.springframework.core.io.ClassPathResource + +class IsbnBarcodeProviderTest { + private val mockAnalyzer = mockk() + private val isbnBarcodeProvider = IsbnBarcodeProvider(mockAnalyzer, ISBNValidator(true)) + + @Test + fun `given book page with barcode when getting book metadata then ISBN is returned`() { + // given + val file = ClassPathResource("barcode/page_384.jpg").file + every { mockAnalyzer.getPageContent(any(), any()) } returns file.readBytes() + + val book = makeBook("Book1") + val media = Media(pages = listOf(BookPage("page", "image/jpeg"))) + + // when + val patch = isbnBarcodeProvider.getBookMetadataFromBook(book, media) + + // then + assertThat(patch?.isbn).isEqualTo("9782811632397") + } + + @Test + fun `given invalid image page when getting book metadata then patch is null`() { + // given + every { mockAnalyzer.getPageContent(any(), any()) } returns ByteArray(0) + + val book = makeBook("Book1") + val media = Media(pages = listOf(BookPage("page", "image/jpeg"))) + + // when + val patch = isbnBarcodeProvider.getBookMetadataFromBook(book, media) + + // then + assertThat(patch).isNull() + } + + @Test + fun `given page without barcode when getting book metadata then patch is null`() { + // given + val file = ClassPathResource("barcode/komga.png").file + every { mockAnalyzer.getPageContent(any(), any()) } returns file.readBytes() + + val book = makeBook("Book1") + val media = Media(pages = listOf(BookPage("page", "image/jpeg"))) + + // when + val patch = isbnBarcodeProvider.getBookMetadataFromBook(book, media) + + // then + assertThat(patch).isNull() + } +} diff --git a/komga/src/test/kotlin/org/gotson/komga/interfaces/rest/BookControllerTest.kt b/komga/src/test/kotlin/org/gotson/komga/interfaces/rest/BookControllerTest.kt index 4af4b9e1d..cffc92e67 100644 --- a/komga/src/test/kotlin/org/gotson/komga/interfaces/rest/BookControllerTest.kt +++ b/komga/src/test/kotlin/org/gotson/komga/interfaces/rest/BookControllerTest.kt @@ -583,7 +583,9 @@ class BookControllerTest( strings = [ """{"title":""}""", """{"number":""}""", - """{"authors":"[{"name":""}]"}""" + """{"authors":"[{"name":""}]"}""", + """{"isbn":"1617290459"}""", // isbn 10 + """{"isbn":"978-123-456-789-6"}""", // invalid check digit ] ) @WithMockCustomUser(roles = [ROLE_ADMIN]) @@ -632,7 +634,9 @@ class BookControllerTest( ], "authorsLock":true, "tags":["tag"], - "tagsLock":true + "tagsLock":true, + "isbn":"978-161-729-045-9abc xxxoefj", + "isbnLock":true } """.trimIndent() @@ -658,6 +662,7 @@ class BookControllerTest( tuple("newAuthor2", "newauthorrole2") ) assertThat(tags).containsExactly("tag") + assertThat(isbn).isEqualTo("9781617290459") assertThat(titleLock).isEqualTo(true) assertThat(summaryLock).isEqualTo(true) @@ -666,6 +671,7 @@ class BookControllerTest( assertThat(releaseDateLock).isEqualTo(true) assertThat(authorsLock).isEqualTo(true) assertThat(tagsLock).isEqualTo(true) + assertThat(isbnLock).isEqualTo(true) } } diff --git a/komga/src/test/resources/barcode/komga.png b/komga/src/test/resources/barcode/komga.png new file mode 100755 index 000000000..af95fb6ca Binary files /dev/null and b/komga/src/test/resources/barcode/komga.png differ diff --git a/komga/src/test/resources/barcode/page_384.jpg b/komga/src/test/resources/barcode/page_384.jpg new file mode 100644 index 000000000..3b3c04c25 Binary files /dev/null and b/komga/src/test/resources/barcode/page_384.jpg differ