feat: detect change in sidecar files during scan

local artwork refresh will be triggered when sidecar files are changed
This commit is contained in:
Gauthier Roebroeck 2021-06-10 14:12:32 +08:00
parent 91ecfdc3c7
commit 4244bcd9ae
25 changed files with 394 additions and 82 deletions

View file

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

View file

@ -40,6 +40,16 @@ sealed class Task(priority: Int = DEFAULT_PRIORITY) : Serializable {
override fun uniqueId() = "AGGREGATE_SERIES_METADATA_$seriesId" 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) { 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 uniqueId(): String = "IMPORT_BOOK_${seriesId}_$sourceFile"
override fun toString(): String = override fun toString(): String =

View file

@ -8,6 +8,7 @@ import org.gotson.komga.domain.service.BookConverter
import org.gotson.komga.domain.service.BookImporter import org.gotson.komga.domain.service.BookImporter
import org.gotson.komga.domain.service.BookLifecycle import org.gotson.komga.domain.service.BookLifecycle
import org.gotson.komga.domain.service.LibraryContentLifecycle 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.domain.service.MetadataLifecycle
import org.gotson.komga.infrastructure.jms.QUEUE_TASKS import org.gotson.komga.infrastructure.jms.QUEUE_TASKS
import org.gotson.komga.infrastructure.jms.QUEUE_TASKS_SELECTOR import org.gotson.komga.infrastructure.jms.QUEUE_TASKS_SELECTOR
@ -27,6 +28,7 @@ class TaskHandler(
private val libraryContentLifecycle: LibraryContentLifecycle, private val libraryContentLifecycle: LibraryContentLifecycle,
private val bookLifecycle: BookLifecycle, private val bookLifecycle: BookLifecycle,
private val metadataLifecycle: MetadataLifecycle, private val metadataLifecycle: MetadataLifecycle,
private val localArtworkLifecycle: LocalArtworkLifecycle,
private val bookImporter: BookImporter, private val bookImporter: BookImporter,
private val bookConverter: BookConverter, private val bookConverter: BookConverter,
) { ) {
@ -76,6 +78,16 @@ class TaskHandler(
metadataLifecycle.aggregateMetadata(series) metadataLifecycle.aggregateMetadata(series)
} ?: logger.warn { "Cannot execute task $task: Series does not exist" } } ?: 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 -> is Task.ImportBook ->
seriesRepository.findByIdOrNull(task.seriesId)?.let { series -> seriesRepository.findByIdOrNull(task.seriesId)?.let { series ->
val importedBook = bookImporter.importBook(Paths.get(task.sourceFile), series, task.copyMode, task.destinationName, task.upgradeBookId) val importedBook = bookImporter.importBook(Paths.get(task.sourceFile), series, task.copyMode, task.destinationName, task.upgradeBookId)

View file

@ -91,6 +91,14 @@ class TaskReceiver(
submitTask(Task.AggregateSeriesMetadata(seriesId)) 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) { 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)) submitTask(Task.ImportBook(sourceFile, seriesId, copyMode, destinationName, upgradeBookId, priority))
} }

View file

@ -0,0 +1,6 @@
package org.gotson.komga.domain.model
data class ScanResult(
val series: Map<Series, List<Book>>,
val sidecars: List<Sidecar>,
)

View file

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

View file

@ -5,9 +5,12 @@ import org.gotson.komga.domain.model.BookSearch
import org.springframework.data.domain.Page import org.springframework.data.domain.Page
import org.springframework.data.domain.Pageable import org.springframework.data.domain.Pageable
import org.springframework.data.domain.Sort import org.springframework.data.domain.Sort
import java.net.URL
interface BookRepository { interface BookRepository {
fun findByIdOrNull(bookId: String): Book? fun findByIdOrNull(bookId: String): Book?
fun findByLibraryIdAndUrlOrNull(libraryId: String, url: URL): Book?
fun findBySeriesId(seriesId: String): Collection<Book> fun findBySeriesId(seriesId: String): Collection<Book>
fun findAll(): Collection<Book> fun findAll(): Collection<Book>
fun findAll(bookSearch: BookSearch): Collection<Book> fun findAll(bookSearch: BookSearch): Collection<Book>
@ -15,6 +18,7 @@ interface BookRepository {
fun getLibraryId(bookId: String): String? fun getLibraryId(bookId: String): String?
fun findFirstIdInSeries(seriesId: String): String? fun findFirstIdInSeries(seriesId: String): String?
fun findAllIdBySeriesId(seriesId: String): Collection<String> fun findAllIdBySeriesId(seriesId: String): Collection<String>
fun findAllIdBySeriesIds(seriesIds: Collection<String>): Collection<String> fun findAllIdBySeriesIds(seriesIds: Collection<String>): Collection<String>
fun findAllIdByLibraryId(libraryId: String): Collection<String> fun findAllIdByLibraryId(libraryId: String): Collection<String>

View file

@ -5,16 +5,19 @@ import org.gotson.komga.domain.model.SeriesSearch
import java.net.URL import java.net.URL
interface SeriesRepository { interface SeriesRepository {
fun findAll(): Collection<Series>
fun findByIdOrNull(seriesId: String): Series? fun findByIdOrNull(seriesId: String): Series?
fun findByLibraryIdAndUrl(libraryId: String, url: URL): Series?
fun findAll(): Collection<Series>
fun findByLibraryId(libraryId: String): Collection<Series> fun findByLibraryId(libraryId: String): Collection<Series>
fun findByLibraryIdAndUrlNotIn(libraryId: String, urls: Collection<URL>): Collection<Series> fun findByLibraryIdAndUrlNotIn(libraryId: String, urls: Collection<URL>): Collection<Series>
fun findByLibraryIdAndUrl(libraryId: String, url: URL): Series?
fun findAll(search: SeriesSearch): Collection<Series> fun findAll(search: SeriesSearch): Collection<Series>
fun findByTitle(title: String): Collection<Series> fun findByTitle(title: String): Collection<Series>
fun getLibraryId(seriesId: String): String? fun getLibraryId(seriesId: String): String?
fun findAllIdByLibraryId(libraryId: String): Collection<String>
fun insert(series: Series) fun insert(series: Series)
fun update(series: Series) fun update(series: Series)

View file

@ -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<SidecarStored>
fun save(libraryId: String, sidecar: Sidecar)
fun deleteByLibraryIdAndUrls(libraryId: String, urls: Collection<URL>)
}

View file

@ -4,10 +4,15 @@ import mu.KotlinLogging
import org.apache.commons.io.FilenameUtils import org.apache.commons.io.FilenameUtils
import org.gotson.komga.domain.model.Book import org.gotson.komga.domain.model.Book
import org.gotson.komga.domain.model.DirectoryNotFoundException 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.Series
import org.gotson.komga.domain.model.Sidecar
import org.gotson.komga.infrastructure.configuration.KomgaProperties 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 org.springframework.stereotype.Service
import java.io.IOException import java.io.IOException
import java.net.URL
import java.nio.file.FileVisitOption import java.nio.file.FileVisitOption
import java.nio.file.FileVisitResult import java.nio.file.FileVisitResult
import java.nio.file.FileVisitor import java.nio.file.FileVisitor
@ -18,6 +23,7 @@ import java.nio.file.attribute.FileTime
import java.time.LocalDateTime 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.name
import kotlin.io.path.readAttributes import kotlin.io.path.readAttributes
import kotlin.time.measureTime import kotlin.time.measureTime
@ -25,12 +31,23 @@ private val logger = KotlinLogging.logger {}
@Service @Service
class FileSystemScanner( class FileSystemScanner(
private val komgaProperties: KomgaProperties private val komgaProperties: KomgaProperties,
private val sidecarBookConsumers: List<SidecarBookConsumer>,
private val sidecarSeriesConsumers: List<SidecarSeriesConsumer>,
) { ) {
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<Series, List<Book>> { 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 { "Scanning folder: $root" }
logger.info { "Supported extensions: $supportedExtensions" } logger.info { "Supported extensions: $supportedExtensions" }
logger.info { "Excluded patterns: ${komgaProperties.librariesScanDirectoryExclusions}" } logger.info { "Excluded patterns: ${komgaProperties.librariesScanDirectoryExclusions}" }
@ -40,10 +57,15 @@ class FileSystemScanner(
throw DirectoryNotFoundException("Folder is not accessible: $root", "ERR_1016") throw DirectoryNotFoundException("Folder is not accessible: $root", "ERR_1016")
val scannedSeries = mutableMapOf<Series, List<Book>>() val scannedSeries = mutableMapOf<Series, List<Book>>()
val scannedSidecars = mutableListOf<Sidecar>()
measureTime { measureTime {
// path is the series directory
val pathToSeries = mutableMapOf<Path, Series>() val pathToSeries = mutableMapOf<Path, Series>()
val pathToSeriesSidecars = mutableMapOf<Path, MutableList<Sidecar>>()
// path is the book's parent directory, ie the series directory
val pathToBooks = mutableMapOf<Path, MutableList<Book>>() val pathToBooks = mutableMapOf<Path, MutableList<Book>>()
val pathToBookSidecars = mutableMapOf<Path, MutableList<TempSidecar>>()
Files.walkFileTree( Files.walkFileTree(
root, setOf(FileVisitOption.FOLLOW_LINKS), Integer.MAX_VALUE, root, setOf(FileVisitOption.FOLLOW_LINKS), Integer.MAX_VALUE,
@ -67,14 +89,28 @@ class FileSystemScanner(
override fun visitFile(file: Path, attrs: BasicFileAttributes): FileVisitResult { override fun visitFile(file: Path, attrs: BasicFileAttributes): FileVisitResult {
logger.trace { "visitFile: $file" } logger.trace { "visitFile: $file" }
if (attrs.isRegularFile && if (attrs.isRegularFile) {
supportedExtensions.contains(FilenameUtils.getExtension(file.fileName.toString()).toLowerCase()) && if (supportedExtensions.contains(FilenameUtils.getExtension(file.fileName.toString()).toLowerCase()) &&
!file.fileName.toString().startsWith(".") !file.fileName.toString().startsWith(".")
) { ) {
val book = pathToBook(file, attrs) val book = pathToBook(file, attrs)
file.parent.let { key -> file.parent.let { key ->
if (pathToBooks.containsKey(key)) pathToBooks[key]!!.add(book) pathToBooks.merge(key, mutableListOf(book)) { prev, one -> prev.union(one).toMutableList() }
else pathToBooks[key] = mutableListOf(book) }
}
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 { override fun postVisitDirectory(dir: Path, exc: IOException?): FileVisitResult {
logger.trace { "postVisit: $dir" } logger.trace { "postVisit: $dir" }
val books = pathToBooks[dir] val books = pathToBooks[dir]
val series = pathToSeries[dir] val tempSeries = pathToSeries[dir]
if (!books.isNullOrEmpty() && series !== null) { if (!books.isNullOrEmpty() && tempSeries !== null) {
val series =
if (forceDirectoryModifiedTime) if (forceDirectoryModifiedTime)
scannedSeries[ tempSeries.copy(fileLastModified = maxOf(tempSeries.fileLastModified, books.maxOf { it.fileLastModified }))
series.copy(
fileLastModified = maxOf(
series.fileLastModified,
books.maxOf { it.fileLastModified }
)
)
] = books
else else
tempSeries
scannedSeries[series] = books 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 return FileVisitResult.CONTINUE
@ -113,7 +163,7 @@ class FileSystemScanner(
logger.info { "Scanned ${scannedSeries.size} series and $countOfBooks books in $it" } logger.info { "Scanned ${scannedSeries.size} series and $countOfBooks books in $it" }
} }
return scannedSeries return ScanResult(scannedSeries, scannedSidecars)
} }
fun scanFile(path: Path): Book? { fun scanFile(path: Path): Book? {
@ -124,7 +174,7 @@ class FileSystemScanner(
private fun pathToBook(path: Path, attrs: BasicFileAttributes): Book = private fun pathToBook(path: Path, attrs: BasicFileAttributes): Book =
Book( Book(
name = FilenameUtils.getBaseName(path.fileName.toString()), name = FilenameUtils.getBaseName(path.name),
url = path.toUri().toURL(), url = path.toUri().toURL(),
fileLastModified = attrs.getUpdatedTime(), fileLastModified = attrs.getUpdatedTime(),
fileSize = attrs.size() fileSize = attrs.size()

View file

@ -4,11 +4,13 @@ import mu.KotlinLogging
import org.gotson.komga.application.tasks.TaskReceiver import org.gotson.komga.application.tasks.TaskReceiver
import org.gotson.komga.domain.model.Library import org.gotson.komga.domain.model.Library
import org.gotson.komga.domain.model.Media 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.BookRepository
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.SeriesCollectionRepository import org.gotson.komga.domain.persistence.SeriesCollectionRepository
import org.gotson.komga.domain.persistence.SeriesRepository import org.gotson.komga.domain.persistence.SeriesRepository
import org.gotson.komga.domain.persistence.SidecarRepository
import org.gotson.komga.infrastructure.configuration.KomgaProperties import org.gotson.komga.infrastructure.configuration.KomgaProperties
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import java.nio.file.Paths import java.nio.file.Paths
@ -27,6 +29,7 @@ class LibraryContentLifecycle(
private val seriesLifecycle: SeriesLifecycle, private val seriesLifecycle: SeriesLifecycle,
private val collectionRepository: SeriesCollectionRepository, private val collectionRepository: SeriesCollectionRepository,
private val readListRepository: ReadListRepository, private val readListRepository: ReadListRepository,
private val sidecarRepository: SidecarRepository,
private val komgaProperties: KomgaProperties, private val komgaProperties: KomgaProperties,
private val taskReceiver: TaskReceiver, private val taskReceiver: TaskReceiver,
) { ) {
@ -34,8 +37,10 @@ class LibraryContentLifecycle(
fun scanRootFolder(library: Library) { fun scanRootFolder(library: Library) {
logger.info { "Updating library: $library" } logger.info { "Updating library: $library" }
measureTime { measureTime {
val scanResult = fileSystemScanner.scanRootFolder(Paths.get(library.root.toURI()), library.scanForceModifiedTime)
val scannedSeries = val scannedSeries =
fileSystemScanner.scanRootFolder(Paths.get(library.root.toURI()), library.scanForceModifiedTime) scanResult
.series
.map { (series, books) -> .map { (series, books) ->
series.copy(libraryId = library.id) to books.map { it.copy(libraryId = library.id) } series.copy(libraryId = library.id) to books.map { it.copy(libraryId = library.id) }
}.toMap() }.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) { if (komgaProperties.deleteEmptyCollections) {
logger.info { "Deleting empty collections" } logger.info { "Deleting empty collections" }
collectionRepository.deleteEmpty() collectionRepository.deleteEmpty()

View file

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

View file

@ -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.barcode.IsbnBarcodeProvider
import org.gotson.komga.infrastructure.metadata.comicrack.ComicInfoProvider import org.gotson.komga.infrastructure.metadata.comicrack.ComicInfoProvider
import org.gotson.komga.infrastructure.metadata.epub.EpubMetadataProvider import org.gotson.komga.infrastructure.metadata.epub.EpubMetadataProvider
import org.gotson.komga.infrastructure.metadata.localartwork.LocalArtworkProvider
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
private val logger = KotlinLogging.logger {} private val logger = KotlinLogging.logger {}
@ -39,13 +38,10 @@ class MetadataLifecycle(
private val bookMetadataAggregationRepository: BookMetadataAggregationRepository, private val bookMetadataAggregationRepository: BookMetadataAggregationRepository,
private val libraryRepository: LibraryRepository, private val libraryRepository: LibraryRepository,
private val bookRepository: BookRepository, private val bookRepository: BookRepository,
private val bookLifecycle: BookLifecycle,
private val seriesLifecycle: SeriesLifecycle,
private val collectionRepository: SeriesCollectionRepository, private val collectionRepository: SeriesCollectionRepository,
private val collectionLifecycle: SeriesCollectionLifecycle, private val collectionLifecycle: SeriesCollectionLifecycle,
private val readListRepository: ReadListRepository, private val readListRepository: ReadListRepository,
private val readListLifecycle: ReadListLifecycle, private val readListLifecycle: ReadListLifecycle,
private val localArtworkProvider: LocalArtworkProvider
) { ) {
fun refreshMetadata(book: Book, capabilities: List<BookMetadataPatchCapability>) { fun refreshMetadata(book: Book, capabilities: List<BookMetadataPatchCapability>) {
@ -82,9 +78,6 @@ class MetadataLifecycle(
} }
} }
} }
if (library.importLocalArtwork && capabilities.contains(BookMetadataPatchCapability.THUMBNAILS))
refreshMetadataLocalArtwork(book)
} }
private fun handlePatchForReadLists( 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) { fun refreshMetadata(series: Series) {
logger.info { "Refresh metadata for 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( private fun handlePatchForCollections(

View file

@ -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") 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) transientBookRepository.saveAll(books)

View file

@ -36,6 +36,12 @@ class BookDao(
override fun findByIdOrNull(bookId: String): Book? = override fun findByIdOrNull(bookId: String): Book? =
findByIdOrNull(dsl, bookId) 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? = private fun findByIdOrNull(dsl: DSLContext, bookId: String): Book? =
dsl.selectFrom(b) dsl.selectFrom(b)
.where(b.ID.eq(bookId)) .where(b.ID.eq(bookId))

View file

@ -65,6 +65,12 @@ class SeriesDao(
.where(s.ID.eq(seriesId)) .where(s.ID.eq(seriesId))
.fetchOne(0, String::class.java) .fetchOne(0, String::class.java)
override fun findAllIdByLibraryId(libraryId: String): Collection<String> =
dsl.select(s.ID)
.from(s)
.where(s.LIBRARY_ID.eq(libraryId))
.fetch(s.ID)
override fun findAll(search: SeriesSearch): Collection<Series> { override fun findAll(search: SeriesSearch): Collection<Series> {
val conditions = search.toCondition() val conditions = search.toCondition()

View file

@ -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<SidecarStored> =
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<URL>) {
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,
)
}

View file

@ -4,9 +4,12 @@ import mu.KotlinLogging
import org.apache.commons.io.FilenameUtils import org.apache.commons.io.FilenameUtils
import org.gotson.komga.domain.model.Book import org.gotson.komga.domain.model.Book
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.ThumbnailBook import org.gotson.komga.domain.model.ThumbnailBook
import org.gotson.komga.domain.model.ThumbnailSeries import org.gotson.komga.domain.model.ThumbnailSeries
import org.gotson.komga.infrastructure.mediacontainer.ContentDetector 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 org.springframework.stereotype.Service
import java.nio.file.Files import java.nio.file.Files
import kotlin.streams.asSequence import kotlin.streams.asSequence
@ -16,7 +19,7 @@ private val logger = KotlinLogging.logger {}
@Service @Service
class LocalArtworkProvider( class LocalArtworkProvider(
private val contentDetector: ContentDetector private val contentDetector: ContentDetector
) { ) : SidecarSeriesConsumer, SidecarBookConsumer {
val supportedExtensions = listOf("png", "jpeg", "jpg", "tbn") val supportedExtensions = listOf("png", "jpeg", "jpg", "tbn")
val supportedSeriesFiles = listOf("cover", "default", "folder", "poster", "series") val supportedSeriesFiles = listOf("cover", "default", "folder", "poster", "series")
@ -65,4 +68,19 @@ class LocalArtworkProvider(
}.toList() }.toList()
} }
} }
override fun getSidecarBookType(): Sidecar.Type = Sidecar.Type.ARTWORK
override fun getSidecarBookPrefilter(): List<Regex> =
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<String> =
supportedSeriesFiles.flatMap { filename ->
supportedExtensions.map { ext -> "$filename.$ext" }
}
} }

View file

@ -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<Regex>
fun isSidecarBookMatch(basename: String, sidecar: String): Boolean
}

View file

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

View file

@ -431,6 +431,7 @@ class BookController(
fun refreshMetadata(@PathVariable bookId: String) { fun refreshMetadata(@PathVariable bookId: String) {
bookRepository.findByIdOrNull(bookId)?.let { book -> bookRepository.findByIdOrNull(bookId)?.let { book ->
taskReceiver.refreshBookMetadata(book.id, priority = HIGH_PRIORITY) taskReceiver.refreshBookMetadata(book.id, priority = HIGH_PRIORITY)
taskReceiver.refreshBookLocalArtwork(book.id, priority = HIGH_PRIORITY)
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
} }

View file

@ -10,6 +10,7 @@ import org.gotson.komga.domain.model.PathContainedInPath
import org.gotson.komga.domain.model.ROLE_ADMIN import org.gotson.komga.domain.model.ROLE_ADMIN
import org.gotson.komga.domain.persistence.BookRepository import org.gotson.komga.domain.persistence.BookRepository
import org.gotson.komga.domain.persistence.LibraryRepository 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.domain.service.LibraryLifecycle
import org.gotson.komga.infrastructure.security.KomgaPrincipal import org.gotson.komga.infrastructure.security.KomgaPrincipal
import org.gotson.komga.infrastructure.web.filePathToUrl import org.gotson.komga.infrastructure.web.filePathToUrl
@ -40,7 +41,8 @@ class LibraryController(
private val taskReceiver: TaskReceiver, private val taskReceiver: TaskReceiver,
private val libraryLifecycle: LibraryLifecycle, private val libraryLifecycle: LibraryLifecycle,
private val libraryRepository: LibraryRepository, private val libraryRepository: LibraryRepository,
private val bookRepository: BookRepository private val bookRepository: BookRepository,
private val seriesRepository: SeriesRepository,
) { ) {
@GetMapping @GetMapping
@ -161,6 +163,10 @@ class LibraryController(
fun refreshMetadata(@PathVariable libraryId: String) { fun refreshMetadata(@PathVariable libraryId: String) {
bookRepository.findAllIdByLibraryId(libraryId).forEach { bookRepository.findAllIdByLibraryId(libraryId).forEach {
taskReceiver.refreshBookMetadata(it, priority = HIGH_PRIORITY) taskReceiver.refreshBookMetadata(it, priority = HIGH_PRIORITY)
taskReceiver.refreshBookLocalArtwork(it, priority = HIGH_PRIORITY)
}
seriesRepository.findAllIdByLibraryId(libraryId).forEach {
taskReceiver.refreshSeriesLocalArtwork(it, priority = HIGH_PRIORITY)
} }
} }
} }

View file

@ -309,7 +309,9 @@ class SeriesController(
fun refreshMetadata(@PathVariable seriesId: String) { fun refreshMetadata(@PathVariable seriesId: String) {
bookRepository.findAllIdBySeriesId(seriesId).forEach { bookRepository.findAllIdBySeriesId(seriesId).forEach {
taskReceiver.refreshBookMetadata(it, priority = HIGH_PRIORITY) taskReceiver.refreshBookMetadata(it, priority = HIGH_PRIORITY)
taskReceiver.refreshBookLocalArtwork(it, priority = HIGH_PRIORITY)
} }
taskReceiver.refreshSeriesLocalArtwork(seriesId, priority = HIGH_PRIORITY)
} }
@PatchMapping("{seriesId}/metadata") @PatchMapping("{seriesId}/metadata")

View file

@ -17,7 +17,7 @@ class FileSystemScannerTest {
librariesScanDirectoryExclusions = listOf("#recycle") librariesScanDirectoryExclusions = listOf("#recycle")
} }
private val scanner = FileSystemScanner(komgaProperties) private val scanner = FileSystemScanner(komgaProperties, emptyList(), emptyList())
@Test @Test
fun `given unavailable root directory when scanning then throw exception`() { fun `given unavailable root directory when scanning then throw exception`() {
@ -41,7 +41,7 @@ class FileSystemScannerTest {
Files.createDirectory(root) Files.createDirectory(root)
// when // when
val scan = scanner.scanRootFolder(root) val scan = scanner.scanRootFolder(root).series
// then // then
assertThat(scan).isEmpty() assertThat(scan).isEmpty()
@ -59,7 +59,7 @@ class FileSystemScannerTest {
files.forEach { Files.createFile(root.resolve(it)) } files.forEach { Files.createFile(root.resolve(it)) }
// when // when
val scan = scanner.scanRootFolder(root) val scan = scanner.scanRootFolder(root).series
val series = scan.keys.first() val series = scan.keys.first()
val books = scan.getValue(series) val books = scan.getValue(series)
@ -81,7 +81,7 @@ class FileSystemScannerTest {
files.forEach { Files.createFile(root.resolve(it)) } files.forEach { Files.createFile(root.resolve(it)) }
// when // when
val scan = scanner.scanRootFolder(root) val scan = scanner.scanRootFolder(root).series
val series = scan.keys.first() val series = scan.keys.first()
val books = scan.getValue(series) val books = scan.getValue(series)
@ -109,7 +109,7 @@ class FileSystemScannerTest {
} }
// when // when
val scan = scanner.scanRootFolder(root) val scan = scanner.scanRootFolder(root).series
val series = scan.keys val series = scan.keys
// then // then
@ -150,7 +150,7 @@ class FileSystemScannerTest {
} }
// when // when
val scan = scanner.scanRootFolder(link) val scan = scanner.scanRootFolder(link).series
val series = scan.keys val series = scan.keys
// then // then
@ -189,7 +189,7 @@ class FileSystemScannerTest {
} }
// when // when
val scan = scanner.scanRootFolder(root) val scan = scanner.scanRootFolder(root).series
val series = scan.keys val series = scan.keys
// then // then
@ -223,7 +223,7 @@ class FileSystemScannerTest {
makeSubDir(recycle, "subtrash", listOf("trash2.cbz")) makeSubDir(recycle, "subtrash", listOf("trash2.cbz"))
// when // when
val scan = scanner.scanRootFolder(root) val scan = scanner.scanRootFolder(root).series
// then // then
assertThat(scan).hasSize(2) assertThat(scan).hasSize(2)
@ -246,7 +246,7 @@ class FileSystemScannerTest {
makeSubDir(hidden, "subhidden", listOf("hidden2.cbz")) makeSubDir(hidden, "subhidden", listOf("hidden2.cbz"))
// when // when
val scan = scanner.scanRootFolder(root) val scan = scanner.scanRootFolder(root).series
// then // then
assertThat(scan).hasSize(2) assertThat(scan).hasSize(2)
@ -267,7 +267,7 @@ class FileSystemScannerTest {
makeSubDir(dir1, "subdir1", listOf("comic2.cbz", ".comic2.cbz")) makeSubDir(dir1, "subdir1", listOf("comic2.cbz", ".comic2.cbz"))
// when // when
val scan = scanner.scanRootFolder(root) val scan = scanner.scanRootFolder(root).series
// then // then
assertThat(scan).hasSize(2) assertThat(scan).hasSize(2)
@ -287,7 +287,7 @@ class FileSystemScannerTest {
makeSubDir(root, "dir1", listOf("comic.Cbz", "comic2.CBR")) makeSubDir(root, "dir1", listOf("comic.Cbz", "comic2.CBR"))
// when // when
val scan = scanner.scanRootFolder(root) val scan = scanner.scanRootFolder(root).series
// then // then
assertThat(scan).hasSize(1) assertThat(scan).hasSize(1)

View file

@ -4,7 +4,10 @@ import com.ninjasquad.springmockk.MockkBean
import io.mockk.every import io.mockk.every
import io.mockk.verify import io.mockk.verify
import org.assertj.core.api.Assertions.assertThat 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.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.makeBook
import org.gotson.komga.domain.model.makeBookPage import org.gotson.komga.domain.model.makeBookPage
import org.gotson.komga.domain.model.makeLibrary import org.gotson.komga.domain.model.makeLibrary
@ -46,6 +49,9 @@ class LibraryContentLifecycleTest(
} }
} }
private fun Map<Series, List<Book>>.toScanResult() =
ScanResult(this, emptyList())
@Test @Test
fun `given existing series when adding files and scanning then only updated Books are persisted`() { fun `given existing series when adding files and scanning then only updated Books are persisted`() {
// given // given
@ -56,8 +62,8 @@ class LibraryContentLifecycleTest(
val moreBooks = listOf(makeBook("book1"), makeBook("book2")) val moreBooks = listOf(makeBook("book1"), makeBook("book2"))
every { mockScanner.scanRootFolder(any()) }.returnsMany( every { mockScanner.scanRootFolder(any()) }.returnsMany(
mapOf(makeSeries(name = "series") to books), mapOf(makeSeries(name = "series") to books).toScanResult(),
mapOf(makeSeries(name = "series") to moreBooks) mapOf(makeSeries(name = "series") to moreBooks).toScanResult(),
) )
libraryContentLifecycle.scanRootFolder(library) libraryContentLifecycle.scanRootFolder(library)
@ -86,8 +92,8 @@ class LibraryContentLifecycleTest(
every { mockScanner.scanRootFolder(any()) } every { mockScanner.scanRootFolder(any()) }
.returnsMany( .returnsMany(
mapOf(makeSeries(name = "series") to books), mapOf(makeSeries(name = "series") to books).toScanResult(),
mapOf(makeSeries(name = "series") to lessBooks) mapOf(makeSeries(name = "series") to lessBooks).toScanResult(),
) )
libraryContentLifecycle.scanRootFolder(library) libraryContentLifecycle.scanRootFolder(library)
@ -117,8 +123,8 @@ class LibraryContentLifecycleTest(
every { mockScanner.scanRootFolder(any()) } every { mockScanner.scanRootFolder(any()) }
.returnsMany( .returnsMany(
mapOf(makeSeries(name = "series") to books), mapOf(makeSeries(name = "series") to books).toScanResult(),
mapOf(makeSeries(name = "series") to updatedBooks) mapOf(makeSeries(name = "series") to updatedBooks).toScanResult(),
) )
libraryContentLifecycle.scanRootFolder(library) libraryContentLifecycle.scanRootFolder(library)
@ -145,8 +151,8 @@ class LibraryContentLifecycleTest(
every { mockScanner.scanRootFolder(any()) } every { mockScanner.scanRootFolder(any()) }
.returnsMany( .returnsMany(
mapOf(makeSeries(name = "series") to listOf(makeBook("book1"))), mapOf(makeSeries(name = "series") to listOf(makeBook("book1"))).toScanResult(),
emptyMap() emptyMap<Series, List<Book>>().toScanResult(),
) )
libraryContentLifecycle.scanRootFolder(library) libraryContentLifecycle.scanRootFolder(library)
@ -170,9 +176,9 @@ class LibraryContentLifecycleTest(
.returnsMany( .returnsMany(
mapOf( mapOf(
makeSeries(name = "series") to listOf(makeBook("book1")), makeSeries(name = "series") to listOf(makeBook("book1")),
makeSeries(name = "series2") to listOf(makeBook("book2")) makeSeries(name = "series2") to listOf(makeBook("book2")),
), ).toScanResult(),
mapOf(makeSeries(name = "series") to listOf(makeBook("book1"))) mapOf(makeSeries(name = "series") to listOf(makeBook("book1"))).toScanResult(),
) )
libraryContentLifecycle.scanRootFolder(library) libraryContentLifecycle.scanRootFolder(library)
@ -195,8 +201,8 @@ class LibraryContentLifecycleTest(
val book1 = makeBook("book1") val book1 = makeBook("book1")
every { mockScanner.scanRootFolder(any()) } every { mockScanner.scanRootFolder(any()) }
.returnsMany( .returnsMany(
mapOf(makeSeries(name = "series") to listOf(book1)), mapOf(makeSeries(name = "series") to listOf(book1)).toScanResult(),
mapOf(makeSeries(name = "series") to listOf(makeBook(name = "book1", fileLastModified = book1.fileLastModified))) mapOf(makeSeries(name = "series") to listOf(makeBook(name = "book1", fileLastModified = book1.fileLastModified))).toScanResult(),
) )
libraryContentLifecycle.scanRootFolder(library) libraryContentLifecycle.scanRootFolder(library)
@ -231,8 +237,8 @@ class LibraryContentLifecycleTest(
val book1 = makeBook("book1") val book1 = makeBook("book1")
every { mockScanner.scanRootFolder(any()) } every { mockScanner.scanRootFolder(any()) }
.returnsMany( .returnsMany(
mapOf(makeSeries(name = "series") to listOf(book1)), mapOf(makeSeries(name = "series") to listOf(book1)).toScanResult(),
mapOf(makeSeries(name = "series") to listOf(makeBook(name = "book1"))) mapOf(makeSeries(name = "series") to listOf(makeBook(name = "book1"))).toScanResult(),
) )
libraryContentLifecycle.scanRootFolder(library) libraryContentLifecycle.scanRootFolder(library)
@ -267,11 +273,11 @@ class LibraryContentLifecycleTest(
libraryRepository.insert(library2) libraryRepository.insert(library2)
every { mockScanner.scanRootFolder(Paths.get(library1.root.toURI())) } returns 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( every { mockScanner.scanRootFolder(Paths.get(library2.root.toURI())) }.returnsMany(
mapOf(makeSeries(name = "series2") to listOf(makeBook("book2"))), mapOf(makeSeries(name = "series2") to listOf(makeBook("book2"))).toScanResult(),
emptyMap() emptyMap<Series, List<Book>>().toScanResult(),
) )
libraryContentLifecycle.scanRootFolder(library1) libraryContentLifecycle.scanRootFolder(library1)