mirror of
https://github.com/gotson/komga.git
synced 2026-01-05 23:36:07 +01:00
feat(api): add basic metadata for transient books
This commit is contained in:
parent
15920b710e
commit
1050f522cc
13 changed files with 87 additions and 46 deletions
|
|
@ -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)
|
||||
|
|
@ -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?
|
||||
|
|
|
|||
|
|
@ -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>)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() }
|
||||
|
||||
|
|
|
|||
|
|
@ -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?
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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?,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Reference in a new issue