support for multiple libraries

if there are some existing series, a library will be created and existing series attached to it
first version of a /libraries endpoint to add/remove/list libraries
some komga properties have been renamed or deprecated
This commit is contained in:
Gauthier Roebroeck 2019-09-27 17:26:10 +08:00
parent eae425e6d3
commit a9ff90596c
28 changed files with 670 additions and 104 deletions

4
gradlew vendored
View File

@ -125,8 +125,8 @@ if $darwin; then
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
fi
# For Cygwin, switch paths to Windows format before running java
if $cygwin ; then
# For Cygwin or MSYS, switch paths to Windows format before running java
if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
JAVACMD=`cygpath --unix "$JAVACMD"`

View File

@ -19,7 +19,7 @@ plugins {
}
group = "org.gotson"
version = "0.4.0"
version = "0.5.0"
val developmentOnly = configurations.create("developmentOnly")
configurations.runtimeClasspath.get().extendsFrom(developmentOnly)

View File

@ -0,0 +1,47 @@
package db.migration
import org.flywaydb.core.api.migration.BaseJavaMigration
import org.flywaydb.core.api.migration.Context
import org.springframework.jdbc.core.JdbcTemplate
import org.springframework.jdbc.datasource.SingleConnectionDataSource
import java.net.URI
import java.nio.file.Paths
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
class V20190926114415__create_library_from_series : BaseJavaMigration() {
override fun migrate(context: Context) {
val jdbcTemplate = JdbcTemplate(SingleConnectionDataSource(context.connection, true))
val urls = jdbcTemplate.queryForList("SELECT url FROM serie", String::class.java)
if (urls.isNotEmpty()) {
val rootFolder = findCommonDirPath(urls, '/')
val libraryId = jdbcTemplate.queryForObject("SELECT NEXTVAL('hibernate_sequence')", Int::class.java)
val libraryName = Paths.get(URI(rootFolder)).fileName
val now = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd hh:mm:ss"))
jdbcTemplate.execute("INSERT INTO library (ID, CREATED_DATE, LAST_MODIFIED_DATE, NAME, ROOT) VALUES ($libraryId, '$now', '$now', '$libraryName', '$rootFolder')")
jdbcTemplate.execute("UPDATE serie SET library_id = $libraryId")
}
}
}
// version 1.1.51 - https://www.rosettacode.org/wiki/Find_common_directory_path#Kotlin
fun findCommonDirPath(paths: List<String>, separator: Char): String {
if (paths.isEmpty()) return ""
if (paths.size == 1) return paths[0]
val splits = paths[0].split(separator)
val n = splits.size
val paths2 = paths.drop(1)
var k = 0
var common = ""
while (true) {
val prevCommon = common
common += if (k == 0) splits[0] else separator + splits[k]
if (!paths2.all { it.startsWith(common + separator) || it == common }) return prevCommon
if (++k == n) return common
}
}

View File

@ -1,4 +1,7 @@
package org.gotson.komga.domain.model
class MetadataNotReadyException : Exception()
class UnsupportedMediaTypeException(msg: String, val mediaType: String) : Exception(msg)
class UnsupportedMediaTypeException(message: String, val mediaType: String) : Exception(message)
class DirectoryNotFoundException(message: String) : Exception(message)
class DuplicateNameException(message: String) : Exception(message)
class PathContainedInPath(message: String) : Exception(message)

View File

@ -1,10 +1,32 @@
package org.gotson.komga.domain.model
import java.nio.file.FileSystem
import java.nio.file.FileSystems
import java.net.URL
import java.nio.file.Path
import java.nio.file.Paths
import javax.persistence.Column
import javax.persistence.Entity
import javax.persistence.GeneratedValue
import javax.persistence.Id
import javax.persistence.Table
import javax.validation.constraints.NotBlank
data class Library(
@Entity
@Table(name = "library")
class Library(
@NotBlank
@Column(name = "name", nullable = false, unique = true)
val name: String,
val root: String,
val fileSystem: FileSystem = FileSystems.getDefault()
)
@NotBlank
@Column(name = "root", nullable = false)
val root: URL
) : AuditableEntity() {
@Id
@GeneratedValue
@Column(name = "id", nullable = false, unique = true)
var id: Long = 0
constructor(name: String, root: String) : this(name, Paths.get(root).toUri().toURL())
}
fun Library.path(): Path = Paths.get(this.root.toURI())

View File

@ -10,10 +10,13 @@ import javax.persistence.Entity
import javax.persistence.FetchType
import javax.persistence.GeneratedValue
import javax.persistence.Id
import javax.persistence.JoinColumn
import javax.persistence.ManyToOne
import javax.persistence.OneToMany
import javax.persistence.OrderColumn
import javax.persistence.Table
import javax.validation.constraints.NotBlank
import javax.validation.constraints.NotNull
private val natSortComparator: Comparator<String> = CaseInsensitiveSimpleNaturalComparator.getInstance()
@ -38,6 +41,11 @@ class Serie(
@Column(name = "id", nullable = false, unique = true)
var id: Long = 0
@NotNull
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "library_id", nullable = false)
lateinit var library: Library
@OneToMany(cascade = [CascadeType.ALL], fetch = FetchType.LAZY, orphanRemoval = true, mappedBy = "serie")
@OrderColumn(name = "index")
private var _books: MutableList<Book> = mutableListOf()

View File

@ -0,0 +1,10 @@
package org.gotson.komga.domain.persistence
import org.gotson.komga.domain.model.Library
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.stereotype.Repository
@Repository
interface LibraryRepository : JpaRepository<Library, Long> {
fun existsByName(name: String): Boolean
}

View File

@ -8,6 +8,7 @@ import java.net.URL
@Repository
interface SerieRepository : JpaRepository<Serie, Long>, JpaSpecificationExecutor<Serie> {
fun findByUrlNotIn(urls: Iterable<URL>): List<Serie>
fun findByUrl(url: URL): Serie?
fun findByLibraryIdAndUrlNotIn(libraryId: Long, urls: Iterable<URL>): List<Serie>
fun findByLibraryIdAndUrl(libraryId: Long, url: URL): Serie?
fun deleteByLibraryId(libraryId: Long)
}

View File

@ -3,8 +3,8 @@ package org.gotson.komga.domain.service
import mu.KotlinLogging
import org.apache.commons.lang3.time.DurationFormatUtils
import org.gotson.komga.domain.model.Book
import org.gotson.komga.domain.model.Library
import org.gotson.komga.domain.persistence.BookRepository
import org.gotson.komga.domain.persistence.LibraryRepository
import org.springframework.scheduling.annotation.Async
import org.springframework.stereotype.Service
import kotlin.system.measureTimeMillis
@ -13,19 +13,27 @@ private val logger = KotlinLogging.logger {}
@Service
class AsyncOrchestrator(
private val libraryManager: LibraryManager,
private val libraryScanner: LibraryScanner,
private val libraryRepository: LibraryRepository,
private val bookRepository: BookRepository,
private val bookManager: BookManager
private val bookLifecyle: BookLifecyle
) {
@Async("periodicScanTaskExecutor")
fun scanAndParse(library: Library) {
logger.info { "Starting periodic library scan" }
libraryManager.scanRootFolder(library)
fun scanAndParse() {
logger.info { "Starting periodic libraries scan" }
val libraries = libraryRepository.findAll()
logger.info { "Starting periodic book parsing" }
libraryManager.parseUnparsedBooks()
if (libraries.isEmpty()) {
logger.info { "No libraries defined, nothing to scan" }
} else {
libraries.forEach {
libraryScanner.scanRootFolder(it)
}
logger.info { "Starting periodic book parsing" }
libraryScanner.parseUnparsedBooks()
}
}
@Async("regenerateThumbnailsTaskExecutor")
@ -44,7 +52,7 @@ class AsyncOrchestrator(
var sumOfTasksTime = 0L
measureTimeMillis {
sumOfTasksTime = books
.map { bookManager.regenerateThumbnailAndPersist(it) }
.map { bookLifecyle.regenerateThumbnailAndPersist(it) }
.map {
try {
it.get()

View File

@ -20,7 +20,7 @@ import kotlin.system.measureTimeMillis
private val logger = KotlinLogging.logger {}
@Service
class BookManager(
class BookLifecyle(
private val bookRepository: BookRepository,
private val bookParser: BookParser,
private val imageConverter: ImageConverter

View File

@ -0,0 +1,70 @@
package org.gotson.komga.domain.service
import mu.KotlinLogging
import org.gotson.komga.domain.model.DirectoryNotFoundException
import org.gotson.komga.domain.model.DuplicateNameException
import org.gotson.komga.domain.model.Library
import org.gotson.komga.domain.model.PathContainedInPath
import org.gotson.komga.domain.model.path
import org.gotson.komga.domain.persistence.LibraryRepository
import org.gotson.komga.domain.persistence.SerieRepository
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.io.FileNotFoundException
import java.nio.file.Files
import java.util.concurrent.RejectedExecutionException
private val logger = KotlinLogging.logger {}
@Service
class LibraryLifecycle(
private val libraryRepository: LibraryRepository,
private val serieRepository: SerieRepository,
private val asyncOrchestrator: AsyncOrchestrator
) {
@Throws(
FileNotFoundException::class,
DirectoryNotFoundException::class,
DuplicateNameException::class,
PathContainedInPath::class
)
fun addLibrary(library: Library): Library {
logger.info { "Adding new library: ${library.name} with root folder: ${library.root}" }
if (!Files.exists(library.path()))
throw FileNotFoundException("Library root folder does not exist: ${library.root}")
if (!Files.isDirectory(library.path()))
throw DirectoryNotFoundException("Library root folder is not a folder: ${library.root}")
if (libraryRepository.existsByName(library.name))
throw DuplicateNameException("Library name already exists")
libraryRepository.findAll().forEach {
if (library.path().startsWith(it.path()))
throw PathContainedInPath("Library path ${library.path()} is a child of existing library ${it.name}: ${it.path()}")
if (it.path().startsWith(library.path()))
throw PathContainedInPath("Library path ${library.path()} is a parent of existing library ${it.name}: ${it.path()}")
}
libraryRepository.save(library)
logger.info { "Trying to launch a scan for the newly added library: ${library.name}" }
try {
asyncOrchestrator.scanAndParse()
} catch (e: RejectedExecutionException) {
logger.warn { "Another scan is already running, skipping" }
}
return library
}
@Transactional
fun deleteLibrary(library: Library) {
logger.info { "Deleting library: ${library.name} with root folder: ${library.root}" }
serieRepository.deleteByLibraryId(library.id)
libraryRepository.delete(library)
}
}

View File

@ -8,31 +8,32 @@ import org.gotson.komga.domain.persistence.BookRepository
import org.gotson.komga.domain.persistence.SerieRepository
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.nio.file.Paths
import kotlin.system.measureTimeMillis
private val logger = KotlinLogging.logger {}
@Service
class LibraryManager(
class LibraryScanner(
private val fileSystemScanner: FileSystemScanner,
private val serieRepository: SerieRepository,
private val bookRepository: BookRepository,
private val bookManager: BookManager
private val bookLifecyle: BookLifecyle
) {
@Transactional
fun scanRootFolder(library: Library) {
logger.info { "Updating library: ${library.name}, root folder: ${library.root}" }
measureTimeMillis {
val series = fileSystemScanner.scanRootFolder(library.fileSystem.getPath(library.root))
val series = fileSystemScanner.scanRootFolder(Paths.get(library.root.toURI()))
// delete series that don't exist anymore
if (series.isEmpty()) {
logger.info { "Scan returned no series, deleting all existing series" }
serieRepository.deleteAll()
serieRepository.deleteByLibraryId(library.id)
} else {
series.map { it.url }.let { urls ->
serieRepository.findByUrlNotIn(urls).forEach {
serieRepository.findByLibraryIdAndUrlNotIn(library.id, urls).forEach {
logger.info { "Deleting serie not on disk anymore: $it" }
serieRepository.delete(it)
}
@ -40,12 +41,12 @@ class LibraryManager(
}
series.forEach { newSerie ->
val existingSerie = serieRepository.findByUrl(newSerie.url)
val existingSerie = serieRepository.findByLibraryIdAndUrl(library.id, newSerie.url)
// if serie does not exist, save it
if (existingSerie == null) {
logger.info { "Adding new serie: $newSerie" }
serieRepository.save(newSerie)
serieRepository.save(newSerie.also { it.library = library })
} else {
// if serie already exists, update it
if (newSerie.fileLastModified != existingSerie.fileLastModified) {
@ -80,7 +81,7 @@ class LibraryManager(
var sumOfTasksTime = 0L
measureTimeMillis {
sumOfTasksTime = booksToParse
.map { bookManager.parseAndPersist(it) }
.map { bookLifecyle.parseAndPersist(it) }
.map {
try {
it.get()

View File

@ -1,6 +1,7 @@
package org.gotson.komga.infrastructure.configuration
import org.springframework.boot.context.properties.ConfigurationProperties
import org.springframework.boot.context.properties.DeprecatedConfigurationProperty
import org.springframework.stereotype.Component
import org.springframework.validation.annotation.Validated
import javax.validation.constraints.Min
@ -10,9 +11,21 @@ import javax.validation.constraints.NotBlank
@ConfigurationProperties(prefix = "komga")
@Validated
class KomgaProperties {
@get:DeprecatedConfigurationProperty(reason = "As of v0.5.0 Komga supports multiple libraries, which must be created via the API")
var rootFolder: String = ""
@get:DeprecatedConfigurationProperty(
reason = "As of v0.5.0 Komga supports multiple libraries, which must be created via the API",
replacement = "komga.libraries-scan-cron"
)
var rootFolderScanCron: String = ""
var librariesScanCron: String = ""
get() {
if (field.isBlank()) return rootFolderScanCron
return field
}
@NotBlank
var userPassword: String = "user"

View File

@ -1,9 +1,7 @@
package org.gotson.komga.interfaces.scheduler
import mu.KotlinLogging
import org.gotson.komga.domain.model.Library
import org.gotson.komga.domain.service.AsyncOrchestrator
import org.gotson.komga.infrastructure.configuration.KomgaProperties
import org.springframework.boot.context.event.ApplicationReadyEvent
import org.springframework.context.annotation.Profile
import org.springframework.context.event.EventListener
@ -15,16 +13,15 @@ private val logger = KotlinLogging.logger {}
@Profile("dev", "prod")
@Controller
class RootScannerController(
private val asyncOrchestrator: AsyncOrchestrator,
private val komgaProperties: KomgaProperties
class PeriodicScannerController(
private val asyncOrchestrator: AsyncOrchestrator
) {
@EventListener(ApplicationReadyEvent::class)
@Scheduled(cron = "#{@komgaProperties.rootFolderScanCron ?: '-'}")
@Scheduled(cron = "#{@komgaProperties.librariesScanCron ?: '-'}")
fun scanRootFolder() {
try {
asyncOrchestrator.scanAndParse(Library("default", komgaProperties.rootFolder))
asyncOrchestrator.scanAndParse()
} catch (e: RejectedExecutionException) {
logger.warn { "Another scan is already running, skipping" }
}

View File

@ -0,0 +1,83 @@
package org.gotson.komga.interfaces.web.rest
import mu.KotlinLogging
import org.gotson.komga.domain.model.DirectoryNotFoundException
import org.gotson.komga.domain.model.DuplicateNameException
import org.gotson.komga.domain.model.Library
import org.gotson.komga.domain.model.PathContainedInPath
import org.gotson.komga.domain.persistence.LibraryRepository
import org.gotson.komga.domain.service.LibraryLifecycle
import org.springframework.data.repository.findByIdOrNull
import org.springframework.http.HttpStatus
import org.springframework.http.MediaType
import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.web.bind.annotation.DeleteMapping
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.ResponseStatus
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.server.ResponseStatusException
import java.io.FileNotFoundException
import javax.validation.Valid
import javax.validation.constraints.NotBlank
private val logger = KotlinLogging.logger {}
@RestController
@RequestMapping("api/v1/libraries", produces = [MediaType.APPLICATION_JSON_VALUE])
class LibraryController(
private val libraryLifecycle: LibraryLifecycle,
private val libraryRepository: LibraryRepository
) {
@GetMapping
fun getAll() =
libraryRepository.findAll().map { it.toDto() }
@PostMapping
@PreAuthorize("hasRole('ADMIN')")
fun addOne(
@Valid @RequestBody library: LibraryCreationDto
): LibraryDto =
try {
libraryLifecycle.addLibrary(Library(library.name, library.root)).toDto()
} catch (e: Exception) {
when (e) {
is FileNotFoundException,
is DirectoryNotFoundException,
is DuplicateNameException,
is PathContainedInPath ->
throw ResponseStatusException(HttpStatus.BAD_REQUEST, e.message)
else -> throw ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR)
}
}
@DeleteMapping("/{id}")
@PreAuthorize("hasRole('ADMIN')")
@ResponseStatus(HttpStatus.NO_CONTENT)
fun deleteOne(@PathVariable id: Long) {
libraryRepository.findByIdOrNull(id)?.let {
libraryLifecycle.deleteLibrary(it)
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
}
}
data class LibraryCreationDto(
@get:NotBlank val name: String,
@get:NotBlank val root: String
)
data class LibraryDto(
val id: Long,
val name: String,
val root: String
)
fun Library.toDto() = LibraryDto(
id = id,
name = name,
root = root.toString()
)

View File

@ -11,7 +11,7 @@ import org.gotson.komga.domain.model.Status
import org.gotson.komga.domain.model.UnsupportedMediaTypeException
import org.gotson.komga.domain.persistence.BookRepository
import org.gotson.komga.domain.persistence.SerieRepository
import org.gotson.komga.domain.service.BookManager
import org.gotson.komga.domain.service.BookLifecyle
import org.gotson.komga.infrastructure.image.ImageType
import org.springframework.data.domain.Page
import org.springframework.data.domain.PageRequest
@ -43,7 +43,7 @@ private val logger = KotlinLogging.logger {}
class SerieController(
private val serieRepository: SerieRepository,
private val bookRepository: BookRepository,
private val bookManager: BookManager
private val bookLifecyle: BookLifecyle
) {
@GetMapping
@ -189,7 +189,7 @@ class SerieController(
val pageNum = if (zeroBasedIndex) pageNumber + 1 else pageNumber
val pageContent = try {
bookManager.getBookPage(book, pageNum, convertFormat)
bookLifecyle.getBookPage(book, pageNum, convertFormat)
} catch (e: UnsupportedMediaTypeException) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, e.message)
} catch (e: Exception) {

View File

@ -2,10 +2,9 @@ logging.level:
web: DEBUG
# org.springframework.security.web.FilterChainProxy: DEBUG
komga:
root-folder: D:\\files\\pdf
threads:
parse: 1
# root-folder-scan-cron: "*/5 * * * * ?"
# libraries-scan-cron: "*/5 * * * * ?"
spring:
profiles:
include: flyway

View File

@ -4,5 +4,4 @@ spring:
datasource:
url: jdbc:h2:/config/database.h2;DB_CLOSE_DELAY=-1
komga:
root-folder: /books
root-folder-scan-cron: "0 */15 * * * ?"
libraries-scan-cron: "0 */15 * * * ?"

View File

@ -0,0 +1,18 @@
create table library
(
id bigint not null,
created_date timestamp not null,
last_modified_date timestamp not null,
name varchar not null,
root varchar not null,
primary key (id)
);
alter table library
add constraint uk_library_name unique (name);
alter table serie
add (library_id bigint);
alter table serie
add constraint fk_serie_library_library_id foreign key (library_id) references library (id);

View File

@ -0,0 +1,2 @@
alter table serie
alter column library_id set not null;

View File

@ -13,5 +13,9 @@ fun makeSerie(name: String, url: String = "file:/$name", books: List<Book> = lis
return Serie(name = name, url = URL(url), fileLastModified = LocalDateTime.now(), books = books.toMutableList())
}
fun makeLibrary(name: String = "default", url: String = "file:/$name"): Library {
return Library(name, URL(url))
}
fun makeBookPage(name: String) =
BookPage(name, "image/png")

View File

@ -2,13 +2,15 @@ package org.gotson.komga.domain.persistence
import org.assertj.core.api.Assertions.assertThat
import org.gotson.komga.domain.model.makeBook
import org.gotson.komga.domain.model.makeLibrary
import org.gotson.komga.domain.model.makeSerie
import org.junit.jupiter.api.AfterAll
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeAll
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
import java.time.LocalDateTime
@ -18,18 +20,30 @@ import java.time.LocalDateTime
@Transactional
class AuditableEntityTest(
@Autowired private val serieRepository: SerieRepository,
@Autowired private val entityManager: TestEntityManager
@Autowired private val libraryRepository: LibraryRepository
) {
private val library = makeLibrary()
@BeforeAll
fun `setup library`() {
libraryRepository.save(library)
}
@AfterAll
fun `teardown library`() {
libraryRepository.deleteAll()
}
@AfterEach
fun `clear repository`() {
entityManager.clear()
serieRepository.deleteAll()
}
@Test
fun `given serie with book when saving then created and modified date is also saved`() {
// given
val serie = makeSerie(name = "serie", books = listOf(makeBook("book1")))
val serie = makeSerie(name = "serie", books = listOf(makeBook("book1"))).also { it.library = library }
// when
serieRepository.save(serie)
@ -44,7 +58,7 @@ class AuditableEntityTest(
@Test
fun `given existing serie with book when updating serie only then created date is kept and modified date is changed for serie only`() {
// given
val serie = makeSerie(name = "serie", books = listOf(makeBook("book1")))
val serie = makeSerie(name = "serie", books = listOf(makeBook("book1"))).also { it.library = library }
serieRepository.save(serie)
@ -74,7 +88,7 @@ class AuditableEntityTest(
@Test
fun `given existing serie with book when updating book only then created date is kept and modified date is changed for book only`() {
// given
val serie = makeSerie(name = "serie", books = listOf(makeBook("book1")))
val serie = makeSerie(name = "serie", books = listOf(makeBook("book1"))).also { it.library = library }
serieRepository.save(serie)

View File

@ -4,13 +4,15 @@ 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.makeLibrary
import org.gotson.komga.domain.model.makeSerie
import org.junit.jupiter.api.AfterAll
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeAll
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.data.domain.PageRequest
import org.springframework.test.context.junit.jupiter.SpringExtension
import org.springframework.transaction.annotation.Transactional
@ -21,12 +23,24 @@ import org.springframework.transaction.annotation.Transactional
class BookRepositoryTest(
@Autowired private val serieRepository: SerieRepository,
@Autowired private val bookRepository: BookRepository,
@Autowired private val entityManager: TestEntityManager
@Autowired private val libraryRepository: LibraryRepository
) {
private val library = makeLibrary()
@BeforeAll
fun `setup library`() {
libraryRepository.save(library)
}
@AfterAll
fun `teardown library`() {
libraryRepository.deleteAll()
}
@AfterEach
fun `clear repository`() {
entityManager.clear()
serieRepository.deleteAll()
}
@Test
@ -34,7 +48,7 @@ class BookRepositoryTest(
val serie = makeSerie(
name = "serie",
books = (1..100 step 2).map { makeBook("$it") }
)
).also { it.library = library }
serieRepository.save(serie)
serie.books = serie.books.toMutableList().also { it.add(makeBook("2")) }
@ -56,7 +70,7 @@ class BookRepositoryTest(
val serie = makeSerie(
name = "serie",
books = (1..100 step 2).map { makeBook("$it") }
)
).also { it.library = library }
serieRepository.save(serie)
serie.books = serie.books.toMutableList().also { it.add(makeBook("2")) }

View File

@ -5,13 +5,15 @@ 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.makeBookPage
import org.gotson.komga.domain.model.makeLibrary
import org.gotson.komga.domain.model.makeSerie
import org.junit.jupiter.api.AfterAll
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeAll
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
@ -22,18 +24,30 @@ class PersistenceTest(
@Autowired private val serieRepository: SerieRepository,
@Autowired private val bookRepository: BookRepository,
@Autowired private val bookMetadataRepository: BookMetadataRepository,
@Autowired private val entityManager: TestEntityManager
@Autowired private val libraryRepository: LibraryRepository
) {
private val library = makeLibrary()
@BeforeAll
fun `setup library`() {
libraryRepository.save(library)
}
@AfterAll
fun `teardown library`() {
libraryRepository.deleteAll()
}
@AfterEach
fun `clear repository`() {
entityManager.clear()
serieRepository.deleteAll()
}
@Test
fun `given serie with book when saving then metadata is also saved`() {
// given
val serie = makeSerie(name = "serie", books = listOf(makeBook("book1")))
val serie = makeSerie(name = "serie", books = listOf(makeBook("book1"))).also { it.library = library }
// when
serieRepository.save(serie)
@ -52,7 +66,7 @@ class PersistenceTest(
makeBook("book 05"),
makeBook("book 6"),
makeBook("book 002")
))
)).also { it.library = library }
// when
serieRepository.save(serie)
@ -67,7 +81,7 @@ class PersistenceTest(
@Test
fun `given existing book when updating metadata then new metadata is saved`() {
// given
val serie = makeSerie(name = "serie", books = listOf(makeBook("book1")))
val serie = makeSerie(name = "serie", books = listOf(makeBook("book1"))).also { it.library = library }
serieRepository.save(serie)
// when
@ -91,7 +105,7 @@ class PersistenceTest(
@Test
fun `given book pages unordered when saving then pages are ordered with natural sort`() {
// given
val serie = makeSerie(name = "serie", books = listOf(makeBook("book1")))
val serie = makeSerie(name = "serie", books = listOf(makeBook("book1"))).also { it.library = library }
serieRepository.save(serie)
// when

View File

@ -0,0 +1,103 @@
package org.gotson.komga.domain.service
import org.assertj.core.api.Assertions.assertThat
import org.assertj.core.api.Assertions.catchThrowable
import org.gotson.komga.domain.model.DirectoryNotFoundException
import org.gotson.komga.domain.model.DuplicateNameException
import org.gotson.komga.domain.model.Library
import org.gotson.komga.domain.model.PathContainedInPath
import org.gotson.komga.domain.persistence.LibraryRepository
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.jdbc.AutoConfigureTestDatabase
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.test.context.junit.jupiter.SpringExtension
import java.io.FileNotFoundException
import java.nio.file.Files
@ExtendWith(SpringExtension::class)
@SpringBootTest
@AutoConfigureTestDatabase
class LibraryLifecycleTest(
@Autowired private val libraryRepository: LibraryRepository,
@Autowired private val libraryLifecycle: LibraryLifecycle
) {
@AfterEach
fun `clear repositories`() {
libraryRepository.deleteAll()
}
@Test
fun `when adding library with non-existent root folder then exception is thrown`() {
// when
val thrown = catchThrowable { libraryLifecycle.addLibrary(Library("test", "/non-existent")) }
// then
assertThat(thrown).isInstanceOf(FileNotFoundException::class.java)
}
@Test
fun `when adding library with non-directory root folder then exception is thrown`() {
// when
val thrown = catchThrowable {
libraryLifecycle.addLibrary(Library("test", Files.createTempFile(null, null).toUri().toURL()))
}
// then
assertThat(thrown).isInstanceOf(DirectoryNotFoundException::class.java)
}
@Test
fun `given existing library when adding library with same name then exception is thrown`() {
// given
libraryLifecycle.addLibrary(Library("test", Files.createTempDirectory(null).toUri().toURL()))
// when
val thrown = catchThrowable {
libraryLifecycle.addLibrary(Library("test", Files.createTempDirectory(null).toUri().toURL()))
}
// then
assertThat(thrown).isInstanceOf(DuplicateNameException::class.java)
}
@Test
fun `given existing library when adding library with root folder as child of existing library then exception is thrown`() {
// given
val parent = Files.createTempDirectory(null)
libraryLifecycle.addLibrary(Library("parent", parent.toUri().toURL()))
// when
val child = Files.createTempDirectory(parent, "")
val thrown = catchThrowable {
libraryLifecycle.addLibrary(Library("child", child.toUri().toURL()))
}
// then
assertThat(thrown)
.isInstanceOf(PathContainedInPath::class.java)
.hasMessageContaining("child")
}
@Test
fun `given existing library when adding library with root folder as parent of existing library then exception is thrown`() {
// given
val parent = Files.createTempDirectory(null)
val child = Files.createTempDirectory(parent, null)
libraryLifecycle.addLibrary(Library("child", child.toUri().toURL()))
// when
val thrown = catchThrowable {
libraryLifecycle.addLibrary(Library("parent", parent.toUri().toURL()))
}
// then
assertThat(thrown)
.isInstanceOf(PathContainedInPath::class.java)
.hasMessageContaining("parent")
}
}

View File

@ -5,12 +5,13 @@ import io.mockk.every
import io.mockk.verify
import org.assertj.core.api.Assertions.assertThat
import org.gotson.komga.domain.model.BookMetadata
import org.gotson.komga.domain.model.Library
import org.gotson.komga.domain.model.Status
import org.gotson.komga.domain.model.makeBook
import org.gotson.komga.domain.model.makeBookPage
import org.gotson.komga.domain.model.makeLibrary
import org.gotson.komga.domain.model.makeSerie
import org.gotson.komga.domain.persistence.BookRepository
import org.gotson.komga.domain.persistence.LibraryRepository
import org.gotson.komga.domain.persistence.SerieRepository
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Test
@ -20,17 +21,17 @@ import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabas
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.test.context.junit.jupiter.SpringExtension
import org.springframework.transaction.annotation.Transactional
import javax.persistence.EntityManager
import java.nio.file.Paths
@ExtendWith(SpringExtension::class)
@SpringBootTest
@AutoConfigureTestDatabase
class LibraryManagerTest(
class LibraryScannerTest(
@Autowired private val serieRepository: SerieRepository,
@Autowired private val libraryRepository: LibraryRepository,
@Autowired private val bookRepository: BookRepository,
@Autowired private val libraryManager: LibraryManager,
@Autowired private val bookManager: BookManager,
@Autowired private val entityManager: EntityManager
@Autowired private val libraryScanner: LibraryScanner,
@Autowired private val bookLifecyle: BookLifecyle
) {
@MockkBean
@ -39,17 +40,18 @@ class LibraryManagerTest(
@MockkBean
private lateinit var mockParser: BookParser
private val library = Library(name = "test", root = "/root")
@AfterEach
fun `clear repositories`() {
entityManager.clear()
serieRepository.deleteAll()
libraryRepository.deleteAll()
}
@Test
@Transactional
fun `given existing Serie when adding files and scanning then only updated Books are persisted`() {
//given
// given
val library = libraryRepository.save(makeLibrary())
val serie = makeSerie(name = "serie", books = listOf(makeBook("book1")))
val serieWithMoreBooks = makeSerie(name = "serie", books = listOf(makeBook("book1"), makeBook("book2")))
@ -57,10 +59,10 @@ class LibraryManagerTest(
listOf(serie),
listOf(serieWithMoreBooks)
)
libraryManager.scanRootFolder(library)
libraryScanner.scanRootFolder(library)
// when
libraryManager.scanRootFolder(library)
libraryScanner.scanRootFolder(library)
// then
val series = serieRepository.findAll()
@ -75,7 +77,9 @@ class LibraryManagerTest(
@Test
@Transactional
fun `given existing Serie when removing files and scanning then only updated Books are persisted`() {
//given
// given
val library = libraryRepository.save(makeLibrary())
val serie = makeSerie(name = "serie", books = listOf(makeBook("book1"), makeBook("book2")))
val serieWithLessBooks = makeSerie(name = "serie", books = listOf(makeBook("book1")))
@ -84,10 +88,10 @@ class LibraryManagerTest(
listOf(serie),
listOf(serieWithLessBooks)
)
libraryManager.scanRootFolder(library)
libraryScanner.scanRootFolder(library)
// when
libraryManager.scanRootFolder(library)
libraryScanner.scanRootFolder(library)
// then
val series = serieRepository.findAll()
@ -103,7 +107,9 @@ class LibraryManagerTest(
@Test
@Transactional
fun `given existing Serie when updating files and scanning then Books are updated`() {
//given
// given
val library = libraryRepository.save(makeLibrary())
val serie = makeSerie(name = "serie", books = listOf(makeBook("book1")))
val serieWithUpdatedBooks = makeSerie(name = "serie", books = listOf(makeBook("book1updated", "file:/book1")))
@ -112,10 +118,10 @@ class LibraryManagerTest(
listOf(serie),
listOf(serieWithUpdatedBooks)
)
libraryManager.scanRootFolder(library)
libraryScanner.scanRootFolder(library)
// when
libraryManager.scanRootFolder(library)
libraryScanner.scanRootFolder(library)
// then
val series = serieRepository.findAll()
@ -131,18 +137,18 @@ class LibraryManagerTest(
@Test
fun `given existing Serie when deleting all books and scanning then Series and Books are removed`() {
//given
val serie = makeSerie(name = "serie", books = listOf(makeBook("book1")))
// given
val library = libraryRepository.save(makeLibrary())
every { mockScanner.scanRootFolder(any()) }
.returnsMany(
listOf(serie),
listOf(makeSerie(name = "serie", books = listOf(makeBook("book1")))),
emptyList()
)
libraryManager.scanRootFolder(library)
libraryScanner.scanRootFolder(library)
// when
libraryManager.scanRootFolder(library)
libraryScanner.scanRootFolder(library)
// then
verify(exactly = 2) { mockScanner.scanRootFolder(any()) }
@ -151,22 +157,46 @@ class LibraryManagerTest(
assertThat(bookRepository.count()).describedAs("Book repository should be empty").isEqualTo(0)
}
@Test
fun `given existing Series when deleting all books of one serie and scanning then Serie and its Books are removed`() {
// given
val library = libraryRepository.save(makeLibrary())
every { mockScanner.scanRootFolder(any()) }
.returnsMany(
listOf(makeSerie(name = "serie", books = listOf(makeBook("book1"))), makeSerie(name = "serie2", books = listOf(makeBook("book2")))),
listOf(makeSerie(name = "serie", books = listOf(makeBook("book1"))))
)
libraryScanner.scanRootFolder(library)
// when
libraryScanner.scanRootFolder(library)
// then
verify(exactly = 2) { mockScanner.scanRootFolder(any()) }
assertThat(serieRepository.count()).describedAs("Serie repository should be empty").isEqualTo(1)
assertThat(bookRepository.count()).describedAs("Book repository should be empty").isEqualTo(1)
}
@Test
fun `given existing Book with metadata when rescanning then metadata is kept intact`() {
//given
// given
val library = libraryRepository.save(makeLibrary())
val book1 = makeBook("book1")
every { mockScanner.scanRootFolder(any()) }
.returnsMany(
listOf(makeSerie(name = "serie", books = listOf(book1))),
listOf(makeSerie(name = "serie", books = listOf(makeBook(name = "book1", fileLastModified = book1.fileLastModified))))
)
libraryManager.scanRootFolder(library)
libraryScanner.scanRootFolder(library)
every { mockParser.parse(any()) } returns BookMetadata(status = Status.READY, mediaType = "application/zip", pages = mutableListOf(makeBookPage("1.jpg"), makeBookPage("2.jpg")))
bookRepository.findAll().map { bookManager.parseAndPersist(it) }.map { it.get() }
bookRepository.findAll().map { bookLifecyle.parseAndPersist(it) }.map { it.get() }
// when
libraryManager.scanRootFolder(library)
libraryScanner.scanRootFolder(library)
// then
verify(exactly = 2) { mockScanner.scanRootFolder(any()) }
@ -179,4 +209,35 @@ class LibraryManagerTest(
assertThat(book.metadata.pages.map { it.fileName }).containsExactly("1.jpg", "2.jpg")
assertThat(book.lastModifiedDate).isNotEqualTo(book.createdDate)
}
@Test
fun `given 2 libraries when deleting all books of one and scanning then the other library is kept intact`() {
// given
val library1 = libraryRepository.save(makeLibrary(name = "library1"))
val library2 = libraryRepository.save(makeLibrary(name = "library2"))
every { mockScanner.scanRootFolder(Paths.get(library1.root.toURI())) } returns
listOf(makeSerie(name = "serie1", books = listOf(makeBook("book1"))))
every { mockScanner.scanRootFolder(Paths.get(library2.root.toURI())) }.returnsMany(
listOf(makeSerie(name = "serie2", books = listOf(makeBook("book2")))),
emptyList()
)
libraryScanner.scanRootFolder(library1)
libraryScanner.scanRootFolder(library2)
assertThat(serieRepository.count()).describedAs("Serie repository should be empty").isEqualTo(2)
assertThat(bookRepository.count()).describedAs("Book repository should be empty").isEqualTo(2)
// when
libraryScanner.scanRootFolder(library2)
// then
verify(exactly = 1) { mockScanner.scanRootFolder(Paths.get(library1.root.toURI())) }
verify(exactly = 2) { mockScanner.scanRootFolder(Paths.get(library2.root.toURI())) }
assertThat(serieRepository.count()).describedAs("Serie repository should be empty").isEqualTo(1)
assertThat(bookRepository.count()).describedAs("Book repository should be empty").isEqualTo(1)
}
}

View File

@ -0,0 +1,66 @@
package org.gotson.komga.interfaces.web.rest
import org.junit.jupiter.api.Nested
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.web.servlet.AutoConfigureMockMvc
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.http.MediaType
import org.springframework.security.test.context.support.WithAnonymousUser
import org.springframework.security.test.context.support.WithMockUser
import org.springframework.test.context.junit.jupiter.SpringExtension
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders
import org.springframework.test.web.servlet.result.MockMvcResultMatchers
@ExtendWith(SpringExtension::class)
@SpringBootTest
@AutoConfigureMockMvc(printOnlyOnFailure = false)
class LibraryControllerTest(
@Autowired private val mockMvc: MockMvc
) {
private val route = "/api/v1/libraries"
@Nested
inner class AnonymousUser {
@Test
@WithAnonymousUser
fun `given anonymous user when getAll then return unauthorized`() {
mockMvc.perform(MockMvcRequestBuilders.get(route))
.andExpect(MockMvcResultMatchers.status().isUnauthorized)
}
@Test
@WithAnonymousUser
fun `given anonymous user when addOne then return unauthorized`() {
val jsonString = """{"name":"test", "root": "C:\\Temp"}"""
mockMvc.perform(MockMvcRequestBuilders.post(route)
.contentType(MediaType.APPLICATION_JSON)
.content(jsonString))
.andExpect(MockMvcResultMatchers.status().isUnauthorized)
}
}
@Nested
inner class UserRole {
@Test
@WithMockUser(roles = ["USER"])
fun `given user with USER role when getAll then return ok`() {
mockMvc.perform(MockMvcRequestBuilders.get(route))
.andExpect(MockMvcResultMatchers.status().isOk)
}
@Test
@WithMockUser(roles = ["USER"])
fun `given user with USER role when addOne then return forbidden`() {
val jsonString = """{"name":"test", "root": "C:\\Temp"}"""
mockMvc.perform(MockMvcRequestBuilders.post(route)
.contentType(MediaType.APPLICATION_JSON)
.content(jsonString))
.andExpect(MockMvcResultMatchers.status().isForbidden)
}
}
}

View File

@ -1,15 +1,16 @@
package org.gotson.komga.interfaces.web
package org.gotson.komga.interfaces.web.rest
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.makeLibrary
import org.gotson.komga.domain.model.makeSerie
import org.gotson.komga.domain.persistence.BookRepository
import org.gotson.komga.domain.persistence.LibraryRepository
import org.gotson.komga.domain.persistence.SerieRepository
import org.gotson.komga.domain.service.BookManager
import org.gotson.komga.domain.service.LibraryManager
import org.hamcrest.CoreMatchers.equalTo
import org.junit.jupiter.api.AfterAll
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeAll
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith
import org.springframework.beans.factory.annotation.Autowired
@ -21,7 +22,6 @@ import org.springframework.test.context.junit.jupiter.SpringExtension
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders
import org.springframework.test.web.servlet.result.MockMvcResultMatchers
import javax.persistence.EntityManager
@ExtendWith(SpringExtension::class)
@SpringBootTest
@ -29,17 +29,26 @@ import javax.persistence.EntityManager
@AutoConfigureMockMvc(printOnlyOnFailure = false)
class SerieControllerTest(
@Autowired private val serieRepository: SerieRepository,
@Autowired private val bookRepository: BookRepository,
@Autowired private val libraryManager: LibraryManager,
@Autowired private val bookManager: BookManager,
@Autowired private val entityManager: EntityManager,
@Autowired private val libraryRepository: LibraryRepository,
@Autowired private val mockMvc: MockMvc
) {
private val library = makeLibrary()
@BeforeAll
fun `setup library`() {
libraryRepository.save(library)
}
@AfterAll
fun `teardown library`() {
libraryRepository.deleteAll()
}
@AfterEach
fun `clear repositories`() {
entityManager.clear()
fun `clear repository`() {
serieRepository.deleteAll()
}
@Test
@ -48,7 +57,7 @@ class SerieControllerTest(
val serie = makeSerie(
name = "serie",
books = listOf(makeBook("1"), makeBook("3"))
)
).also { it.library = library }
serieRepository.save(serie)
serie.books = serie.books.toMutableList().also { it.add(makeBook("2")) }
@ -67,7 +76,7 @@ class SerieControllerTest(
val serie = makeSerie(
name = "serie",
books = (1..100 step 2).map { makeBook("$it") }
)
).also { it.library = library }
serieRepository.save(serie)
serie.books = serie.books.toMutableList().also { it.add(makeBook("2")) }
@ -90,7 +99,7 @@ class SerieControllerTest(
val serie = makeSerie(
name = "serie",
books = (1..100 step 2).map { makeBook("$it") }
)
).also { it.library = library }
serieRepository.save(serie)
serie.books = serie.books.toMutableList().also { it.add(makeBook("2")) }