mirror of
https://github.com/gotson/komga.git
synced 2025-12-20 15:34:17 +01:00
feat: library option to automatically empty trash after scan
This commit is contained in:
parent
31fbf2a829
commit
21781a3a23
7 changed files with 128 additions and 33 deletions
|
|
@ -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;
|
||||
|
|
@ -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(),
|
||||
|
||||
|
|
|
|||
|
|
@ -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" } }
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue