mirror of
https://github.com/gotson/komga.git
synced 2025-01-09 04:08:00 +08:00
parent
2987260ca6
commit
6431b1f000
@ -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")
|
||||
|
@ -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;
|
@ -11,6 +11,7 @@ class BookMetadata(
|
||||
val releaseDate: LocalDate? = null,
|
||||
val authors: List<Author> = emptyList(),
|
||||
tags: Set<String> = 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<Author> = this.authors.toList(),
|
||||
tags: Set<String> = 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)"
|
||||
}
|
||||
|
@ -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<Author>?,
|
||||
val title: String? = null,
|
||||
val summary: String? = null,
|
||||
val number: String? = null,
|
||||
val numberSort: Float? = null,
|
||||
val releaseDate: LocalDate? = null,
|
||||
val authors: List<Author>? = null,
|
||||
val isbn: String? = null,
|
||||
|
||||
val readLists: List<ReadListEntry> = emptyList()
|
||||
) {
|
||||
|
@ -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,
|
||||
|
||||
|
@ -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),
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -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<SeriesMetadataPatch>,
|
||||
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<SeriesMetadataPatch>,
|
||||
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) {
|
||||
|
@ -346,6 +346,8 @@ class BookDtoDao(
|
||||
authorsLock = authorsLock,
|
||||
tags = tags,
|
||||
tagsLock = tagsLock,
|
||||
isbn = isbn,
|
||||
isbnLock = isbnLock,
|
||||
created = createdDate,
|
||||
lastModified = lastModifiedDate
|
||||
)
|
||||
|
@ -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() =
|
||||
|
@ -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,
|
||||
|
@ -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
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
@ -56,8 +56,6 @@ class EpubMetadataProvider(
|
||||
return BookMetadataPatch(
|
||||
title = title,
|
||||
summary = description,
|
||||
number = null,
|
||||
numberSort = null,
|
||||
releaseDate = date,
|
||||
authors = authors
|
||||
)
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
)
|
||||
|
@ -52,6 +52,8 @@ data class BookMetadataDto(
|
||||
val authorsLock: Boolean,
|
||||
val tags: Set<String>,
|
||||
val tagsLock: Boolean,
|
||||
val isbn: String,
|
||||
val isbnLock: Boolean,
|
||||
|
||||
@JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss")
|
||||
val created: LocalDateTime,
|
||||
|
@ -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 {
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
|
@ -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<BookAnalyzer>()
|
||||
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()
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
BIN
komga/src/test/resources/barcode/komga.png
Executable file
BIN
komga/src/test/resources/barcode/komga.png
Executable file
Binary file not shown.
After Width: | Height: | Size: 3.0 KiB |
BIN
komga/src/test/resources/barcode/page_384.jpg
Normal file
BIN
komga/src/test/resources/barcode/page_384.jpg
Normal file
Binary file not shown.
After Width: | Height: | Size: 193 KiB |
Loading…
Reference in New Issue
Block a user