diff --git a/komga/src/flyway/resources/db/migration/sqlite/V20210609165742__sidecars.sql b/komga/src/flyway/resources/db/migration/sqlite/V20210609165742__sidecars.sql new file mode 100644 index 000000000..dbbe7002e --- /dev/null +++ b/komga/src/flyway/resources/db/migration/sqlite/V20210609165742__sidecars.sql @@ -0,0 +1,7 @@ +CREATE TABLE SIDECAR +( + URL varchar NOT NULL PRIMARY KEY, + PARENT_URL varchar NOT NULL, + LAST_MODIFIED_TIME datetime NOT NULL, + LIBRARY_ID varchar NOT NULL +); 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 fc02bc688..3a0545505 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 @@ -40,6 +40,16 @@ sealed class Task(priority: Int = DEFAULT_PRIORITY) : Serializable { override fun uniqueId() = "AGGREGATE_SERIES_METADATA_$seriesId" } + class RefreshBookLocalArtwork(val bookId: String, priority: Int = DEFAULT_PRIORITY) : Task(priority) { + override fun uniqueId(): String = "REFRESH_BOOK_LOCAL_ARTWORK_$bookId" + override fun toString(): String = "RefreshBookLocalArtwork(bookId='$bookId', priority='$priority')" + } + + class RefreshSeriesLocalArtwork(val seriesId: String, priority: Int = DEFAULT_PRIORITY) : Task(priority) { + override fun uniqueId(): String = "REFRESH_SERIES_LOCAL_ARTWORK_$seriesId" + override fun toString(): String = "RefreshSeriesLocalArtwork(seriesId=$seriesId, priority='$priority')" + } + class ImportBook(val sourceFile: String, val seriesId: String, val copyMode: CopyMode, val destinationName: String?, val upgradeBookId: String?, priority: Int = DEFAULT_PRIORITY) : Task(priority) { override fun uniqueId(): String = "IMPORT_BOOK_${seriesId}_$sourceFile" override fun toString(): String = 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 ee0634c7f..2e676056c 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 @@ -8,6 +8,7 @@ import org.gotson.komga.domain.service.BookConverter 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.LocalArtworkLifecycle import org.gotson.komga.domain.service.MetadataLifecycle import org.gotson.komga.infrastructure.jms.QUEUE_TASKS import org.gotson.komga.infrastructure.jms.QUEUE_TASKS_SELECTOR @@ -27,6 +28,7 @@ class TaskHandler( private val libraryContentLifecycle: LibraryContentLifecycle, private val bookLifecycle: BookLifecycle, private val metadataLifecycle: MetadataLifecycle, + private val localArtworkLifecycle: LocalArtworkLifecycle, private val bookImporter: BookImporter, private val bookConverter: BookConverter, ) { @@ -76,6 +78,16 @@ class TaskHandler( metadataLifecycle.aggregateMetadata(series) } ?: logger.warn { "Cannot execute task $task: Series does not exist" } + is Task.RefreshBookLocalArtwork -> + bookRepository.findByIdOrNull(task.bookId)?.let { book -> + localArtworkLifecycle.refreshLocalArtwork(book) + } ?: logger.warn { "Cannot execute task $task: Book does not exist" } + + is Task.RefreshSeriesLocalArtwork -> + seriesRepository.findByIdOrNull(task.seriesId)?.let { series -> + localArtworkLifecycle.refreshLocalArtwork(series) + } ?: logger.warn { "Cannot execute task $task: Series does not exist" } + is Task.ImportBook -> seriesRepository.findByIdOrNull(task.seriesId)?.let { series -> val importedBook = bookImporter.importBook(Paths.get(task.sourceFile), series, task.copyMode, task.destinationName, task.upgradeBookId) 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 f8507abb8..b3bbff033 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 @@ -91,6 +91,14 @@ class TaskReceiver( submitTask(Task.AggregateSeriesMetadata(seriesId)) } + fun refreshBookLocalArtwork(bookId: String, priority: Int = DEFAULT_PRIORITY) { + submitTask(Task.RefreshBookLocalArtwork(bookId, priority)) + } + + fun refreshSeriesLocalArtwork(seriesId: String, priority: Int = DEFAULT_PRIORITY) { + submitTask(Task.RefreshSeriesLocalArtwork(seriesId, priority)) + } + fun importBook(sourceFile: String, seriesId: String, copyMode: CopyMode, destinationName: String?, upgradeBookId: String?, priority: Int = DEFAULT_PRIORITY) { submitTask(Task.ImportBook(sourceFile, seriesId, copyMode, destinationName, upgradeBookId, priority)) } diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/model/ScanResult.kt b/komga/src/main/kotlin/org/gotson/komga/domain/model/ScanResult.kt new file mode 100644 index 000000000..47bd46035 --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/domain/model/ScanResult.kt @@ -0,0 +1,6 @@ +package org.gotson.komga.domain.model + +data class ScanResult( + val series: Map>, + val sidecars: List, +) diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/model/Sidecar.kt b/komga/src/main/kotlin/org/gotson/komga/domain/model/Sidecar.kt new file mode 100644 index 000000000..10bbff975 --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/domain/model/Sidecar.kt @@ -0,0 +1,28 @@ +package org.gotson.komga.domain.model + +import java.net.URL +import java.time.LocalDateTime + +data class Sidecar( + val url: URL, + val parentUrl: URL, + val lastModifiedTime: LocalDateTime, + val type: Type, + val source: Source, +) { + + enum class Type { + ARTWORK + } + + enum class Source { + SERIES, BOOK + } +} + +data class SidecarStored( + val url: URL, + val parentUrl: URL, + val lastModifiedTime: LocalDateTime, + val libraryId: String, +) diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/persistence/BookRepository.kt b/komga/src/main/kotlin/org/gotson/komga/domain/persistence/BookRepository.kt index 264eec997..73a126ae1 100644 --- a/komga/src/main/kotlin/org/gotson/komga/domain/persistence/BookRepository.kt +++ b/komga/src/main/kotlin/org/gotson/komga/domain/persistence/BookRepository.kt @@ -5,9 +5,12 @@ import org.gotson.komga.domain.model.BookSearch import org.springframework.data.domain.Page import org.springframework.data.domain.Pageable import org.springframework.data.domain.Sort +import java.net.URL interface BookRepository { fun findByIdOrNull(bookId: String): Book? + fun findByLibraryIdAndUrlOrNull(libraryId: String, url: URL): Book? + fun findBySeriesId(seriesId: String): Collection fun findAll(): Collection fun findAll(bookSearch: BookSearch): Collection @@ -15,6 +18,7 @@ interface BookRepository { fun getLibraryId(bookId: String): String? fun findFirstIdInSeries(seriesId: String): String? + fun findAllIdBySeriesId(seriesId: String): Collection fun findAllIdBySeriesIds(seriesIds: Collection): Collection fun findAllIdByLibraryId(libraryId: String): Collection diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/persistence/SeriesRepository.kt b/komga/src/main/kotlin/org/gotson/komga/domain/persistence/SeriesRepository.kt index 42be14584..6b9807124 100644 --- a/komga/src/main/kotlin/org/gotson/komga/domain/persistence/SeriesRepository.kt +++ b/komga/src/main/kotlin/org/gotson/komga/domain/persistence/SeriesRepository.kt @@ -5,16 +5,19 @@ import org.gotson.komga.domain.model.SeriesSearch import java.net.URL interface SeriesRepository { - fun findAll(): Collection fun findByIdOrNull(seriesId: String): Series? + fun findByLibraryIdAndUrl(libraryId: String, url: URL): Series? + + fun findAll(): Collection fun findByLibraryId(libraryId: String): Collection fun findByLibraryIdAndUrlNotIn(libraryId: String, urls: Collection): Collection - fun findByLibraryIdAndUrl(libraryId: String, url: URL): Series? fun findAll(search: SeriesSearch): Collection fun findByTitle(title: String): Collection fun getLibraryId(seriesId: String): String? + fun findAllIdByLibraryId(libraryId: String): Collection + fun insert(series: Series) fun update(series: Series) diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/persistence/SidecarRepository.kt b/komga/src/main/kotlin/org/gotson/komga/domain/persistence/SidecarRepository.kt new file mode 100644 index 000000000..b104c073f --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/domain/persistence/SidecarRepository.kt @@ -0,0 +1,13 @@ +package org.gotson.komga.domain.persistence + +import org.gotson.komga.domain.model.Sidecar +import org.gotson.komga.domain.model.SidecarStored +import java.net.URL + +interface SidecarRepository { + fun findAll(): Collection + + fun save(libraryId: String, sidecar: Sidecar) + + fun deleteByLibraryIdAndUrls(libraryId: String, urls: Collection) +} diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/service/FileSystemScanner.kt b/komga/src/main/kotlin/org/gotson/komga/domain/service/FileSystemScanner.kt index 77f2c2ffb..ca9ff9aa1 100644 --- a/komga/src/main/kotlin/org/gotson/komga/domain/service/FileSystemScanner.kt +++ b/komga/src/main/kotlin/org/gotson/komga/domain/service/FileSystemScanner.kt @@ -4,10 +4,15 @@ import mu.KotlinLogging import org.apache.commons.io.FilenameUtils import org.gotson.komga.domain.model.Book import org.gotson.komga.domain.model.DirectoryNotFoundException +import org.gotson.komga.domain.model.ScanResult import org.gotson.komga.domain.model.Series +import org.gotson.komga.domain.model.Sidecar import org.gotson.komga.infrastructure.configuration.KomgaProperties +import org.gotson.komga.infrastructure.sidecar.SidecarBookConsumer +import org.gotson.komga.infrastructure.sidecar.SidecarSeriesConsumer import org.springframework.stereotype.Service import java.io.IOException +import java.net.URL import java.nio.file.FileVisitOption import java.nio.file.FileVisitResult import java.nio.file.FileVisitor @@ -18,6 +23,7 @@ import java.nio.file.attribute.FileTime import java.time.LocalDateTime import java.time.ZoneId import kotlin.io.path.exists +import kotlin.io.path.name import kotlin.io.path.readAttributes import kotlin.time.measureTime @@ -25,12 +31,23 @@ private val logger = KotlinLogging.logger {} @Service class FileSystemScanner( - private val komgaProperties: KomgaProperties + private val komgaProperties: KomgaProperties, + private val sidecarBookConsumers: List, + private val sidecarSeriesConsumers: List, ) { - val supportedExtensions = listOf("cbz", "zip", "cbr", "rar", "pdf", "epub") + private val supportedExtensions = listOf("cbz", "zip", "cbr", "rar", "pdf", "epub") - fun scanRootFolder(root: Path, forceDirectoryModifiedTime: Boolean = false): Map> { + private data class TempSidecar( + val name: String, + val url: URL, + val lastModifiedTime: LocalDateTime, + val type: Sidecar.Type? = null, + ) + + private val sidecarBookPrefilter = sidecarBookConsumers.flatMap { it.getSidecarBookPrefilter() } + + fun scanRootFolder(root: Path, forceDirectoryModifiedTime: Boolean = false): ScanResult { logger.info { "Scanning folder: $root" } logger.info { "Supported extensions: $supportedExtensions" } logger.info { "Excluded patterns: ${komgaProperties.librariesScanDirectoryExclusions}" } @@ -40,10 +57,15 @@ class FileSystemScanner( throw DirectoryNotFoundException("Folder is not accessible: $root", "ERR_1016") val scannedSeries = mutableMapOf>() + val scannedSidecars = mutableListOf() measureTime { + // path is the series directory val pathToSeries = mutableMapOf() + val pathToSeriesSidecars = mutableMapOf>() + // path is the book's parent directory, ie the series directory val pathToBooks = mutableMapOf>() + val pathToBookSidecars = mutableMapOf>() Files.walkFileTree( root, setOf(FileVisitOption.FOLLOW_LINKS), Integer.MAX_VALUE, @@ -67,14 +89,28 @@ class FileSystemScanner( override fun visitFile(file: Path, attrs: BasicFileAttributes): FileVisitResult { logger.trace { "visitFile: $file" } - if (attrs.isRegularFile && - supportedExtensions.contains(FilenameUtils.getExtension(file.fileName.toString()).toLowerCase()) && - !file.fileName.toString().startsWith(".") - ) { - val book = pathToBook(file, attrs) - file.parent.let { key -> - if (pathToBooks.containsKey(key)) pathToBooks[key]!!.add(book) - else pathToBooks[key] = mutableListOf(book) + if (attrs.isRegularFile) { + if (supportedExtensions.contains(FilenameUtils.getExtension(file.fileName.toString()).toLowerCase()) && + !file.fileName.toString().startsWith(".") + ) { + val book = pathToBook(file, attrs) + file.parent.let { key -> + pathToBooks.merge(key, mutableListOf(book)) { prev, one -> prev.union(one).toMutableList() } + } + } + + sidecarSeriesConsumers.firstOrNull { consumer -> + consumer.getSidecarSeriesFilenames().any { file.name.equals(it, ignoreCase = true) } + }?.let { + val sidecar = Sidecar(file.toUri().toURL(), file.parent.toUri().toURL(), attrs.getUpdatedTime(), it.getSidecarSeriesType(), Sidecar.Source.SERIES) + pathToSeriesSidecars.merge(file.parent, mutableListOf(sidecar)) { prev, one -> prev.union(one).toMutableList() } + } + + // book sidecars can't be exactly matched during a file visit + // this prefilters files to reduce the candidates + if (sidecarBookPrefilter.any { it.matches(file.name) }) { + val sidecar = TempSidecar(file.name, file.toUri().toURL(), attrs.getUpdatedTime()) + pathToBookSidecars.merge(file.parent, mutableListOf(sidecar)) { prev, one -> prev.union(one).toMutableList() } } } @@ -89,19 +125,33 @@ class FileSystemScanner( override fun postVisitDirectory(dir: Path, exc: IOException?): FileVisitResult { logger.trace { "postVisit: $dir" } val books = pathToBooks[dir] - val series = pathToSeries[dir] - if (!books.isNullOrEmpty() && series !== null) { - if (forceDirectoryModifiedTime) - scannedSeries[ - series.copy( - fileLastModified = maxOf( - series.fileLastModified, - books.maxOf { it.fileLastModified } - ) - ) - ] = books - else - scannedSeries[series] = books + val tempSeries = pathToSeries[dir] + if (!books.isNullOrEmpty() && tempSeries !== null) { + val series = + if (forceDirectoryModifiedTime) + tempSeries.copy(fileLastModified = maxOf(tempSeries.fileLastModified, books.maxOf { it.fileLastModified })) + else + tempSeries + + scannedSeries[series] = books + + // only add series sidecars if series has books + pathToSeriesSidecars[dir]?.let { scannedSidecars.addAll(it) } + + // book sidecars are matched here, with the actual list of books + books.forEach { book -> + val sidecars = pathToBookSidecars[dir] + ?.mapNotNull { sidecar -> + sidecarBookConsumers.firstOrNull { it.isSidecarBookMatch(book.name, sidecar.name) }?.let { + sidecar to it.getSidecarBookType() + } + }?.toMap() ?: emptyMap() + pathToBookSidecars[dir]?.minusAssign(sidecars.keys) + + sidecars.mapTo(scannedSidecars) { (sidecar, type) -> + Sidecar(sidecar.url, book.url, sidecar.lastModifiedTime, type, Sidecar.Source.BOOK) + } + } } return FileVisitResult.CONTINUE @@ -113,7 +163,7 @@ class FileSystemScanner( logger.info { "Scanned ${scannedSeries.size} series and $countOfBooks books in $it" } } - return scannedSeries + return ScanResult(scannedSeries, scannedSidecars) } fun scanFile(path: Path): Book? { @@ -124,7 +174,7 @@ class FileSystemScanner( private fun pathToBook(path: Path, attrs: BasicFileAttributes): Book = Book( - name = FilenameUtils.getBaseName(path.fileName.toString()), + name = FilenameUtils.getBaseName(path.name), url = path.toUri().toURL(), fileLastModified = attrs.getUpdatedTime(), fileSize = attrs.size() diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/service/LibraryContentLifecycle.kt b/komga/src/main/kotlin/org/gotson/komga/domain/service/LibraryContentLifecycle.kt index 58c2cf8bd..957ce7ac8 100644 --- a/komga/src/main/kotlin/org/gotson/komga/domain/service/LibraryContentLifecycle.kt +++ b/komga/src/main/kotlin/org/gotson/komga/domain/service/LibraryContentLifecycle.kt @@ -4,11 +4,13 @@ import mu.KotlinLogging import org.gotson.komga.application.tasks.TaskReceiver import org.gotson.komga.domain.model.Library import org.gotson.komga.domain.model.Media +import org.gotson.komga.domain.model.Sidecar 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.SeriesCollectionRepository import org.gotson.komga.domain.persistence.SeriesRepository +import org.gotson.komga.domain.persistence.SidecarRepository import org.gotson.komga.infrastructure.configuration.KomgaProperties import org.springframework.stereotype.Service import java.nio.file.Paths @@ -27,6 +29,7 @@ class LibraryContentLifecycle( private val seriesLifecycle: SeriesLifecycle, private val collectionRepository: SeriesCollectionRepository, private val readListRepository: ReadListRepository, + private val sidecarRepository: SidecarRepository, private val komgaProperties: KomgaProperties, private val taskReceiver: TaskReceiver, ) { @@ -34,8 +37,10 @@ class LibraryContentLifecycle( fun scanRootFolder(library: Library) { logger.info { "Updating library: $library" } measureTime { + val scanResult = fileSystemScanner.scanRootFolder(Paths.get(library.root.toURI()), library.scanForceModifiedTime) val scannedSeries = - fileSystemScanner.scanRootFolder(Paths.get(library.root.toURI()), library.scanForceModifiedTime) + scanResult + .series .map { (series, books) -> series.copy(libraryId = library.id) to books.map { it.copy(libraryId = library.id) } }.toMap() @@ -123,6 +128,37 @@ class LibraryContentLifecycle( } } + val existingSidecars = sidecarRepository.findAll() + scanResult.sidecars.forEach { newSidecar -> + val existingSidecar = existingSidecars.firstOrNull { it.url == newSidecar.url } + if (existingSidecar == null || existingSidecar.lastModifiedTime.isBefore(newSidecar.lastModifiedTime)) { + when (newSidecar.source) { + Sidecar.Source.SERIES -> + seriesRepository.findByLibraryIdAndUrl(library.id, newSidecar.parentUrl)?.let { series -> + when (newSidecar.type) { + Sidecar.Type.ARTWORK -> taskReceiver.refreshSeriesLocalArtwork(series.id) + } + } + Sidecar.Source.BOOK -> + bookRepository.findByLibraryIdAndUrlOrNull(library.id, newSidecar.parentUrl)?.let { book -> + when (newSidecar.type) { + Sidecar.Type.ARTWORK -> taskReceiver.refreshBookLocalArtwork(book.id) + } + } + } + sidecarRepository.save(library.id, newSidecar) + } + } + + // cleanup sidecars that don't exist anymore + scanResult.sidecars.map { it.url }.let { newSidecarsUrls -> + existingSidecars + .filterNot { existing -> newSidecarsUrls.contains(existing.url) } + .let { sidecars -> + sidecarRepository.deleteByLibraryIdAndUrls(library.id, sidecars.map { it.url }) + } + } + if (komgaProperties.deleteEmptyCollections) { logger.info { "Deleting empty collections" } collectionRepository.deleteEmpty() diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/service/LocalArtworkLifecycle.kt b/komga/src/main/kotlin/org/gotson/komga/domain/service/LocalArtworkLifecycle.kt new file mode 100644 index 000000000..57e3fd36b --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/domain/service/LocalArtworkLifecycle.kt @@ -0,0 +1,43 @@ +package org.gotson.komga.domain.service + +import mu.KotlinLogging +import org.gotson.komga.domain.model.Book +import org.gotson.komga.domain.model.Series +import org.gotson.komga.domain.persistence.LibraryRepository +import org.gotson.komga.infrastructure.metadata.localartwork.LocalArtworkProvider +import org.springframework.stereotype.Service + +private val logger = KotlinLogging.logger {} + +@Service +class LocalArtworkLifecycle( + private val libraryRepository: LibraryRepository, + private val bookLifecycle: BookLifecycle, + private val seriesLifecycle: SeriesLifecycle, + private val localArtworkProvider: LocalArtworkProvider +) { + + fun refreshLocalArtwork(book: Book) { + logger.info { "Refresh local artwork for book: $book" } + val library = libraryRepository.findById(book.libraryId) + + if (library.importLocalArtwork) + localArtworkProvider.getBookThumbnails(book).forEach { + bookLifecycle.addThumbnailForBook(it) + } + else + logger.info { "Library is not set to import local artwork, skipping" } + } + + fun refreshLocalArtwork(series: Series) { + logger.info { "Refresh local artwork for series: $series" } + val library = libraryRepository.findById(series.libraryId) + + if (library.importLocalArtwork) + localArtworkProvider.getSeriesThumbnails(series).forEach { + seriesLifecycle.addThumbnailForSeries(it) + } + else + logger.info { "Library is not set to import local artwork, skipping" } + } +} diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/service/MetadataLifecycle.kt b/komga/src/main/kotlin/org/gotson/komga/domain/service/MetadataLifecycle.kt index 3eea94d0b..943d41951 100644 --- a/komga/src/main/kotlin/org/gotson/komga/domain/service/MetadataLifecycle.kt +++ b/komga/src/main/kotlin/org/gotson/komga/domain/service/MetadataLifecycle.kt @@ -22,7 +22,6 @@ import org.gotson.komga.infrastructure.metadata.SeriesMetadataProvider import org.gotson.komga.infrastructure.metadata.barcode.IsbnBarcodeProvider import org.gotson.komga.infrastructure.metadata.comicrack.ComicInfoProvider import org.gotson.komga.infrastructure.metadata.epub.EpubMetadataProvider -import org.gotson.komga.infrastructure.metadata.localartwork.LocalArtworkProvider import org.springframework.stereotype.Service private val logger = KotlinLogging.logger {} @@ -39,13 +38,10 @@ class MetadataLifecycle( private val bookMetadataAggregationRepository: BookMetadataAggregationRepository, private val libraryRepository: LibraryRepository, private val bookRepository: BookRepository, - private val bookLifecycle: BookLifecycle, - private val seriesLifecycle: SeriesLifecycle, private val collectionRepository: SeriesCollectionRepository, private val collectionLifecycle: SeriesCollectionLifecycle, private val readListRepository: ReadListRepository, private val readListLifecycle: ReadListLifecycle, - private val localArtworkProvider: LocalArtworkProvider ) { fun refreshMetadata(book: Book, capabilities: List) { @@ -82,9 +78,6 @@ class MetadataLifecycle( } } } - - if (library.importLocalArtwork && capabilities.contains(BookMetadataPatchCapability.THUMBNAILS)) - refreshMetadataLocalArtwork(book) } private fun handlePatchForReadLists( @@ -139,12 +132,6 @@ class MetadataLifecycle( } } - private fun refreshMetadataLocalArtwork(book: Book) { - localArtworkProvider.getBookThumbnails(book).forEach { - bookLifecycle.addThumbnailForBook(it) - } - } - fun refreshMetadata(series: Series) { logger.info { "Refresh metadata for series: $series" } @@ -172,14 +159,6 @@ class MetadataLifecycle( } } } - - if (library.importLocalArtwork) refreshMetadataLocalArtwork(series) - } - - private fun refreshMetadataLocalArtwork(series: Series) { - localArtworkProvider.getSeriesThumbnails(series).forEach { - seriesLifecycle.addThumbnailForSeries(it) - } } private fun handlePatchForCollections( diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/service/TransientBookLifecycle.kt b/komga/src/main/kotlin/org/gotson/komga/domain/service/TransientBookLifecycle.kt index 8b34cb577..df2863628 100644 --- a/komga/src/main/kotlin/org/gotson/komga/domain/service/TransientBookLifecycle.kt +++ b/komga/src/main/kotlin/org/gotson/komga/domain/service/TransientBookLifecycle.kt @@ -25,7 +25,7 @@ class TransientBookLifecycle( if (folderToScan.startsWith(library.path)) throw PathContainedInPath("Cannot scan folder that is part of an existing library", "ERR_1017") } - val books = fileSystemScanner.scanRootFolder(folderToScan).values.flatten().map { BookWithMedia(it, Media()) } + val books = fileSystemScanner.scanRootFolder(folderToScan).series.values.flatten().map { BookWithMedia(it, Media()) } transientBookRepository.saveAll(books) diff --git a/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/BookDao.kt b/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/BookDao.kt index b6cb75e11..3825f3eda 100644 --- a/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/BookDao.kt +++ b/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/BookDao.kt @@ -36,6 +36,12 @@ class BookDao( override fun findByIdOrNull(bookId: String): Book? = findByIdOrNull(dsl, bookId) + override fun findByLibraryIdAndUrlOrNull(libraryId: String, url: URL): Book? = + dsl.selectFrom(b) + .where(b.LIBRARY_ID.eq(libraryId).and(b.URL.eq(url.toString()))) + .fetchOneInto(b) + ?.toDomain() + private fun findByIdOrNull(dsl: DSLContext, bookId: String): Book? = dsl.selectFrom(b) .where(b.ID.eq(bookId)) diff --git a/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/SeriesDao.kt b/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/SeriesDao.kt index dc63382b8..d28ce7ad3 100644 --- a/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/SeriesDao.kt +++ b/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/SeriesDao.kt @@ -65,6 +65,12 @@ class SeriesDao( .where(s.ID.eq(seriesId)) .fetchOne(0, String::class.java) + override fun findAllIdByLibraryId(libraryId: String): Collection = + dsl.select(s.ID) + .from(s) + .where(s.LIBRARY_ID.eq(libraryId)) + .fetch(s.ID) + override fun findAll(search: SeriesSearch): Collection { val conditions = search.toCondition() diff --git a/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/SidecarDao.kt b/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/SidecarDao.kt new file mode 100644 index 000000000..da3d807ba --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/SidecarDao.kt @@ -0,0 +1,51 @@ +package org.gotson.komga.infrastructure.jooq + +import org.gotson.komga.domain.model.Sidecar +import org.gotson.komga.domain.model.SidecarStored +import org.gotson.komga.domain.persistence.SidecarRepository +import org.gotson.komga.jooq.Tables +import org.gotson.komga.jooq.tables.records.SidecarRecord +import org.jooq.DSLContext +import org.springframework.stereotype.Component +import java.net.URL + +@Component +class SidecarDao( + private val dsl: DSLContext +) : SidecarRepository { + + private val sc = Tables.SIDECAR + + override fun findAll(): Collection = + dsl.selectFrom(sc).fetch().map { it.toDomain() } + + override fun save(libraryId: String, sidecar: Sidecar) { + dsl.insertInto(sc) + .values( + sidecar.url.toString(), + sidecar.parentUrl.toString(), + sidecar.lastModifiedTime, + libraryId, + ) + .onDuplicateKeyUpdate() + .set(sc.LAST_MODIFIED_TIME, sidecar.lastModifiedTime) + .set(sc.PARENT_URL, sidecar.parentUrl.toString()) + .set(sc.LIBRARY_ID, libraryId) + .execute() + } + + override fun deleteByLibraryIdAndUrls(libraryId: String, urls: Collection) { + dsl.deleteFrom(sc) + .where(sc.LIBRARY_ID.eq(libraryId)) + .and(sc.URL.`in`(urls.map { it.toString() })) + .execute() + } + + private fun SidecarRecord.toDomain() = + SidecarStored( + url = URL(url), + parentUrl = URL(parentUrl), + lastModifiedTime = lastModifiedTime, + libraryId = libraryId, + ) +} diff --git a/komga/src/main/kotlin/org/gotson/komga/infrastructure/metadata/localartwork/LocalArtworkProvider.kt b/komga/src/main/kotlin/org/gotson/komga/infrastructure/metadata/localartwork/LocalArtworkProvider.kt index ed906aaae..f21260128 100644 --- a/komga/src/main/kotlin/org/gotson/komga/infrastructure/metadata/localartwork/LocalArtworkProvider.kt +++ b/komga/src/main/kotlin/org/gotson/komga/infrastructure/metadata/localartwork/LocalArtworkProvider.kt @@ -4,9 +4,12 @@ import mu.KotlinLogging import org.apache.commons.io.FilenameUtils import org.gotson.komga.domain.model.Book import org.gotson.komga.domain.model.Series +import org.gotson.komga.domain.model.Sidecar import org.gotson.komga.domain.model.ThumbnailBook import org.gotson.komga.domain.model.ThumbnailSeries import org.gotson.komga.infrastructure.mediacontainer.ContentDetector +import org.gotson.komga.infrastructure.sidecar.SidecarBookConsumer +import org.gotson.komga.infrastructure.sidecar.SidecarSeriesConsumer import org.springframework.stereotype.Service import java.nio.file.Files import kotlin.streams.asSequence @@ -16,7 +19,7 @@ private val logger = KotlinLogging.logger {} @Service class LocalArtworkProvider( private val contentDetector: ContentDetector -) { +) : SidecarSeriesConsumer, SidecarBookConsumer { val supportedExtensions = listOf("png", "jpeg", "jpg", "tbn") val supportedSeriesFiles = listOf("cover", "default", "folder", "poster", "series") @@ -65,4 +68,19 @@ class LocalArtworkProvider( }.toList() } } + + override fun getSidecarBookType(): Sidecar.Type = Sidecar.Type.ARTWORK + + override fun getSidecarBookPrefilter(): List = + supportedExtensions.map { ext -> ".*(-\\d+)?\\.$ext".toRegex(RegexOption.IGNORE_CASE) } + + override fun isSidecarBookMatch(basename: String, sidecar: String): Boolean = + "${Regex.escape(basename)}(-\\d+)?".toRegex(RegexOption.IGNORE_CASE).matches(FilenameUtils.getBaseName(sidecar)) + + override fun getSidecarSeriesType(): Sidecar.Type = Sidecar.Type.ARTWORK + + override fun getSidecarSeriesFilenames(): List = + supportedSeriesFiles.flatMap { filename -> + supportedExtensions.map { ext -> "$filename.$ext" } + } } diff --git a/komga/src/main/kotlin/org/gotson/komga/infrastructure/sidecar/SidecarBookConsumer.kt b/komga/src/main/kotlin/org/gotson/komga/infrastructure/sidecar/SidecarBookConsumer.kt new file mode 100644 index 000000000..00597f338 --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/infrastructure/sidecar/SidecarBookConsumer.kt @@ -0,0 +1,9 @@ +package org.gotson.komga.infrastructure.sidecar + +import org.gotson.komga.domain.model.Sidecar + +interface SidecarBookConsumer { + fun getSidecarBookType(): Sidecar.Type + fun getSidecarBookPrefilter(): List + fun isSidecarBookMatch(basename: String, sidecar: String): Boolean +} diff --git a/komga/src/main/kotlin/org/gotson/komga/infrastructure/sidecar/SidecarSeriesConsumer.kt b/komga/src/main/kotlin/org/gotson/komga/infrastructure/sidecar/SidecarSeriesConsumer.kt new file mode 100644 index 000000000..a41950bf9 --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/infrastructure/sidecar/SidecarSeriesConsumer.kt @@ -0,0 +1,8 @@ +package org.gotson.komga.infrastructure.sidecar + +import org.gotson.komga.domain.model.Sidecar + +interface SidecarSeriesConsumer { + fun getSidecarSeriesType(): Sidecar.Type + fun getSidecarSeriesFilenames(): List +} 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 8ffd17748..81461ddd2 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 @@ -431,6 +431,7 @@ class BookController( fun refreshMetadata(@PathVariable bookId: String) { bookRepository.findByIdOrNull(bookId)?.let { book -> taskReceiver.refreshBookMetadata(book.id, priority = HIGH_PRIORITY) + taskReceiver.refreshBookLocalArtwork(book.id, priority = HIGH_PRIORITY) } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) } diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/LibraryController.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/LibraryController.kt index ad26d7124..91fdb364c 100644 --- a/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/LibraryController.kt +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/LibraryController.kt @@ -10,6 +10,7 @@ import org.gotson.komga.domain.model.PathContainedInPath import org.gotson.komga.domain.model.ROLE_ADMIN 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.LibraryLifecycle import org.gotson.komga.infrastructure.security.KomgaPrincipal import org.gotson.komga.infrastructure.web.filePathToUrl @@ -40,7 +41,8 @@ class LibraryController( private val taskReceiver: TaskReceiver, private val libraryLifecycle: LibraryLifecycle, private val libraryRepository: LibraryRepository, - private val bookRepository: BookRepository + private val bookRepository: BookRepository, + private val seriesRepository: SeriesRepository, ) { @GetMapping @@ -161,6 +163,10 @@ class LibraryController( fun refreshMetadata(@PathVariable libraryId: String) { bookRepository.findAllIdByLibraryId(libraryId).forEach { taskReceiver.refreshBookMetadata(it, priority = HIGH_PRIORITY) + taskReceiver.refreshBookLocalArtwork(it, priority = HIGH_PRIORITY) + } + seriesRepository.findAllIdByLibraryId(libraryId).forEach { + taskReceiver.refreshSeriesLocalArtwork(it, priority = HIGH_PRIORITY) } } } diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/SeriesController.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/SeriesController.kt index 0fa507f82..20ff50b7e 100644 --- a/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/SeriesController.kt +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/SeriesController.kt @@ -309,7 +309,9 @@ class SeriesController( fun refreshMetadata(@PathVariable seriesId: String) { bookRepository.findAllIdBySeriesId(seriesId).forEach { taskReceiver.refreshBookMetadata(it, priority = HIGH_PRIORITY) + taskReceiver.refreshBookLocalArtwork(it, priority = HIGH_PRIORITY) } + taskReceiver.refreshSeriesLocalArtwork(seriesId, priority = HIGH_PRIORITY) } @PatchMapping("{seriesId}/metadata") diff --git a/komga/src/test/kotlin/org/gotson/komga/domain/service/FileSystemScannerTest.kt b/komga/src/test/kotlin/org/gotson/komga/domain/service/FileSystemScannerTest.kt index e27be26f8..c9bfce58d 100644 --- a/komga/src/test/kotlin/org/gotson/komga/domain/service/FileSystemScannerTest.kt +++ b/komga/src/test/kotlin/org/gotson/komga/domain/service/FileSystemScannerTest.kt @@ -17,7 +17,7 @@ class FileSystemScannerTest { librariesScanDirectoryExclusions = listOf("#recycle") } - private val scanner = FileSystemScanner(komgaProperties) + private val scanner = FileSystemScanner(komgaProperties, emptyList(), emptyList()) @Test fun `given unavailable root directory when scanning then throw exception`() { @@ -41,7 +41,7 @@ class FileSystemScannerTest { Files.createDirectory(root) // when - val scan = scanner.scanRootFolder(root) + val scan = scanner.scanRootFolder(root).series // then assertThat(scan).isEmpty() @@ -59,7 +59,7 @@ class FileSystemScannerTest { files.forEach { Files.createFile(root.resolve(it)) } // when - val scan = scanner.scanRootFolder(root) + val scan = scanner.scanRootFolder(root).series val series = scan.keys.first() val books = scan.getValue(series) @@ -81,7 +81,7 @@ class FileSystemScannerTest { files.forEach { Files.createFile(root.resolve(it)) } // when - val scan = scanner.scanRootFolder(root) + val scan = scanner.scanRootFolder(root).series val series = scan.keys.first() val books = scan.getValue(series) @@ -109,7 +109,7 @@ class FileSystemScannerTest { } // when - val scan = scanner.scanRootFolder(root) + val scan = scanner.scanRootFolder(root).series val series = scan.keys // then @@ -150,7 +150,7 @@ class FileSystemScannerTest { } // when - val scan = scanner.scanRootFolder(link) + val scan = scanner.scanRootFolder(link).series val series = scan.keys // then @@ -189,7 +189,7 @@ class FileSystemScannerTest { } // when - val scan = scanner.scanRootFolder(root) + val scan = scanner.scanRootFolder(root).series val series = scan.keys // then @@ -223,7 +223,7 @@ class FileSystemScannerTest { makeSubDir(recycle, "subtrash", listOf("trash2.cbz")) // when - val scan = scanner.scanRootFolder(root) + val scan = scanner.scanRootFolder(root).series // then assertThat(scan).hasSize(2) @@ -246,7 +246,7 @@ class FileSystemScannerTest { makeSubDir(hidden, "subhidden", listOf("hidden2.cbz")) // when - val scan = scanner.scanRootFolder(root) + val scan = scanner.scanRootFolder(root).series // then assertThat(scan).hasSize(2) @@ -267,7 +267,7 @@ class FileSystemScannerTest { makeSubDir(dir1, "subdir1", listOf("comic2.cbz", ".comic2.cbz")) // when - val scan = scanner.scanRootFolder(root) + val scan = scanner.scanRootFolder(root).series // then assertThat(scan).hasSize(2) @@ -287,7 +287,7 @@ class FileSystemScannerTest { makeSubDir(root, "dir1", listOf("comic.Cbz", "comic2.CBR")) // when - val scan = scanner.scanRootFolder(root) + val scan = scanner.scanRootFolder(root).series // then assertThat(scan).hasSize(1) diff --git a/komga/src/test/kotlin/org/gotson/komga/domain/service/LibraryContentLifecycleTest.kt b/komga/src/test/kotlin/org/gotson/komga/domain/service/LibraryContentLifecycleTest.kt index c8379a930..5b781618f 100644 --- a/komga/src/test/kotlin/org/gotson/komga/domain/service/LibraryContentLifecycleTest.kt +++ b/komga/src/test/kotlin/org/gotson/komga/domain/service/LibraryContentLifecycleTest.kt @@ -4,7 +4,10 @@ import com.ninjasquad.springmockk.MockkBean import io.mockk.every import io.mockk.verify import org.assertj.core.api.Assertions.assertThat +import org.gotson.komga.domain.model.Book import org.gotson.komga.domain.model.Media +import org.gotson.komga.domain.model.ScanResult +import org.gotson.komga.domain.model.Series import org.gotson.komga.domain.model.makeBook import org.gotson.komga.domain.model.makeBookPage import org.gotson.komga.domain.model.makeLibrary @@ -46,6 +49,9 @@ class LibraryContentLifecycleTest( } } + private fun Map>.toScanResult() = + ScanResult(this, emptyList()) + @Test fun `given existing series when adding files and scanning then only updated Books are persisted`() { // given @@ -56,8 +62,8 @@ class LibraryContentLifecycleTest( val moreBooks = listOf(makeBook("book1"), makeBook("book2")) every { mockScanner.scanRootFolder(any()) }.returnsMany( - mapOf(makeSeries(name = "series") to books), - mapOf(makeSeries(name = "series") to moreBooks) + mapOf(makeSeries(name = "series") to books).toScanResult(), + mapOf(makeSeries(name = "series") to moreBooks).toScanResult(), ) libraryContentLifecycle.scanRootFolder(library) @@ -86,8 +92,8 @@ class LibraryContentLifecycleTest( every { mockScanner.scanRootFolder(any()) } .returnsMany( - mapOf(makeSeries(name = "series") to books), - mapOf(makeSeries(name = "series") to lessBooks) + mapOf(makeSeries(name = "series") to books).toScanResult(), + mapOf(makeSeries(name = "series") to lessBooks).toScanResult(), ) libraryContentLifecycle.scanRootFolder(library) @@ -117,8 +123,8 @@ class LibraryContentLifecycleTest( every { mockScanner.scanRootFolder(any()) } .returnsMany( - mapOf(makeSeries(name = "series") to books), - mapOf(makeSeries(name = "series") to updatedBooks) + mapOf(makeSeries(name = "series") to books).toScanResult(), + mapOf(makeSeries(name = "series") to updatedBooks).toScanResult(), ) libraryContentLifecycle.scanRootFolder(library) @@ -145,8 +151,8 @@ class LibraryContentLifecycleTest( every { mockScanner.scanRootFolder(any()) } .returnsMany( - mapOf(makeSeries(name = "series") to listOf(makeBook("book1"))), - emptyMap() + mapOf(makeSeries(name = "series") to listOf(makeBook("book1"))).toScanResult(), + emptyMap>().toScanResult(), ) libraryContentLifecycle.scanRootFolder(library) @@ -170,9 +176,9 @@ class LibraryContentLifecycleTest( .returnsMany( mapOf( makeSeries(name = "series") to listOf(makeBook("book1")), - makeSeries(name = "series2") to listOf(makeBook("book2")) - ), - mapOf(makeSeries(name = "series") to listOf(makeBook("book1"))) + makeSeries(name = "series2") to listOf(makeBook("book2")), + ).toScanResult(), + mapOf(makeSeries(name = "series") to listOf(makeBook("book1"))).toScanResult(), ) libraryContentLifecycle.scanRootFolder(library) @@ -195,8 +201,8 @@ class LibraryContentLifecycleTest( val book1 = makeBook("book1") every { mockScanner.scanRootFolder(any()) } .returnsMany( - mapOf(makeSeries(name = "series") to listOf(book1)), - mapOf(makeSeries(name = "series") to listOf(makeBook(name = "book1", fileLastModified = book1.fileLastModified))) + mapOf(makeSeries(name = "series") to listOf(book1)).toScanResult(), + mapOf(makeSeries(name = "series") to listOf(makeBook(name = "book1", fileLastModified = book1.fileLastModified))).toScanResult(), ) libraryContentLifecycle.scanRootFolder(library) @@ -231,8 +237,8 @@ class LibraryContentLifecycleTest( val book1 = makeBook("book1") every { mockScanner.scanRootFolder(any()) } .returnsMany( - mapOf(makeSeries(name = "series") to listOf(book1)), - mapOf(makeSeries(name = "series") to listOf(makeBook(name = "book1"))) + mapOf(makeSeries(name = "series") to listOf(book1)).toScanResult(), + mapOf(makeSeries(name = "series") to listOf(makeBook(name = "book1"))).toScanResult(), ) libraryContentLifecycle.scanRootFolder(library) @@ -267,11 +273,11 @@ class LibraryContentLifecycleTest( libraryRepository.insert(library2) every { mockScanner.scanRootFolder(Paths.get(library1.root.toURI())) } returns - mapOf(makeSeries(name = "series1") to listOf(makeBook("book1"))) + mapOf(makeSeries(name = "series1") to listOf(makeBook("book1"))).toScanResult() every { mockScanner.scanRootFolder(Paths.get(library2.root.toURI())) }.returnsMany( - mapOf(makeSeries(name = "series2") to listOf(makeBook("book2"))), - emptyMap() + mapOf(makeSeries(name = "series2") to listOf(makeBook("book2"))).toScanResult(), + emptyMap>().toScanResult(), ) libraryContentLifecycle.scanRootFolder(library1)