mirror of
https://github.com/gotson/komga.git
synced 2025-12-20 23:45:11 +01:00
feat(api): import books
Books can be imported directly into an existing Series
This commit is contained in:
parent
02b08932ba
commit
d41dcefd3e
12 changed files with 579 additions and 22 deletions
|
|
@ -2,31 +2,15 @@
|
|||
<code_scheme name="Project" version="173">
|
||||
<JetCodeStyleSettings>
|
||||
<option name="PACKAGES_TO_USE_STAR_IMPORTS">
|
||||
<value>
|
||||
<package name="kotlinx.android.synthetic" alias="false" withSubpackages="true" />
|
||||
</value>
|
||||
<value />
|
||||
</option>
|
||||
<option name="NAME_COUNT_TO_USE_STAR_IMPORT" value="2147483647" />
|
||||
<option name="NAME_COUNT_TO_USE_STAR_IMPORT_FOR_MEMBERS" value="2147483647" />
|
||||
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
|
||||
</JetCodeStyleSettings>
|
||||
<codeStyleSettings language="XML">
|
||||
<indentOptions>
|
||||
<option name="CONTINUATION_INDENT_SIZE" value="4" />
|
||||
</indentOptions>
|
||||
</codeStyleSettings>
|
||||
<codeStyleSettings language="kotlin">
|
||||
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
|
||||
<option name="LINE_COMMENT_AT_FIRST_COLUMN" value="false" />
|
||||
<option name="LINE_COMMENT_ADD_SPACE" value="true" />
|
||||
<option name="KEEP_BLANK_LINES_IN_DECLARATIONS" value="1" />
|
||||
<option name="KEEP_BLANK_LINES_IN_CODE" value="1" />
|
||||
<option name="KEEP_BLANK_LINES_BEFORE_RBRACE" value="0" />
|
||||
<option name="ALIGN_MULTILINE_PARAMETERS" value="false" />
|
||||
<indentOptions>
|
||||
<option name="INDENT_SIZE" value="2" />
|
||||
<option name="CONTINUATION_INDENT_SIZE" value="4" />
|
||||
</indentOptions>
|
||||
</codeStyleSettings>
|
||||
</code_scheme>
|
||||
</component>
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
package org.gotson.komga.application.tasks
|
||||
|
||||
import org.gotson.komga.domain.model.BookMetadataPatchCapability
|
||||
import org.gotson.komga.domain.model.CopyMode
|
||||
import java.io.Serializable
|
||||
|
||||
sealed class Task : Serializable {
|
||||
|
|
@ -29,4 +30,8 @@ sealed class Task : Serializable {
|
|||
data class AggregateSeriesMetadata(val seriesId: String) : Task() {
|
||||
override fun uniqueId() = "AGGREGATE_SERIES_METADATA_$seriesId"
|
||||
}
|
||||
|
||||
data class ImportBook(val sourceFile: String, val seriesId: String, val copyMode: CopyMode, val destinationName: String?, val upgradeBookId: String?) : Task() {
|
||||
override fun uniqueId(): String = "IMPORT_BOOK_${seriesId}_$sourceFile"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import mu.KotlinLogging
|
|||
import org.gotson.komga.domain.persistence.BookRepository
|
||||
import org.gotson.komga.domain.persistence.LibraryRepository
|
||||
import org.gotson.komga.domain.persistence.SeriesRepository
|
||||
import org.gotson.komga.domain.service.BookImporter
|
||||
import org.gotson.komga.domain.service.BookLifecycle
|
||||
import org.gotson.komga.domain.service.LibraryContentLifecycle
|
||||
import org.gotson.komga.domain.service.MetadataLifecycle
|
||||
|
|
@ -11,6 +12,7 @@ import org.gotson.komga.infrastructure.jms.QUEUE_TASKS
|
|||
import org.gotson.komga.infrastructure.jms.QUEUE_TASKS_SELECTOR
|
||||
import org.springframework.jms.annotation.JmsListener
|
||||
import org.springframework.stereotype.Service
|
||||
import java.nio.file.Paths
|
||||
import kotlin.time.measureTime
|
||||
|
||||
private val logger = KotlinLogging.logger {}
|
||||
|
|
@ -23,7 +25,8 @@ class TaskHandler(
|
|||
private val seriesRepository: SeriesRepository,
|
||||
private val libraryContentLifecycle: LibraryContentLifecycle,
|
||||
private val bookLifecycle: BookLifecycle,
|
||||
private val metadataLifecycle: MetadataLifecycle
|
||||
private val metadataLifecycle: MetadataLifecycle,
|
||||
private val bookImporter: BookImporter,
|
||||
) {
|
||||
|
||||
@JmsListener(destination = QUEUE_TASKS, selector = QUEUE_TASKS_SELECTOR)
|
||||
|
|
@ -68,6 +71,11 @@ class TaskHandler(
|
|||
seriesRepository.findByIdOrNull(task.seriesId)?.let {
|
||||
metadataLifecycle.aggregateMetadata(it)
|
||||
} ?: logger.warn { "Cannot execute task $task: Series does not exist" }
|
||||
|
||||
is Task.ImportBook ->
|
||||
seriesRepository.findByIdOrNull(task.seriesId)?.let { series ->
|
||||
bookImporter.importBook(Paths.get(task.sourceFile), series, task.copyMode, task.destinationName, task.upgradeBookId)
|
||||
} ?: logger.warn { "Cannot execute task $task: Series does not exist" }
|
||||
}
|
||||
}.also {
|
||||
logger.info { "Task $task executed in $it" }
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import mu.KotlinLogging
|
|||
import org.gotson.komga.domain.model.Book
|
||||
import org.gotson.komga.domain.model.BookMetadataPatchCapability
|
||||
import org.gotson.komga.domain.model.BookSearch
|
||||
import org.gotson.komga.domain.model.CopyMode
|
||||
import org.gotson.komga.domain.model.Library
|
||||
import org.gotson.komga.domain.model.Media
|
||||
import org.gotson.komga.domain.persistence.BookRepository
|
||||
|
|
@ -77,6 +78,10 @@ class TaskReceiver(
|
|||
submitTask(Task.AggregateSeriesMetadata(seriesId))
|
||||
}
|
||||
|
||||
fun importBook(sourceFile: String, seriesId: String, copyMode: CopyMode, destinationName: String?, upgradeBookId: String?) {
|
||||
submitTask(Task.ImportBook(sourceFile, seriesId, copyMode, destinationName, upgradeBookId))
|
||||
}
|
||||
|
||||
private fun submitTask(task: Task) {
|
||||
logger.info { "Sending task: $task" }
|
||||
jmsTemplate.convertAndSend(QUEUE_TASKS, task) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,7 @@
|
|||
package org.gotson.komga.domain.model
|
||||
|
||||
enum class CopyMode {
|
||||
MOVE,
|
||||
COPY,
|
||||
HARDLINK,
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ interface ReadProgressRepository {
|
|||
fun findAll(): Collection<ReadProgress>
|
||||
fun findByBookIdAndUserId(bookId: String, userId: String): ReadProgress?
|
||||
fun findByUserId(userId: String): Collection<ReadProgress>
|
||||
fun findByBookId(bookId: String): Collection<ReadProgress>
|
||||
|
||||
fun save(readProgress: ReadProgress)
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,138 @@
|
|||
package org.gotson.komga.domain.service
|
||||
|
||||
import mu.KotlinLogging
|
||||
import org.gotson.komga.application.tasks.TaskReceiver
|
||||
import org.gotson.komga.domain.model.CopyMode
|
||||
import org.gotson.komga.domain.model.Media
|
||||
import org.gotson.komga.domain.model.Series
|
||||
import org.gotson.komga.domain.persistence.BookRepository
|
||||
import org.gotson.komga.domain.persistence.MediaRepository
|
||||
import org.gotson.komga.domain.persistence.ReadListRepository
|
||||
import org.gotson.komga.domain.persistence.ReadProgressRepository
|
||||
import org.gotson.komga.infrastructure.language.toIndexedMap
|
||||
import org.springframework.stereotype.Service
|
||||
import java.io.FileNotFoundException
|
||||
import java.nio.file.FileAlreadyExistsException
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.NoSuchFileException
|
||||
import java.nio.file.Path
|
||||
import java.nio.file.Paths
|
||||
import kotlin.io.path.copyTo
|
||||
import kotlin.io.path.deleteExisting
|
||||
import kotlin.io.path.deleteIfExists
|
||||
import kotlin.io.path.exists
|
||||
import kotlin.io.path.extension
|
||||
import kotlin.io.path.moveTo
|
||||
import kotlin.io.path.notExists
|
||||
|
||||
private val logger = KotlinLogging.logger {}
|
||||
|
||||
@Service
|
||||
class BookImporter(
|
||||
private val bookLifecycle: BookLifecycle,
|
||||
private val fileSystemScanner: FileSystemScanner,
|
||||
private val seriesLifecycle: SeriesLifecycle,
|
||||
private val bookRepository: BookRepository,
|
||||
private val mediaRepository: MediaRepository,
|
||||
private val readProgressRepository: ReadProgressRepository,
|
||||
private val readListRepository: ReadListRepository,
|
||||
private val taskReceiver: TaskReceiver,
|
||||
) {
|
||||
|
||||
fun importBook(sourceFile: Path, series: Series, copyMode: CopyMode, destinationName: String? = null, upgradeBookId: String? = null) {
|
||||
if (sourceFile.notExists()) throw FileNotFoundException("File not found: $sourceFile")
|
||||
|
||||
val destFile = series.path().resolve(
|
||||
if (destinationName != null) Paths.get("$destinationName.${sourceFile.extension}").fileName.toString()
|
||||
else sourceFile.fileName.toString()
|
||||
)
|
||||
|
||||
val upgradedBookId =
|
||||
if (upgradeBookId != null) {
|
||||
bookRepository.findByIdOrNull(upgradeBookId)?.let {
|
||||
if (it.seriesId != series.id) throw IllegalArgumentException("Book to upgrade ($upgradeBookId) does not belong to series: $series")
|
||||
it.id
|
||||
}
|
||||
} else null
|
||||
val upgradedBookPath =
|
||||
if (upgradedBookId != null)
|
||||
bookRepository.findByIdOrNull(upgradedBookId)?.path()
|
||||
else null
|
||||
|
||||
var deletedUpgradedFile = false
|
||||
when {
|
||||
upgradedBookPath != null && destFile == upgradedBookPath -> {
|
||||
logger.info { "Deleting existing file: $upgradedBookPath" }
|
||||
try {
|
||||
upgradedBookPath.deleteExisting()
|
||||
deletedUpgradedFile = true
|
||||
} catch (e: NoSuchFileException) {
|
||||
logger.warn { "Could not delete upgraded book: $upgradedBookPath" }
|
||||
}
|
||||
}
|
||||
destFile.exists() -> throw FileAlreadyExistsException("Destination file already exists: $destFile")
|
||||
}
|
||||
|
||||
when (copyMode) {
|
||||
CopyMode.MOVE -> {
|
||||
logger.info { "Moving file $sourceFile to $destFile" }
|
||||
sourceFile.moveTo(destFile)
|
||||
}
|
||||
CopyMode.COPY -> {
|
||||
logger.info { "Copying file $sourceFile to $destFile" }
|
||||
sourceFile.copyTo(destFile)
|
||||
}
|
||||
CopyMode.HARDLINK -> try {
|
||||
logger.info { "Hardlink file $sourceFile to $destFile" }
|
||||
Files.createLink(destFile, sourceFile)
|
||||
} catch (e: UnsupportedOperationException) {
|
||||
logger.warn { "Filesystem does not support hardlinks, copying instead" }
|
||||
sourceFile.copyTo(destFile)
|
||||
}
|
||||
}
|
||||
|
||||
val importedBook = fileSystemScanner.scanFile(destFile)
|
||||
?.copy(libraryId = series.libraryId)
|
||||
?: throw IllegalStateException("Newly imported book could not be scanned: $destFile")
|
||||
|
||||
seriesLifecycle.addBooks(series, listOf(importedBook))
|
||||
|
||||
if (upgradedBookId != null) {
|
||||
// copy media and mark it as outdated
|
||||
mediaRepository.findById(upgradedBookId).let {
|
||||
mediaRepository.update(
|
||||
it.copy(
|
||||
bookId = importedBook.id,
|
||||
status = Media.Status.OUTDATED,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// copy read progress
|
||||
readProgressRepository.findByBookId(upgradedBookId)
|
||||
.map { it.copy(bookId = importedBook.id) }
|
||||
.forEach { readProgressRepository.save(it) }
|
||||
|
||||
// replace upgraded book by imported book in read lists
|
||||
readListRepository.findAllByBook(upgradedBookId, filterOnLibraryIds = null)
|
||||
.forEach { rl ->
|
||||
readListRepository.update(
|
||||
rl.copy(
|
||||
bookIds = rl.bookIds.values.map { if (it == upgradedBookId) importedBook.id else it }.toIndexedMap()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// delete upgraded book file on disk if it has not been replaced earlier
|
||||
if (upgradedBookPath != null && !deletedUpgradedFile && upgradedBookPath.deleteIfExists())
|
||||
logger.info { "Deleted existing file: $upgradedBookPath" }
|
||||
|
||||
// delete upgraded book
|
||||
bookLifecycle.deleteOne(upgradedBookId)
|
||||
}
|
||||
|
||||
seriesLifecycle.sortBooks(series)
|
||||
|
||||
taskReceiver.analyzeBook(importedBook)
|
||||
}
|
||||
}
|
||||
|
|
@ -33,6 +33,12 @@ class ReadProgressDao(
|
|||
.fetchInto(r)
|
||||
.map { it.toDomain() }
|
||||
|
||||
override fun findByBookId(bookId: String): Collection<ReadProgress> =
|
||||
dsl.selectFrom(r)
|
||||
.where(r.BOOK_ID.eq(bookId))
|
||||
.fetchInto(r)
|
||||
.map { it.toDomain() }
|
||||
|
||||
override fun save(readProgress: ReadProgress) {
|
||||
dsl.insertInto(r, r.BOOK_ID, r.USER_ID, r.PAGE, r.COMPLETED)
|
||||
.values(readProgress.bookId, readProgress.userId, readProgress.page, readProgress.completed)
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ import org.gotson.komga.infrastructure.swagger.PageableWithoutSortAsQueryParam
|
|||
import org.gotson.komga.infrastructure.web.getMediaTypeOrDefault
|
||||
import org.gotson.komga.infrastructure.web.setCachePrivate
|
||||
import org.gotson.komga.interfaces.rest.dto.BookDto
|
||||
import org.gotson.komga.interfaces.rest.dto.BookImportBatchDto
|
||||
import org.gotson.komga.interfaces.rest.dto.BookMetadataUpdateDto
|
||||
import org.gotson.komga.interfaces.rest.dto.PageDto
|
||||
import org.gotson.komga.interfaces.rest.dto.ReadListDto
|
||||
|
|
@ -499,6 +500,27 @@ class BookController(
|
|||
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
|
||||
}
|
||||
|
||||
@PostMapping("api/v1/books/import")
|
||||
@PreAuthorize("hasRole('$ROLE_ADMIN')")
|
||||
@ResponseStatus(HttpStatus.ACCEPTED)
|
||||
fun importBooks(
|
||||
@RequestBody bookImportBatch: BookImportBatchDto,
|
||||
) {
|
||||
bookImportBatch.books.forEach {
|
||||
try {
|
||||
taskReceiver.importBook(
|
||||
sourceFile = it.sourceFile,
|
||||
seriesId = it.seriesId,
|
||||
copyMode = bookImportBatch.copyMode,
|
||||
destinationName = it.destinationName,
|
||||
upgradeBookId = it.upgradeBookId,
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error(e) { "Error while creating import task for: $it" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun ResponseEntity.BodyBuilder.setNotModified(media: Media) =
|
||||
this.setCachePrivate().lastModified(getBookLastModified(media))
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,15 @@
|
|||
package org.gotson.komga.interfaces.rest.dto
|
||||
|
||||
import org.gotson.komga.domain.model.CopyMode
|
||||
|
||||
data class BookImportBatchDto(
|
||||
val books: List<BookImportDto> = emptyList(),
|
||||
val copyMode: CopyMode,
|
||||
)
|
||||
|
||||
data class BookImportDto(
|
||||
val sourceFile: String,
|
||||
val seriesId: String,
|
||||
val upgradeBookId: String? = null,
|
||||
val destinationName: String? = null,
|
||||
)
|
||||
|
|
@ -4,22 +4,22 @@ import com.github.f4b6a3.tsid.TsidCreator
|
|||
import java.net.URL
|
||||
import java.time.LocalDateTime
|
||||
|
||||
fun makeBook(name: String, fileLastModified: LocalDateTime = LocalDateTime.now(), libraryId: String = "", seriesId: String = ""): Book {
|
||||
fun makeBook(name: String, fileLastModified: LocalDateTime = LocalDateTime.now(), libraryId: String = "", seriesId: String = "", url: URL? = null): Book {
|
||||
Thread.sleep(5)
|
||||
return Book(
|
||||
name = name,
|
||||
url = URL("file:/$name"),
|
||||
url = url ?: URL("file:/$name"),
|
||||
fileLastModified = fileLastModified,
|
||||
libraryId = libraryId,
|
||||
seriesId = seriesId
|
||||
)
|
||||
}
|
||||
|
||||
fun makeSeries(name: String, libraryId: String = ""): Series {
|
||||
fun makeSeries(name: String, libraryId: String = "", url: URL? = null): Series {
|
||||
Thread.sleep(5)
|
||||
return Series(
|
||||
name = name,
|
||||
url = URL("file:/$name"),
|
||||
url = url ?: URL("file:/$name"),
|
||||
fileLastModified = LocalDateTime.now(),
|
||||
libraryId = libraryId
|
||||
)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,366 @@
|
|||
package org.gotson.komga.domain.service
|
||||
|
||||
import com.google.common.jimfs.Configuration
|
||||
import com.google.common.jimfs.Jimfs
|
||||
import com.ninjasquad.springmockk.MockkBean
|
||||
import io.mockk.Runs
|
||||
import io.mockk.every
|
||||
import io.mockk.just
|
||||
import io.mockk.verify
|
||||
import org.assertj.core.api.Assertions
|
||||
import org.assertj.core.api.Assertions.assertThat
|
||||
import org.gotson.komga.application.tasks.TaskReceiver
|
||||
import org.gotson.komga.domain.model.Book
|
||||
import org.gotson.komga.domain.model.BookPage
|
||||
import org.gotson.komga.domain.model.CopyMode
|
||||
import org.gotson.komga.domain.model.KomgaUser
|
||||
import org.gotson.komga.domain.model.Media
|
||||
import org.gotson.komga.domain.model.ReadList
|
||||
import org.gotson.komga.domain.model.makeBook
|
||||
import org.gotson.komga.domain.model.makeLibrary
|
||||
import org.gotson.komga.domain.model.makeSeries
|
||||
import org.gotson.komga.domain.persistence.BookRepository
|
||||
import org.gotson.komga.domain.persistence.KomgaUserRepository
|
||||
import org.gotson.komga.domain.persistence.LibraryRepository
|
||||
import org.gotson.komga.domain.persistence.MediaRepository
|
||||
import org.gotson.komga.domain.persistence.ReadListRepository
|
||||
import org.gotson.komga.domain.persistence.ReadProgressRepository
|
||||
import org.gotson.komga.domain.persistence.SeriesRepository
|
||||
import org.gotson.komga.infrastructure.language.toIndexedMap
|
||||
import org.junit.jupiter.api.AfterAll
|
||||
import org.junit.jupiter.api.AfterEach
|
||||
import org.junit.jupiter.api.BeforeAll
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
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.context.SpringBootTest
|
||||
import org.springframework.test.context.junit.jupiter.SpringExtension
|
||||
import java.io.FileNotFoundException
|
||||
import java.nio.file.FileAlreadyExistsException
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Paths
|
||||
import kotlin.io.path.createDirectories
|
||||
import kotlin.io.path.createDirectory
|
||||
import kotlin.io.path.createFile
|
||||
|
||||
@ExtendWith(SpringExtension::class)
|
||||
@SpringBootTest
|
||||
class BookImporterTest(
|
||||
@Autowired private val bookImporter: BookImporter,
|
||||
@Autowired private val bookRepository: BookRepository,
|
||||
@Autowired private val bookLifecycle: BookLifecycle,
|
||||
@Autowired private val readProgressRepository: ReadProgressRepository,
|
||||
@Autowired private val libraryRepository: LibraryRepository,
|
||||
@Autowired private val seriesRepository: SeriesRepository,
|
||||
@Autowired private val seriesLifecycle: SeriesLifecycle,
|
||||
@Autowired private val mediaRepository: MediaRepository,
|
||||
@Autowired private val userRepository: KomgaUserRepository,
|
||||
@Autowired private val readListRepository: ReadListRepository,
|
||||
@Autowired private val readListLifecycle: ReadListLifecycle,
|
||||
) {
|
||||
|
||||
@MockkBean
|
||||
private lateinit var mockTaskReceiver: TaskReceiver
|
||||
|
||||
private val library = makeLibrary("lib", "file:/library")
|
||||
private val user1 = KomgaUser("user1@example.org", "", false)
|
||||
private val user2 = KomgaUser("user2@example.org", "", false)
|
||||
|
||||
@BeforeAll
|
||||
fun init() {
|
||||
libraryRepository.insert(library)
|
||||
|
||||
userRepository.insert(user1)
|
||||
userRepository.insert(user2)
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
fun beforeEach() {
|
||||
every { mockTaskReceiver.analyzeBook(any<Book>()) } just Runs
|
||||
every { mockTaskReceiver.refreshBookMetadata(any<String>(), any()) } just Runs
|
||||
}
|
||||
|
||||
@AfterAll
|
||||
fun teardown() {
|
||||
libraryRepository.deleteAll()
|
||||
userRepository.deleteAll()
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
fun `clear repository`() {
|
||||
seriesLifecycle.deleteMany(seriesRepository.findAll().map { it.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `given non-existent source file when importing then exception is thrown`() {
|
||||
// given
|
||||
val sourceFile = Paths.get("/non-existent")
|
||||
|
||||
// when
|
||||
val thrown = Assertions.catchThrowable {
|
||||
bookImporter.importBook(sourceFile, makeSeries("a series"), CopyMode.COPY)
|
||||
}
|
||||
|
||||
// then
|
||||
assertThat(thrown).isInstanceOf(FileNotFoundException::class.java)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `given existing target when importing then exception is thrown`() {
|
||||
Jimfs.newFileSystem(Configuration.unix()).use { fs ->
|
||||
// given
|
||||
val sourceDir = fs.getPath("/source").createDirectory()
|
||||
val sourceFile = sourceDir.resolve("source.cbz").createFile()
|
||||
val destDir = fs.getPath("/dest").createDirectory()
|
||||
destDir.resolve("source.cbz").createFile()
|
||||
|
||||
val series = makeSeries("dest", url = destDir.toUri().toURL())
|
||||
|
||||
// when
|
||||
val thrown = Assertions.catchThrowable {
|
||||
bookImporter.importBook(sourceFile, series, CopyMode.COPY)
|
||||
}
|
||||
|
||||
// then
|
||||
assertThat(thrown).isInstanceOf(FileAlreadyExistsException::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `given existing target when importing with destination name then exception is thrown`() {
|
||||
Jimfs.newFileSystem(Configuration.unix()).use { fs ->
|
||||
// given
|
||||
val sourceDir = fs.getPath("/source").createDirectory()
|
||||
val sourceFile = sourceDir.resolve("source.cbz").createFile()
|
||||
val destDir = fs.getPath("/dest").createDirectory()
|
||||
destDir.resolve("dest.cbz").createFile()
|
||||
|
||||
val series = makeSeries("dest").copy(url = destDir.toUri().toURL())
|
||||
|
||||
// when
|
||||
val thrown = Assertions.catchThrowable {
|
||||
bookImporter.importBook(sourceFile, series, CopyMode.COPY, destinationName = "dest")
|
||||
}
|
||||
|
||||
// then
|
||||
assertThat(thrown).isInstanceOf(FileAlreadyExistsException::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `given book when importing then book is imported and series is sorted`() {
|
||||
Jimfs.newFileSystem(Configuration.unix()).use { fs ->
|
||||
// given
|
||||
val sourceDir = fs.getPath("/source").createDirectory()
|
||||
val sourceFile = sourceDir.resolve("2.cbz").createFile()
|
||||
val destDir = fs.getPath("/library/series").createDirectories()
|
||||
|
||||
val existingBooks = listOf(
|
||||
makeBook("1", libraryId = library.id),
|
||||
makeBook("3", libraryId = library.id),
|
||||
)
|
||||
val series = makeSeries("series", url = destDir.toUri().toURL(), libraryId = library.id)
|
||||
.also { series ->
|
||||
seriesLifecycle.createSeries(series)
|
||||
seriesLifecycle.addBooks(series, existingBooks)
|
||||
seriesLifecycle.sortBooks(series)
|
||||
}
|
||||
|
||||
// when
|
||||
bookImporter.importBook(sourceFile, series, CopyMode.COPY)
|
||||
|
||||
// then
|
||||
val books = bookRepository.findBySeriesId(series.id).sortedBy { it.number }
|
||||
assertThat(books).hasSize(3)
|
||||
assertThat(books[0].id).isEqualTo(existingBooks[0].id)
|
||||
assertThat(books[2].id).isEqualTo(existingBooks[1].id)
|
||||
|
||||
with(books[1]) {
|
||||
assertThat(id)
|
||||
.isNotEqualTo(existingBooks[0].id)
|
||||
.isNotEqualTo(existingBooks[1].id)
|
||||
assertThat(number).isEqualTo(2)
|
||||
assertThat(name).isEqualTo("2")
|
||||
|
||||
val newMedia = mediaRepository.findById(id)
|
||||
assertThat(newMedia.status).isEqualTo(Media.Status.UNKNOWN)
|
||||
}
|
||||
|
||||
verify(exactly = 1) { mockTaskReceiver.analyzeBook(any<Book>()) }
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `given existing book when importing with upgrade then existing book is deleted`() {
|
||||
Jimfs.newFileSystem(Configuration.unix()).use { fs ->
|
||||
// given
|
||||
val sourceDir = fs.getPath("/source").createDirectory()
|
||||
val sourceFile = sourceDir.resolve("source.cbz").createFile()
|
||||
val destDir = fs.getPath("/library/series").createDirectories()
|
||||
val existingFile = destDir.resolve("4.cbz").createFile()
|
||||
|
||||
val bookToUpgrade = makeBook("2", libraryId = library.id, url = existingFile.toUri().toURL())
|
||||
val otherBooks = listOf(
|
||||
makeBook("1", libraryId = library.id),
|
||||
makeBook("3", libraryId = library.id),
|
||||
)
|
||||
val series = makeSeries("series", url = destDir.toUri().toURL(), libraryId = library.id)
|
||||
.also { series ->
|
||||
seriesLifecycle.createSeries(series)
|
||||
seriesLifecycle.addBooks(series, listOf(bookToUpgrade) + otherBooks)
|
||||
seriesLifecycle.sortBooks(series)
|
||||
}
|
||||
|
||||
// when
|
||||
bookImporter.importBook(sourceFile, series, CopyMode.MOVE, upgradeBookId = bookToUpgrade.id)
|
||||
|
||||
// then
|
||||
val books = bookRepository.findBySeriesId(series.id).sortedBy { it.number }
|
||||
assertThat(books).hasSize(3)
|
||||
assertThat(books[0].id).isEqualTo(otherBooks[0].id)
|
||||
assertThat(books[1].id).isEqualTo(otherBooks[1].id)
|
||||
assertThat(books[2].id).isNotEqualTo(bookToUpgrade.id)
|
||||
|
||||
assertThat(bookRepository.findByIdOrNull(bookToUpgrade.id)).isNull()
|
||||
|
||||
val upgradedMedia = mediaRepository.findById(books[2].id)
|
||||
assertThat(upgradedMedia.status).isEqualTo(Media.Status.OUTDATED)
|
||||
|
||||
assertThat(Files.notExists(sourceFile)).isTrue
|
||||
|
||||
verify(exactly = 1) { mockTaskReceiver.analyzeBook(any<Book>()) }
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `given existing book when importing with upgrade and same name then existing book is replaced`() {
|
||||
Jimfs.newFileSystem(Configuration.unix()).use { fs ->
|
||||
// given
|
||||
val sourceDir = fs.getPath("/source").createDirectory()
|
||||
val sourceFile = sourceDir.resolve("source.cbz").createFile()
|
||||
val destDir = fs.getPath("/library/series").createDirectories()
|
||||
val existingFile = destDir.resolve("2.cbz").createFile()
|
||||
|
||||
val bookToUpgrade = makeBook("2", libraryId = library.id, url = existingFile.toUri().toURL())
|
||||
val otherBooks = listOf(
|
||||
makeBook("1", libraryId = library.id),
|
||||
makeBook("3", libraryId = library.id),
|
||||
)
|
||||
val series = makeSeries("series", url = destDir.toUri().toURL(), libraryId = library.id)
|
||||
.also { series ->
|
||||
seriesLifecycle.createSeries(series)
|
||||
seriesLifecycle.addBooks(series, listOf(bookToUpgrade) + otherBooks)
|
||||
seriesLifecycle.sortBooks(series)
|
||||
}
|
||||
|
||||
// when
|
||||
bookImporter.importBook(sourceFile, series, CopyMode.COPY, destinationName = "2", upgradeBookId = bookToUpgrade.id)
|
||||
|
||||
// then
|
||||
val books = bookRepository.findBySeriesId(series.id).sortedBy { it.number }
|
||||
assertThat(books).hasSize(3)
|
||||
assertThat(books[0].id).isEqualTo(otherBooks[0].id)
|
||||
assertThat(books[1].id).isNotEqualTo(bookToUpgrade.id)
|
||||
assertThat(books[2].id).isEqualTo(otherBooks[1].id)
|
||||
|
||||
assertThat(bookRepository.findByIdOrNull(bookToUpgrade.id)).isNull()
|
||||
|
||||
val upgradedMedia = mediaRepository.findById(books[1].id)
|
||||
assertThat(upgradedMedia.status).isEqualTo(Media.Status.OUTDATED)
|
||||
|
||||
assertThat(Files.exists(sourceFile)).isTrue
|
||||
|
||||
verify(exactly = 1) { mockTaskReceiver.analyzeBook(any<Book>()) }
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `given book with read progress when importing with upgrade then read progress is kept`() {
|
||||
Jimfs.newFileSystem(Configuration.unix()).use { fs ->
|
||||
// given
|
||||
val sourceDir = fs.getPath("/source").createDirectory()
|
||||
val sourceFile = sourceDir.resolve("source.cbz").createFile()
|
||||
val destDir = fs.getPath("/library/series").createDirectories()
|
||||
val existingFile = destDir.resolve("1.cbz").createFile()
|
||||
|
||||
val bookToUpgrade = makeBook("1", libraryId = library.id, url = existingFile.toUri().toURL())
|
||||
val series = makeSeries("series", url = destDir.toUri().toURL(), libraryId = library.id)
|
||||
.also { series ->
|
||||
seriesLifecycle.createSeries(series)
|
||||
seriesLifecycle.addBooks(series, listOf(bookToUpgrade))
|
||||
seriesLifecycle.sortBooks(series)
|
||||
}
|
||||
|
||||
mediaRepository.findById(bookToUpgrade.id).let { media ->
|
||||
mediaRepository.update(
|
||||
media.copy(
|
||||
status = Media.Status.READY,
|
||||
pages = (1..10).map { BookPage("$it", "image/jpeg") }
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
bookLifecycle.markReadProgressCompleted(bookToUpgrade.id, user1)
|
||||
bookLifecycle.markReadProgress(bookToUpgrade, user2, 4)
|
||||
|
||||
// when
|
||||
bookImporter.importBook(sourceFile, series, CopyMode.MOVE, upgradeBookId = bookToUpgrade.id)
|
||||
|
||||
// then
|
||||
val books = bookRepository.findBySeriesId(series.id).sortedBy { it.number }
|
||||
assertThat(books).hasSize(1)
|
||||
|
||||
val progress = readProgressRepository.findByBookId(books[0].id)
|
||||
assertThat(progress).hasSize(2)
|
||||
with(progress.find { it.userId == user1.id }!!) {
|
||||
assertThat(completed).isTrue
|
||||
}
|
||||
with(progress.find { it.userId == user2.id }!!) {
|
||||
assertThat(completed).isFalse
|
||||
assertThat(page).isEqualTo(4)
|
||||
}
|
||||
|
||||
verify(exactly = 1) { mockTaskReceiver.analyzeBook(any<Book>()) }
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `given book part of a read list when importing with upgrade then imported book replaces upgraded book in the read list`() {
|
||||
Jimfs.newFileSystem(Configuration.unix()).use { fs ->
|
||||
// given
|
||||
val sourceDir = fs.getPath("/source").createDirectory()
|
||||
val sourceFile = sourceDir.resolve("source.cbz").createFile()
|
||||
val destDir = fs.getPath("/library/series").createDirectories()
|
||||
val existingFile = destDir.resolve("1.cbz").createFile()
|
||||
|
||||
val bookToUpgrade = makeBook("1", libraryId = library.id, url = existingFile.toUri().toURL())
|
||||
val series = makeSeries("series", url = destDir.toUri().toURL(), libraryId = library.id)
|
||||
.also { series ->
|
||||
seriesLifecycle.createSeries(series)
|
||||
seriesLifecycle.addBooks(series, listOf(bookToUpgrade))
|
||||
seriesLifecycle.sortBooks(series)
|
||||
}
|
||||
|
||||
val readList = ReadList(
|
||||
name = "readlist",
|
||||
bookIds = listOf(bookToUpgrade.id).toIndexedMap(),
|
||||
)
|
||||
readListLifecycle.addReadList(readList)
|
||||
|
||||
// when
|
||||
bookImporter.importBook(sourceFile, series, CopyMode.MOVE, upgradeBookId = bookToUpgrade.id)
|
||||
|
||||
// then
|
||||
val books = bookRepository.findBySeriesId(series.id).sortedBy { it.number }
|
||||
assertThat(books).hasSize(1)
|
||||
|
||||
with(readListRepository.findByIdOrNull(readList.id)!!) {
|
||||
assertThat(bookIds).hasSize(1)
|
||||
assertThat(bookIds[0]).isEqualTo(books[0].id)
|
||||
}
|
||||
|
||||
verify(exactly = 1) { mockTaskReceiver.analyzeBook(any<Book>()) }
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue