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()) } + } + } +}