feat(importer): import sidecars alongside books

closes #611
This commit is contained in:
Gauthier Roebroeck 2021-08-16 16:08:18 +08:00
parent 3129a9759a
commit a3c3a48038
3 changed files with 146 additions and 1 deletions

View file

@ -2,6 +2,7 @@ package org.gotson.komga.domain.service
import mu.KotlinLogging import mu.KotlinLogging
import org.gotson.komga.application.events.EventPublisher import org.gotson.komga.application.events.EventPublisher
import org.gotson.komga.application.tasks.TaskReceiver
import org.gotson.komga.domain.model.Book import org.gotson.komga.domain.model.Book
import org.gotson.komga.domain.model.CodedException import org.gotson.komga.domain.model.CodedException
import org.gotson.komga.domain.model.CopyMode import org.gotson.komga.domain.model.CopyMode
@ -9,6 +10,7 @@ import org.gotson.komga.domain.model.DomainEvent
import org.gotson.komga.domain.model.Media import org.gotson.komga.domain.model.Media
import org.gotson.komga.domain.model.PathContainedInPath import org.gotson.komga.domain.model.PathContainedInPath
import org.gotson.komga.domain.model.Series import org.gotson.komga.domain.model.Series
import org.gotson.komga.domain.model.Sidecar
import org.gotson.komga.domain.model.withCode import org.gotson.komga.domain.model.withCode
import org.gotson.komga.domain.persistence.BookMetadataRepository import org.gotson.komga.domain.persistence.BookMetadataRepository
import org.gotson.komga.domain.persistence.BookRepository import org.gotson.komga.domain.persistence.BookRepository
@ -16,6 +18,7 @@ import org.gotson.komga.domain.persistence.LibraryRepository
import org.gotson.komga.domain.persistence.MediaRepository import org.gotson.komga.domain.persistence.MediaRepository
import org.gotson.komga.domain.persistence.ReadListRepository import org.gotson.komga.domain.persistence.ReadListRepository
import org.gotson.komga.domain.persistence.ReadProgressRepository import org.gotson.komga.domain.persistence.ReadProgressRepository
import org.gotson.komga.domain.persistence.SidecarRepository
import org.gotson.komga.infrastructure.language.toIndexedMap import org.gotson.komga.infrastructure.language.toIndexedMap
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import java.io.FileNotFoundException import java.io.FileNotFoundException
@ -24,6 +27,7 @@ import java.nio.file.Files
import java.nio.file.NoSuchFileException import java.nio.file.NoSuchFileException
import java.nio.file.Path import java.nio.file.Path
import java.nio.file.Paths import java.nio.file.Paths
import java.nio.file.attribute.BasicFileAttributes
import kotlin.io.path.copyTo import kotlin.io.path.copyTo
import kotlin.io.path.deleteExisting import kotlin.io.path.deleteExisting
import kotlin.io.path.deleteIfExists import kotlin.io.path.deleteIfExists
@ -31,7 +35,10 @@ import kotlin.io.path.exists
import kotlin.io.path.extension import kotlin.io.path.extension
import kotlin.io.path.moveTo import kotlin.io.path.moveTo
import kotlin.io.path.name import kotlin.io.path.name
import kotlin.io.path.nameWithoutExtension
import kotlin.io.path.notExists import kotlin.io.path.notExists
import kotlin.io.path.readAttributes
import kotlin.io.path.toPath
private val logger = KotlinLogging.logger {} private val logger = KotlinLogging.logger {}
@ -46,7 +53,9 @@ class BookImporter(
private val readProgressRepository: ReadProgressRepository, private val readProgressRepository: ReadProgressRepository,
private val readListRepository: ReadListRepository, private val readListRepository: ReadListRepository,
private val libraryRepository: LibraryRepository, private val libraryRepository: LibraryRepository,
private val sidecarRepository: SidecarRepository,
private val eventPublisher: EventPublisher, private val eventPublisher: EventPublisher,
private val taskReceiver: TaskReceiver,
) { ) {
fun importBook(sourceFile: Path, series: Series, copyMode: CopyMode, destinationName: String? = null, upgradeBookId: String? = null): Book { fun importBook(sourceFile: Path, series: Series, copyMode: CopyMode, destinationName: String? = null, upgradeBookId: String? = null): Book {
@ -61,6 +70,12 @@ class BookImporter(
if (destinationName != null) Paths.get("$destinationName.${sourceFile.extension}").name if (destinationName != null) Paths.get("$destinationName.${sourceFile.extension}").name
else sourceFile.name else sourceFile.name
) )
val sidecars = fileSystemScanner.scanBookSidecars(sourceFile).associateWith {
series.path.resolve(
if (destinationName != null) it.url.toURI().toPath().name.replace(sourceFile.nameWithoutExtension, destinationName, true)
else it.url.toURI().toPath().name
)
}
val upgradedBook = val upgradedBook =
if (upgradeBookId != null) { if (upgradeBookId != null) {
@ -92,17 +107,39 @@ class BookImporter(
CopyMode.MOVE -> { CopyMode.MOVE -> {
logger.info { "Moving file $sourceFile to $destFile" } logger.info { "Moving file $sourceFile to $destFile" }
sourceFile.moveTo(destFile) sourceFile.moveTo(destFile)
sidecars.forEach {
it.key.url.toURI().toPath().let { sourcePath ->
logger.info { "Moving file $sourcePath to ${it.value}" }
sourcePath.moveTo(it.value, true)
}
}
} }
CopyMode.COPY -> { CopyMode.COPY -> {
logger.info { "Copying file $sourceFile to $destFile" } logger.info { "Copying file $sourceFile to $destFile" }
sourceFile.copyTo(destFile) sourceFile.copyTo(destFile)
sidecars.forEach {
it.key.url.toURI().toPath().let { sourcePath ->
logger.info { "Copying file $sourcePath to ${it.value}" }
sourcePath.copyTo(it.value, true)
}
}
} }
CopyMode.HARDLINK -> try { CopyMode.HARDLINK -> try {
logger.info { "Hardlink file $sourceFile to $destFile" } logger.info { "Hardlink file $sourceFile to $destFile" }
Files.createLink(destFile, sourceFile) Files.createLink(destFile, sourceFile)
sidecars.forEach {
it.key.url.toURI().toPath().let { sourcePath ->
logger.info { "Hardlink file $sourcePath to ${it.value}" }
it.value.deleteIfExists()
Files.createLink(it.value, sourcePath)
}
}
} catch (e: Exception) { } catch (e: Exception) {
logger.warn(e) { "Filesystem does not support hardlinks, copying instead" } logger.warn(e) { "Filesystem does not support hardlinks, copying instead" }
sourceFile.copyTo(destFile) sourceFile.copyTo(destFile)
sidecars.forEach {
it.key.url.toURI().toPath().copyTo(it.value, true)
}
} }
} }
@ -153,6 +190,19 @@ class BookImporter(
seriesLifecycle.sortBooks(series) seriesLifecycle.sortBooks(series)
sidecars.forEach { (sourceSidecar, destPath) ->
when (sourceSidecar.type) {
Sidecar.Type.ARTWORK -> taskReceiver.refreshBookLocalArtwork(importedBook.id)
Sidecar.Type.METADATA -> taskReceiver.refreshBookMetadata(importedBook.id)
}
val destSidecar = sourceSidecar.copy(
url = destPath.toUri().toURL(),
parentUrl = destPath.parent.toUri().toURL(),
lastModifiedTime = destPath.readAttributes<BasicFileAttributes>().getUpdatedTime()
)
sidecarRepository.save(importedBook.libraryId, destSidecar)
}
eventPublisher.publishEvent(DomainEvent.BookImported(importedBook, sourceFile.toUri().toURL(), success = true)) eventPublisher.publishEvent(DomainEvent.BookImported(importedBook, sourceFile.toUri().toURL(), success = true))
return importedBook return importedBook

View file

@ -23,6 +23,7 @@ import java.time.LocalDateTime
import java.time.ZoneId import java.time.ZoneId
import kotlin.io.path.exists import kotlin.io.path.exists
import kotlin.io.path.extension import kotlin.io.path.extension
import kotlin.io.path.listDirectoryEntries
import kotlin.io.path.name import kotlin.io.path.name
import kotlin.io.path.nameWithoutExtension import kotlin.io.path.nameWithoutExtension
import kotlin.io.path.pathString import kotlin.io.path.pathString
@ -174,6 +175,18 @@ class FileSystemScanner(
return pathToBook(path, path.readAttributes()) return pathToBook(path, path.readAttributes())
} }
fun scanBookSidecars(path: Path): List<Sidecar> {
val bookBaseName = path.nameWithoutExtension
val parent = path.parent
return parent.listDirectoryEntries()
.filter { candidate -> sidecarBookPrefilter.any { it.matches(candidate.name) } }
.mapNotNull { candidate ->
sidecarBookConsumers.firstOrNull { it.isSidecarBookMatch(bookBaseName, candidate.name) }?.let {
Sidecar(candidate.toUri().toURL(), parent.toUri().toURL(), candidate.readAttributes<BasicFileAttributes>().getUpdatedTime(), it.getSidecarBookType(), Sidecar.Source.BOOK)
}
}
}
private fun pathToBook(path: Path, attrs: BasicFileAttributes): Book = private fun pathToBook(path: Path, attrs: BasicFileAttributes): Book =
Book( Book(
name = path.nameWithoutExtension, name = path.nameWithoutExtension,

View file

@ -2,8 +2,14 @@ package org.gotson.komga.domain.service
import com.google.common.jimfs.Configuration import com.google.common.jimfs.Configuration
import com.google.common.jimfs.Jimfs 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
import org.assertj.core.api.Assertions.assertThat import org.assertj.core.api.Assertions.assertThat
import org.gotson.komga.application.tasks.TaskReceiver
import org.gotson.komga.domain.model.BookPage import org.gotson.komga.domain.model.BookPage
import org.gotson.komga.domain.model.CopyMode import org.gotson.komga.domain.model.CopyMode
import org.gotson.komga.domain.model.KomgaUser import org.gotson.komga.domain.model.KomgaUser
@ -25,6 +31,7 @@ import org.gotson.komga.infrastructure.language.toIndexedMap
import org.junit.jupiter.api.AfterAll import org.junit.jupiter.api.AfterAll
import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeAll import org.junit.jupiter.api.BeforeAll
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith import org.junit.jupiter.api.extension.ExtendWith
import org.springframework.beans.factory.annotation.Autowired import org.springframework.beans.factory.annotation.Autowired
@ -59,6 +66,9 @@ class BookImporterTest(
private val user1 = KomgaUser("user1@example.org", "", false) private val user1 = KomgaUser("user1@example.org", "", false)
private val user2 = KomgaUser("user2@example.org", "", false) private val user2 = KomgaUser("user2@example.org", "", false)
@MockkBean
private lateinit var mockTackReceiver: TaskReceiver
@BeforeAll @BeforeAll
fun init() { fun init() {
libraryRepository.insert(library) libraryRepository.insert(library)
@ -74,6 +84,12 @@ class BookImporterTest(
userRepository.deleteAll() userRepository.deleteAll()
} }
@BeforeEach
fun initMocks() {
every { mockTackReceiver.refreshBookMetadata(any(), any(), any()) } just Runs
every { mockTackReceiver.refreshBookLocalArtwork(any(), any()) } just Runs
}
@AfterEach @AfterEach
fun `clear repository`() { fun `clear repository`() {
seriesLifecycle.deleteMany(seriesRepository.findAll()) seriesLifecycle.deleteMany(seriesRepository.findAll())
@ -136,7 +152,7 @@ class BookImporterTest(
} }
@Test @Test
fun `given source file parf of a Komga library when importing then exception is thrown`() { fun `given source file part of a Komga library when importing then exception is thrown`() {
Jimfs.newFileSystem(Configuration.unix()).use { fs -> Jimfs.newFileSystem(Configuration.unix()).use { fs ->
// given // given
val sourceDir = fs.getPath("/source").createDirectory() val sourceDir = fs.getPath("/source").createDirectory()
@ -201,6 +217,72 @@ class BookImporterTest(
} }
} }
@Test
fun `given book with sidecars when importing then book and sidecars are imported`() {
Jimfs.newFileSystem(Configuration.unix()).use { fs ->
// given
val sourceDir = fs.getPath("/source").createDirectory()
val sourceFile = sourceDir.resolve("book 2.cbz").createFile()
sourceDir.resolve("book 2.jpg").createFile()
sourceDir.resolve("BOOK 2-1.jpg").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
verify(exactly = 2) { mockTackReceiver.refreshBookLocalArtwork(any()) }
assertThat(destDir.resolve("book 2.cbz")).exists()
assertThat(destDir.resolve("book 2.jpg")).exists()
assertThat(destDir.resolve("BOOK 2-1.jpg")).exists()
}
}
@Test
fun `given book with sidecars when importing with destination name then book and sidecars are imported`() {
Jimfs.newFileSystem(Configuration.unix()).use { fs ->
// given
val sourceDir = fs.getPath("/source").createDirectory()
val sourceFile = sourceDir.resolve("book 2.cbz").createFile()
sourceDir.resolve("book 2.jpg").createFile()
sourceDir.resolve("BOOK 2-1.jpg").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, destinationName = "book 5")
// then
verify(exactly = 2) { mockTackReceiver.refreshBookLocalArtwork(any()) }
assertThat(destDir.resolve("book 5.cbz")).exists()
assertThat(destDir.resolve("book 5.jpg")).exists()
assertThat(destDir.resolve("book 5-1.jpg")).exists()
}
}
@Test @Test
fun `given existing book when importing with upgrade then existing book is deleted`() { fun `given existing book when importing with upgrade then existing book is deleted`() {
Jimfs.newFileSystem(Configuration.unix()).use { fs -> Jimfs.newFileSystem(Configuration.unix()).use { fs ->