diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml
index 2acbb5bd6..ea881c68b 100644
--- a/.idea/codeStyles/Project.xml
+++ b/.idea/codeStyles/Project.xml
@@ -2,31 +2,15 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/komga/src/main/kotlin/org/gotson/komga/application/tasks/Task.kt b/komga/src/main/kotlin/org/gotson/komga/application/tasks/Task.kt
index e9e0e1059..6885b0b4b 100644
--- a/komga/src/main/kotlin/org/gotson/komga/application/tasks/Task.kt
+++ b/komga/src/main/kotlin/org/gotson/komga/application/tasks/Task.kt
@@ -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"
+ }
}
diff --git a/komga/src/main/kotlin/org/gotson/komga/application/tasks/TaskHandler.kt b/komga/src/main/kotlin/org/gotson/komga/application/tasks/TaskHandler.kt
index 016c63734..bff748aab 100644
--- a/komga/src/main/kotlin/org/gotson/komga/application/tasks/TaskHandler.kt
+++ b/komga/src/main/kotlin/org/gotson/komga/application/tasks/TaskHandler.kt
@@ -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" }
diff --git a/komga/src/main/kotlin/org/gotson/komga/application/tasks/TaskReceiver.kt b/komga/src/main/kotlin/org/gotson/komga/application/tasks/TaskReceiver.kt
index 0b5d6d2f9..5928fdae4 100644
--- a/komga/src/main/kotlin/org/gotson/komga/application/tasks/TaskReceiver.kt
+++ b/komga/src/main/kotlin/org/gotson/komga/application/tasks/TaskReceiver.kt
@@ -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) {
diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/model/CopyMode.kt b/komga/src/main/kotlin/org/gotson/komga/domain/model/CopyMode.kt
new file mode 100644
index 000000000..f885b9de8
--- /dev/null
+++ b/komga/src/main/kotlin/org/gotson/komga/domain/model/CopyMode.kt
@@ -0,0 +1,7 @@
+package org.gotson.komga.domain.model
+
+enum class CopyMode {
+ MOVE,
+ COPY,
+ HARDLINK,
+}
diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/persistence/ReadProgressRepository.kt b/komga/src/main/kotlin/org/gotson/komga/domain/persistence/ReadProgressRepository.kt
index fb7b6cb2e..69fed2ccd 100644
--- a/komga/src/main/kotlin/org/gotson/komga/domain/persistence/ReadProgressRepository.kt
+++ b/komga/src/main/kotlin/org/gotson/komga/domain/persistence/ReadProgressRepository.kt
@@ -6,6 +6,7 @@ interface ReadProgressRepository {
fun findAll(): Collection
fun findByBookIdAndUserId(bookId: String, userId: String): ReadProgress?
fun findByUserId(userId: String): Collection
+ fun findByBookId(bookId: String): Collection
fun save(readProgress: ReadProgress)
diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/service/BookImporter.kt b/komga/src/main/kotlin/org/gotson/komga/domain/service/BookImporter.kt
new file mode 100644
index 000000000..b2b0660a8
--- /dev/null
+++ b/komga/src/main/kotlin/org/gotson/komga/domain/service/BookImporter.kt
@@ -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)
+ }
+}
diff --git a/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/ReadProgressDao.kt b/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/ReadProgressDao.kt
index 16bda71d1..f7463870f 100644
--- a/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/ReadProgressDao.kt
+++ b/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/ReadProgressDao.kt
@@ -33,6 +33,12 @@ class ReadProgressDao(
.fetchInto(r)
.map { it.toDomain() }
+ override fun findByBookId(bookId: String): Collection =
+ 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)
diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/BookController.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/BookController.kt
index bd3de0ecd..3cf54f2d8 100644
--- a/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/BookController.kt
+++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/BookController.kt
@@ -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))
diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/dto/BookImportBatchDto.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/dto/BookImportBatchDto.kt
new file mode 100644
index 000000000..1b5c48fa2
--- /dev/null
+++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/dto/BookImportBatchDto.kt
@@ -0,0 +1,15 @@
+package org.gotson.komga.interfaces.rest.dto
+
+import org.gotson.komga.domain.model.CopyMode
+
+data class BookImportBatchDto(
+ val books: List = emptyList(),
+ val copyMode: CopyMode,
+)
+
+data class BookImportDto(
+ val sourceFile: String,
+ val seriesId: String,
+ val upgradeBookId: String? = null,
+ val destinationName: String? = null,
+)
diff --git a/komga/src/test/kotlin/org/gotson/komga/domain/model/Utils.kt b/komga/src/test/kotlin/org/gotson/komga/domain/model/Utils.kt
index 4c9ba58a9..c91d2eab9 100644
--- a/komga/src/test/kotlin/org/gotson/komga/domain/model/Utils.kt
+++ b/komga/src/test/kotlin/org/gotson/komga/domain/model/Utils.kt
@@ -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
)
diff --git a/komga/src/test/kotlin/org/gotson/komga/domain/service/BookImporterTest.kt b/komga/src/test/kotlin/org/gotson/komga/domain/service/BookImporterTest.kt
new file mode 100644
index 000000000..c6c50264c
--- /dev/null
+++ b/komga/src/test/kotlin/org/gotson/komga/domain/service/BookImporterTest.kt
@@ -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()) } just Runs
+ every { mockTaskReceiver.refreshBookMetadata(any(), 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()) }
+ }
+ }
+
+ @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()) }
+ }
+ }
+
+ @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()) }
+ }
+ }
+
+ @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()) }
+ }
+ }
+
+ @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()) }
+ }
+ }
+}