diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/persistence/BookRepository.kt b/komga/src/main/kotlin/org/gotson/komga/domain/persistence/BookRepository.kt index e0a71071..4f973ccb 100644 --- a/komga/src/main/kotlin/org/gotson/komga/domain/persistence/BookRepository.kt +++ b/komga/src/main/kotlin/org/gotson/komga/domain/persistence/BookRepository.kt @@ -14,8 +14,10 @@ interface BookRepository { fun findAll(): Collection fun findAllBySeriesId(seriesId: String): Collection fun findAllBySeriesIds(seriesIds: Collection): Collection + fun findAllByLibraryIdAndUrlNotIn(libraryId: String, urls: Collection): Collection fun findAll(bookSearch: BookSearch): Collection fun findAll(bookSearch: BookSearch, pageable: Pageable): Page + fun findAllDeletedByFileSize(fileSize: Long): Collection fun getLibraryIdOrNull(bookId: String): String? fun getSeriesIdOrNull(bookId: String): String? 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 d74fad8e..03e29241 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 @@ -2,19 +2,31 @@ package org.gotson.komga.domain.service import mu.KotlinLogging import org.gotson.komga.application.tasks.TaskReceiver +import org.gotson.komga.domain.model.Book +import org.gotson.komga.domain.model.BookMetadataPatchCapability 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.Series import org.gotson.komga.domain.model.SeriesSearch import org.gotson.komga.domain.model.Sidecar +import org.gotson.komga.domain.model.ThumbnailBook +import org.gotson.komga.domain.persistence.BookMetadataRepository import org.gotson.komga.domain.persistence.BookRepository import org.gotson.komga.domain.persistence.MediaRepository +import org.gotson.komga.domain.persistence.ReadListRepository +import org.gotson.komga.domain.persistence.ReadProgressRepository +import org.gotson.komga.domain.persistence.SeriesCollectionRepository +import org.gotson.komga.domain.persistence.SeriesMetadataRepository import org.gotson.komga.domain.persistence.SeriesRepository import org.gotson.komga.domain.persistence.SidecarRepository +import org.gotson.komga.domain.persistence.ThumbnailBookRepository import org.gotson.komga.infrastructure.configuration.KomgaProperties +import org.gotson.komga.infrastructure.hash.Hasher import org.gotson.komga.infrastructure.language.notEquals +import org.gotson.komga.infrastructure.language.toIndexedMap import org.springframework.stereotype.Service import org.springframework.transaction.support.TransactionTemplate import java.nio.file.Paths @@ -36,6 +48,13 @@ class LibraryContentLifecycle( private val komgaProperties: KomgaProperties, private val taskReceiver: TaskReceiver, private val transactionTemplate: TransactionTemplate, + private val hasher: Hasher, + private val bookMetadataRepository: BookMetadataRepository, + private val seriesMetadataRepository: SeriesMetadataRepository, + private val readListRepository: ReadListRepository, + private val readProgressRepository: ReadProgressRepository, + private val collectionRepository: SeriesCollectionRepository, + private val thumbnailBookRepository: ThumbnailBookRepository, ) { fun scanRootFolder(library: Library) { @@ -61,7 +80,7 @@ class LibraryContentLifecycle( seriesLifecycle.softDeleteMany(series) } else { scannedSeries.keys.map { it.url }.let { urls -> - val series = seriesRepository.findAllByLibraryIdAndUrlNotIn(library.id, urls) + val series = seriesRepository.findAllByLibraryIdAndUrlNotIn(library.id, urls).filterNot { it.deletedDate != null } if (series.isNotEmpty()) { logger.info { "Soft deleting series not on disk anymore: $series" } seriesLifecycle.softDeleteMany(series) @@ -69,6 +88,16 @@ class LibraryContentLifecycle( } } + // delete books that don't exist anymore. We need to do this now, so trash bin can work + val seriesToSortAndRefresh = scannedSeries.values.flatten().map { it.url }.let { urls -> + val books = bookRepository.findAllByLibraryIdAndUrlNotIn(library.id, urls).filterNot { it.deletedDate != null } + if (books.isNotEmpty()) { + logger.info { "Soft deleting books not on disk anymore: $books" } + bookLifecycle.softDeleteMany(books) + books.map { it.seriesId }.distinct().mapNotNull { seriesRepository.findByIdOrNull(it) }.toMutableList() + } else mutableListOf() + } + scannedSeries.forEach { (newSeries, newBooks) -> val existingSeries = seriesRepository.findByLibraryIdAndUrlOrNull(library.id, newSeries.url) @@ -77,7 +106,9 @@ class LibraryContentLifecycle( logger.info { "Adding new series: $newSeries" } val createdSeries = seriesLifecycle.createSeries(newSeries) seriesLifecycle.addBooks(createdSeries, newBooks) - seriesLifecycle.sortBooks(createdSeries) + tryRestoreSeries(createdSeries, newBooks) + tryRestoreBooks(newBooks) + seriesToSortAndRefresh.add(createdSeries) } else { // if series already exists, update it logger.debug { "Scanned series already exists. Scanned: $newSeries, Existing: $existingSeries" } @@ -90,18 +121,18 @@ class LibraryContentLifecycle( // update list of books with existing entities if they exist val existingBooks = bookRepository.findAllBySeriesId(existingSeries.id) logger.debug { "Existing books: $existingBooks" } + // update existing books newBooks.forEach { newBook -> logger.debug { "Trying to match scanned book by url: $newBook" } - existingBooks.find { it.url == newBook.url }?.let { existingBook -> + existingBooks.find { it.url == newBook.url && it.deletedDate == null }?.let { existingBook -> logger.debug { "Matched existing book: $existingBook" } - if (newBook.fileLastModified.notEquals(existingBook.fileLastModified) || existingBook.deletedDate != null) { + if (newBook.fileLastModified.notEquals(existingBook.fileLastModified)) { logger.info { "Book changed on disk, update and reset media status: $existingBook" } val updatedBook = existingBook.copy( fileLastModified = newBook.fileLastModified, fileSize = newBook.fileSize, fileHash = "", - deletedDate = null, ) transactionTemplate.executeWithoutResult { mediaRepository.findById(existingBook.id).let { @@ -113,28 +144,23 @@ class LibraryContentLifecycle( } } - // remove books not present anymore - val newBooksUrls = newBooks.map { it.url } - existingBooks - .filterNot { existingBook -> newBooksUrls.contains(existingBook.url) } - .let { books -> - logger.info { "Deleting books not on disk anymore: $books" } - bookLifecycle.softDeleteMany(books) - books.map { it.seriesId }.distinct().forEach { taskReceiver.refreshSeriesMetadata(it) } - } - // add new books - val existingBooksUrls = existingBooks.map { it.url } + val existingBooksUrls = existingBooks.filterNot { it.deletedDate != null }.map { it.url } val booksToAdd = newBooks.filterNot { newBook -> existingBooksUrls.contains(newBook.url) } logger.info { "Adding new books: $booksToAdd" } seriesLifecycle.addBooks(existingSeries, booksToAdd) - - // sort all books - seriesLifecycle.sortBooks(existingSeries) + tryRestoreBooks(booksToAdd) + seriesToSortAndRefresh.add(existingSeries) } } } + // for all series where books have been removed or added, trigger a sort and refresh metadata + seriesToSortAndRefresh.distinctBy { it.id }.forEach { + seriesLifecycle.sortBooks(it) + taskReceiver.refreshSeriesMetadata(it.id) + } + if (!rootFolderInaccessible) { val existingSidecars = sidecarRepository.findAll() scanResult.sidecars.forEach { newSidecar -> @@ -175,6 +201,147 @@ class LibraryContentLifecycle( }.also { logger.info { "Library updated in $it" } } } + /** + * This will try to match newSeries with a deleted series. + * Series are matched if: + * - they have the same number of books + * - all the books are matched by file size and file hash + * + * If a series is matched, the following will be restored from the deleted series to the new series: + * - Collections + * - Metadata. The metadata title will only be copied if locked. If not locked, the folder name is used. + * - all books, via #tryRestoreBooks + */ + private fun tryRestoreSeries(newSeries: Series, newBooks: List) { + logger.info { "Try to restore series: $newSeries" } + val bookSizes = newBooks.map { it.fileSize } + + val deletedCandidates = seriesRepository.findAll(SeriesSearch(deleted = true)) + .mapNotNull { deletedCandidate -> + val deletedBooks = bookRepository.findAllBySeriesId(deletedCandidate.id) + val deletedBooksSizes = deletedBooks.map { it.fileSize } + if (newBooks.size == deletedBooks.size && bookSizes.containsAll(deletedBooksSizes) && deletedBooksSizes.containsAll(bookSizes) && deletedBooks.all { it.fileHash.isNotBlank() }) { + deletedCandidate to deletedBooks + } else null + } + logger.debug { "Deleted series candidates: $deletedCandidates" } + + if (deletedCandidates.isNotEmpty()) { + val newBooksWithHash = newBooks.map { book -> bookRepository.findByIdOrNull(book.id)!!.copy(fileHash = hasher.computeHash(book.path)) } + bookRepository.update(newBooksWithHash) + + val match = deletedCandidates.find { (_, books) -> + books.map { it.fileHash }.containsAll(newBooksWithHash.map { it.fileHash }) && newBooksWithHash.map { it.fileHash }.containsAll(books.map { it.fileHash }) + } + + if (match != null) { + // restore series + logger.info { "Match found, restore $match into $newSeries" } + transactionTemplate.executeWithoutResult { + // copy metadata + seriesMetadataRepository.findById(match.first.id).let { deleted -> + val newlyAdded = seriesMetadataRepository.findById(newSeries.id) + seriesMetadataRepository.update( + deleted.copy( + seriesId = newSeries.id, + title = if (deleted.titleLock) deleted.title else newlyAdded.title, + ) + ) + } + + // replace deleted series by new series in collections + collectionRepository.findAllContainingSeriesId(match.first.id, filterOnLibraryIds = null) + .forEach { col -> + collectionRepository.update( + col.copy( + seriesIds = col.seriesIds.map { if (it == match.first.id) newSeries.id else it } + ) + ) + } + + tryRestoreBooks(newBooksWithHash) + + // delete upgraded series + seriesLifecycle.deleteMany(listOf(match.first)) + } + } + } + } + + /** + * This will try to match each book in newBooks with a deleted book. + * Books are matched by file size, then by file hash. + * + * If a book is matched, the following will be restored from the deleted book to the new book: + * - Media + * - Read Progress + * - Read Lists + * - Metadata. The metadata title will only be copied if locked. If not locked, the filename is used, but a refresh for Title will be requested. + */ + private fun tryRestoreBooks(newBooks: List) { + logger.info { "Try to restore books: $newBooks" } + newBooks.forEach { bookToAdd -> + // try to find a deleted book that matches the file size + val deletedCandidates = bookRepository.findAllDeletedByFileSize(bookToAdd.fileSize).filter { it.fileHash.isNotBlank() } + logger.debug { "Deleted candidates: $deletedCandidates" } + + if (deletedCandidates.isNotEmpty()) { + // if the book has no hash, compute the hash and store it + val bookWithHash = + if (bookToAdd.fileHash.isNotBlank()) bookToAdd + else bookRepository.findByIdOrNull(bookToAdd.id)!!.copy(fileHash = hasher.computeHash(bookToAdd.path)).also { bookRepository.update(it) } + + val match = deletedCandidates.find { it.fileHash == bookWithHash.fileHash } + + if (match != null) { + // restore book + logger.info { "Match found, restore $match into $bookToAdd" } + transactionTemplate.executeWithoutResult { + // copy media + mediaRepository.findById(match.id).let { deleted -> + mediaRepository.update(deleted.copy(bookId = bookToAdd.id)) + } + + // copy generated thumbnails + thumbnailBookRepository.findAllByBookIdAndType(match.id, ThumbnailBook.Type.GENERATED).forEach { deleted -> + thumbnailBookRepository.update(deleted.copy(bookId = bookToAdd.id)) + } + + // copy metadata + bookMetadataRepository.findById(match.id).let { deleted -> + val newlyAdded = bookMetadataRepository.findById(bookToAdd.id) + bookMetadataRepository.update( + deleted.copy( + bookId = bookToAdd.id, + title = if (deleted.titleLock) deleted.title else newlyAdded.title, + ) + ) + if (!deleted.titleLock) taskReceiver.refreshBookMetadata(bookToAdd.id, listOf(BookMetadataPatchCapability.TITLE)) + } + + // copy read progress + readProgressRepository.findAllByBookId(match.id) + .map { it.copy(bookId = bookToAdd.id) } + .forEach { readProgressRepository.save(it) } + + // replace deleted book by new book in read lists + readListRepository.findAllContainingBookId(match.id, filterOnLibraryIds = null) + .forEach { rl -> + readListRepository.update( + rl.copy( + bookIds = rl.bookIds.values.map { if (it == match.id) bookToAdd.id else it }.toIndexedMap() + ) + ) + } + + // delete soft-deleted book + bookLifecycle.deleteOne(match) + } + } + } + } + } + fun emptyTrash(library: Library) { logger.info { "Empty trash for library: $library" } diff --git a/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/BookDao.kt b/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/BookDao.kt index 1c0db8ee..389c3a51 100644 --- a/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/BookDao.kt +++ b/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/BookDao.kt @@ -61,6 +61,18 @@ class BookDao( .fetchInto(b) .map { it.toDomain() } + override fun findAllByLibraryIdAndUrlNotIn(libraryId: String, urls: Collection): Collection = + dsl.selectFrom(b) + .where(b.LIBRARY_ID.eq(libraryId).and(b.URL.notIn(urls.map { it.toString() }))) + .fetchInto(b) + .map { it.toDomain() } + + override fun findAllDeletedByFileSize(fileSize: Long): Collection = + dsl.selectFrom(b) + .where(b.DELETED_DATE.isNotNull.and(b.FILE_SIZE.eq(fileSize))) + .fetchInto(b) + .map { it.toDomain() } + override fun findAll(): Collection = dsl.selectFrom(b) .fetchInto(b) 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 26318341..5a73898c 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 @@ -1,29 +1,45 @@ package org.gotson.komga.domain.service import com.ninjasquad.springmockk.MockkBean +import io.mockk.Runs import io.mockk.every +import io.mockk.just +import io.mockk.slot import io.mockk.verify import org.assertj.core.api.Assertions.assertThat +import org.gotson.komga.application.tasks.TaskReceiver import org.gotson.komga.domain.model.Book +import org.gotson.komga.domain.model.BookMetadataPatchCapability import org.gotson.komga.domain.model.DirectoryNotFoundException +import org.gotson.komga.domain.model.KomgaUser 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.Series import org.gotson.komga.domain.model.SeriesCollection +import org.gotson.komga.domain.model.ThumbnailBook import org.gotson.komga.domain.model.makeBook import org.gotson.komga.domain.model.makeBookPage import org.gotson.komga.domain.model.makeLibrary import org.gotson.komga.domain.model.makeSeries +import org.gotson.komga.domain.persistence.BookMetadataRepository import org.gotson.komga.domain.persistence.BookRepository +import org.gotson.komga.domain.persistence.KomgaUserRepository import org.gotson.komga.domain.persistence.LibraryRepository import org.gotson.komga.domain.persistence.MediaRepository import org.gotson.komga.domain.persistence.ReadListRepository +import org.gotson.komga.domain.persistence.ReadProgressRepository import org.gotson.komga.domain.persistence.SeriesCollectionRepository +import org.gotson.komga.domain.persistence.SeriesMetadataRepository import org.gotson.komga.domain.persistence.SeriesRepository +import org.gotson.komga.domain.persistence.ThumbnailBookRepository import org.gotson.komga.infrastructure.hash.Hasher import org.gotson.komga.infrastructure.language.toIndexedMap +import org.gotson.komga.interfaces.rest.persistence.SeriesDtoRepository +import org.junit.jupiter.api.AfterAll import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith @@ -31,7 +47,10 @@ import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.context.SpringBootTest import org.springframework.data.domain.Pageable import org.springframework.test.context.junit.jupiter.SpringExtension +import java.net.URL +import java.nio.file.Path import java.nio.file.Paths +import kotlin.io.path.nameWithoutExtension @ExtendWith(SpringExtension::class) @SpringBootTest @@ -41,10 +60,20 @@ class LibraryContentLifecycleTest( @Autowired private val bookRepository: BookRepository, @Autowired private val libraryContentLifecycle: LibraryContentLifecycle, @Autowired private val bookLifecycle: BookLifecycle, + @Autowired private val seriesLifecycle: SeriesLifecycle, @Autowired private val mediaRepository: MediaRepository, + @Autowired private val bookMetadataRepository: BookMetadataRepository, + @Autowired private val seriesMetadataRepository: SeriesMetadataRepository, @Autowired private val libraryLifecycle: LibraryLifecycle, - @Autowired private val collectionRepository: SeriesCollectionRepository, @Autowired private val readListRepository: ReadListRepository, + @Autowired private val readListLifecycle: ReadListLifecycle, + @Autowired private val collectionRepository: SeriesCollectionRepository, + @Autowired private val collectionLifecycle: SeriesCollectionLifecycle, + @Autowired private val readProgressRepository: ReadProgressRepository, + @Autowired private val userRepository: KomgaUserRepository, + @Autowired private val userLifecycle: KomgaUserLifecycle, + @Autowired private val seriesDtoRepository: SeriesDtoRepository, + @Autowired private val thumbnailBookRepository: ThumbnailBookRepository, ) { @MockkBean @@ -56,6 +85,29 @@ class LibraryContentLifecycleTest( @MockkBean private lateinit var mockHasher: Hasher + @MockkBean + private lateinit var mockTaskReceiver: TaskReceiver + + private val user = KomgaUser("user@example.org", "", false, id = "1") + + @BeforeAll + fun setup() { + userRepository.insert(user) + } + + @BeforeEach + fun beforeEach() { + every { mockTaskReceiver.refreshBookMetadata(any(), any()) } just Runs + every { mockTaskReceiver.refreshSeriesMetadata(any(), any()) } just Runs + } + + @AfterAll + fun tearDown() { + userRepository.findAll().forEach { + userLifecycle.deleteUser(it) + } + } + @AfterEach fun `clear repositories`() { libraryRepository.findAll().forEach { @@ -69,17 +121,14 @@ class LibraryContentLifecycleTest( @Nested inner class Scan { @Test - fun `given existing series when adding files and scanning then only updated Books are persisted`() { + fun `given existing series when adding files and scanning then only updated books are persisted`() { // given val library = makeLibrary() libraryRepository.insert(library) - val books = listOf(makeBook("book1")) - val moreBooks = listOf(makeBook("book1"), makeBook("book2")) - every { mockScanner.scanRootFolder(any()) }.returnsMany( - mapOf(makeSeries(name = "series") to books).toScanResult(), - mapOf(makeSeries(name = "series") to moreBooks).toScanResult(), + mapOf(makeSeries(name = "series") to listOf(makeBook("book1"))).toScanResult(), + mapOf(makeSeries(name = "series") to listOf(makeBook("book1"), makeBook("book2"))).toScanResult(), ) libraryContentLifecycle.scanRootFolder(library) @@ -103,13 +152,10 @@ class LibraryContentLifecycleTest( 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 lessBooks).toScanResult(), + mapOf(makeSeries(name = "series") to listOf(makeBook("book1"), makeBook("book2"))).toScanResult(), + mapOf(makeSeries(name = "series") to listOf(makeBook("book1"))).toScanResult(), ) libraryContentLifecycle.scanRootFolder(library) @@ -129,50 +175,15 @@ class LibraryContentLifecycleTest( } @Test - fun `given existing series when removing files and scanning, restoring files and scanning then restored books are available`() { + fun `given existing series when updating files and scanning then books are updated`() { // 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 lessBooks).toScanResult(), - mapOf(makeSeries(name = "series") to books).toScanResult(), - ) - libraryContentLifecycle.scanRootFolder(library) // creation - libraryContentLifecycle.scanRootFolder(library) // deletion - - // when - libraryContentLifecycle.scanRootFolder(library) // restore - - // then - val allSeries = seriesRepository.findAll() - val allBooks = bookRepository.findAll().sortedBy { it.number } - - verify(exactly = 3) { mockScanner.scanRootFolder(any()) } - - assertThat(allSeries).hasSize(1) - assertThat(allBooks).hasSize(2) - assertThat(allBooks.map { it.deletedDate }).containsOnlyNulls() - } - - @Test - fun `given existing series when updating files and scanning then Books are updated`() { - // given - val library = makeLibrary() - libraryRepository.insert(library) - - val books = listOf(makeBook("book1")) - val updatedBooks = listOf(makeBook("book1")) - - every { mockScanner.scanRootFolder(any()) } - .returnsMany( - mapOf(makeSeries(name = "series") to books).toScanResult(), - mapOf(makeSeries(name = "series") to updatedBooks).toScanResult(), + mapOf(makeSeries(name = "series") to listOf(makeBook("book1"))).toScanResult(), + mapOf(makeSeries(name = "series") to listOf(makeBook("book1"))).toScanResult(), ) libraryContentLifecycle.scanRootFolder(library) @@ -220,39 +231,7 @@ class LibraryContentLifecycleTest( } @Test - fun `given existing series when deleting all books and scanning then restoring and scanning then Series and Books are available`() { - // given - val library = makeLibrary() - libraryRepository.insert(library) - - val series = makeSeries(name = "series") - val book = makeBook("book1") - every { mockScanner.scanRootFolder(any()) } - .returnsMany( - mapOf(series to listOf(book)).toScanResult(), - emptyMap>().toScanResult(), - mapOf(series to listOf(book)).toScanResult(), - ) - libraryContentLifecycle.scanRootFolder(library) // creation - libraryContentLifecycle.scanRootFolder(library) // deletion - - // when - libraryContentLifecycle.scanRootFolder(library) // restore - - // then - verify(exactly = 3) { mockScanner.scanRootFolder(any()) } - - val allSeries = seriesRepository.findAll() - val allBooks = bookRepository.findAll() - - assertThat(allSeries.map { it.deletedDate }).containsOnlyNulls() - assertThat(allSeries).hasSize(1) - assertThat(allBooks.map { it.deletedDate }).containsOnlyNulls() - assertThat(allBooks).hasSize(1) - } - - @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) @@ -483,6 +462,845 @@ class LibraryContentLifecycleTest( } } + @Nested + inner class Restore { + @Test + fun `given existing series when removing files and scanning, restoring files and scanning then restored books are available and media status is not set to outdated`() { + // given + val library = makeLibrary() + libraryRepository.insert(library) + + val book2 = makeBook("book2") + + every { mockScanner.scanRootFolder(any()) } + .returnsMany( + mapOf(makeSeries(name = "series") to listOf(makeBook("book1"), book2)).toScanResult(), + mapOf(makeSeries(name = "series") to listOf(makeBook("book1"))).toScanResult(), + mapOf(makeSeries(name = "series") to listOf(makeBook("book1"), makeBook("book2"))).toScanResult(), + ) + libraryContentLifecycle.scanRootFolder(library) // creation + + bookRepository.findByIdOrNull(book2.id)?.let { + bookRepository.update(it.copy(fileHash = "sameHash")) + mediaRepository.update(mediaRepository.findById(it.id).copy(status = Media.Status.READY)) + } + + every { mockHasher.computeHash(any()) } returns "sameHash" + + libraryContentLifecycle.scanRootFolder(library) // deletion + + // when + libraryContentLifecycle.scanRootFolder(library) // restore + + // then + val allSeries = seriesRepository.findAll() + val allBooks = bookRepository.findAll().sortedBy { it.number } + + verify(exactly = 3) { mockScanner.scanRootFolder(any()) } + + assertThat(allSeries).hasSize(1) + assertThat(allBooks).hasSize(2) + assertThat(allBooks.map { it.deletedDate }).containsOnlyNulls() + + with(allBooks.last()) { + assertThat(mediaRepository.findById(id).status).`as` { "Book media should be kept intact" }.isEqualTo(Media.Status.READY) + } + } + + @Test + fun `given existing series when deleting all books and scanning then restoring and scanning then Series and Books are available and media status is not set to outdated`() { + // given + val library = makeLibrary() + libraryRepository.insert(library) + + every { mockScanner.scanRootFolder(any()) } + .returnsMany( + mapOf(makeSeries(name = "series") to listOf(makeBook("book1"), makeBook("book2"))).toScanResult(), + emptyMap>().toScanResult(), + mapOf(makeSeries(name = "series") to listOf(makeBook("book1"), makeBook("book2"))).toScanResult(), + ) + libraryContentLifecycle.scanRootFolder(library) // creation + + bookRepository.findAll().forEach { book -> + bookRepository.update(book.copy(fileHash = "HASH-${book.name}")) + mediaRepository.findById(book.id).let { mediaRepository.update(it.copy(status = Media.Status.READY)) } + } + + val slot = slot() + every { mockHasher.computeHash(capture(slot)) } answers { + "HASH-${slot.captured.nameWithoutExtension}" + } + + libraryContentLifecycle.scanRootFolder(library) // deletion + + // when + libraryContentLifecycle.scanRootFolder(library) // restore + + // then + verify(exactly = 3) { mockScanner.scanRootFolder(any()) } + + val allSeries = seriesRepository.findAll() + val allBooks = bookRepository.findAll() + + assertThat(allSeries.map { it.deletedDate }).containsOnlyNulls() + assertThat(allSeries).hasSize(1) + assertThat(allBooks.map { it.deletedDate }).containsOnlyNulls() + assertThat(allBooks).hasSize(2) + + allBooks.forEach { book -> + assertThat(mediaRepository.findById(book.id).status).isEqualTo(Media.Status.READY) + } + } + } + + @Nested + inner class FileRename { + @Test + fun `given existing series when renaming 1 file and scanning then renamed book media and generated thumbnails are kept`() { + // given + val library = makeLibrary() + libraryRepository.insert(library) + + val book = makeBook("book").copy(fileSize = 324) + val bookRenamed = makeBook("book3").copy(fileSize = 324) + + every { mockScanner.scanRootFolder(any()) } + .returnsMany( + mapOf(makeSeries(name = "series") to listOf(book, makeBook("book2"))).toScanResult(), + mapOf(makeSeries(name = "series") to listOf(bookRenamed, makeBook("book2"))).toScanResult(), + ) + libraryContentLifecycle.scanRootFolder(library) // creation + + bookRepository.findByIdOrNull(book.id)?.let { + bookRepository.update(it.copy(fileHash = "sameHash")) + mediaRepository.update(mediaRepository.findById(it.id).copy(status = Media.Status.READY)) + bookLifecycle.addThumbnailForBook(ThumbnailBook(thumbnail = ByteArray(0), type = ThumbnailBook.Type.GENERATED, bookId = book.id)) + bookLifecycle.addThumbnailForBook(ThumbnailBook(url = URL("file:/sidecar"), type = ThumbnailBook.Type.SIDECAR, bookId = book.id)) + } + + every { mockHasher.computeHash(any()) } returns "sameHash" + + // when + libraryContentLifecycle.scanRootFolder(library) // rename + + // then + verify(exactly = 1) { mockHasher.computeHash(any()) } + + val allSeries = seriesRepository.findAll() + val allBooks = bookRepository.findAll().sortedBy { it.number } + + assertThat(allSeries).hasSize(1) + assertThat(allBooks).hasSize(2) + assertThat(allBooks.map { it.deletedDate }).containsOnlyNulls() + with(allBooks.last()) { + assertThat(name).`as` { "Book name should have changed to match the filename" }.isEqualTo("book3") + assertThat(mediaRepository.findById(id).status).`as` { "Book media should be kept intact" }.isEqualTo(Media.Status.READY) + assertThat(thumbnailBookRepository.findAllByBookIdAndType(id, ThumbnailBook.Type.SIDECAR)).hasSize(0) + assertThat(thumbnailBookRepository.findAllByBookIdAndType(id, ThumbnailBook.Type.GENERATED)).hasSize(1) + } + } + + @Test + fun `given existing series when renaming 1 file and scanning then renamed book read progress is kept`() { + // given + val library = makeLibrary() + libraryRepository.insert(library) + + val book = makeBook("book").copy(fileSize = 324) + val bookRenamed = makeBook("book3").copy(fileSize = 324) + + every { mockScanner.scanRootFolder(any()) } + .returnsMany( + mapOf(makeSeries(name = "series") to listOf(book, makeBook("book2"))).toScanResult(), + mapOf(makeSeries(name = "series") to listOf(bookRenamed, makeBook("book2"))).toScanResult(), + ) + libraryContentLifecycle.scanRootFolder(library) // creation + + bookRepository.findByIdOrNull(book.id)?.let { + bookRepository.update(it.copy(fileHash = "sameHash")) + bookLifecycle.markReadProgressCompleted(it.id, user) + } + + every { mockHasher.computeHash(any()) } returns "sameHash" + + // when + libraryContentLifecycle.scanRootFolder(library) // rename + + // then + verify(exactly = 1) { mockHasher.computeHash(any()) } + + val allSeries = seriesRepository.findAll() + val allBooks = bookRepository.findAll().sortedBy { it.number } + + assertThat(allSeries).hasSize(1) + assertThat(allBooks).hasSize(2) + assertThat(allBooks.map { it.deletedDate }).containsOnlyNulls() + with(allBooks.last()) { + assertThat(name).isEqualTo("book3") + assertThat(readProgressRepository.findByBookIdAndUserIdOrNull(id, user.id)).isNotNull + } + + assertThat(readProgressRepository.findAll()).hasSize(1) + } + + @Test + fun `given existing series when renaming 1 file and scanning then renamed book is still in read lists`() { + // given + val library = makeLibrary() + libraryRepository.insert(library) + + val book = makeBook("book").copy(fileSize = 324) + val bookRenamed = makeBook("book3").copy(fileSize = 324) + + every { mockScanner.scanRootFolder(any()) } + .returnsMany( + mapOf(makeSeries(name = "series") to listOf(book, makeBook("book2"))).toScanResult(), + mapOf(makeSeries(name = "series") to listOf(bookRenamed, makeBook("book2"))).toScanResult(), + ) + libraryContentLifecycle.scanRootFolder(library) // creation + + bookRepository.findByIdOrNull(book.id)?.let { + bookRepository.update(it.copy(fileHash = "sameHash")) + readListLifecycle.addReadList(ReadList("read list", listOf(it.id).toIndexedMap())) + } + + every { mockHasher.computeHash(any()) } returns "sameHash" + + // when + libraryContentLifecycle.scanRootFolder(library) // rename + + // then + verify(exactly = 1) { mockHasher.computeHash(any()) } + + val allSeries = seriesRepository.findAll() + val allBooks = bookRepository.findAll().sortedBy { it.number } + + assertThat(allSeries).hasSize(1) + assertThat(allBooks).hasSize(2) + assertThat(allBooks.map { it.deletedDate }).containsOnlyNulls() + with(allBooks.last()) { + assertThat(name).isEqualTo("book3") + + readListRepository.findAllContainingBookId(id, null).let { readLists -> + assertThat(readLists).hasSize(1) + assertThat(readLists.first().name).isEqualTo("read list") + } + } + } + + @Test + fun `given existing series when renaming 1 file with locked title and scanning then renamed book's title is not changed and book metadata is not refreshed for title`() { + // given + val library = makeLibrary() + libraryRepository.insert(library) + + val book = makeBook("book").copy(fileSize = 324) + val bookRenamed = makeBook("book3").copy(fileSize = 324) + + every { mockScanner.scanRootFolder(any()) } + .returnsMany( + mapOf(makeSeries(name = "series") to listOf(book, makeBook("book2"))).toScanResult(), + mapOf(makeSeries(name = "series") to listOf(bookRenamed, makeBook("book2"))).toScanResult(), + ) + libraryContentLifecycle.scanRootFolder(library) // creation + + bookRepository.findByIdOrNull(book.id)?.let { + bookRepository.update(it.copy(fileHash = "sameHash")) + bookMetadataRepository.update( + bookMetadataRepository.findById(it.id).copy( + title = "Updated Title", + titleLock = true, + ) + ) + } + + every { mockHasher.computeHash(any()) } returns "sameHash" + + // when + libraryContentLifecycle.scanRootFolder(library) // rename + + // then + verify(exactly = 1) { mockHasher.computeHash(any()) } + verify(exactly = 0) { mockTaskReceiver.refreshBookMetadata(bookRenamed.id, listOf(BookMetadataPatchCapability.TITLE)) } + + val allSeries = seriesRepository.findAll() + val allBooks = bookRepository.findAll().sortedBy { it.number } + + assertThat(allSeries).hasSize(1) + assertThat(allBooks).hasSize(2) + assertThat(allBooks.map { it.deletedDate }).containsOnlyNulls() + with(allBooks.last()) { + assertThat(name).isEqualTo("book3") + bookMetadataRepository.findById(id).let { + assertThat(it.title).isEqualTo("Updated Title") + assertThat(it.titleLock).isTrue + } + } + } + + @Test + fun `given existing series when renaming 1 file and scanning then renamed book's title matches the filename and book metadata is refreshed for title only`() { + // given + val library = makeLibrary() + libraryRepository.insert(library) + + val book = makeBook("book").copy(fileSize = 324) + val bookRenamed = makeBook("book3").copy(fileSize = 324) + + every { mockScanner.scanRootFolder(any()) } + .returnsMany( + mapOf(makeSeries(name = "series") to listOf(book, makeBook("book2"))).toScanResult(), + mapOf(makeSeries(name = "series") to listOf(bookRenamed, makeBook("book2"))).toScanResult(), + ) + libraryContentLifecycle.scanRootFolder(library) // creation + + bookRepository.findByIdOrNull(book.id)?.let { + bookRepository.update(it.copy(fileHash = "sameHash")) + } + + every { mockHasher.computeHash(any()) } returns "sameHash" + + // when + libraryContentLifecycle.scanRootFolder(library) // rename + + // then + verify(exactly = 1) { mockHasher.computeHash(any()) } + verify(exactly = 1) { mockTaskReceiver.refreshBookMetadata(bookRenamed.id, listOf(BookMetadataPatchCapability.TITLE)) } + + val allSeries = seriesRepository.findAll() + val allBooks = bookRepository.findAll().sortedBy { it.number } + + assertThat(allSeries).hasSize(1) + assertThat(allBooks).hasSize(2) + assertThat(allBooks.map { it.deletedDate }).containsOnlyNulls() + with(allBooks.last()) { + assertThat(name).isEqualTo("book3") + assertThat(bookMetadataRepository.findById(id).title).`as` { "Book metadata title should have changed to match the filename" }.isEqualTo("book3") + } + } + } + + @Nested + inner class FileMoveToAnotherFolder { + @Test + fun `given 2 series when moving 1 file from 1 series to another and scanning then moved book's media is kept`() { + // given + val library = makeLibrary() + libraryRepository.insert(library) + + val book2 = makeBook("book2", url = URL("file:/series1/book2")).copy(fileSize = 324) + val book2Moved = makeBook("book2", url = URL("file:/series2/book2")).copy(fileSize = 324) + + every { mockScanner.scanRootFolder(any()) } + .returnsMany( + mapOf( + makeSeries(name = "series1") to listOf(makeBook("book1"), book2), + makeSeries(name = "series2") to listOf(makeBook("book1")), + ).toScanResult(), + mapOf( + makeSeries(name = "series1") to listOf(makeBook("book1")), + makeSeries(name = "series2") to listOf(makeBook("book1"), book2Moved), + ).toScanResult(), + ) + libraryContentLifecycle.scanRootFolder(library) // creation + + bookRepository.findByIdOrNull(book2.id)?.let { + bookRepository.update(it.copy(fileHash = "sameHash")) + mediaRepository.update(mediaRepository.findById(it.id).copy(status = Media.Status.READY)) + } + + every { mockHasher.computeHash(any()) } returns "sameHash" + + // when + libraryContentLifecycle.scanRootFolder(library) // rename + + // then + val allSeries = seriesRepository.findAll() + val allBooks = bookRepository.findAll().sortedBy { it.number } + + assertThat(allSeries).hasSize(2) + with(allSeries.first { it.name == "series1" }) { + assertThat(bookRepository.findAllBySeriesId(id)).hasSize(1) + } + + with(allSeries.first { it.name == "series2" }) { + val books = bookRepository.findAllBySeriesId(id) + assertThat(books).hasSize(2) + + books.first { it.name == "book2" }.let { + assertThat(mediaRepository.findById(it.id).status).isEqualTo(Media.Status.READY) + } + } + + assertThat(allBooks).hasSize(3) + assertThat(allBooks.map { it.deletedDate }).containsOnlyNulls() + } + + @Test + fun `given 2 series when moving 1 file from 1 series to another and scanning then moved book read progress is kept`() { + // given + val library = makeLibrary() + libraryRepository.insert(library) + + val book2 = makeBook("book2", url = URL("file:/series1/book2")).copy(fileSize = 324) + val book2Moved = makeBook("book2", url = URL("file:/series2/book2")).copy(fileSize = 324) + + every { mockScanner.scanRootFolder(any()) } + .returnsMany( + mapOf( + makeSeries(name = "series1") to listOf(makeBook("book1"), book2), + makeSeries(name = "series2") to listOf(makeBook("book1")), + ).toScanResult(), + mapOf( + makeSeries(name = "series1") to listOf(makeBook("book1")), + makeSeries(name = "series2") to listOf(makeBook("book1"), book2Moved), + ).toScanResult(), + ) + libraryContentLifecycle.scanRootFolder(library) // creation + + bookRepository.findByIdOrNull(book2.id)?.let { + bookRepository.update(it.copy(fileHash = "sameHash")) + bookLifecycle.markReadProgressCompleted(it.id, user) + } + + every { mockHasher.computeHash(any()) } returns "sameHash" + + // when + libraryContentLifecycle.scanRootFolder(library) // rename + + // then + val allSeries = seriesRepository.findAll() + val allBooks = bookRepository.findAll().sortedBy { it.number } + + assertThat(allSeries).hasSize(2) + with(allSeries.first { it.name == "series1" }) { + assertThat(bookRepository.findAllBySeriesId(id)).hasSize(1) + } + + with(allSeries.first { it.name == "series2" }) { + val books = bookRepository.findAllBySeriesId(id) + assertThat(books).hasSize(2) + + books.first { it.name == "book2" }.let { + assertThat(readProgressRepository.findByBookIdAndUserIdOrNull(it.id, user.id)).isNotNull + } + } + + assertThat(allBooks).hasSize(3) + assertThat(allBooks.map { it.deletedDate }).containsOnlyNulls() + + assertThat(readProgressRepository.findAll()).hasSize(1) + } + + @Test + fun `given 2 series when moving 1 file from 1 series to another and scanning then moved book is still in read lists`() { + // given + val library = makeLibrary() + libraryRepository.insert(library) + + val book2 = makeBook("book2", url = URL("file:/series1/book2")).copy(fileSize = 324) + val book2Moved = makeBook("book2", url = URL("file:/series2/book2")).copy(fileSize = 324) + + every { mockScanner.scanRootFolder(any()) } + .returnsMany( + mapOf( + makeSeries(name = "series1") to listOf(makeBook("book1"), book2), + makeSeries(name = "series2") to listOf(makeBook("book1")), + ).toScanResult(), + mapOf( + makeSeries(name = "series1") to listOf(makeBook("book1")), + makeSeries(name = "series2") to listOf(makeBook("book1"), book2Moved), + ).toScanResult(), + ) + libraryContentLifecycle.scanRootFolder(library) // creation + + bookRepository.findByIdOrNull(book2.id)?.let { + bookRepository.update(it.copy(fileHash = "sameHash")) + readListLifecycle.addReadList(ReadList("read list", listOf(it.id).toIndexedMap())) + } + + every { mockHasher.computeHash(any()) } returns "sameHash" + + // when + libraryContentLifecycle.scanRootFolder(library) // rename + + // then + val allSeries = seriesRepository.findAll() + val allBooks = bookRepository.findAll().sortedBy { it.number } + + assertThat(allSeries).hasSize(2) + with(allSeries.first { it.name == "series1" }) { + assertThat(bookRepository.findAllBySeriesId(id)).hasSize(1) + } + + with(allSeries.first { it.name == "series2" }) { + val books = bookRepository.findAllBySeriesId(id) + assertThat(books).hasSize(2) + + books.first { it.name == "book2" }.let { + readListRepository.findAllContainingBookId(it.id, null).let { readLists -> + assertThat(readLists).hasSize(1) + assertThat(readLists.first().name).isEqualTo("read list") + } + } + } + + assertThat(allBooks).hasSize(3) + assertThat(allBooks.map { it.deletedDate }).containsOnlyNulls() + } + + @Test + fun `given 2 series when moving 1 file with locked title from 1 series to another and scanning then moved book's title is not changed and book metadata is not refreshed for title`() { + // given + val library = makeLibrary() + libraryRepository.insert(library) + + val book2 = makeBook("book2", url = URL("file:/series1/book2")).copy(fileSize = 324) + val book2Moved = makeBook("book2", url = URL("file:/series2/book2")).copy(fileSize = 324) + + every { mockScanner.scanRootFolder(any()) } + .returnsMany( + mapOf( + makeSeries(name = "series1") to listOf(makeBook("book1"), book2), + makeSeries(name = "series2") to listOf(makeBook("book1")), + ).toScanResult(), + mapOf( + makeSeries(name = "series1") to listOf(makeBook("book1")), + makeSeries(name = "series2") to listOf(makeBook("book1"), book2Moved), + ).toScanResult(), + ) + libraryContentLifecycle.scanRootFolder(library) // creation + + bookRepository.findByIdOrNull(book2.id)?.let { + bookRepository.update(it.copy(fileHash = "sameHash")) + bookMetadataRepository.update( + bookMetadataRepository.findById(it.id).copy( + title = "Updated Title", + titleLock = true, + ) + ) + } + + every { mockHasher.computeHash(any()) } returns "sameHash" + + // when + libraryContentLifecycle.scanRootFolder(library) // rename + + // then + verify(exactly = 0) { mockTaskReceiver.refreshBookMetadata(book2Moved.id, listOf(BookMetadataPatchCapability.TITLE)) } + + val allSeries = seriesRepository.findAll() + val allBooks = bookRepository.findAll().sortedBy { it.number } + + assertThat(allSeries).hasSize(2) + with(allSeries.first { it.name == "series1" }) { + assertThat(bookRepository.findAllBySeriesId(id)).hasSize(1) + } + + with(allSeries.first { it.name == "series2" }) { + val books = bookRepository.findAllBySeriesId(id) + assertThat(books).hasSize(2) + + books.first { it.name == "book2" }.let { book2 -> + bookMetadataRepository.findById(book2.id).let { + assertThat(it.title).isEqualTo("Updated Title") + assertThat(it.titleLock).isTrue + } + } + } + + assertThat(allBooks).hasSize(3) + assertThat(allBooks.map { it.deletedDate }).containsOnlyNulls() + } + + @Test + fun `given 2 series when moving 1 file from 1 series to another and scanning then moved book's title matches the filename and book metadata is refreshed for title only`() { + // given + val library = makeLibrary() + libraryRepository.insert(library) + + val book2 = makeBook("book2", url = URL("file:/series1/book2")).copy(fileSize = 324) + val book2Moved = makeBook("book2", url = URL("file:/series2/book2")).copy(fileSize = 324) + + every { mockScanner.scanRootFolder(any()) } + .returnsMany( + mapOf( + makeSeries(name = "series1") to listOf(makeBook("book1"), book2), + makeSeries(name = "series2") to listOf(makeBook("book1")), + ).toScanResult(), + mapOf( + makeSeries(name = "series1") to listOf(makeBook("book1")), + makeSeries(name = "series2") to listOf(makeBook("book1"), book2Moved), + ).toScanResult(), + ) + libraryContentLifecycle.scanRootFolder(library) // creation + + bookRepository.findByIdOrNull(book2.id)?.let { + bookRepository.update(it.copy(fileHash = "sameHash")) + } + + every { mockHasher.computeHash(any()) } returns "sameHash" + + // when + libraryContentLifecycle.scanRootFolder(library) // rename + + // then + verify(exactly = 1) { mockTaskReceiver.refreshBookMetadata(book2Moved.id, listOf(BookMetadataPatchCapability.TITLE)) } + + val allSeries = seriesRepository.findAll() + val allBooks = bookRepository.findAll().sortedBy { it.number } + + assertThat(allSeries).hasSize(2) + with(allSeries.first { it.name == "series1" }) { + assertThat(bookRepository.findAllBySeriesId(id)).hasSize(1) + } + + with(allSeries.first { it.name == "series2" }) { + val books = bookRepository.findAllBySeriesId(id) + assertThat(books).hasSize(2) + + books.first { it.name == "book2" }.let { + assertThat(bookMetadataRepository.findById(it.id).title).isEqualTo("book2") + } + } + + assertThat(allBooks).hasSize(3) + assertThat(allBooks.map { it.deletedDate }).containsOnlyNulls() + } + } + + @Nested + inner class RenameFolder { + @Test + fun `given series when renaming folder and scanning then renamed series books media is kept`() { + // given + val library = makeLibrary() + libraryRepository.insert(library) + + every { mockScanner.scanRootFolder(any()) } + .returnsMany( + mapOf(makeSeries(name = "series1") to listOf(makeBook("book1").copy(fileSize = 1), makeBook("book2").copy(fileSize = 2))).toScanResult(), + mapOf(makeSeries(name = "series2") to listOf(makeBook("book1").copy(fileSize = 1), makeBook("book2").copy(fileSize = 2))).toScanResult(), + ) + libraryContentLifecycle.scanRootFolder(library) // creation + + bookRepository.findAll().forEach { book -> + bookRepository.update(book.copy(fileHash = "HASH-${book.name}")) + mediaRepository.findById(book.id).let { mediaRepository.update(it.copy(status = Media.Status.READY)) } + } + + val slot = slot() + every { mockHasher.computeHash(capture(slot)) } answers { + "HASH-${slot.captured.nameWithoutExtension}" + } + + // when + libraryContentLifecycle.scanRootFolder(library) // rename + + // then + verify(exactly = 2) { mockHasher.computeHash(any()) } + + val allSeries = seriesRepository.findAll() + val allBooks = bookRepository.findAll().sortedBy { it.number } + + assertThat(allSeries).hasSize(1) + allSeries.first().let { series2 -> + assertThat(series2.name).isEqualTo("series2") + val books = bookRepository.findAllBySeriesId(series2.id) + assertThat(books).hasSize(2) + books.forEach { book -> + assertThat(mediaRepository.findById(book.id).status).isEqualTo(Media.Status.READY) + } + } + + assertThat(allBooks).hasSize(2) + assertThat(allBooks.map { it.deletedDate }).containsOnlyNulls() + } + + @Test + fun `given series when renaming folder and scanning then renamed series read progress is kept`() { + // given + val library = makeLibrary() + libraryRepository.insert(library) + + every { mockScanner.scanRootFolder(any()) } + .returnsMany( + mapOf(makeSeries(name = "series1") to listOf(makeBook("book1").copy(fileSize = 1), makeBook("book2").copy(fileSize = 2))).toScanResult(), + mapOf(makeSeries(name = "series2") to listOf(makeBook("book1").copy(fileSize = 1), makeBook("book2").copy(fileSize = 2))).toScanResult(), + ) + libraryContentLifecycle.scanRootFolder(library) // creation + + seriesRepository.findAll().first().let { + seriesLifecycle.markReadProgressCompleted(it.id, user) + } + + bookRepository.findAll().forEach { book -> + bookRepository.update(book.copy(fileHash = "HASH-${book.name}")) + } + + val slot = slot() + every { mockHasher.computeHash(capture(slot)) } answers { + "HASH-${slot.captured.nameWithoutExtension}" + } + + // when + libraryContentLifecycle.scanRootFolder(library) // rename + + // then + val allSeries = seriesRepository.findAll() + val allBooks = bookRepository.findAll().sortedBy { it.number } + + assertThat(allSeries).hasSize(1) + allSeries.first().let { series2 -> + assertThat(series2.name).isEqualTo("series2") + + seriesDtoRepository.findByIdOrNull(series2.id, user.id)?.let { seriesDto -> + assertThat(seriesDto.booksReadCount).isEqualTo(2) + } + } + + assertThat(allBooks).hasSize(2) + assertThat(allBooks.map { it.deletedDate }).containsOnlyNulls() + + assertThat(readProgressRepository.findAll()).hasSize(2) + } + + @Test + fun `given series when renaming folder and scanning then renamed series is still in collections`() { + // given + val library = makeLibrary() + libraryRepository.insert(library) + + every { mockScanner.scanRootFolder(any()) } + .returnsMany( + mapOf(makeSeries(name = "series1") to listOf(makeBook("book1").copy(fileSize = 1), makeBook("book2").copy(fileSize = 2))).toScanResult(), + mapOf(makeSeries(name = "series2") to listOf(makeBook("book1").copy(fileSize = 1), makeBook("book2").copy(fileSize = 2))).toScanResult(), + ) + libraryContentLifecycle.scanRootFolder(library) // creation + + seriesRepository.findAll().first().let { + collectionLifecycle.addCollection(SeriesCollection("collection", seriesIds = listOf(it.id))) + } + + bookRepository.findAll().forEach { book -> + bookRepository.update(book.copy(fileHash = "HASH-${book.name}")) + } + + val slot = slot() + every { mockHasher.computeHash(capture(slot)) } answers { + "HASH-${slot.captured.nameWithoutExtension}" + } + + // when + libraryContentLifecycle.scanRootFolder(library) // rename + + // then + val allSeries = seriesRepository.findAll() + val allBooks = bookRepository.findAll().sortedBy { it.number } + + assertThat(allSeries).hasSize(1) + allSeries.first().let { series2 -> + assertThat(series2.name).isEqualTo("series2") + assertThat(bookRepository.findAllBySeriesId(series2.id)).hasSize(2) + + collectionRepository.findAllContainingSeriesId(series2.id, null).let { collections -> + assertThat(collections).hasSize(1) + assertThat(collections.first().name).isEqualTo("collection") + } + } + + assertThat(allBooks).hasSize(2) + assertThat(allBooks.map { it.deletedDate }).containsOnlyNulls() + } + + @Test + fun `given series when renaming folder with locked title and scanning then renamed series title is not changed`() { + // given + val library = makeLibrary() + libraryRepository.insert(library) + + every { mockScanner.scanRootFolder(any()) } + .returnsMany( + mapOf(makeSeries(name = "series1") to listOf(makeBook("book1").copy(fileSize = 1), makeBook("book2").copy(fileSize = 2))).toScanResult(), + mapOf(makeSeries(name = "series2") to listOf(makeBook("book1").copy(fileSize = 1), makeBook("book2").copy(fileSize = 2))).toScanResult(), + ) + libraryContentLifecycle.scanRootFolder(library) // creation + + seriesRepository.findAll().first().let { + seriesMetadataRepository.update(seriesMetadataRepository.findById(it.id).copy(title = "Updated", titleLock = true)) + } + + bookRepository.findAll().forEach { book -> + bookRepository.update(book.copy(fileHash = "HASH-${book.name}")) + } + + val slot = slot() + every { mockHasher.computeHash(capture(slot)) } answers { + "HASH-${slot.captured.nameWithoutExtension}" + } + + // when + libraryContentLifecycle.scanRootFolder(library) // rename + + // then + val allSeries = seriesRepository.findAll() + val allBooks = bookRepository.findAll().sortedBy { it.number } + + assertThat(allSeries).hasSize(1) + allSeries.first().let { series2 -> + assertThat(series2.name).isEqualTo("series2") + seriesMetadataRepository.findById(series2.id).let { + assertThat(it.title).isEqualTo("Updated") + assertThat(it.titleLock).isTrue + } + assertThat(bookRepository.findAllBySeriesId(series2.id)).hasSize(2) + } + + assertThat(allBooks).hasSize(2) + assertThat(allBooks.map { it.deletedDate }).containsOnlyNulls() + } + + @Test + fun `given series when renaming folder and scanning then renamed series title matches the folder name`() { + // given + val library = makeLibrary() + libraryRepository.insert(library) + + every { mockScanner.scanRootFolder(any()) } + .returnsMany( + mapOf(makeSeries(name = "series1") to listOf(makeBook("book1").copy(fileSize = 1), makeBook("book2").copy(fileSize = 2))).toScanResult(), + mapOf(makeSeries(name = "series2") to listOf(makeBook("book1").copy(fileSize = 1), makeBook("book2").copy(fileSize = 2))).toScanResult(), + ) + libraryContentLifecycle.scanRootFolder(library) // creation + + bookRepository.findAll().forEach { book -> + bookRepository.update(book.copy(fileHash = "HASH-${book.name}")) + } + + val slot = slot() + every { mockHasher.computeHash(capture(slot)) } answers { + "HASH-${slot.captured.nameWithoutExtension}" + } + + // when + libraryContentLifecycle.scanRootFolder(library) // rename + + // then + val allSeries = seriesRepository.findAll() + val allBooks = bookRepository.findAll().sortedBy { it.number } + + assertThat(allSeries).hasSize(1) + allSeries.first().let { series2 -> + assertThat(series2.name).isEqualTo("series2") + assertThat(seriesMetadataRepository.findById(series2.id).title).isEqualTo("series2") + assertThat(bookRepository.findAllBySeriesId(series2.id)).hasSize(2) + } + + assertThat(allBooks).hasSize(2) + assertThat(allBooks.map { it.deletedDate }).containsOnlyNulls() + } + } + @Nested inner class EmptyTrash { @Test diff --git a/komga/src/test/resources/application-test.yml b/komga/src/test/resources/application-test.yml index f223304c..4080d144 100644 --- a/komga/src/test/resources/application-test.yml +++ b/komga/src/test/resources/application-test.yml @@ -13,4 +13,5 @@ spring: logging: level: - org.jooq: DEBUG + org.gotson.komga: DEBUG +# org.jooq: DEBUG