feat(api): add endpoint to empty trash per library

This commit is contained in:
Gauthier Roebroeck 2021-07-08 15:51:34 +08:00
parent c1d34e430c
commit 4dac73ea9f
12 changed files with 469 additions and 282 deletions

View file

@ -17,6 +17,11 @@ sealed class Task(priority: Int = DEFAULT_PRIORITY) : Serializable {
override fun uniqueId() = "SCAN_LIBRARY_$libraryId" override fun uniqueId() = "SCAN_LIBRARY_$libraryId"
} }
class EmptyTrash(val libraryId: String, priority: Int = DEFAULT_PRIORITY) : Task(priority) {
override fun uniqueId() = "EMPTY_TRASH_$libraryId"
override fun toString(): String = "EmptyTrash(libraryId='$libraryId', priority='$priority')"
}
class AnalyzeBook(val bookId: String, priority: Int = DEFAULT_PRIORITY) : Task(priority) { class AnalyzeBook(val bookId: String, priority: Int = DEFAULT_PRIORITY) : Task(priority) {
override fun uniqueId() = "ANALYZE_BOOK_$bookId" override fun uniqueId() = "ANALYZE_BOOK_$bookId"
override fun toString(): String = "AnalyzeBook(bookId='$bookId', priority='$priority')" override fun toString(): String = "AnalyzeBook(bookId='$bookId', priority='$priority')"

View file

@ -49,6 +49,11 @@ class TaskHandler(
if (library.convertToCbz) taskReceiver.convertBooksToCbz(library, LOWEST_PRIORITY) if (library.convertToCbz) taskReceiver.convertBooksToCbz(library, LOWEST_PRIORITY)
} ?: logger.warn { "Cannot execute task $task: Library does not exist" } } ?: logger.warn { "Cannot execute task $task: Library does not exist" }
is Task.EmptyTrash ->
libraryRepository.findByIdOrNull(task.libraryId)?.let { library ->
libraryContentLifecycle.emptyTrash(library)
} ?: logger.warn { "Cannot execute task $task: Library does not exist" }
is Task.AnalyzeBook -> is Task.AnalyzeBook ->
bookRepository.findByIdOrNull(task.bookId)?.let { book -> bookRepository.findByIdOrNull(task.bookId)?.let { book ->
if (bookLifecycle.analyzeAndPersist(book)) { if (bookLifecycle.analyzeAndPersist(book)) {

View file

@ -44,6 +44,10 @@ class TaskReceiver(
submitTask(Task.ScanLibrary(libraryId)) submitTask(Task.ScanLibrary(libraryId))
} }
fun emptyTrash(libraryId: String, priority: Int = DEFAULT_PRIORITY) {
submitTask(Task.EmptyTrash(libraryId, priority))
}
fun analyzeUnknownAndOutdatedBooks(library: Library) { fun analyzeUnknownAndOutdatedBooks(library: Library) {
bookRepository.findAllIds( bookRepository.findAllIds(
BookSearch( BookSearch(

View file

@ -27,6 +27,8 @@ interface ReadListRepository {
*/ */
fun findAllContainingBookId(containsBookId: String, filterOnLibraryIds: Collection<String>?): Collection<ReadList> fun findAllContainingBookId(containsBookId: String, filterOnLibraryIds: Collection<String>?): Collection<ReadList>
fun findAllEmpty(): Collection<ReadList>
fun findByNameOrNull(name: String): ReadList? fun findByNameOrNull(name: String): ReadList?
fun insert(readList: ReadList) fun insert(readList: ReadList)

View file

@ -27,6 +27,8 @@ interface SeriesCollectionRepository {
*/ */
fun findAllContainingSeriesId(containsSeriesId: String, filterOnLibraryIds: Collection<String>?): Collection<SeriesCollection> fun findAllContainingSeriesId(containsSeriesId: String, filterOnLibraryIds: Collection<String>?): Collection<SeriesCollection>
fun findAllEmpty(): Collection<SeriesCollection>
fun findByNameOrNull(name: String): SeriesCollection? fun findByNameOrNull(name: String): SeriesCollection?
fun insert(collection: SeriesCollection) fun insert(collection: SeriesCollection)

View file

@ -2,13 +2,13 @@ package org.gotson.komga.domain.service
import mu.KotlinLogging import mu.KotlinLogging
import org.gotson.komga.application.tasks.TaskReceiver import org.gotson.komga.application.tasks.TaskReceiver
import org.gotson.komga.domain.model.BookSearch
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.SeriesSearch
import org.gotson.komga.domain.model.Sidecar import org.gotson.komga.domain.model.Sidecar
import org.gotson.komga.domain.persistence.BookRepository import org.gotson.komga.domain.persistence.BookRepository
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.SeriesCollectionRepository
import org.gotson.komga.domain.persistence.SeriesRepository import org.gotson.komga.domain.persistence.SeriesRepository
import org.gotson.komga.domain.persistence.SidecarRepository import org.gotson.komga.domain.persistence.SidecarRepository
import org.gotson.komga.infrastructure.configuration.KomgaProperties import org.gotson.komga.infrastructure.configuration.KomgaProperties
@ -28,8 +28,8 @@ class LibraryContentLifecycle(
private val bookLifecycle: BookLifecycle, private val bookLifecycle: BookLifecycle,
private val mediaRepository: MediaRepository, private val mediaRepository: MediaRepository,
private val seriesLifecycle: SeriesLifecycle, private val seriesLifecycle: SeriesLifecycle,
private val collectionRepository: SeriesCollectionRepository, private val collectionLifecycle: SeriesCollectionLifecycle,
private val readListRepository: ReadListRepository, private val readListLifecycle: ReadListLifecycle,
private val sidecarRepository: SidecarRepository, private val sidecarRepository: SidecarRepository,
private val komgaProperties: KomgaProperties, private val komgaProperties: KomgaProperties,
private val taskReceiver: TaskReceiver, private val taskReceiver: TaskReceiver,
@ -161,15 +161,32 @@ class LibraryContentLifecycle(
} }
} }
if (komgaProperties.deleteEmptyCollections) { cleanupEmptySets()
logger.info { "Deleting empty collections" }
collectionRepository.deleteEmpty()
}
if (komgaProperties.deleteEmptyReadLists) {
logger.info { "Deleting empty read lists" }
readListRepository.deleteEmpty()
}
}.also { logger.info { "Library updated in $it" } } }.also { logger.info { "Library updated in $it" } }
} }
fun emptyTrash(library: Library) {
logger.info { "Empty trash for library: $library" }
val seriesToDelete = seriesRepository.findAll(SeriesSearch(deleted = true))
seriesLifecycle.deleteMany(seriesToDelete)
val booksToDelete = bookRepository.findAll(BookSearch(deleted = true))
bookLifecycle.deleteMany(booksToDelete)
booksToDelete.map { it.seriesId }.distinct().forEach { seriesId ->
seriesRepository.findByIdOrNull(seriesId)?.let { seriesLifecycle.sortBooks(it) }
}
cleanupEmptySets()
}
private fun cleanupEmptySets() {
if (komgaProperties.deleteEmptyCollections) {
collectionLifecycle.deleteEmptyCollections()
}
if (komgaProperties.deleteEmptyReadLists) {
readListLifecycle.deleteEmptyReadLists()
}
}
} }

View file

@ -58,6 +58,13 @@ class ReadListLifecycle(
eventPublisher.publishEvent(DomainEvent.ReadListDeleted(readList)) eventPublisher.publishEvent(DomainEvent.ReadListDeleted(readList))
} }
fun deleteEmptyReadLists() {
logger.info { "Deleting empty read lists" }
val toDelete = readListRepository.findAllEmpty()
readListRepository.deleteEmpty()
toDelete.forEach { eventPublisher.publishEvent(DomainEvent.ReadListDeleted(it)) }
}
fun getThumbnailBytes(readList: ReadList): ByteArray { fun getThumbnailBytes(readList: ReadList): ByteArray {
val ids = with(mutableListOf<String>()) { val ids = with(mutableListOf<String>()) {
while (size < 4) { while (size < 4) {

View file

@ -54,6 +54,13 @@ class SeriesCollectionLifecycle(
eventPublisher.publishEvent(DomainEvent.CollectionDeleted(collection)) eventPublisher.publishEvent(DomainEvent.CollectionDeleted(collection))
} }
fun deleteEmptyCollections() {
logger.info { "Deleting empty collections" }
val toDelete = collectionRepository.findAllEmpty()
collectionRepository.deleteEmpty()
toDelete.forEach { eventPublisher.publishEvent(DomainEvent.CollectionDeleted(it)) }
}
fun getThumbnailBytes(collection: SeriesCollection): ByteArray { fun getThumbnailBytes(collection: SeriesCollection): ByteArray {
val ids = with(mutableListOf<String>()) { val ids = with(mutableListOf<String>()) {
while (size < 4) { while (size < 4) {

View file

@ -114,6 +114,18 @@ class ReadListDao(
.fetchAndMap(filterOnLibraryIds) .fetchAndMap(filterOnLibraryIds)
} }
override fun findAllEmpty(): Collection<ReadList> =
dsl.selectFrom(rl)
.where(
rl.ID.`in`(
dsl.select(rl.ID)
.from(rl)
.leftJoin(rlb).on(rl.ID.eq(rlb.READLIST_ID))
.where(rlb.READLIST_ID.isNull)
)
).fetchInto(rl)
.map { it.toDomain(sortedMapOf()) }
override fun findByNameOrNull(name: String): ReadList? = override fun findByNameOrNull(name: String): ReadList? =
selectBase() selectBase()
.where(rl.NAME.equalIgnoreCase(name)) .where(rl.NAME.equalIgnoreCase(name))

View file

@ -113,6 +113,18 @@ class SeriesCollectionDao(
.fetchAndMap(filterOnLibraryIds) .fetchAndMap(filterOnLibraryIds)
} }
override fun findAllEmpty(): Collection<SeriesCollection> =
dsl.selectFrom(c)
.where(
c.ID.`in`(
dsl.select(c.ID)
.from(c)
.leftJoin(cs).on(c.ID.eq(cs.COLLECTION_ID))
.where(cs.COLLECTION_ID.isNull)
)
).fetchInto(c)
.map { it.toDomain(emptyList()) }
override fun findByNameOrNull(name: String): SeriesCollection? = override fun findByNameOrNull(name: String): SeriesCollection? =
selectBase() selectBase()
.where(c.NAME.equalIgnoreCase(name)) .where(c.NAME.equalIgnoreCase(name))

View file

@ -169,6 +169,15 @@ class LibraryController(
taskReceiver.refreshSeriesLocalArtwork(it, priority = HIGH_PRIORITY) taskReceiver.refreshSeriesLocalArtwork(it, priority = HIGH_PRIORITY)
} }
} }
@PostMapping("{libraryId}/empty-trash")
@PreAuthorize("hasRole('$ROLE_ADMIN')")
@ResponseStatus(HttpStatus.ACCEPTED)
fun emptyTrash(@PathVariable libraryId: String) {
libraryRepository.findByIdOrNull(libraryId)?.let { library ->
taskReceiver.emptyTrash(library.id, HIGH_PRIORITY)
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
}
} }
data class LibraryCreationDto( data class LibraryCreationDto(

View file

@ -6,8 +6,10 @@ import io.mockk.verify
import org.assertj.core.api.Assertions.assertThat import org.assertj.core.api.Assertions.assertThat
import org.gotson.komga.domain.model.Book import org.gotson.komga.domain.model.Book
import org.gotson.komga.domain.model.Media import org.gotson.komga.domain.model.Media
import org.gotson.komga.domain.model.ReadList
import org.gotson.komga.domain.model.ScanResult 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.SeriesCollection
import org.gotson.komga.domain.model.makeBook import org.gotson.komga.domain.model.makeBook
import org.gotson.komga.domain.model.makeBookPage import org.gotson.komga.domain.model.makeBookPage
import org.gotson.komga.domain.model.makeLibrary import org.gotson.komga.domain.model.makeLibrary
@ -15,13 +17,18 @@ import org.gotson.komga.domain.model.makeSeries
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.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.SeriesCollectionRepository
import org.gotson.komga.domain.persistence.SeriesRepository import org.gotson.komga.domain.persistence.SeriesRepository
import org.gotson.komga.infrastructure.hash.Hasher import org.gotson.komga.infrastructure.hash.Hasher
import org.gotson.komga.infrastructure.language.toIndexedMap
import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith import org.junit.jupiter.api.extension.ExtendWith
import org.springframework.beans.factory.annotation.Autowired import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest import org.springframework.boot.test.context.SpringBootTest
import org.springframework.data.domain.Pageable
import org.springframework.test.context.junit.jupiter.SpringExtension import org.springframework.test.context.junit.jupiter.SpringExtension
import java.nio.file.Paths import java.nio.file.Paths
@ -34,7 +41,9 @@ class LibraryContentLifecycleTest(
@Autowired private val libraryContentLifecycle: LibraryContentLifecycle, @Autowired private val libraryContentLifecycle: LibraryContentLifecycle,
@Autowired private val bookLifecycle: BookLifecycle, @Autowired private val bookLifecycle: BookLifecycle,
@Autowired private val mediaRepository: MediaRepository, @Autowired private val mediaRepository: MediaRepository,
@Autowired private val libraryLifecycle: LibraryLifecycle @Autowired private val libraryLifecycle: LibraryLifecycle,
@Autowired private val collectionRepository: SeriesCollectionRepository,
@Autowired private val readListRepository: ReadListRepository,
) { ) {
@MockkBean @MockkBean
@ -56,348 +65,444 @@ class LibraryContentLifecycleTest(
private fun Map<Series, List<Book>>.toScanResult() = private fun Map<Series, List<Book>>.toScanResult() =
ScanResult(this, emptyList()) ScanResult(this, emptyList())
@Test @Nested
fun `given existing series when adding files and scanning then only updated Books are persisted`() { inner class Scan {
// given @Test
val library = makeLibrary() fun `given existing series when adding files and scanning then only updated Books are persisted`() {
libraryRepository.insert(library) // given
val library = makeLibrary()
libraryRepository.insert(library)
val books = listOf(makeBook("book1")) val books = listOf(makeBook("book1"))
val moreBooks = listOf(makeBook("book1"), makeBook("book2")) val moreBooks = listOf(makeBook("book1"), makeBook("book2"))
every { mockScanner.scanRootFolder(any()) }.returnsMany( every { mockScanner.scanRootFolder(any()) }.returnsMany(
mapOf(makeSeries(name = "series") to books).toScanResult(),
mapOf(makeSeries(name = "series") to moreBooks).toScanResult(),
)
libraryContentLifecycle.scanRootFolder(library)
// when
libraryContentLifecycle.scanRootFolder(library)
// then
val allSeries = seriesRepository.findAll()
val allBooks = bookRepository.findAll().sortedBy { it.number }
verify(exactly = 2) { mockScanner.scanRootFolder(any()) }
assertThat(allSeries).hasSize(1)
assertThat(allBooks).hasSize(2)
assertThat(allBooks.map { it.name }).containsExactly("book1", "book2")
}
@Test
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)
val books = listOf(makeBook("book1"), makeBook("book2"))
val lessBooks = listOf(makeBook("book1"))
every { mockScanner.scanRootFolder(any()) }
.returnsMany(
mapOf(makeSeries(name = "series") to books).toScanResult(), mapOf(makeSeries(name = "series") to books).toScanResult(),
mapOf(makeSeries(name = "series") to lessBooks).toScanResult(), mapOf(makeSeries(name = "series") to moreBooks).toScanResult(),
) )
libraryContentLifecycle.scanRootFolder(library) libraryContentLifecycle.scanRootFolder(library)
// when // when
libraryContentLifecycle.scanRootFolder(library) libraryContentLifecycle.scanRootFolder(library)
// then // then
val allSeries = seriesRepository.findAll() val allSeries = seriesRepository.findAll()
val allBooks = bookRepository.findAll().sortedBy { it.number } val allBooks = bookRepository.findAll().sortedBy { it.number }
verify(exactly = 2) { mockScanner.scanRootFolder(any()) } verify(exactly = 2) { mockScanner.scanRootFolder(any()) }
assertThat(allSeries).hasSize(1) assertThat(allSeries).hasSize(1)
assertThat(allBooks).hasSize(2) assertThat(allBooks).hasSize(2)
assertThat(allBooks.filter { it.deletedDate == null }.map { it.name }).containsExactly("book1") assertThat(allBooks.map { it.name }).containsExactly("book1", "book2")
assertThat(allBooks.filter { it.deletedDate != null }.map { it.name }).containsExactly("book2") }
}
@Test @Test
fun `given existing series when removing files and scanning, restoring files and scanning then restored books are available`() { fun `given existing series when removing files and scanning then updated Books are persisted and removed books are marked as such`() {
// given // given
val library = makeLibrary() val library = makeLibrary()
libraryRepository.insert(library) libraryRepository.insert(library)
val books = listOf(makeBook("book1"), makeBook("book2")) val books = listOf(makeBook("book1"), makeBook("book2"))
val lessBooks = listOf(makeBook("book1")) val lessBooks = listOf(makeBook("book1"))
every { mockScanner.scanRootFolder(any()) } every { mockScanner.scanRootFolder(any()) }
.returnsMany( .returnsMany(
mapOf(makeSeries(name = "series") to books).toScanResult(), mapOf(makeSeries(name = "series") to books).toScanResult(),
mapOf(makeSeries(name = "series") to lessBooks).toScanResult(), mapOf(makeSeries(name = "series") to lessBooks).toScanResult(),
mapOf(makeSeries(name = "series") to books).toScanResult(), )
) libraryContentLifecycle.scanRootFolder(library)
libraryContentLifecycle.scanRootFolder(library) // creation
libraryContentLifecycle.scanRootFolder(library) // deletion
// when // when
libraryContentLifecycle.scanRootFolder(library) // restore libraryContentLifecycle.scanRootFolder(library)
// then // then
val allSeries = seriesRepository.findAll() val allSeries = seriesRepository.findAll()
val allBooks = bookRepository.findAll().sortedBy { it.number } val allBooks = bookRepository.findAll().sortedBy { it.number }
verify(exactly = 3) { mockScanner.scanRootFolder(any()) } verify(exactly = 2) { mockScanner.scanRootFolder(any()) }
assertThat(allSeries).hasSize(1) assertThat(allSeries).hasSize(1)
assertThat(allBooks).hasSize(2) assertThat(allBooks).hasSize(2)
assertThat(allBooks.map { it.deletedDate }).containsOnlyNulls() assertThat(allBooks.filter { it.deletedDate == null }.map { it.name }).containsExactly("book1")
} assertThat(allBooks.filter { it.deletedDate != null }.map { it.name }).containsExactly("book2")
}
@Test @Test
fun `given existing series when updating files and scanning then Books are updated`() { fun `given existing series when removing files and scanning, restoring files and scanning then restored books are available`() {
// given // given
val library = makeLibrary() val library = makeLibrary()
libraryRepository.insert(library) libraryRepository.insert(library)
val books = listOf(makeBook("book1")) val books = listOf(makeBook("book1"), makeBook("book2"))
val updatedBooks = listOf(makeBook("book1")) val lessBooks = listOf(makeBook("book1"))
every { mockScanner.scanRootFolder(any()) } every { mockScanner.scanRootFolder(any()) }
.returnsMany( .returnsMany(
mapOf(makeSeries(name = "series") to books).toScanResult(), mapOf(makeSeries(name = "series") to books).toScanResult(),
mapOf(makeSeries(name = "series") to updatedBooks).toScanResult(), mapOf(makeSeries(name = "series") to lessBooks).toScanResult(),
) mapOf(makeSeries(name = "series") to books).toScanResult(),
libraryContentLifecycle.scanRootFolder(library) )
libraryContentLifecycle.scanRootFolder(library) // creation
libraryContentLifecycle.scanRootFolder(library) // deletion
// when // when
libraryContentLifecycle.scanRootFolder(library) libraryContentLifecycle.scanRootFolder(library) // restore
// then // then
val allSeries = seriesRepository.findAll() val allSeries = seriesRepository.findAll()
val allBooks = bookRepository.findAll() val allBooks = bookRepository.findAll().sortedBy { it.number }
verify(exactly = 2) { mockScanner.scanRootFolder(any()) } verify(exactly = 3) { mockScanner.scanRootFolder(any()) }
assertThat(allSeries).hasSize(1) assertThat(allSeries).hasSize(1)
assertThat(allBooks).hasSize(1) assertThat(allBooks).hasSize(2)
assertThat(allBooks.map { it.name }).containsExactly("book1") assertThat(allBooks.map { it.deletedDate }).containsOnlyNulls()
assertThat(allBooks.first().lastModifiedDate).isNotEqualTo(allBooks.first().createdDate) }
}
@Test @Test
fun `given existing series when deleting all books and scanning then Series and Books are marked as deleted`() { fun `given existing series when updating files and scanning then Books are updated`() {
// given // given
val library = makeLibrary() val library = makeLibrary()
libraryRepository.insert(library) libraryRepository.insert(library)
every { mockScanner.scanRootFolder(any()) } val books = listOf(makeBook("book1"))
.returnsMany( val updatedBooks = listOf(makeBook("book1"))
mapOf(makeSeries(name = "series") to listOf(makeBook("book1"))).toScanResult(),
emptyMap<Series, List<Book>>().toScanResult(),
)
libraryContentLifecycle.scanRootFolder(library)
// when every { mockScanner.scanRootFolder(any()) }
libraryContentLifecycle.scanRootFolder(library) .returnsMany(
mapOf(makeSeries(name = "series") to books).toScanResult(),
mapOf(makeSeries(name = "series") to updatedBooks).toScanResult(),
)
libraryContentLifecycle.scanRootFolder(library)
// then // when
verify(exactly = 2) { mockScanner.scanRootFolder(any()) } libraryContentLifecycle.scanRootFolder(library)
val allSeries = seriesRepository.findAll() // then
val allBooks = bookRepository.findAll() val allSeries = seriesRepository.findAll()
val allBooks = bookRepository.findAll()
assertThat(allSeries.map { it.deletedDate }).doesNotContainNull() verify(exactly = 2) { mockScanner.scanRootFolder(any()) }
assertThat(allSeries).hasSize(1)
assertThat(allBooks.map { it.deletedDate }).doesNotContainNull()
assertThat(allBooks).hasSize(1)
}
@Test assertThat(allSeries).hasSize(1)
fun `given existing series when deleting all books and scanning then restoring and scanning then Series and Books are available`() { assertThat(allBooks).hasSize(1)
// given assertThat(allBooks.map { it.name }).containsExactly("book1")
val library = makeLibrary() assertThat(allBooks.first().lastModifiedDate).isNotEqualTo(allBooks.first().createdDate)
libraryRepository.insert(library) }
val series = makeSeries(name = "series") @Test
val book = makeBook("book1") fun `given existing series when deleting all books and scanning then Series and Books are marked as deleted`() {
every { mockScanner.scanRootFolder(any()) } // given
.returnsMany( val library = makeLibrary()
mapOf(series to listOf(book)).toScanResult(), libraryRepository.insert(library)
emptyMap<Series, List<Book>>().toScanResult(),
mapOf(series to listOf(book)).toScanResult(),
)
libraryContentLifecycle.scanRootFolder(library) // creation
libraryContentLifecycle.scanRootFolder(library) // deletion
// when every { mockScanner.scanRootFolder(any()) }
libraryContentLifecycle.scanRootFolder(library) // restore .returnsMany(
mapOf(makeSeries(name = "series") to listOf(makeBook("book1"))).toScanResult(),
emptyMap<Series, List<Book>>().toScanResult(),
)
libraryContentLifecycle.scanRootFolder(library)
// then // when
verify(exactly = 3) { mockScanner.scanRootFolder(any()) } libraryContentLifecycle.scanRootFolder(library)
val allSeries = seriesRepository.findAll() // then
val allBooks = bookRepository.findAll() verify(exactly = 2) { mockScanner.scanRootFolder(any()) }
assertThat(allSeries.map { it.deletedDate }).containsOnlyNulls() val allSeries = seriesRepository.findAll()
assertThat(allSeries).hasSize(1) val allBooks = bookRepository.findAll()
assertThat(allBooks.map { it.deletedDate }).containsOnlyNulls()
assertThat(allBooks).hasSize(1)
}
@Test assertThat(allSeries.map { it.deletedDate }).doesNotContainNull()
fun `given existing Series when deleting all books of one series and scanning then series and its Books are marked as deleted`() { assertThat(allSeries).hasSize(1)
// given assertThat(allBooks.map { it.deletedDate }).doesNotContainNull()
val library = makeLibrary() assertThat(allBooks).hasSize(1)
libraryRepository.insert(library) }
every { mockScanner.scanRootFolder(any()) } @Test
.returnsMany( fun `given existing series when deleting all books and scanning then restoring and scanning then Series and Books are available`() {
mapOf( // given
makeSeries(name = "series") to listOf(makeBook("book1")), val library = makeLibrary()
makeSeries(name = "series2") to listOf(makeBook("book2")), libraryRepository.insert(library)
).toScanResult(),
mapOf(makeSeries(name = "series") to listOf(makeBook("book1"))).toScanResult(),
)
libraryContentLifecycle.scanRootFolder(library)
// when val series = makeSeries(name = "series")
libraryContentLifecycle.scanRootFolder(library) val book = makeBook("book1")
every { mockScanner.scanRootFolder(any()) }
.returnsMany(
mapOf(series to listOf(book)).toScanResult(),
emptyMap<Series, List<Book>>().toScanResult(),
mapOf(series to listOf(book)).toScanResult(),
)
libraryContentLifecycle.scanRootFolder(library) // creation
libraryContentLifecycle.scanRootFolder(library) // deletion
// then // when
verify(exactly = 2) { mockScanner.scanRootFolder(any()) } libraryContentLifecycle.scanRootFolder(library) // restore
val (series, deletedSeries) = seriesRepository.findAll().partition { it.deletedDate == null } // then
val (books, deletedBooks) = bookRepository.findAll().partition { it.deletedDate == null } verify(exactly = 3) { mockScanner.scanRootFolder(any()) }
assertThat(series).hasSize(1) val allSeries = seriesRepository.findAll()
assertThat(series.map { it.name }).containsExactlyInAnyOrder("series") val allBooks = bookRepository.findAll()
assertThat(deletedSeries).hasSize(1) assertThat(allSeries.map { it.deletedDate }).containsOnlyNulls()
assertThat(deletedSeries.map { it.name }).containsExactlyInAnyOrder("series2") assertThat(allSeries).hasSize(1)
assertThat(allBooks.map { it.deletedDate }).containsOnlyNulls()
assertThat(allBooks).hasSize(1)
}
assertThat(books).hasSize(1) @Test
assertThat(books.map { it.name }).containsExactlyInAnyOrder("book1") 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)
assertThat(deletedBooks).hasSize(1) every { mockScanner.scanRootFolder(any()) }
assertThat(deletedBooks.map { it.name }).containsExactlyInAnyOrder("book2") .returnsMany(
} 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)
@Test // when
fun `given existing Book with media when rescanning then media is kept intact`() { libraryContentLifecycle.scanRootFolder(library)
// given
val library = makeLibrary()
libraryRepository.insert(library)
val book1 = makeBook("book1") // then
every { mockScanner.scanRootFolder(any()) } verify(exactly = 2) { mockScanner.scanRootFolder(any()) }
.returnsMany(
mapOf(makeSeries(name = "series") to listOf(book1)).toScanResult(),
mapOf(makeSeries(name = "series") to listOf(makeBook(name = "book1", fileLastModified = book1.fileLastModified))).toScanResult(),
)
libraryContentLifecycle.scanRootFolder(library)
every { mockAnalyzer.analyze(any()) } returns Media(status = Media.Status.READY, mediaType = "application/zip", pages = mutableListOf(makeBookPage("1.jpg"), makeBookPage("2.jpg")), bookId = book1.id) val (series, deletedSeries) = seriesRepository.findAll().partition { it.deletedDate == null }
bookRepository.findAll().map { bookLifecycle.analyzeAndPersist(it) } val (books, deletedBooks) = bookRepository.findAll().partition { it.deletedDate == null }
// when assertThat(series).hasSize(1)
libraryContentLifecycle.scanRootFolder(library) assertThat(series.map { it.name }).containsExactlyInAnyOrder("series")
// then assertThat(deletedSeries).hasSize(1)
verify(exactly = 2) { mockScanner.scanRootFolder(any()) } assertThat(deletedSeries.map { it.name }).containsExactlyInAnyOrder("series2")
verify(exactly = 1) { mockAnalyzer.analyze(any()) }
bookRepository.findAll().first().let { book -> assertThat(books).hasSize(1)
assertThat(book.lastModifiedDate).isNotEqualTo(book.createdDate) assertThat(books.map { it.name }).containsExactlyInAnyOrder("book1")
mediaRepository.findById(book.id).let { media -> assertThat(deletedBooks).hasSize(1)
assertThat(media.status).isEqualTo(Media.Status.READY) assertThat(deletedBooks.map { it.name }).containsExactlyInAnyOrder("book2")
assertThat(media.mediaType).isEqualTo("application/zip") }
assertThat(media.pages).hasSize(2)
assertThat(media.pages.map { it.fileName }).containsExactly("1.jpg", "2.jpg") @Test
fun `given existing Book with media when rescanning then media is kept intact`() {
// given
val library = makeLibrary()
libraryRepository.insert(library)
val book1 = makeBook("book1")
every { mockScanner.scanRootFolder(any()) }
.returnsMany(
mapOf(makeSeries(name = "series") to listOf(book1)).toScanResult(),
mapOf(makeSeries(name = "series") to listOf(makeBook(name = "book1", fileLastModified = book1.fileLastModified))).toScanResult(),
)
libraryContentLifecycle.scanRootFolder(library)
every { mockAnalyzer.analyze(any()) } returns Media(status = Media.Status.READY, mediaType = "application/zip", pages = mutableListOf(makeBookPage("1.jpg"), makeBookPage("2.jpg")), bookId = book1.id)
bookRepository.findAll().map { bookLifecycle.analyzeAndPersist(it) }
// when
libraryContentLifecycle.scanRootFolder(library)
// then
verify(exactly = 2) { mockScanner.scanRootFolder(any()) }
verify(exactly = 1) { mockAnalyzer.analyze(any()) }
bookRepository.findAll().first().let { book ->
assertThat(book.lastModifiedDate).isNotEqualTo(book.createdDate)
mediaRepository.findById(book.id).let { media ->
assertThat(media.status).isEqualTo(Media.Status.READY)
assertThat(media.mediaType).isEqualTo("application/zip")
assertThat(media.pages).hasSize(2)
assertThat(media.pages.map { it.fileName }).containsExactly("1.jpg", "2.jpg")
}
} }
} }
}
@Test @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 // given
val library = makeLibrary() val library = makeLibrary()
libraryRepository.insert(library) libraryRepository.insert(library)
val book1 = makeBook("book1") val book1 = makeBook("book1")
every { mockScanner.scanRootFolder(any()) } every { mockScanner.scanRootFolder(any()) }
.returnsMany( .returnsMany(
mapOf(makeSeries(name = "series") to listOf(book1)).toScanResult(), mapOf(makeSeries(name = "series") to listOf(book1)).toScanResult(),
mapOf(makeSeries(name = "series") to listOf(makeBook(name = "book1"))).toScanResult(), mapOf(makeSeries(name = "series") to listOf(makeBook(name = "book1"))).toScanResult(),
) )
libraryContentLifecycle.scanRootFolder(library) libraryContentLifecycle.scanRootFolder(library)
every { mockAnalyzer.analyze(any()) } returns Media(status = Media.Status.READY, mediaType = "application/zip", pages = mutableListOf(makeBookPage("1.jpg"), makeBookPage("2.jpg")), bookId = book1.id) every { mockAnalyzer.analyze(any()) } returns Media(status = Media.Status.READY, mediaType = "application/zip", pages = mutableListOf(makeBookPage("1.jpg"), makeBookPage("2.jpg")), bookId = book1.id)
every { mockHasher.computeHash(any()) }.returnsMany("abc", "def") every { mockHasher.computeHash(any()) }.returnsMany("abc", "def")
bookRepository.findAll().map { bookRepository.findAll().map {
bookLifecycle.analyzeAndPersist(it) bookLifecycle.analyzeAndPersist(it)
bookLifecycle.hashAndPersist(it) bookLifecycle.hashAndPersist(it)
} }
// when // when
libraryContentLifecycle.scanRootFolder(library) libraryContentLifecycle.scanRootFolder(library)
// then // then
verify(exactly = 2) { mockScanner.scanRootFolder(any()) } verify(exactly = 2) { mockScanner.scanRootFolder(any()) }
verify(exactly = 1) { mockAnalyzer.analyze(any()) } verify(exactly = 1) { mockAnalyzer.analyze(any()) }
verify(exactly = 1) { mockHasher.computeHash(any()) } verify(exactly = 1) { mockHasher.computeHash(any()) }
bookRepository.findAll().first().let { book -> bookRepository.findAll().first().let { book ->
assertThat(book.lastModifiedDate).isNotEqualTo(book.createdDate) assertThat(book.lastModifiedDate).isNotEqualTo(book.createdDate)
assertThat(book.fileHash).isEmpty() assertThat(book.fileHash).isEmpty()
mediaRepository.findById(book.id).let { media -> mediaRepository.findById(book.id).let { media ->
assertThat(media.status).isEqualTo(Media.Status.OUTDATED) assertThat(media.status).isEqualTo(Media.Status.OUTDATED)
assertThat(media.mediaType).isEqualTo("application/zip") assertThat(media.mediaType).isEqualTo("application/zip")
assertThat(media.pages).hasSize(2) assertThat(media.pages).hasSize(2)
assertThat(media.pages.map { it.fileName }).containsExactly("1.jpg", "2.jpg") assertThat(media.pages.map { it.fileName }).containsExactly("1.jpg", "2.jpg")
}
} }
} }
@Test
fun `given 2 libraries when deleting all books of one and scanning then the other library is kept intact`() {
// given
val library1 = makeLibrary(name = "library1")
libraryRepository.insert(library1)
val library2 = makeLibrary(name = "library2")
libraryRepository.insert(library2)
every { mockScanner.scanRootFolder(Paths.get(library1.root.toURI())) } returns
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"))).toScanResult(),
emptyMap<Series, List<Book>>().toScanResult(),
)
libraryContentLifecycle.scanRootFolder(library1)
libraryContentLifecycle.scanRootFolder(library2)
assertThat(seriesRepository.count()).describedAs("Series repository should not be empty").isEqualTo(2)
assertThat(bookRepository.count()).describedAs("Book repository should not be empty").isEqualTo(2)
// when
libraryContentLifecycle.scanRootFolder(library2)
// then
verify(exactly = 1) { mockScanner.scanRootFolder(Paths.get(library1.root.toURI())) }
verify(exactly = 2) { mockScanner.scanRootFolder(Paths.get(library2.root.toURI())) }
val (seriesLib1, seriesLib2) = seriesRepository.findAll().partition { it.libraryId == library1.id }
val (booksLib1, booksLib2) = bookRepository.findAll().partition { it.libraryId == library1.id }
assertThat(seriesLib1.map { it.deletedDate }).containsOnlyNulls()
assertThat(seriesLib1.map { it.name }).containsExactlyInAnyOrder("series1")
assertThat(seriesLib2.map { it.deletedDate }).doesNotContainNull()
assertThat(seriesLib2.map { it.name }).containsExactlyInAnyOrder("series2")
assertThat(booksLib1.map { it.deletedDate }).containsOnlyNulls()
assertThat(booksLib1.map { it.name }).containsExactlyInAnyOrder("book1")
assertThat(booksLib2.map { it.deletedDate }).doesNotContainNull()
assertThat(booksLib2.map { it.name }).containsExactlyInAnyOrder("book2")
}
} }
@Test @Nested
fun `given 2 libraries when deleting all books of one and scanning then the other library is kept intact`() { inner class EmptyTrash {
// given @Test
val library1 = makeLibrary(name = "library1") fun `given library with deleted series and books when emptying the trash then deleted elements are permanently removed`() {
libraryRepository.insert(library1) // given
val library2 = makeLibrary(name = "library2") val library = makeLibrary()
libraryRepository.insert(library2) libraryRepository.insert(library)
every { mockScanner.scanRootFolder(Paths.get(library1.root.toURI())) } returns every { mockScanner.scanRootFolder(any()) }
mapOf(makeSeries(name = "series1") to listOf(makeBook("book1"))).toScanResult() .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(),
)
repeat(2) { libraryContentLifecycle.scanRootFolder(library) }
every { mockScanner.scanRootFolder(Paths.get(library2.root.toURI())) }.returnsMany( // when
mapOf(makeSeries(name = "series2") to listOf(makeBook("book2"))).toScanResult(), libraryContentLifecycle.emptyTrash(library)
emptyMap<Series, List<Book>>().toScanResult(),
)
libraryContentLifecycle.scanRootFolder(library1) // then
libraryContentLifecycle.scanRootFolder(library2) val (series, deletedSeries) = seriesRepository.findAll().partition { it.deletedDate == null }
val (books, deletedBooks) = bookRepository.findAll().partition { it.deletedDate == null }
assertThat(seriesRepository.count()).describedAs("Series repository should not be empty").isEqualTo(2) assertThat(series).hasSize(1)
assertThat(bookRepository.count()).describedAs("Book repository should not be empty").isEqualTo(2) assertThat(series.map { it.name }).containsExactlyInAnyOrder("series")
assertThat(deletedSeries).isEmpty()
// when assertThat(books).hasSize(1)
libraryContentLifecycle.scanRootFolder(library2) assertThat(books.map { it.name }).containsExactlyInAnyOrder("book1")
assertThat(deletedBooks).isEmpty()
}
// then @Test
verify(exactly = 1) { mockScanner.scanRootFolder(Paths.get(library1.root.toURI())) } fun `given series with books when emptying the trash then the series is properly sorted`() {
verify(exactly = 2) { mockScanner.scanRootFolder(Paths.get(library2.root.toURI())) } // given
val library = makeLibrary()
libraryRepository.insert(library)
val (seriesLib1, seriesLib2) = seriesRepository.findAll().partition { it.libraryId == library1.id } every { mockScanner.scanRootFolder(any()) }
val (booksLib1, booksLib2) = bookRepository.findAll().partition { it.libraryId == library1.id } .returnsMany(
mapOf(makeSeries(name = "series") to listOf(makeBook("book1"), makeBook("book2"), makeBook("book3"))).toScanResult(),
mapOf(makeSeries(name = "series") to listOf(makeBook("book2"), makeBook("book3"))).toScanResult(),
)
repeat(2) { libraryContentLifecycle.scanRootFolder(library) }
assertThat(seriesLib1.map { it.deletedDate }).containsOnlyNulls() // when
assertThat(seriesLib1.map { it.name }).containsExactlyInAnyOrder("series1") libraryContentLifecycle.emptyTrash(library)
assertThat(seriesLib2.map { it.deletedDate }).doesNotContainNull() // then
assertThat(seriesLib2.map { it.name }).containsExactlyInAnyOrder("series2") val series = seriesRepository.findAll().first()
val books = bookRepository.findAllBySeriesId(series.id).sortedBy { it.number }
assertThat(booksLib1.map { it.deletedDate }).containsOnlyNulls() assertThat(books).hasSize(2)
assertThat(booksLib1.map { it.name }).containsExactlyInAnyOrder("book1") with(books.first()) {
assertThat(name).isEqualTo("book2")
assertThat(number).isEqualTo(1)
}
with(books.last()) {
assertThat(name).isEqualTo("book3")
assertThat(number).isEqualTo(2)
}
}
assertThat(booksLib2.map { it.deletedDate }).doesNotContainNull() @Test
assertThat(booksLib2.map { it.name }).containsExactlyInAnyOrder("book2") fun `given collection and read list with deleted elements when emptying the trash then those sets are deleted`() {
// given
val library = makeLibrary()
libraryRepository.insert(library)
every { mockScanner.scanRootFolder(any()) }
.returnsMany(
mapOf(makeSeries(name = "series") to listOf(makeBook("book1"), makeBook("book2"), makeBook("book3"))).toScanResult(),
emptyMap<Series, List<Book>>().toScanResult(),
)
repeat(2) { libraryContentLifecycle.scanRootFolder(library) }
collectionRepository.insert(SeriesCollection("collection", seriesIds = seriesRepository.findAllIdsByLibraryId(library.id).toList()))
readListRepository.insert(ReadList("readlist", bookIds = bookRepository.findAllIdsByLibraryId(library.id).toList().toIndexedMap()))
// when
libraryContentLifecycle.emptyTrash(library)
// then
val collections = collectionRepository.searchAll(null, Pageable.unpaged())
val readLists = readListRepository.searchAll(null, Pageable.unpaged())
assertThat(collections.content).isEmpty()
assertThat(readLists.content).isEmpty()
}
} }
} }