added content detection for archives and archive content

zip archives support
reviewed JPA entities
added logs
This commit is contained in:
Gauthier Roebroeck 2019-08-16 17:05:26 +08:00
parent e1af34778e
commit 52168815d4
21 changed files with 577 additions and 65 deletions

View file

@ -54,6 +54,9 @@ dependencies {
implementation("commons-io:commons-io:2.6") implementation("commons-io:commons-io:2.6")
implementation("org.apache.tika:tika-core:1.22")
implementation("net.lingala.zip4j:zip4j:2.1.2")
runtimeOnly("com.h2database:h2:1.4.199") runtimeOnly("com.h2database:h2:1.4.199")
testImplementation("org.springframework.boot:spring-boot-starter-test") { testImplementation("org.springframework.boot:spring-boot-starter-test") {

View file

@ -1,28 +1,41 @@
package org.gotson.komga.domain.model package org.gotson.komga.domain.model
import java.net.URL import java.net.URL
import java.nio.file.Path
import java.nio.file.Paths
import java.time.LocalDateTime import java.time.LocalDateTime
import javax.persistence.CascadeType
import javax.persistence.Entity import javax.persistence.Entity
import javax.persistence.FetchType import javax.persistence.FetchType
import javax.persistence.GeneratedValue import javax.persistence.GeneratedValue
import javax.persistence.Id import javax.persistence.Id
import javax.persistence.ManyToOne import javax.persistence.ManyToOne
import javax.persistence.OneToOne
import javax.validation.constraints.NotBlank import javax.validation.constraints.NotBlank
import javax.validation.constraints.NotNull import javax.validation.constraints.NotNull
@Entity @Entity
data class Book( class Book(
@Id
@GeneratedValue
var id: Long? = null,
@NotBlank @NotBlank
val name: String, val name: String,
val url: URL, val url: URL,
val updated: LocalDateTime val updated: LocalDateTime
) { ) {
@Id
@GeneratedValue
var id: Long = 0
@NotNull @NotNull
@ManyToOne(fetch = FetchType.LAZY, optional = false) @ManyToOne(fetch = FetchType.LAZY, optional = false)
lateinit var serie: Serie lateinit var serie: Serie
@OneToOne(optional = false, orphanRemoval = true, cascade = [CascadeType.ALL], fetch = FetchType.LAZY, mappedBy = "book")
var metadata: BookMetadata = BookMetadata().also { it.book = this }
set(value) {
value.book = this
field = value
}
} }
fun Book.path(): Path = Paths.get(this.url.toURI())

View file

@ -0,0 +1,43 @@
package org.gotson.komga.domain.model
import javax.persistence.CollectionTable
import javax.persistence.Column
import javax.persistence.ElementCollection
import javax.persistence.Entity
import javax.persistence.EnumType
import javax.persistence.Enumerated
import javax.persistence.FetchType
import javax.persistence.GeneratedValue
import javax.persistence.Id
import javax.persistence.OneToOne
@Entity
class BookMetadata(
@Enumerated(EnumType.STRING)
val status: Status = Status.UNKNOWN,
val mediaType: String? = null,
pages: List<String> = emptyList()
) {
@Id
@GeneratedValue
val id: Long = 0
@OneToOne(optional = false, fetch = FetchType.LAZY)
lateinit var book: Book
@ElementCollection(fetch = FetchType.EAGER)
@CollectionTable(name = "book_metadata_pages")
@Column(name = "pages")
private val _pages: MutableList<String> = mutableListOf()
val pages: List<String>
get() = _pages.toList()
init {
_pages.addAll(pages)
}
}
enum class Status {
UNKNOWN, ERROR, READY, UNSUPPORTED
}

View file

@ -0,0 +1,6 @@
package org.gotson.komga.domain.model
data class BookPage(
val mediaType: String,
val content: ByteArray
)

View file

@ -11,21 +11,28 @@ import javax.persistence.OneToMany
import javax.validation.constraints.NotBlank import javax.validation.constraints.NotBlank
@Entity @Entity
data class Serie( class Serie(
@Id
@GeneratedValue
var id: Long? = null,
@NotBlank @NotBlank
val name: String, val name: String,
val url: URL, val url: URL,
val updated: LocalDateTime val updated: LocalDateTime
) { ) {
@Id
@GeneratedValue
var id: Long = 0
@OneToMany(cascade = [CascadeType.ALL], fetch = FetchType.LAZY, mappedBy = "serie", orphanRemoval = true) @OneToMany(cascade = [CascadeType.ALL], fetch = FetchType.LAZY, mappedBy = "serie", orphanRemoval = true)
var books: MutableList<Book> = mutableListOf() private var _books: MutableList<Book> = mutableListOf()
set(value) { set(value) {
value.forEach { it.serie = this } value.forEach { it.serie = this }
field = value field = value
} }
val books: List<Book>
get() = _books.toList()
fun setBooks(books: List<Book>) {
_books = books.toMutableList()
}
} }

View file

@ -0,0 +1,8 @@
package org.gotson.komga.domain.persistence
import org.gotson.komga.domain.model.BookMetadata
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.stereotype.Repository
@Repository
interface BookMetadataRepository : JpaRepository<BookMetadata, Long>

View file

@ -8,5 +8,6 @@ import java.net.URL
@Repository @Repository
interface SerieRepository : JpaRepository<Serie, Long> { interface SerieRepository : JpaRepository<Serie, Long> {
fun deleteAllByUrlNotIn(urls: Iterable<URL>) fun deleteAllByUrlNotIn(urls: Iterable<URL>)
fun countByUrlNotIn(urls: Iterable<URL>): Long
fun findByUrl(url: URL): Serie? fun findByUrl(url: URL): Serie?
} }

View file

@ -0,0 +1,73 @@
package org.gotson.komga.domain.service
import mu.KotlinLogging
import org.gotson.komga.domain.model.Book
import org.gotson.komga.domain.model.BookMetadata
import org.gotson.komga.domain.model.BookPage
import org.gotson.komga.domain.model.Status
import org.gotson.komga.domain.persistence.BookRepository
import org.gotson.komga.infrastructure.archive.ContentDetector
import org.springframework.stereotype.Service
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
private val logger = KotlinLogging.logger {}
@Service
class BookManager(
private val bookRepository: BookRepository,
private val bookParser: BookParser,
private val contentDetector: ContentDetector
) {
fun parseAndPersist(book: Book) {
logger.info { "Parse and persist book: ${book.url}" }
try {
book.metadata = bookParser.parse(book)
} catch (ex: UnsupportedMediaTypeException) {
logger.info(ex) { "Unsupported media type: ${ex.mediaType}" }
book.metadata = BookMetadata(status = Status.UNSUPPORTED, mediaType = ex.mediaType)
} catch (ex: Exception) {
logger.error(ex) { "Error while parsing" }
book.metadata = BookMetadata(status = Status.ERROR)
}
bookRepository.save(book)
}
fun getPage(book: Book, number: Int): BookPage {
logger.info { "Get page #$number for book: ${book.url}" }
if (book.metadata.status == Status.UNKNOWN) {
logger.info { "Book metadata is unknown, parsing it now" }
parseAndPersist(book)
}
if (book.metadata.status != Status.READY) {
logger.warn { "Book metadata is not ready, cannot get pages" }
throw MetadataNotReadyException()
}
lateinit var mediaType: String
lateinit var content: ByteArray
bookParser.getPage(book, number).use { stream ->
if (stream.markSupported()) {
logger.debug { "Stream supports mark, passing it as is for content detection" }
mediaType = contentDetector.detectMediaType(stream)
content = stream.readBytes()
} else {
logger.debug { "Stream does not support mark, using a cloned stream for content detection" }
val buffer = ByteArrayOutputStream()
stream.copyTo(buffer)
val clonedStream = ByteArrayInputStream(buffer.toByteArray())
mediaType = clonedStream.use { contentDetector.detectMediaType(it) }
content = buffer.toByteArray()
}
}
logger.info { "Page media type: $mediaType" }
return BookPage(mediaType, content)
}
}

View file

@ -0,0 +1,57 @@
package org.gotson.komga.domain.service
import mu.KotlinLogging
import org.gotson.komga.domain.model.Book
import org.gotson.komga.domain.model.BookMetadata
import org.gotson.komga.domain.model.Status
import org.gotson.komga.domain.model.path
import org.gotson.komga.infrastructure.archive.ContentDetector
import org.gotson.komga.infrastructure.archive.ZipExtractor
import org.springframework.stereotype.Service
import java.io.InputStream
private val logger = KotlinLogging.logger {}
@Service
class BookParser(
private val contentDetector: ContentDetector,
private val zipExtractor: ZipExtractor
) {
val supportedMimeTypes = listOf(
"application/zip"
)
fun parse(book: Book): BookMetadata {
logger.info { "Trying to parse book: ${book.url}" }
val mediaType = contentDetector.detectMediaType(book.path())
logger.info { "Detected media type: $mediaType" }
if (!supportedMimeTypes.contains(mediaType))
throw UnsupportedMediaTypeException("Unsupported mime type: $mediaType. File: ${book.url}", mediaType)
val pageNames = zipExtractor.getFilenames(book.path())
logger.info { "Book has ${pageNames.size} pages" }
return BookMetadata(mediaType = mediaType, status = Status.READY, pages = pageNames)
}
fun getPage(book: Book, number: Int): InputStream {
logger.info { "Get page #$number for book: ${book.url}" }
if (book.metadata.status != Status.READY) {
logger.warn { "Book metadata is not ready, cannot get pages" }
throw MetadataNotReadyException()
}
if (number > book.metadata.pages.size || number <= 0) {
logger.error { "Page number #$number is out of bounds. Book has ${book.metadata.pages.size} pages" }
throw ArrayIndexOutOfBoundsException("Page $number does not exist")
}
return zipExtractor.getEntryStream(book.path(), book.metadata.pages[number - 1])
}
}
class MetadataNotReadyException : Exception()
class UnsupportedMediaTypeException(msg: String, val mediaType: String) : Exception(msg)

View file

@ -13,46 +13,56 @@ import java.time.LocalDateTime
import java.time.ZoneId import java.time.ZoneId
import kotlin.streams.asSequence import kotlin.streams.asSequence
import kotlin.streams.toList import kotlin.streams.toList
import kotlin.system.measureTimeMillis
private val logger = KotlinLogging.logger {} private val logger = KotlinLogging.logger {}
@Service @Service
class FileSystemScanner( class FileSystemScanner {
) {
val supportedExtensions = listOf("cbr", "rar", "cbz", "zip") val supportedExtensions = listOf("cbz", "zip")
fun scanRootFolder(root: Path): List<Serie> { fun scanRootFolder(root: Path): List<Serie> {
logger.info { "Scanning folder: $root" } logger.info { "Scanning folder: $root" }
logger.info { "Supported extensions: $supportedExtensions" }
return Files.walk(root).asSequence() lateinit var scannedSeries: List<Serie>
.filter { !Files.isHidden(it) }
.filter { Files.isDirectory(it) } measureTimeMillis {
.mapNotNull { dir -> scannedSeries = Files.walk(root).asSequence()
val books = Files.list(dir) .filter { !Files.isHidden(it) }
.filter { Files.isRegularFile(it) } .filter { Files.isDirectory(it) }
.filter { supportedExtensions.contains(FilenameUtils.getExtension(it.fileName.toString())) } .mapNotNull { dir ->
.map { val books = Files.list(dir)
Book( .filter { Files.isRegularFile(it) }
name = FilenameUtils.getBaseName(it.fileName.toString()), .filter { supportedExtensions.contains(FilenameUtils.getExtension(it.fileName.toString())) }
url = it.toUri().toURL(), .map {
updated = it.getUpdatedTime() Book(
) name = FilenameUtils.getBaseName(it.fileName.toString()),
}.toList() url = it.toUri().toURL(),
if (books.isNullOrEmpty()) return@mapNotNull null updated = it.getUpdatedTime()
Serie( )
name = dir.fileName.toString(), }.toList()
url = dir.toUri().toURL(), if (books.isNullOrEmpty()) return@mapNotNull null
updated = dir.getUpdatedTime() Serie(
).also { it.books = books.toMutableList() } name = dir.fileName.toString(),
}.toList() url = dir.toUri().toURL(),
updated = dir.getUpdatedTime()
).also { it.setBooks(books) }
}.toList()
}.also {
val countOfBooks = scannedSeries.sumBy { it.books.size }
logger.info { "Scanned ${scannedSeries.size} series and $countOfBooks books in $it ms" }
}
return scannedSeries
} }
} }
fun Path.getUpdatedTime() = fun Path.getUpdatedTime(): LocalDateTime =
Files.readAttributes(this, BasicFileAttributes::class.java).let { Files.readAttributes(this, BasicFileAttributes::class.java).let {
maxOf(it.creationTime(), it.lastModifiedTime()).toLocalDateTime() maxOf(it.creationTime(), it.lastModifiedTime()).toLocalDateTime()
} }
fun FileTime.toLocalDateTime() = fun FileTime.toLocalDateTime(): LocalDateTime =
LocalDateTime.ofInstant(this.toInstant(), ZoneId.systemDefault()) LocalDateTime.ofInstant(this.toInstant(), ZoneId.systemDefault())

View file

@ -19,15 +19,22 @@ class LibraryManager(
@Transactional @Transactional
fun scanRootFolder(library: Library) { fun scanRootFolder(library: Library) {
logger.info { "Scanning ${library.name}'s root folder: ${library.root}" } logger.info { "Updating library: ${library.name}, root folder: ${library.root}" }
measureTimeMillis { measureTimeMillis {
val series = fileSystemScanner.scanRootFolder(library.fileSystem.getPath(library.root)) val series = fileSystemScanner.scanRootFolder(library.fileSystem.getPath(library.root))
// delete series that don't exist anymore // delete series that don't exist anymore
if (series.isEmpty()) if (series.isEmpty()) {
logger.info { "Scan returned no series, deleting all existing series" }
serieRepository.deleteAll() serieRepository.deleteAll()
else } else {
serieRepository.deleteAllByUrlNotIn(series.map { it.url }) val urls = series.map { it.url }
val countOfSeriesToDelete = serieRepository.countByUrlNotIn(urls)
if (countOfSeriesToDelete > 0) {
logger.info { "Deleting $countOfSeriesToDelete series not on disk anymore" }
serieRepository.deleteAllByUrlNotIn(urls)
}
}
// match IDs for existing entities // match IDs for existing entities
series.forEach { newSerie -> series.forEach { newSerie ->
@ -36,6 +43,11 @@ class LibraryManager(
newSerie.books.forEach { newBook -> newSerie.books.forEach { newBook ->
bookRepository.findByUrl(newBook.url)?.let { existingBook -> bookRepository.findByUrl(newBook.url)?.let { existingBook ->
newBook.id = existingBook.id newBook.id = existingBook.id
// conserve metadata if book has not changed
if (newBook.updated == existingBook.updated)
newBook.metadata = existingBook.metadata
else
logger.info { "Book changed on disk, reset metadata status: ${newBook.url}" }
} }
} }
} }
@ -43,6 +55,6 @@ class LibraryManager(
serieRepository.saveAll(series) serieRepository.saveAll(series)
}.also { logger.info { "Scan finished in $it ms" } } }.also { logger.info { "Update finished in $it ms" } }
} }
} }

View file

@ -0,0 +1,39 @@
package org.gotson.komga.infrastructure.archive
import mu.KotlinLogging
import org.apache.tika.config.TikaConfig
import org.apache.tika.io.TikaInputStream
import org.apache.tika.metadata.Metadata
import org.springframework.stereotype.Service
import java.io.InputStream
import java.nio.file.Path
private val logger = KotlinLogging.logger {}
@Service
class ContentDetector(
private val tika: TikaConfig
) {
fun detectMediaType(path: Path): String {
logger.info { "detect media type for path: $path" }
val metadata = Metadata().also {
it[Metadata.RESOURCE_NAME_KEY] = path.fileName.toString()
}
val mediaType = tika.detector.detect(TikaInputStream.get(path), metadata)
logger.info { "media type detected: $mediaType" }
return mediaType.toString()
}
fun detectMediaType(stream: InputStream): String {
logger.info { "detect media type for stream" }
stream.use {
val mediaType = tika.detector.detect(TikaInputStream.get(it), Metadata())
logger.info { "media type detected: $mediaType" }
return mediaType.toString()
}
}
}

View file

@ -0,0 +1,12 @@
package org.gotson.komga.infrastructure.archive
import org.apache.tika.config.TikaConfig
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
@Configuration
class TikaConfiguration {
@Bean
fun tika() = TikaConfig()
}

View file

@ -0,0 +1,18 @@
package org.gotson.komga.infrastructure.archive
import net.lingala.zip4j.ZipFile
import org.springframework.stereotype.Service
import java.io.InputStream
import java.nio.file.Path
@Service
class ZipExtractor {
fun getFilenames(path: Path) =
ZipFile(path.toFile()).fileHeaders.map { it.fileName }.toMutableList()
fun getEntryStream(path: Path, entryName: String): InputStream =
ZipFile(path.toFile()).let {
it.getInputStream(it.getFileHeader(entryName))
}
}

View file

@ -22,6 +22,7 @@ class RootScannerController(
@EventListener(ApplicationReadyEvent::class) @EventListener(ApplicationReadyEvent::class)
@Scheduled(cron = "#{@komgaProperties.rootFolderScanCron ?: '-'}") @Scheduled(cron = "#{@komgaProperties.rootFolderScanCron ?: '-'}")
fun scanRootFolder() { fun scanRootFolder() {
logger.info { "Starting periodic library scan" }
libraryManager.scanRootFolder(Library("default", komgaProperties.rootFolder)) libraryManager.scanRootFolder(Library("default", komgaProperties.rootFolder))
} }
} }

View file

@ -2,13 +2,17 @@ package org.gotson.komga.interfaces.web
import org.gotson.komga.domain.model.Book import org.gotson.komga.domain.model.Book
import org.gotson.komga.domain.model.Serie import org.gotson.komga.domain.model.Serie
import org.gotson.komga.domain.model.Status
import org.gotson.komga.domain.persistence.BookRepository import org.gotson.komga.domain.persistence.BookRepository
import org.gotson.komga.domain.persistence.SerieRepository import org.gotson.komga.domain.persistence.SerieRepository
import org.gotson.komga.domain.service.BookManager
import org.gotson.komga.domain.service.MetadataNotReadyException
import org.springframework.data.domain.Page import org.springframework.data.domain.Page
import org.springframework.data.domain.Pageable import org.springframework.data.domain.Pageable
import org.springframework.data.repository.findByIdOrNull import org.springframework.data.repository.findByIdOrNull
import org.springframework.http.HttpStatus import org.springframework.http.HttpStatus
import org.springframework.http.MediaType import org.springframework.http.MediaType
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RequestMapping
@ -21,7 +25,8 @@ import java.net.URL
@RequestMapping("api/v1/series") @RequestMapping("api/v1/series")
class SerieController( class SerieController(
private val serieRepository: SerieRepository, private val serieRepository: SerieRepository,
private val bookRepository: BookRepository private val bookRepository: BookRepository,
private val bookManager: BookManager
) { ) {
@GetMapping @GetMapping
@ -62,6 +67,49 @@ class SerieController(
File(it.url.toURI()).readBytes() File(it.url.toURI()).readBytes()
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
} }
@GetMapping("{serieId}/books/{bookId}/pages")
fun getBookPages(
@PathVariable serieId: Long,
@PathVariable bookId: Long
): List<PageDto> {
if (!serieRepository.existsById(serieId)) throw ResponseStatusException(HttpStatus.NOT_FOUND)
return bookRepository.findByIdOrNull((bookId))?.let {
if (it.metadata.status == Status.UNKNOWN) bookManager.parseAndPersist(it)
if (it.metadata.status in listOf(Status.ERROR, Status.UNSUPPORTED)) throw ResponseStatusException(HttpStatus.NO_CONTENT, "Book cannot be parsed")
it.metadata.pages.mapIndexed { index, s -> PageDto(index + 1, s) }
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
}
@GetMapping("{serieId}/books/{bookId}/pages/{pageNumber}")
fun getBookPage(
@PathVariable serieId: Long,
@PathVariable bookId: Long,
@PathVariable pageNumber: Int
): ResponseEntity<ByteArray> {
if (!serieRepository.existsById(serieId)) throw ResponseStatusException(HttpStatus.NOT_FOUND)
try {
return bookRepository.findByIdOrNull((bookId))?.let { book ->
val page = bookManager.getPage(book, pageNumber)
val mediaType = try {
MediaType.parseMediaType(page.mediaType)
} catch (ex: Exception) {
MediaType.APPLICATION_OCTET_STREAM
}
ResponseEntity.ok()
.contentType(mediaType)
.body(page.content)
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
} catch (ex: ArrayIndexOutOfBoundsException) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Page number does not exist")
} catch (ex: MetadataNotReadyException) {
throw ResponseStatusException(HttpStatus.NO_CONTENT, "Book cannot be parsed")
}
}
} }
data class SerieDto( data class SerieDto(
@ -70,13 +118,33 @@ data class SerieDto(
val url: URL val url: URL
) )
fun Serie.toDto() = SerieDto(id!!, name, url) fun Serie.toDto() = SerieDto(id = id, name = name, url = url)
data class BookDto( data class BookDto(
val id: Long, val id: Long,
val name: String, val name: String,
val url: URL val url: URL,
val metadata: BookMetadataDto
) )
fun Book.toDto() = BookDto(id!!, name, url) data class BookMetadataDto(
val status: String,
val mediaType: String
)
fun Book.toDto() =
BookDto(
id = id,
name = name,
url = url,
metadata = BookMetadataDto(
status = metadata.status.toString(),
mediaType = metadata.mediaType ?: ""
)
)
data class PageDto(
val number: Int,
val fileName: String
)

View file

@ -0,0 +1,10 @@
package org.gotson.komga.domain.model
import java.net.URL
import java.time.LocalDateTime
fun makeBook(name: String, url: String = "file:/$name") =
Book(name = name, url = URL(url), updated = LocalDateTime.now())
fun makeSerie(name: String, url: String = "file:/$name", books: List<Book> = listOf()) =
Serie(name = name, url = URL(url), updated = LocalDateTime.now()).also { it.setBooks(books) }

View file

@ -0,0 +1,70 @@
package org.gotson.komga.domain.persistence
import org.assertj.core.api.Assertions.assertThat
import org.gotson.komga.domain.model.BookMetadata
import org.gotson.komga.domain.model.Status
import org.gotson.komga.domain.model.makeBook
import org.gotson.komga.domain.model.makeSerie
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest
import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager
import org.springframework.test.context.junit.jupiter.SpringExtension
import org.springframework.transaction.annotation.Transactional
@ExtendWith(SpringExtension::class)
@DataJpaTest
@Transactional
class PersistenceTest(
@Autowired private val serieRepository: SerieRepository,
@Autowired private val bookRepository: BookRepository,
@Autowired private val bookMetadataRepository: BookMetadataRepository,
@Autowired private val entityManager: TestEntityManager
) {
@AfterEach
fun `clear repository`() {
entityManager.clear()
}
@Test
fun `given serie with book when saving then metadata is also saved`() {
// given
val serie = makeSerie(name = "serie", books = mutableListOf(makeBook("book1")))
// when
serieRepository.save(serie)
// then
assertThat(serieRepository.count()).isEqualTo(1)
assertThat(bookRepository.count()).isEqualTo(1)
assertThat(bookMetadataRepository.count()).isEqualTo(1)
}
@Test
fun `given existing book when updating metadata then new metadata is saved`() {
// given
val serie = makeSerie(name = "serie", books = mutableListOf(makeBook("book1")))
serieRepository.save(serie)
// when
val book = bookRepository.findAll().first()
book.metadata = BookMetadata(status = Status.READY, mediaType = "test", pages = listOf("page1"))
bookRepository.save(book)
// then
assertThat(serieRepository.count()).isEqualTo(1)
assertThat(bookRepository.count()).isEqualTo(1)
assertThat(bookMetadataRepository.count()).isEqualTo(1)
bookMetadataRepository.findAll().first().let {
assertThat(it.status == Status.READY)
assertThat(it.mediaType == "test")
assertThat(it.pages)
.hasSize(1)
.containsExactly("page1")
}
}
}

View file

@ -4,9 +4,11 @@ import com.ninjasquad.springmockk.MockkBean
import io.mockk.every import io.mockk.every
import io.mockk.verify import io.mockk.verify
import org.assertj.core.api.Assertions.assertThat import org.assertj.core.api.Assertions.assertThat
import org.gotson.komga.domain.model.Book import org.gotson.komga.domain.model.BookMetadata
import org.gotson.komga.domain.model.Library import org.gotson.komga.domain.model.Library
import org.gotson.komga.domain.model.Serie import org.gotson.komga.domain.model.Status
import org.gotson.komga.domain.model.makeBook
import org.gotson.komga.domain.model.makeSerie
import org.gotson.komga.domain.persistence.BookRepository import org.gotson.komga.domain.persistence.BookRepository
import org.gotson.komga.domain.persistence.SerieRepository import org.gotson.komga.domain.persistence.SerieRepository
import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.AfterEach
@ -17,8 +19,6 @@ import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabas
import org.springframework.boot.test.context.SpringBootTest import org.springframework.boot.test.context.SpringBootTest
import org.springframework.test.context.junit.jupiter.SpringExtension import org.springframework.test.context.junit.jupiter.SpringExtension
import org.springframework.transaction.annotation.Transactional import org.springframework.transaction.annotation.Transactional
import java.net.URL
import java.time.LocalDateTime
@ExtendWith(SpringExtension::class) @ExtendWith(SpringExtension::class)
@SpringBootTest @SpringBootTest
@ -27,12 +27,16 @@ import java.time.LocalDateTime
class LibraryManagerTest( class LibraryManagerTest(
@Autowired private val serieRepository: SerieRepository, @Autowired private val serieRepository: SerieRepository,
@Autowired private val bookRepository: BookRepository, @Autowired private val bookRepository: BookRepository,
@Autowired private val libraryManager: LibraryManager @Autowired private val libraryManager: LibraryManager,
@Autowired private val bookManager: BookManager
) { ) {
@MockkBean @MockkBean
private lateinit var mockScanner: FileSystemScanner private lateinit var mockScanner: FileSystemScanner
@MockkBean
private lateinit var mockParser: BookParser
private val library = Library(name = "test", root = "/root") private val library = Library(name = "test", root = "/root")
@AfterEach @AfterEach
@ -40,17 +44,11 @@ class LibraryManagerTest(
serieRepository.deleteAll() serieRepository.deleteAll()
} }
private fun makeBook(name: String, url: String = "file:/$name") =
Book(name = name, url = URL(url), updated = LocalDateTime.now())
private fun makeSerie(name: String, url: String = "file:/$name", books: MutableList<Book> = mutableListOf()) =
Serie(name = name, url = URL(url), updated = LocalDateTime.now()).also { it.books = books }
@Test @Test
fun `given existing Serie when adding files and scanning then only updated Books are persisted`() { fun `given existing Serie when adding files and scanning then only updated Books are persisted`() {
//given //given
val serie = makeSerie(name = "serie", books = mutableListOf(makeBook("book1"))) val serie = makeSerie(name = "serie", books = listOf(makeBook("book1")))
val serieWithMoreBooks = makeSerie(name = "serie", books = mutableListOf(makeBook("book1"), makeBook("book2"))) val serieWithMoreBooks = makeSerie(name = "serie", books = listOf(makeBook("book1"), makeBook("book2")))
every { mockScanner.scanRootFolder(any()) }.returnsMany( every { mockScanner.scanRootFolder(any()) }.returnsMany(
listOf(serie), listOf(serie),
@ -74,8 +72,8 @@ class LibraryManagerTest(
@Test @Test
fun `given existing Serie when removing files and scanning then only updated Books are persisted`() { fun `given existing Serie when removing files and scanning then only updated Books are persisted`() {
//given //given
val serie = makeSerie(name = "serie", books = mutableListOf(makeBook("book1"), makeBook("book2"))) val serie = makeSerie(name = "serie", books = listOf(makeBook("book1"), makeBook("book2")))
val serieWithLessBooks = makeSerie(name = "serie", books = mutableListOf(makeBook("book1"))) val serieWithLessBooks = makeSerie(name = "serie", books = listOf(makeBook("book1")))
every { mockScanner.scanRootFolder(any()) } every { mockScanner.scanRootFolder(any()) }
.returnsMany( .returnsMany(
@ -101,8 +99,8 @@ class LibraryManagerTest(
@Test @Test
fun `given existing Serie when updating files and scanning then Books are updated`() { fun `given existing Serie when updating files and scanning then Books are updated`() {
//given //given
val serie = makeSerie(name = "serie", books = mutableListOf(makeBook("book1"))) val serie = makeSerie(name = "serie", books = listOf(makeBook("book1")))
val serieWithUpdatedBooks = makeSerie(name = "serie", books = mutableListOf(makeBook("book1updated", "file:/book1"))) val serieWithUpdatedBooks = makeSerie(name = "serie", books = listOf(makeBook("book1updated", "file:/book1")))
every { mockScanner.scanRootFolder(any()) } every { mockScanner.scanRootFolder(any()) }
.returnsMany( .returnsMany(
@ -127,7 +125,7 @@ class LibraryManagerTest(
@Test @Test
fun `given existing Serie when deleting all books and scanning then Series and Books are removed`() { fun `given existing Serie when deleting all books and scanning then Series and Books are removed`() {
//given //given
val serie = makeSerie(name = "serie", books = mutableListOf(makeBook("book1"))) val serie = makeSerie(name = "serie", books = listOf(makeBook("book1")))
every { mockScanner.scanRootFolder(any()) } every { mockScanner.scanRootFolder(any()) }
.returnsMany( .returnsMany(
@ -145,4 +143,32 @@ class LibraryManagerTest(
assertThat(serieRepository.count()).describedAs("Serie repository should be empty").isEqualTo(0) assertThat(serieRepository.count()).describedAs("Serie repository should be empty").isEqualTo(0)
assertThat(bookRepository.count()).describedAs("Book repository should be empty").isEqualTo(0) assertThat(bookRepository.count()).describedAs("Book repository should be empty").isEqualTo(0)
} }
@Test
fun `given existing Book with metadata when rescanning then metadata is kept intact`() {
//given
every { mockScanner.scanRootFolder(any()) }
.returnsMany(
listOf(makeSerie(name = "serie", books = listOf(makeBook("book1")))),
listOf(makeSerie(name = "serie", books = listOf(makeBook("book1"))))
)
libraryManager.scanRootFolder(library)
every { mockParser.parse(any()) } returns BookMetadata(status = Status.READY, mediaType = "application/zip", pages = listOf("1.jpg", "2.jpg"))
bookRepository.findAll().forEach { bookManager.parseAndPersist(it) }
// when
libraryManager.scanRootFolder(library)
// then
verify(exactly = 2) { mockScanner.scanRootFolder(any()) }
verify(exactly = 1) { mockParser.parse(any()) }
val book = bookRepository.findAll().first()
assertThat(book.metadata.status).isEqualTo(Status.READY)
assertThat(book.metadata.mediaType).isEqualTo("application/zip")
assertThat(book.metadata.pages)
.hasSize(2)
.containsExactly("1.jpg", "2.jpg")
}
} }

View file

@ -0,0 +1,28 @@
package org.gotson.komga.infrastructure
import mu.KotlinLogging
import org.apache.tika.config.TikaConfig
import org.apache.tika.io.TikaInputStream
import org.apache.tika.metadata.Metadata
import org.junit.jupiter.api.Test
import java.nio.file.Paths
private val logger = KotlinLogging.logger {}
class ParserTest {
@Test
fun parseTest() {
val filePath = """D:\files\comics\Chrononauts\Chrononauts 001 (2015) (Digital) (Zone-Empire).cbz"""
val path = Paths.get(filePath)
val tika = TikaConfig()
val metadata = Metadata().also {
it[Metadata.RESOURCE_NAME_KEY] = path.fileName.toString()
}
val mimeType = tika.detector.detect(TikaInputStream.get(path), metadata)
logger.info { mimeType }
logger.info { tika.detector.detect(TikaInputStream.get(path), Metadata()) }
}
}

View file

@ -1 +1,8 @@
application.version: TESTING application.version: TESTING
spring:
jpa:
show-sql: true
properties:
hibernate:
format_sql: true