fix(scanner): fail scan if root folder is unavailable

this will prevent soft deleting the whole library, and rehash everything when available again

closes #617
This commit is contained in:
Gauthier Roebroeck 2021-08-16 14:41:56 +08:00
parent 538be86082
commit 871ec60869
6 changed files with 72 additions and 46 deletions

View file

@ -0,0 +1,2 @@
ALTER TABLE LIBRARY
ADD COLUMN UNAVAILABLE_DATE datetime NULL DEFAULT NULL;

View file

@ -26,6 +26,8 @@ data class Library(
val emptyTrashAfterScan: Boolean = false, val emptyTrashAfterScan: Boolean = false,
val seriesCover: SeriesCover = SeriesCover.FIRST, val seriesCover: SeriesCover = SeriesCover.FIRST,
val unavailableDate: LocalDateTime? = null,
val id: String = TsidCreator.getTsid256().toString(), val id: String = TsidCreator.getTsid256().toString(),
override val createdDate: LocalDateTime = LocalDateTime.now(), override val createdDate: LocalDateTime = LocalDateTime.now(),

View file

@ -1,20 +1,22 @@
package org.gotson.komga.domain.service package org.gotson.komga.domain.service
import mu.KotlinLogging import mu.KotlinLogging
import org.gotson.komga.application.events.EventPublisher
import org.gotson.komga.application.tasks.TaskReceiver import org.gotson.komga.application.tasks.TaskReceiver
import org.gotson.komga.domain.model.Book import org.gotson.komga.domain.model.Book
import org.gotson.komga.domain.model.BookMetadataPatchCapability import org.gotson.komga.domain.model.BookMetadataPatchCapability
import org.gotson.komga.domain.model.BookSearch import org.gotson.komga.domain.model.BookSearch
import org.gotson.komga.domain.model.DirectoryNotFoundException import org.gotson.komga.domain.model.DirectoryNotFoundException
import org.gotson.komga.domain.model.DomainEvent
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.ScanResult
import org.gotson.komga.domain.model.Series import org.gotson.komga.domain.model.Series
import org.gotson.komga.domain.model.SeriesSearch import org.gotson.komga.domain.model.SeriesSearch
import org.gotson.komga.domain.model.Sidecar 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.persistence.BookMetadataRepository import org.gotson.komga.domain.persistence.BookMetadataRepository
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.MediaRepository import org.gotson.komga.domain.persistence.MediaRepository
import org.gotson.komga.domain.persistence.ReadListRepository import org.gotson.komga.domain.persistence.ReadListRepository
import org.gotson.komga.domain.persistence.ReadProgressRepository import org.gotson.komga.domain.persistence.ReadProgressRepository
@ -30,6 +32,7 @@ import org.gotson.komga.infrastructure.language.toIndexedMap
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import org.springframework.transaction.support.TransactionTemplate import org.springframework.transaction.support.TransactionTemplate
import java.nio.file.Paths import java.nio.file.Paths
import java.time.LocalDateTime
import kotlin.time.measureTime import kotlin.time.measureTime
private val logger = KotlinLogging.logger {} private val logger = KotlinLogging.logger {}
@ -39,6 +42,7 @@ class LibraryContentLifecycle(
private val fileSystemScanner: FileSystemScanner, private val fileSystemScanner: FileSystemScanner,
private val seriesRepository: SeriesRepository, private val seriesRepository: SeriesRepository,
private val bookRepository: BookRepository, private val bookRepository: BookRepository,
private val libraryRepository: LibraryRepository,
private val bookLifecycle: BookLifecycle, private val bookLifecycle: BookLifecycle,
private val mediaRepository: MediaRepository, private val mediaRepository: MediaRepository,
private val seriesLifecycle: SeriesLifecycle, private val seriesLifecycle: SeriesLifecycle,
@ -55,15 +59,27 @@ class LibraryContentLifecycle(
private val readProgressRepository: ReadProgressRepository, private val readProgressRepository: ReadProgressRepository,
private val collectionRepository: SeriesCollectionRepository, private val collectionRepository: SeriesCollectionRepository,
private val thumbnailBookRepository: ThumbnailBookRepository, private val thumbnailBookRepository: ThumbnailBookRepository,
private val eventPublisher: EventPublisher,
) { ) {
fun scanRootFolder(library: Library) { fun scanRootFolder(library: Library) {
logger.info { "Updating library: $library" } logger.info { "Updating library: $library" }
measureTime { measureTime {
val (scanResult, rootFolderInaccessible) = try { val scanResult = try {
fileSystemScanner.scanRootFolder(Paths.get(library.root.toURI()), library.scanForceModifiedTime) to false fileSystemScanner.scanRootFolder(Paths.get(library.root.toURI()), library.scanForceModifiedTime)
} catch (e: DirectoryNotFoundException) { } catch (e: DirectoryNotFoundException) {
ScanResult(emptyMap(), emptyList()) to true library.copy(unavailableDate = LocalDateTime.now()).let {
libraryRepository.update(it)
eventPublisher.publishEvent(DomainEvent.LibraryUpdated(it))
}
throw e
}
if (library.unavailableDate != null) {
library.copy(unavailableDate = null).let {
libraryRepository.update(it)
eventPublisher.publishEvent(DomainEvent.LibraryUpdated(it))
}
} }
val scannedSeries = val scannedSeries =
@ -164,45 +180,43 @@ class LibraryContentLifecycle(
taskReceiver.refreshSeriesMetadata(it.id) taskReceiver.refreshSeriesMetadata(it.id)
} }
if (!rootFolderInaccessible) { val existingSidecars = sidecarRepository.findAll()
val existingSidecars = sidecarRepository.findAll() scanResult.sidecars.forEach { newSidecar ->
scanResult.sidecars.forEach { newSidecar -> val existingSidecar = existingSidecars.firstOrNull { it.url == newSidecar.url }
val existingSidecar = existingSidecars.firstOrNull { it.url == newSidecar.url } if (existingSidecar == null || existingSidecar.lastModifiedTime.notEquals(newSidecar.lastModifiedTime)) {
if (existingSidecar == null || existingSidecar.lastModifiedTime.notEquals(newSidecar.lastModifiedTime)) { when (newSidecar.source) {
when (newSidecar.source) { Sidecar.Source.SERIES ->
Sidecar.Source.SERIES -> seriesRepository.findByLibraryIdAndUrlOrNull(library.id, newSidecar.parentUrl)?.let { series ->
seriesRepository.findByLibraryIdAndUrlOrNull(library.id, newSidecar.parentUrl)?.let { series -> logger.info { "Sidecar changed on disk (${newSidecar.url}, refresh Series for ${newSidecar.type}: $series" }
logger.info { "Sidecar changed on disk (${newSidecar.url}, refresh Series for ${newSidecar.type}: $series" } when (newSidecar.type) {
when (newSidecar.type) { Sidecar.Type.ARTWORK -> taskReceiver.refreshSeriesLocalArtwork(series.id)
Sidecar.Type.ARTWORK -> taskReceiver.refreshSeriesLocalArtwork(series.id) Sidecar.Type.METADATA -> taskReceiver.refreshSeriesMetadata(series.id)
Sidecar.Type.METADATA -> taskReceiver.refreshSeriesMetadata(series.id)
}
} }
Sidecar.Source.BOOK -> }
bookRepository.findByLibraryIdAndUrlOrNull(library.id, newSidecar.parentUrl)?.let { book -> Sidecar.Source.BOOK ->
logger.info { "Sidecar changed on disk (${newSidecar.url}, refresh Book for ${newSidecar.type}: $book" } bookRepository.findByLibraryIdAndUrlOrNull(library.id, newSidecar.parentUrl)?.let { book ->
when (newSidecar.type) { logger.info { "Sidecar changed on disk (${newSidecar.url}, refresh Book for ${newSidecar.type}: $book" }
Sidecar.Type.ARTWORK -> taskReceiver.refreshBookLocalArtwork(book.id) when (newSidecar.type) {
Sidecar.Type.METADATA -> taskReceiver.refreshBookMetadata(book.id) Sidecar.Type.ARTWORK -> taskReceiver.refreshBookLocalArtwork(book.id)
} Sidecar.Type.METADATA -> taskReceiver.refreshBookMetadata(book.id)
} }
} }
sidecarRepository.save(library.id, newSidecar)
} }
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 (library.emptyTrashAfterScan) emptyTrash(library)
else cleanupEmptySets()
} }
// 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 (library.emptyTrashAfterScan) emptyTrash(library)
else cleanupEmptySets()
}.also { logger.info { "Library updated in $it" } } }.also { logger.info { "Library updated in $it" } }
} }

View file

@ -75,6 +75,7 @@ class LibraryDao(
.set(l.REPAIR_EXTENSIONS, library.repairExtensions) .set(l.REPAIR_EXTENSIONS, library.repairExtensions)
.set(l.CONVERT_TO_CBZ, library.convertToCbz) .set(l.CONVERT_TO_CBZ, library.convertToCbz)
.set(l.EMPTY_TRASH_AFTER_SCAN, library.emptyTrashAfterScan) .set(l.EMPTY_TRASH_AFTER_SCAN, library.emptyTrashAfterScan)
.set(l.UNAVAILABLE_DATE, library.unavailableDate)
.set(l.SERIES_COVER, library.seriesCover.toString()) .set(l.SERIES_COVER, library.seriesCover.toString())
.execute() .execute()
} }
@ -99,6 +100,7 @@ class LibraryDao(
.set(l.CONVERT_TO_CBZ, library.convertToCbz) .set(l.CONVERT_TO_CBZ, library.convertToCbz)
.set(l.EMPTY_TRASH_AFTER_SCAN, library.emptyTrashAfterScan) .set(l.EMPTY_TRASH_AFTER_SCAN, library.emptyTrashAfterScan)
.set(l.SERIES_COVER, library.seriesCover.toString()) .set(l.SERIES_COVER, library.seriesCover.toString())
.set(l.UNAVAILABLE_DATE, library.unavailableDate)
.set(l.LAST_MODIFIED_DATE, LocalDateTime.now(ZoneId.of("Z"))) .set(l.LAST_MODIFIED_DATE, LocalDateTime.now(ZoneId.of("Z")))
.where(l.ID.eq(library.id)) .where(l.ID.eq(library.id))
.execute() .execute()
@ -125,6 +127,7 @@ class LibraryDao(
convertToCbz = convertToCbz, convertToCbz = convertToCbz,
emptyTrashAfterScan = emptyTrashAfterScan, emptyTrashAfterScan = emptyTrashAfterScan,
seriesCover = Library.SeriesCover.valueOf(seriesCover), seriesCover = Library.SeriesCover.valueOf(seriesCover),
unavailableDate = unavailableDate,
id = id, id = id,
createdDate = createdDate.toCurrentTimeZone(), createdDate = createdDate.toCurrentTimeZone(),
lastModifiedDate = lastModifiedDate.toCurrentTimeZone() lastModifiedDate = lastModifiedDate.toCurrentTimeZone()

View file

@ -22,6 +22,7 @@ data class LibraryDto(
val convertToCbz: Boolean, val convertToCbz: Boolean,
val emptyTrashAfterScan: Boolean, val emptyTrashAfterScan: Boolean,
val seriesCover: SeriesCoverDto, val seriesCover: SeriesCoverDto,
val unavailable: Boolean,
) )
fun Library.toDto(includeRoot: Boolean) = LibraryDto( fun Library.toDto(includeRoot: Boolean) = LibraryDto(
@ -43,4 +44,5 @@ fun Library.toDto(includeRoot: Boolean) = LibraryDto(
convertToCbz = convertToCbz, convertToCbz = convertToCbz,
emptyTrashAfterScan = emptyTrashAfterScan, emptyTrashAfterScan = emptyTrashAfterScan,
seriesCover = seriesCover.toDto(), seriesCover = seriesCover.toDto(),
unavailable = unavailableDate != null,
) )

View file

@ -7,6 +7,7 @@ import io.mockk.just
import io.mockk.slot import io.mockk.slot
import io.mockk.verify import io.mockk.verify
import org.assertj.core.api.Assertions.assertThat import org.assertj.core.api.Assertions.assertThat
import org.assertj.core.api.Assertions.catchThrowable
import org.gotson.komga.application.tasks.TaskReceiver import org.gotson.komga.application.tasks.TaskReceiver
import org.gotson.komga.domain.model.Book import org.gotson.komga.domain.model.Book
import org.gotson.komga.domain.model.BookMetadataPatchCapability import org.gotson.komga.domain.model.BookMetadataPatchCapability
@ -429,7 +430,7 @@ class LibraryContentLifecycleTest(
} }
@Test @Test
fun `given library with auto empty trash when scanning and the root folder is not accessible then trash is not emptied automatically`() { fun `given library when scanning and the root folder is not accessible then exception is thrown`() {
// given // given
val library = makeLibrary().copy(emptyTrashAfterScan = true) val library = makeLibrary().copy(emptyTrashAfterScan = true)
libraryRepository.insert(library) libraryRepository.insert(library)
@ -442,7 +443,7 @@ class LibraryContentLifecycleTest(
libraryContentLifecycle.scanRootFolder(library) libraryContentLifecycle.scanRootFolder(library)
// when // when
libraryContentLifecycle.scanRootFolder(library) val thrown = catchThrowable { libraryContentLifecycle.scanRootFolder(library) }
// then // then
verify(exactly = 2) { mockScanner.scanRootFolder(any()) } verify(exactly = 2) { mockScanner.scanRootFolder(any()) }
@ -450,15 +451,17 @@ class LibraryContentLifecycleTest(
val (series, deletedSeries) = seriesRepository.findAll().partition { it.deletedDate == null } val (series, deletedSeries) = seriesRepository.findAll().partition { it.deletedDate == null }
val (books, deletedBooks) = bookRepository.findAll().partition { it.deletedDate == null } val (books, deletedBooks) = bookRepository.findAll().partition { it.deletedDate == null }
assertThat(series).isEmpty() assertThat(series).hasSize(2)
assertThat(series.map { it.name }).containsExactlyInAnyOrder("series", "series2")
assertThat(deletedSeries).hasSize(2) assertThat(deletedSeries).isEmpty()
assertThat(deletedSeries.map { it.name }).containsExactlyInAnyOrder("series", "series2")
assertThat(books).isEmpty() assertThat(books).hasSize(3)
assertThat(books.map { it.name }).containsExactlyInAnyOrder("book1", "book2", "book3")
assertThat(deletedBooks).hasSize(3) assertThat(deletedBooks).isEmpty()
assertThat(deletedBooks.map { it.name }).containsExactlyInAnyOrder("book1", "book2", "book3")
assertThat(thrown).isExactlyInstanceOf(DirectoryNotFoundException::class.java)
} }
} }