feat(api): import books

Books can be imported directly into an existing Series
This commit is contained in:
Gauthier Roebroeck 2021-04-19 17:19:03 +08:00
parent 02b08932ba
commit d41dcefd3e
12 changed files with 579 additions and 22 deletions

View file

@ -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>

View file

@ -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"
}
}

View file

@ -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" }

View file

@ -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) {

View file

@ -0,0 +1,7 @@
package org.gotson.komga.domain.model
enum class CopyMode {
MOVE,
COPY,
HARDLINK,
}

View file

@ -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)

View file

@ -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)
}
}

View file

@ -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)

View file

@ -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))

View file

@ -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,
)

View file

@ -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
)

View file

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