feat: library option to automatically empty trash after scan

This commit is contained in:
Gauthier Roebroeck 2021-07-09 15:20:48 +08:00
parent 31fbf2a829
commit 21781a3a23
7 changed files with 128 additions and 33 deletions

View file

@ -6,3 +6,6 @@ ALTER TABLE BOOK
ALTER TABLE SERIES
ADD COLUMN DELETED_DATE datetime NULL DEFAULT NULL;
alter table library
add column EMPTY_TRASH_AFTER_SCAN boolean NOT NULL DEFAULT 0;

View file

@ -22,6 +22,7 @@ data class Library(
val scanDeep: Boolean = false,
val repairExtensions: Boolean = false,
val convertToCbz: Boolean = false,
val emptyTrashAfterScan: Boolean = false,
val id: String = TsidCreator.getTsid256().toString(),

View file

@ -3,8 +3,10 @@ package org.gotson.komga.domain.service
import mu.KotlinLogging
import org.gotson.komga.application.tasks.TaskReceiver
import org.gotson.komga.domain.model.BookSearch
import org.gotson.komga.domain.model.DirectoryNotFoundException
import org.gotson.komga.domain.model.Library
import org.gotson.komga.domain.model.Media
import org.gotson.komga.domain.model.ScanResult
import org.gotson.komga.domain.model.SeriesSearch
import org.gotson.komga.domain.model.Sidecar
import org.gotson.komga.domain.persistence.BookRepository
@ -39,7 +41,12 @@ class LibraryContentLifecycle(
fun scanRootFolder(library: Library) {
logger.info { "Updating library: $library" }
measureTime {
val scanResult = fileSystemScanner.scanRootFolder(Paths.get(library.root.toURI()), library.scanForceModifiedTime)
val (scanResult, rootFolderInaccessible) = try {
fileSystemScanner.scanRootFolder(Paths.get(library.root.toURI()), library.scanForceModifiedTime) to false
} catch (e: DirectoryNotFoundException) {
ScanResult(emptyMap(), emptyList()) to true
}
val scannedSeries =
scanResult
.series
@ -128,40 +135,43 @@ class LibraryContentLifecycle(
}
}
val existingSidecars = sidecarRepository.findAll()
scanResult.sidecars.forEach { newSidecar ->
val existingSidecar = existingSidecars.firstOrNull { it.url == newSidecar.url }
if (existingSidecar == null || existingSidecar.lastModifiedTime.notEquals(newSidecar.lastModifiedTime)) {
when (newSidecar.source) {
Sidecar.Source.SERIES ->
seriesRepository.findByLibraryIdAndUrlOrNull(library.id, newSidecar.parentUrl)?.let { series ->
logger.info { "Sidecar changed on disk (${newSidecar.url}, refresh Series for ${newSidecar.type}: $series" }
when (newSidecar.type) {
Sidecar.Type.ARTWORK -> taskReceiver.refreshSeriesLocalArtwork(series.id)
if (!rootFolderInaccessible) {
val existingSidecars = sidecarRepository.findAll()
scanResult.sidecars.forEach { newSidecar ->
val existingSidecar = existingSidecars.firstOrNull { it.url == newSidecar.url }
if (existingSidecar == null || existingSidecar.lastModifiedTime.notEquals(newSidecar.lastModifiedTime)) {
when (newSidecar.source) {
Sidecar.Source.SERIES ->
seriesRepository.findByLibraryIdAndUrlOrNull(library.id, newSidecar.parentUrl)?.let { series ->
logger.info { "Sidecar changed on disk (${newSidecar.url}, refresh Series for ${newSidecar.type}: $series" }
when (newSidecar.type) {
Sidecar.Type.ARTWORK -> taskReceiver.refreshSeriesLocalArtwork(series.id)
}
}
}
Sidecar.Source.BOOK ->
bookRepository.findByLibraryIdAndUrlOrNull(library.id, newSidecar.parentUrl)?.let { book ->
logger.info { "Sidecar changed on disk (${newSidecar.url}, refresh Book for ${newSidecar.type}: $book" }
when (newSidecar.type) {
Sidecar.Type.ARTWORK -> taskReceiver.refreshBookLocalArtwork(book.id)
Sidecar.Source.BOOK ->
bookRepository.findByLibraryIdAndUrlOrNull(library.id, newSidecar.parentUrl)?.let { book ->
logger.info { "Sidecar changed on disk (${newSidecar.url}, refresh Book for ${newSidecar.type}: $book" }
when (newSidecar.type) {
Sidecar.Type.ARTWORK -> taskReceiver.refreshBookLocalArtwork(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 })
}
}
// 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 })
}
}
cleanupEmptySets()
if (library.emptyTrashAfterScan) emptyTrash(library)
else cleanupEmptySets()
}
}.also { logger.info { "Library updated in $it" } }
}

View file

@ -73,6 +73,7 @@ class LibraryDao(
.set(l.SCAN_DEEP, library.scanDeep)
.set(l.REPAIR_EXTENSIONS, library.repairExtensions)
.set(l.CONVERT_TO_CBZ, library.convertToCbz)
.set(l.EMPTY_TRASH_AFTER_SCAN, library.emptyTrashAfterScan)
.execute()
}
@ -93,6 +94,7 @@ class LibraryDao(
.set(l.SCAN_DEEP, library.scanDeep)
.set(l.REPAIR_EXTENSIONS, library.repairExtensions)
.set(l.CONVERT_TO_CBZ, library.convertToCbz)
.set(l.EMPTY_TRASH_AFTER_SCAN, library.emptyTrashAfterScan)
.set(l.LAST_MODIFIED_DATE, LocalDateTime.now(ZoneId.of("Z")))
.where(l.ID.eq(library.id))
.execute()
@ -116,6 +118,7 @@ class LibraryDao(
scanDeep = scanDeep,
repairExtensions = repairExtensions,
convertToCbz = convertToCbz,
emptyTrashAfterScan = emptyTrashAfterScan,
id = id,
createdDate = createdDate.toCurrentTimeZone(),
lastModifiedDate = lastModifiedDate.toCurrentTimeZone()

View file

@ -88,6 +88,7 @@ class LibraryController(
scanDeep = library.scanDeep,
repairExtensions = library.repairExtensions,
convertToCbz = library.convertToCbz,
emptyTrashAfterScan = library.emptyTrashAfterScan,
)
).toDto(includeRoot = principal.user.roleAdmin)
} catch (e: Exception) {
@ -125,6 +126,7 @@ class LibraryController(
scanDeep = library.scanDeep,
repairExtensions = library.repairExtensions,
convertToCbz = library.convertToCbz,
emptyTrashAfterScan = library.emptyTrashAfterScan,
)
libraryLifecycle.updateLibrary(toUpdate)
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
@ -195,6 +197,7 @@ data class LibraryCreationDto(
val scanDeep: Boolean = false,
val repairExtensions: Boolean = false,
val convertToCbz: Boolean = false,
val emptyTrashAfterScan: Boolean = false,
)
data class LibraryDto(
@ -213,6 +216,7 @@ data class LibraryDto(
val scanDeep: Boolean,
val repairExtensions: Boolean,
val convertToCbz: Boolean,
val emptyTrashAfterScan: Boolean,
)
data class LibraryUpdateDto(
@ -230,6 +234,7 @@ data class LibraryUpdateDto(
val scanDeep: Boolean,
val repairExtensions: Boolean,
val convertToCbz: Boolean,
val emptyTrashAfterScan: Boolean,
)
fun Library.toDto(includeRoot: Boolean) = LibraryDto(
@ -248,4 +253,5 @@ fun Library.toDto(includeRoot: Boolean) = LibraryDto(
scanDeep = scanDeep,
repairExtensions = repairExtensions,
convertToCbz = convertToCbz,
emptyTrashAfterScan = emptyTrashAfterScan,
)

View file

@ -5,6 +5,7 @@ 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.DirectoryNotFoundException
import org.gotson.komga.domain.model.Media
import org.gotson.komga.domain.model.ReadList
import org.gotson.komga.domain.model.ScanResult
@ -97,7 +98,7 @@ class LibraryContentLifecycleTest(
}
@Test
fun `given existing series when removing files and scanning then updated Books are persisted and removed books are marked as such`() {
fun `given existing series when removing files and scanning then updated books are persisted and removed books are marked as such`() {
// given
val library = makeLibrary()
libraryRepository.insert(library)
@ -251,7 +252,7 @@ class LibraryContentLifecycleTest(
}
@Test
fun `given existing Series when deleting all books of one series and scanning then series and its Books are marked as deleted`() {
fun `given existing Series when deleting all books of one series and scanning then series and its books are marked as deleted`() {
// given
val library = makeLibrary()
libraryRepository.insert(library)
@ -289,7 +290,7 @@ class LibraryContentLifecycleTest(
}
@Test
fun `given existing Book with media when rescanning then media is kept intact`() {
fun `given existing book with media when rescanning then media is kept intact`() {
// given
val library = makeLibrary()
libraryRepository.insert(library)
@ -325,7 +326,7 @@ class LibraryContentLifecycleTest(
}
@Test
fun `given existing Book with different last modified date when rescanning then media is marked as outdated and hash is reset`() {
fun `given existing book with different last modified date when rescanning then media is marked as outdated and hash is reset`() {
// given
val library = makeLibrary()
libraryRepository.insert(library)
@ -411,6 +412,75 @@ class LibraryContentLifecycleTest(
assertThat(booksLib2.map { it.deletedDate }).doesNotContainNull()
assertThat(booksLib2.map { it.name }).containsExactlyInAnyOrder("book2")
}
@Test
fun `given library with auto empty trash when scanning then removed series and books are deleted permanently`() {
// given
val library = makeLibrary().copy(emptyTrashAfterScan = true)
libraryRepository.insert(library)
every { mockScanner.scanRootFolder(any()) }
.returnsMany(
mapOf(
makeSeries(name = "series") to listOf(makeBook("book1"), makeBook("book3")),
makeSeries(name = "series2") to listOf(makeBook("book2")),
).toScanResult(),
mapOf(makeSeries(name = "series") to listOf(makeBook("book1"))).toScanResult(),
)
libraryContentLifecycle.scanRootFolder(library)
// when
libraryContentLifecycle.scanRootFolder(library)
// then
verify(exactly = 2) { mockScanner.scanRootFolder(any()) }
val (series, deletedSeries) = seriesRepository.findAll().partition { it.deletedDate == null }
val (books, deletedBooks) = bookRepository.findAll().partition { it.deletedDate == null }
assertThat(series).hasSize(1)
assertThat(series.map { it.name }).containsExactlyInAnyOrder("series")
assertThat(deletedSeries).isEmpty()
assertThat(books).hasSize(1)
assertThat(books.map { it.name }).containsExactlyInAnyOrder("book1")
assertThat(deletedBooks).isEmpty()
}
@Test
fun `given library with auto empty trash when scanning and the root folder is not accessible then trash is not emptied automatically`() {
// given
val library = makeLibrary().copy(emptyTrashAfterScan = true)
libraryRepository.insert(library)
every { mockScanner.scanRootFolder(any()) } returns mapOf(
makeSeries(name = "series") to listOf(makeBook("book1"), makeBook("book3")),
makeSeries(name = "series2") to listOf(makeBook("book2")),
).toScanResult() andThenThrows DirectoryNotFoundException("")
libraryContentLifecycle.scanRootFolder(library)
// when
libraryContentLifecycle.scanRootFolder(library)
// then
verify(exactly = 2) { mockScanner.scanRootFolder(any()) }
val (series, deletedSeries) = seriesRepository.findAll().partition { it.deletedDate == null }
val (books, deletedBooks) = bookRepository.findAll().partition { it.deletedDate == null }
assertThat(series).isEmpty()
assertThat(deletedSeries).hasSize(2)
assertThat(deletedSeries.map { it.name }).containsExactlyInAnyOrder("series", "series2")
assertThat(books).isEmpty()
assertThat(deletedBooks).hasSize(3)
assertThat(deletedBooks.map { it.name }).containsExactlyInAnyOrder("book1", "book2", "book3")
}
}
@Nested

View file

@ -65,6 +65,7 @@ class LibraryDaoTest(
importLocalArtwork = false,
repairExtensions = true,
convertToCbz = true,
emptyTrashAfterScan = true,
)
}
@ -89,6 +90,7 @@ class LibraryDaoTest(
assertThat(modified.importLocalArtwork).isEqualTo(updated.importLocalArtwork)
assertThat(modified.repairExtensions).isEqualTo(updated.repairExtensions)
assertThat(modified.convertToCbz).isEqualTo(updated.convertToCbz)
assertThat(modified.emptyTrashAfterScan).isEqualTo(updated.emptyTrashAfterScan)
}
@Test