mirror of
https://github.com/gotson/komga.git
synced 2025-12-22 00:13:30 +01:00
feat(analysis): handle read progress during book analysis
when a book is changed on disk, it is marked as outdated. If an outdated book has a different page count during analysis, then all existing read progress for that book will be removed.
This commit is contained in:
parent
31e21fed45
commit
1fc893ecb3
9 changed files with 190 additions and 12 deletions
|
|
@ -9,7 +9,8 @@ export enum MediaStatus {
|
|||
READY = 'READY',
|
||||
UNKNOWN = 'UNKNOWN',
|
||||
ERROR = 'ERROR',
|
||||
UNSUPPORTED = 'UNSUPPORTED'
|
||||
UNSUPPORTED = 'UNSUPPORTED',
|
||||
OUTDATED = 'OUTDATED'
|
||||
}
|
||||
|
||||
export enum ReadProgress {
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ class TaskHandler(
|
|||
is Task.ScanLibrary ->
|
||||
libraryRepository.findByIdOrNull(task.libraryId)?.let {
|
||||
libraryScanner.scanRootFolder(it)
|
||||
taskReceiver.analyzeUnknownBooks(it)
|
||||
taskReceiver.analyzeUnknownAndOutdatedBooks(it)
|
||||
} ?: logger.warn { "Cannot execute task $task: Library does not exist" }
|
||||
|
||||
is Task.AnalyzeBook ->
|
||||
|
|
|
|||
|
|
@ -31,10 +31,10 @@ class TaskReceiver(
|
|||
submitTask(Task.ScanLibrary(libraryId))
|
||||
}
|
||||
|
||||
fun analyzeUnknownBooks(library: Library) {
|
||||
fun analyzeUnknownAndOutdatedBooks(library: Library) {
|
||||
bookRepository.findAllId(BookSearch(
|
||||
libraryIds = listOf(library.id),
|
||||
mediaStatus = listOf(Media.Status.UNKNOWN)
|
||||
mediaStatus = listOf(Media.Status.UNKNOWN, Media.Status.OUTDATED)
|
||||
)).forEach {
|
||||
submitTask(Task.AnalyzeBook(it))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,8 +14,6 @@ class Media(
|
|||
override val lastModifiedDate: LocalDateTime = LocalDateTime.now()
|
||||
) : Auditable() {
|
||||
|
||||
fun reset() = Media(bookId = this.bookId)
|
||||
|
||||
fun copy(
|
||||
status: Status = this.status,
|
||||
mediaType: String? = this.mediaType,
|
||||
|
|
@ -40,7 +38,7 @@ class Media(
|
|||
)
|
||||
|
||||
enum class Status {
|
||||
UNKNOWN, ERROR, READY, UNSUPPORTED
|
||||
UNKNOWN, ERROR, READY, UNSUPPORTED, OUTDATED
|
||||
}
|
||||
|
||||
override fun toString(): String =
|
||||
|
|
|
|||
|
|
@ -36,6 +36,13 @@ class BookLifecycle(
|
|||
logger.error(ex) { "Error while analyzing book: $book" }
|
||||
Media(status = Media.Status.ERROR, comment = ex.message)
|
||||
}.copy(bookId = book.id)
|
||||
|
||||
// if the number of pages has changed, delete all read progress for that book
|
||||
val previous = mediaRepository.findById(book.id)
|
||||
if (previous.status == Media.Status.OUTDATED && previous.pages.size != media.pages.size) {
|
||||
readProgressRepository.deleteByBookId(book.id)
|
||||
}
|
||||
|
||||
mediaRepository.update(media)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package org.gotson.komga.domain.service
|
|||
|
||||
import mu.KotlinLogging
|
||||
import org.gotson.komga.domain.model.Library
|
||||
import org.gotson.komga.domain.model.Media
|
||||
import org.gotson.komga.domain.persistence.BookRepository
|
||||
import org.gotson.komga.domain.persistence.MediaRepository
|
||||
import org.gotson.komga.domain.persistence.SeriesRepository
|
||||
|
|
@ -74,7 +75,7 @@ class LibraryScanner(
|
|||
fileSize = newBook.fileSize
|
||||
)
|
||||
mediaRepository.findById(existingBook.id).let {
|
||||
mediaRepository.update(it.reset())
|
||||
mediaRepository.update(it.copy(status = Media.Status.OUTDATED))
|
||||
}
|
||||
bookRepository.update(updatedBook)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -222,10 +222,13 @@ class BookController(
|
|||
if (!principal.user.canAccessBook(book)) throw ResponseStatusException(HttpStatus.UNAUTHORIZED)
|
||||
|
||||
val media = mediaRepository.findById(book.id)
|
||||
if (media.status == Media.Status.UNKNOWN) throw ResponseStatusException(HttpStatus.NOT_FOUND, "Book has not been analyzed yet")
|
||||
if (media.status in listOf(Media.Status.ERROR, Media.Status.UNSUPPORTED)) throw ResponseStatusException(HttpStatus.NOT_FOUND, "Book analysis failed")
|
||||
|
||||
media.pages.mapIndexed { index, s -> PageDto(index + 1, s.fileName, s.mediaType) }
|
||||
when (media.status) {
|
||||
Media.Status.UNKNOWN -> throw ResponseStatusException(HttpStatus.NOT_FOUND, "Book has not been analyzed yet")
|
||||
Media.Status.OUTDATED -> throw ResponseStatusException(HttpStatus.NOT_FOUND, "Book is outdated and must be re-analyzed")
|
||||
Media.Status.ERROR -> throw ResponseStatusException(HttpStatus.NOT_FOUND, "Book analysis failed")
|
||||
Media.Status.UNSUPPORTED -> throw ResponseStatusException(HttpStatus.NOT_FOUND, "Book format is not supported")
|
||||
Media.Status.READY -> media.pages.mapIndexed { index, s -> PageDto(index + 1, s.fileName, s.mediaType) }
|
||||
}
|
||||
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
|
||||
|
||||
@ApiResponse(content = [Content(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,132 @@
|
|||
package org.gotson.komga.domain.service
|
||||
|
||||
import com.ninjasquad.springmockk.MockkBean
|
||||
import io.mockk.every
|
||||
import org.assertj.core.api.Assertions.assertThat
|
||||
import org.gotson.komga.domain.model.BookPage
|
||||
import org.gotson.komga.domain.model.KomgaUser
|
||||
import org.gotson.komga.domain.model.Media
|
||||
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.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.ReadProgressRepository
|
||||
import org.gotson.komga.domain.persistence.SeriesRepository
|
||||
import org.junit.jupiter.api.AfterAll
|
||||
import org.junit.jupiter.api.AfterEach
|
||||
import org.junit.jupiter.api.BeforeAll
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.extension.ExtendWith
|
||||
import org.springframework.beans.factory.annotation.Autowired
|
||||
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase
|
||||
import org.springframework.boot.test.context.SpringBootTest
|
||||
import org.springframework.test.context.junit.jupiter.SpringExtension
|
||||
|
||||
@ExtendWith(SpringExtension::class)
|
||||
@SpringBootTest
|
||||
@AutoConfigureTestDatabase
|
||||
class BookLifecycleTest(
|
||||
@Autowired private val bookLifecycle: BookLifecycle,
|
||||
@Autowired private val bookRepository: BookRepository,
|
||||
@Autowired private val libraryRepository: LibraryRepository,
|
||||
@Autowired private val seriesRepository: SeriesRepository,
|
||||
@Autowired private val seriesLifecycle: SeriesLifecycle,
|
||||
@Autowired private val readProgressRepository: ReadProgressRepository,
|
||||
@Autowired private val mediaRepository: MediaRepository,
|
||||
@Autowired private val userRepository: KomgaUserRepository
|
||||
) {
|
||||
|
||||
@MockkBean
|
||||
private lateinit var mockAnalyzer: BookAnalyzer
|
||||
|
||||
private var library = makeLibrary()
|
||||
private var user1 = KomgaUser("user1@example.org", "", false)
|
||||
private var user2 = KomgaUser("user2@example.org", "", false)
|
||||
|
||||
@BeforeAll
|
||||
fun `setup library`() {
|
||||
library = libraryRepository.insert(library)
|
||||
|
||||
user1 = userRepository.save(user1)
|
||||
user2 = userRepository.save(user2)
|
||||
}
|
||||
|
||||
@AfterAll
|
||||
fun teardown() {
|
||||
libraryRepository.deleteAll()
|
||||
userRepository.deleteAll()
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
fun `clear repository`() {
|
||||
seriesRepository.findAll().forEach {
|
||||
seriesLifecycle.deleteSeries(it.id)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `given outdated book with different number of pages than before when analyzing then existing read progress is deleted`() {
|
||||
// given
|
||||
makeSeries(name = "series", libraryId = library.id).let { series ->
|
||||
seriesLifecycle.createSeries(series).let { created ->
|
||||
val books = listOf(makeBook("1", libraryId = library.id))
|
||||
seriesLifecycle.addBooks(created, books)
|
||||
}
|
||||
}
|
||||
|
||||
val book = bookRepository.findAll().first()
|
||||
mediaRepository.findById(book.id).let { media ->
|
||||
mediaRepository.update(media.copy(
|
||||
status = Media.Status.OUTDATED,
|
||||
pages = (1..10).map { BookPage("$it", "image/jpeg") }
|
||||
))
|
||||
}
|
||||
|
||||
bookLifecycle.markReadProgressCompleted(book.id, user1)
|
||||
bookLifecycle.markReadProgress(book, user2, 4)
|
||||
|
||||
assertThat(readProgressRepository.findAll()).hasSize(2)
|
||||
|
||||
// when
|
||||
every { mockAnalyzer.analyze(any()) } returns Media(status = Media.Status.READY, mediaType = "application/zip", pages = mutableListOf(makeBookPage("1.jpg"), makeBookPage("2.jpg")))
|
||||
bookLifecycle.analyzeAndPersist(book)
|
||||
|
||||
// then
|
||||
assertThat(readProgressRepository.findAll()).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `given outdated book with same number of pages than before when analyzing then existing read progress is kept`() {
|
||||
// given
|
||||
makeSeries(name = "series", libraryId = library.id).let { series ->
|
||||
seriesLifecycle.createSeries(series).let { created ->
|
||||
val books = listOf(makeBook("1", libraryId = library.id))
|
||||
seriesLifecycle.addBooks(created, books)
|
||||
}
|
||||
}
|
||||
|
||||
val book = bookRepository.findAll().first()
|
||||
mediaRepository.findById(book.id).let { media ->
|
||||
mediaRepository.update(media.copy(
|
||||
status = Media.Status.OUTDATED,
|
||||
pages = (1..10).map { BookPage("$it", "image/jpeg") }
|
||||
))
|
||||
}
|
||||
|
||||
bookLifecycle.markReadProgressCompleted(book.id, user1)
|
||||
bookLifecycle.markReadProgress(book, user2, 4)
|
||||
|
||||
assertThat(readProgressRepository.findAll()).hasSize(2)
|
||||
|
||||
// when
|
||||
every { mockAnalyzer.analyze(any()) } returns Media(status = Media.Status.READY, mediaType = "application/zip", pages = (1..10).map { BookPage("$it", "image/jpeg") })
|
||||
bookLifecycle.analyzeAndPersist(book)
|
||||
|
||||
// then
|
||||
assertThat(readProgressRepository.findAll()).hasSize(2)
|
||||
}
|
||||
}
|
||||
|
|
@ -219,6 +219,42 @@ class LibraryScannerTest(
|
|||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `given existing Book with different last modified date when rescanning then media is marked as outdated`() {
|
||||
// given
|
||||
val library = libraryRepository.insert(makeLibrary())
|
||||
|
||||
val book1 = makeBook("book1")
|
||||
every { mockScanner.scanRootFolder(any()) }
|
||||
.returnsMany(
|
||||
mapOf(makeSeries(name = "series") to listOf(book1)),
|
||||
mapOf(makeSeries(name = "series") to listOf(makeBook(name = "book1")))
|
||||
)
|
||||
libraryScanner.scanRootFolder(library)
|
||||
|
||||
every { mockAnalyzer.analyze(any()) } returns Media(status = Media.Status.READY, mediaType = "application/zip", pages = mutableListOf(makeBookPage("1.jpg"), makeBookPage("2.jpg")))
|
||||
bookRepository.findAll().map { bookLifecycle.analyzeAndPersist(it) }
|
||||
|
||||
// when
|
||||
libraryScanner.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.OUTDATED)
|
||||
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 2 libraries when deleting all books of one and scanning then the other library is kept intact`() {
|
||||
// given
|
||||
|
|
|
|||
Loading…
Reference in a new issue