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

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

View file

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

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.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<Book>
fun findAll(): Collection<Book>
fun findAll(bookSearch: BookSearch): Collection<Book>
@ -15,6 +18,7 @@ interface BookRepository {
fun getLibraryId(bookId: String): String?
fun findFirstIdInSeries(seriesId: String): String?
fun findAllIdBySeriesId(seriesId: String): Collection<String>
fun findAllIdBySeriesIds(seriesIds: Collection<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
interface SeriesRepository {
fun findAll(): Collection<Series>
fun findByIdOrNull(seriesId: String): Series?
fun findByLibraryIdAndUrl(libraryId: String, url: URL): Series?
fun findAll(): Collection<Series>
fun findByLibraryId(libraryId: String): 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 findByTitle(title: String): Collection<Series>
fun getLibraryId(seriesId: String): String?
fun findAllIdByLibraryId(libraryId: String): Collection<String>
fun insert(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.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<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 { "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<Series, List<Book>>()
val scannedSidecars = mutableListOf<Sidecar>()
measureTime {
// path is the series directory
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 pathToBookSidecars = mutableMapOf<Path, MutableList<TempSidecar>>()
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()

View file

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

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.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<BookMetadataPatchCapability>) {
@ -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(

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

View file

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

View file

@ -65,6 +65,12 @@ class SeriesDao(
.where(s.ID.eq(seriesId))
.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> {
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.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<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) {
bookRepository.findByIdOrNull(bookId)?.let { book ->
taskReceiver.refreshBookMetadata(book.id, priority = HIGH_PRIORITY)
taskReceiver.refreshBookLocalArtwork(book.id, priority = HIGH_PRIORITY)
} ?: 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.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)
}
}
}

View file

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

View file

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

View file

@ -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<Series, List<Book>>.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<Series, List<Book>>().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<Series, List<Book>>().toScanResult(),
)
libraryContentLifecycle.scanRootFolder(library1)