diff --git a/komga/src/flyway/resources/db/migration/sqlite/V20210706162229__file_hash.sql b/komga/src/flyway/resources/db/migration/sqlite/V20210706162229__trash_bin.sql similarity index 71% rename from komga/src/flyway/resources/db/migration/sqlite/V20210706162229__file_hash.sql rename to komga/src/flyway/resources/db/migration/sqlite/V20210706162229__trash_bin.sql index 04b120e0a..fa4c31b96 100644 --- a/komga/src/flyway/resources/db/migration/sqlite/V20210706162229__file_hash.sql +++ b/komga/src/flyway/resources/db/migration/sqlite/V20210706162229__trash_bin.sql @@ -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; diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/model/Library.kt b/komga/src/main/kotlin/org/gotson/komga/domain/model/Library.kt index e34a0ddd1..1b6482ee6 100644 --- a/komga/src/main/kotlin/org/gotson/komga/domain/model/Library.kt +++ b/komga/src/main/kotlin/org/gotson/komga/domain/model/Library.kt @@ -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(), diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/service/LibraryContentLifecycle.kt b/komga/src/main/kotlin/org/gotson/komga/domain/service/LibraryContentLifecycle.kt index b787f7a3d..d74fad8e4 100644 --- a/komga/src/main/kotlin/org/gotson/komga/domain/service/LibraryContentLifecycle.kt +++ b/komga/src/main/kotlin/org/gotson/komga/domain/service/LibraryContentLifecycle.kt @@ -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" } } } diff --git a/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/LibraryDao.kt b/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/LibraryDao.kt index e858ea694..ad020a981 100644 --- a/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/LibraryDao.kt +++ b/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/LibraryDao.kt @@ -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() diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/LibraryController.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/LibraryController.kt index cfecf1fa3..5307ef0a1 100644 --- a/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/LibraryController.kt +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/LibraryController.kt @@ -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, ) diff --git a/komga/src/test/kotlin/org/gotson/komga/domain/service/LibraryContentLifecycleTest.kt b/komga/src/test/kotlin/org/gotson/komga/domain/service/LibraryContentLifecycleTest.kt index 5d30b5944..263183411 100644 --- a/komga/src/test/kotlin/org/gotson/komga/domain/service/LibraryContentLifecycleTest.kt +++ b/komga/src/test/kotlin/org/gotson/komga/domain/service/LibraryContentLifecycleTest.kt @@ -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 diff --git a/komga/src/test/kotlin/org/gotson/komga/infrastructure/jooq/LibraryDaoTest.kt b/komga/src/test/kotlin/org/gotson/komga/infrastructure/jooq/LibraryDaoTest.kt index 2e6310334..1b0b10655 100644 --- a/komga/src/test/kotlin/org/gotson/komga/infrastructure/jooq/LibraryDaoTest.kt +++ b/komga/src/test/kotlin/org/gotson/komga/infrastructure/jooq/LibraryDaoTest.kt @@ -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