feat(api): add basic metadata for transient books

This commit is contained in:
Gauthier Roebroeck 2023-12-14 11:50:30 +08:00
parent 15920b710e
commit 1050f522cc
13 changed files with 87 additions and 46 deletions

View file

@ -0,0 +1,14 @@
package org.gotson.komga.domain.model
data class TransientBook(
val book: Book,
val media: Media,
val metadata: Metadata = Metadata(),
) {
data class Metadata(
val number: Float? = null,
val seriesId: String? = null,
)
}
fun TransientBook.toBookWithMedia() = BookWithMedia(book, media)

View file

@ -11,7 +11,7 @@ interface SeriesRepository {
fun findAll(): Collection<Series>
fun findAllByLibraryId(libraryId: String): Collection<Series>
fun findAllNotDeletedByLibraryIdAndUrlNotIn(libraryId: String, urls: Collection<URL>): Collection<Series>
fun findAllByTitle(title: String): Collection<Series>
fun findAllByTitleContaining(title: String): Collection<Series>
fun findAll(search: SeriesSearch): Collection<Series>
fun getLibraryId(seriesId: String): String?

View file

@ -1,9 +1,9 @@
package org.gotson.komga.domain.persistence
import org.gotson.komga.domain.model.BookWithMedia
import org.gotson.komga.domain.model.TransientBook
interface TransientBookRepository {
fun findByIdOrNull(transientBookId: String): BookWithMedia?
fun save(transientBook: BookWithMedia)
fun save(transientBooks: Collection<BookWithMedia>)
fun findByIdOrNull(transientBookId: String): TransientBook?
fun save(transientBook: TransientBook)
fun save(transientBooks: Collection<TransientBook>)
}

View file

@ -52,7 +52,7 @@ class SeriesMetadataLifecycle(
val patches = bookRepository.findAllBySeriesId(series.id)
.mapNotNull { book ->
try {
provider.getSeriesMetadataFromBook(BookWithMedia(book, mediaRepository.findById(book.id)), library)
provider.getSeriesMetadataFromBook(BookWithMedia(book, mediaRepository.findById(book.id)), library.importComicInfoSeriesAppendVolume)
} catch (e: Exception) {
logger.error(e) { "Error while getting metadata from ${provider.javaClass.simpleName} for book: $book" }
null

View file

@ -1,14 +1,19 @@
package org.gotson.komga.domain.service
import org.gotson.komga.domain.model.BookWithMedia
import org.gotson.komga.domain.model.BookMetadataPatchCapability
import org.gotson.komga.domain.model.Media
import org.gotson.komga.domain.model.MediaNotReadyException
import org.gotson.komga.domain.model.MediaProfile
import org.gotson.komga.domain.model.PathContainedInPath
import org.gotson.komga.domain.model.TransientBook
import org.gotson.komga.domain.model.TypedBytes
import org.gotson.komga.domain.model.toBookWithMedia
import org.gotson.komga.domain.persistence.LibraryRepository
import org.gotson.komga.domain.persistence.SeriesRepository
import org.gotson.komga.domain.persistence.TransientBookRepository
import org.gotson.komga.infrastructure.image.ImageType
import org.gotson.komga.infrastructure.metadata.BookMetadataProvider
import org.gotson.komga.infrastructure.metadata.SeriesMetadataFromBookProvider
import org.springframework.beans.factory.annotation.Qualifier
import org.springframework.stereotype.Service
import java.nio.file.Paths
@ -21,37 +26,59 @@ class TransientBookLifecycle(
private val libraryRepository: LibraryRepository,
@Qualifier("pdfImageType")
private val pdfImageType: ImageType,
private val seriesRepository: SeriesRepository,
private val seriesMetadataProviders: List<SeriesMetadataFromBookProvider>,
bookMetadataProviders: List<BookMetadataProvider>,
) {
fun scanAndPersist(filePath: String): List<BookWithMedia> {
val bookMetadataProviders = bookMetadataProviders.filter { it.getCapabilities().contains(BookMetadataPatchCapability.NUMBER_SORT) }
fun scanAndPersist(filePath: String): List<TransientBook> {
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).series.values.flatten().map { BookWithMedia(it, Media()) }
val books = fileSystemScanner.scanRootFolder(folderToScan).series.values.flatten().map { TransientBook(it, Media()) }
transientBookRepository.save(books)
return books
}
fun analyzeAndPersist(transientBook: BookWithMedia): BookWithMedia {
fun analyzeAndPersist(transientBook: TransientBook): TransientBook {
val media = bookAnalyzer.analyze(transientBook.book, true)
val (seriesId, number) = getMetadata(transientBook.copy(media = media))
val updated = transientBook.copy(media = media)
val updated = transientBook.copy(media = media, metadata = TransientBook.Metadata(number, seriesId))
transientBookRepository.save(updated)
return updated
}
fun getMetadata(transientBook: TransientBook): Pair<String?, Float?> {
val bookWithMedia = transientBook.toBookWithMedia()
val number = bookMetadataProviders.firstNotNullOfOrNull { it.getBookMetadataFromBook(bookWithMedia)?.numberSort }
val series = seriesMetadataProviders
.flatMap {
buildList {
if (it.supportsAppendVolume) add(it.getSeriesMetadataFromBook(bookWithMedia, true)?.title)
add(it.getSeriesMetadataFromBook(bookWithMedia, false)?.title)
}
}
.filterNotNull()
.firstNotNullOfOrNull { seriesRepository.findAllByTitleContaining(it).firstOrNull() }
return series?.id to number
}
@Throws(
MediaNotReadyException::class,
IndexOutOfBoundsException::class,
)
fun getBookPage(transientBook: BookWithMedia, number: Int): TypedBytes {
val pageContent = bookAnalyzer.getPageContent(transientBook, number)
fun getBookPage(transientBook: TransientBook, number: Int): TypedBytes {
val pageContent = bookAnalyzer.getPageContent(transientBook.toBookWithMedia(), number)
val pageMediaType =
if (transientBook.media.profile == MediaProfile.PDF) pdfImageType.mediaType
else transientBook.media.pages[number - 1].mediaType

View file

@ -2,7 +2,7 @@ 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.model.TransientBook
import org.gotson.komga.domain.persistence.TransientBookRepository
import org.springframework.stereotype.Service
import java.util.concurrent.TimeUnit
@ -13,15 +13,15 @@ private val logger = KotlinLogging.logger {}
class TransientBookCache : TransientBookRepository {
private val cache = Caffeine.newBuilder()
.expireAfterAccess(1, TimeUnit.HOURS)
.build<String, BookWithMedia>()
.build<String, TransientBook>()
override fun findByIdOrNull(transientBookId: String): BookWithMedia? = cache.getIfPresent(transientBookId)
override fun findByIdOrNull(transientBookId: String): TransientBook? = cache.getIfPresent(transientBookId)
override fun save(transientBook: BookWithMedia) {
override fun save(transientBook: TransientBook) {
cache.put(transientBook.book.id, transientBook)
}
override fun save(transientBooks: Collection<BookWithMedia>) {
override fun save(transientBooks: Collection<TransientBook>) {
cache.putAll(transientBooks.associateBy { it.book.id })
}
}

View file

@ -67,11 +67,11 @@ class SeriesDao(
.firstOrNull()
?.toDomain()
override fun findAllByTitle(title: String): Collection<Series> =
override fun findAllByTitleContaining(title: String): Collection<Series> =
dsl.selectDistinct(*s.fields())
.from(s)
.leftJoin(d).on(s.ID.eq(d.SERIES_ID))
.where(d.TITLE.equalIgnoreCase(title))
.where(d.TITLE.containsIgnoreCase(title))
.fetchInto(s)
.map { it.toDomain() }

View file

@ -1,9 +1,9 @@
package org.gotson.komga.infrastructure.metadata
import org.gotson.komga.domain.model.BookWithMedia
import org.gotson.komga.domain.model.Library
import org.gotson.komga.domain.model.SeriesMetadataPatch
interface SeriesMetadataFromBookProvider : MetadataProvider {
fun getSeriesMetadataFromBook(book: BookWithMedia, library: Library): SeriesMetadataPatch?
val supportsAppendVolume: Boolean
fun getSeriesMetadataFromBook(book: BookWithMedia, appendVolumeToTitle: Boolean): SeriesMetadataPatch?
}

View file

@ -119,7 +119,9 @@ class ComicInfoProvider(
return null
}
override fun getSeriesMetadataFromBook(book: BookWithMedia, library: Library): SeriesMetadataPatch? {
override val supportsAppendVolume = true
override fun getSeriesMetadataFromBook(book: BookWithMedia, appendVolumeToTitle: Boolean): SeriesMetadataPatch? {
getComicInfo(book)?.let { comicInfo ->
val readingDirection = when (comicInfo.manga) {
Manga.NO -> SeriesMetadata.ReadingDirection.LEFT_TO_RIGHT
@ -128,7 +130,7 @@ class ComicInfoProvider(
}
val genres = comicInfo.genre?.split(',')?.mapNotNull { it.trim().ifBlank { null } }
val series = if (library.importComicInfoSeriesAppendVolume) computeSeriesFromSeriesAndVolume(comicInfo.series, comicInfo.volume) else comicInfo.series
val series = if (appendVolumeToTitle) computeSeriesFromSeriesAndVolume(comicInfo.series, comicInfo.volume) else comicInfo.series
return SeriesMetadataPatch(
title = series,

View file

@ -84,7 +84,9 @@ class EpubMetadataProvider(
return null
}
override fun getSeriesMetadataFromBook(book: BookWithMedia, library: Library): SeriesMetadataPatch? {
override val supportsAppendVolume = false
override fun getSeriesMetadataFromBook(book: BookWithMedia, appendVolumeToTitle: Boolean): SeriesMetadataPatch? {
if (book.media.mediaType != MediaType.EPUB.type) return null
getPackageFile(book.book.path)?.let { packageFile ->
val opf = Jsoup.parse(packageFile, "", Parser.xmlParser())

View file

@ -2,11 +2,11 @@ package org.gotson.komga.interfaces.api.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.MediaProfile
import org.gotson.komga.domain.model.ROLE_ADMIN
import org.gotson.komga.domain.model.TransientBook
import org.gotson.komga.domain.persistence.TransientBookRepository
import org.gotson.komga.domain.service.BookAnalyzer
import org.gotson.komga.domain.service.TransientBookLifecycle
@ -81,8 +81,7 @@ class TransientBooksController(
throw ResponseStatusException(HttpStatus.NOT_FOUND, "File not found, it may have moved")
}
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
private fun BookWithMedia.toDto(): TransientBookDto {
private fun TransientBook.toDto(): TransientBookDto {
val pages = if (media.profile == MediaProfile.PDF) bookAnalyzer.getPdfPagesDynamic(media) else media.pages
return TransientBookDto(
id = book.id,
@ -104,6 +103,8 @@ class TransientBooksController(
},
files = media.files.map { it.fileName },
comment = media.comment ?: "",
number = metadata.number,
seriesId = metadata.seriesId,
)
}
}
@ -124,4 +125,6 @@ data class TransientBookDto(
val pages: List<PageDto>,
val files: List<String>,
val comment: String,
val number: Float?,
val seriesId: String?,
)

View file

@ -12,7 +12,6 @@ import org.gotson.komga.domain.model.MediaFile
import org.gotson.komga.domain.model.SeriesMetadata
import org.gotson.komga.domain.model.WebLink
import org.gotson.komga.domain.model.makeBook
import org.gotson.komga.domain.model.makeLibrary
import org.gotson.komga.domain.service.BookAnalyzer
import org.gotson.komga.infrastructure.metadata.comicrack.dto.AgeRating
import org.gotson.komga.infrastructure.metadata.comicrack.dto.ComicInfo
@ -338,9 +337,6 @@ class ComicInfoProviderTest {
@Nested
inner class Series {
private val library = makeLibrary()
private val libraryNoAppend = library.copy(importComicInfoSeriesAppendVolume = false)
@Test
fun `given comicInfo when getting series metadata then metadata patch is valid`() {
val comicInfo = ComicInfo().apply {
@ -356,7 +352,7 @@ class ComicInfoProviderTest {
every { mockMapper.readValue(any<ByteArray>(), ComicInfo::class.java) } returns comicInfo
val patch = comicInfoProvider.getSeriesMetadataFromBook(BookWithMedia(book, media), library)!!
val patch = comicInfoProvider.getSeriesMetadataFromBook(BookWithMedia(book, media), true)!!
with(patch) {
assertThat(title).isEqualTo("séries")
@ -382,13 +378,13 @@ class ComicInfoProviderTest {
every { mockMapper.readValue(any<ByteArray>(), ComicInfo::class.java) } returns comicInfo
val patch = comicInfoProvider.getSeriesMetadataFromBook(BookWithMedia(book, media), library)!!
val patch = comicInfoProvider.getSeriesMetadataFromBook(BookWithMedia(book, media), true)!!
with(patch) {
assertThat(title).isEqualTo("series (2020)")
}
val patchNoAppend = comicInfoProvider.getSeriesMetadataFromBook(BookWithMedia(book, media), libraryNoAppend)!!
val patchNoAppend = comicInfoProvider.getSeriesMetadataFromBook(BookWithMedia(book, media), false)!!
with(patchNoAppend) {
assertThat(title).isEqualTo("series")
@ -404,13 +400,13 @@ class ComicInfoProviderTest {
every { mockMapper.readValue(any<ByteArray>(), ComicInfo::class.java) } returns comicInfo
val patch = comicInfoProvider.getSeriesMetadataFromBook(BookWithMedia(book, media), library)!!
val patch = comicInfoProvider.getSeriesMetadataFromBook(BookWithMedia(book, media), true)!!
with(patch) {
assertThat(title).isEqualTo("series")
}
val patchNoAppend = comicInfoProvider.getSeriesMetadataFromBook(BookWithMedia(book, media), libraryNoAppend)!!
val patchNoAppend = comicInfoProvider.getSeriesMetadataFromBook(BookWithMedia(book, media), false)!!
with(patchNoAppend) {
assertThat(title).isEqualTo("series")
@ -425,7 +421,7 @@ class ComicInfoProviderTest {
every { mockMapper.readValue(any<ByteArray>(), ComicInfo::class.java) } returns comicInfo
val patch = comicInfoProvider.getSeriesMetadataFromBook(BookWithMedia(book, media), library)!!
val patch = comicInfoProvider.getSeriesMetadataFromBook(BookWithMedia(book, media), true)!!
with(patch) {
assertThat(language).isNull()
@ -441,7 +437,7 @@ class ComicInfoProviderTest {
every { mockMapper.readValue(any<ByteArray>(), ComicInfo::class.java) } returns comicInfo
val patch = comicInfoProvider.getSeriesMetadataFromBook(BookWithMedia(book, media), library)!!
val patch = comicInfoProvider.getSeriesMetadataFromBook(BookWithMedia(book, media), true)!!
with(patch) {
assertThat(language).isEqualTo(expected)
@ -469,7 +465,7 @@ class ComicInfoProviderTest {
every { mockMapper.readValue(any<ByteArray>(), ComicInfo::class.java) } returns comicInfo
val patch = comicInfoProvider.getSeriesMetadataFromBook(BookWithMedia(book, media), library)!!
val patch = comicInfoProvider.getSeriesMetadataFromBook(BookWithMedia(book, media), true)!!
with(patch) {
assertThat(title).isNull()

View file

@ -10,7 +10,6 @@ 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
import org.gotson.komga.domain.model.makeLibrary
import org.gotson.komga.infrastructure.mediacontainer.epub.getPackageFile
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Nested
@ -122,15 +121,13 @@ class EpubMetadataProviderTest {
@Nested
inner class Series {
private val library = makeLibrary()
@Test
fun `given epub 3 opf when getting series metadata then metadata patch is valid`() {
val opf = ClassPathResource("epub/Panik im Paradies.opf")
mockkStatic(::getPackageFile)
every { getPackageFile(any()) } returns opf.file.readText()
val patch = epubMetadataProvider.getSeriesMetadataFromBook(BookWithMedia(book, media), library)
val patch = epubMetadataProvider.getSeriesMetadataFromBook(BookWithMedia(book, media), true)
with(patch!!) {
assertThat(title).isEqualTo("Die drei ??? Kids")
@ -148,7 +145,7 @@ class EpubMetadataProviderTest {
mockkStatic(::getPackageFile)
every { getPackageFile(any()) } returns opf.file.readText()
val patch = epubMetadataProvider.getSeriesMetadataFromBook(BookWithMedia(book, media), library)
val patch = epubMetadataProvider.getSeriesMetadataFromBook(BookWithMedia(book, media), true)
with(patch!!) {
assertThat(title).isEqualTo("Die drei ??? Kids")
@ -166,7 +163,7 @@ class EpubMetadataProviderTest {
mockkStatic(::getPackageFile)
every { getPackageFile(any()) } returns opf.file.readText()
val patch = epubMetadataProvider.getSeriesMetadataFromBook(BookWithMedia(book, media), library)
val patch = epubMetadataProvider.getSeriesMetadataFromBook(BookWithMedia(book, media), true)
with(patch!!) {
assertThat(title).isNull()