mirror of
https://github.com/gotson/komga.git
synced 2026-02-15 03:43:05 +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',
|
READY = 'READY',
|
||||||
UNKNOWN = 'UNKNOWN',
|
UNKNOWN = 'UNKNOWN',
|
||||||
ERROR = 'ERROR',
|
ERROR = 'ERROR',
|
||||||
UNSUPPORTED = 'UNSUPPORTED'
|
UNSUPPORTED = 'UNSUPPORTED',
|
||||||
|
OUTDATED = 'OUTDATED'
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum ReadProgress {
|
export enum ReadProgress {
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,7 @@ class TaskHandler(
|
||||||
is Task.ScanLibrary ->
|
is Task.ScanLibrary ->
|
||||||
libraryRepository.findByIdOrNull(task.libraryId)?.let {
|
libraryRepository.findByIdOrNull(task.libraryId)?.let {
|
||||||
libraryScanner.scanRootFolder(it)
|
libraryScanner.scanRootFolder(it)
|
||||||
taskReceiver.analyzeUnknownBooks(it)
|
taskReceiver.analyzeUnknownAndOutdatedBooks(it)
|
||||||
} ?: logger.warn { "Cannot execute task $task: Library does not exist" }
|
} ?: logger.warn { "Cannot execute task $task: Library does not exist" }
|
||||||
|
|
||||||
is Task.AnalyzeBook ->
|
is Task.AnalyzeBook ->
|
||||||
|
|
|
||||||
|
|
@ -31,10 +31,10 @@ class TaskReceiver(
|
||||||
submitTask(Task.ScanLibrary(libraryId))
|
submitTask(Task.ScanLibrary(libraryId))
|
||||||
}
|
}
|
||||||
|
|
||||||
fun analyzeUnknownBooks(library: Library) {
|
fun analyzeUnknownAndOutdatedBooks(library: Library) {
|
||||||
bookRepository.findAllId(BookSearch(
|
bookRepository.findAllId(BookSearch(
|
||||||
libraryIds = listOf(library.id),
|
libraryIds = listOf(library.id),
|
||||||
mediaStatus = listOf(Media.Status.UNKNOWN)
|
mediaStatus = listOf(Media.Status.UNKNOWN, Media.Status.OUTDATED)
|
||||||
)).forEach {
|
)).forEach {
|
||||||
submitTask(Task.AnalyzeBook(it))
|
submitTask(Task.AnalyzeBook(it))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,8 +14,6 @@ class Media(
|
||||||
override val lastModifiedDate: LocalDateTime = LocalDateTime.now()
|
override val lastModifiedDate: LocalDateTime = LocalDateTime.now()
|
||||||
) : Auditable() {
|
) : Auditable() {
|
||||||
|
|
||||||
fun reset() = Media(bookId = this.bookId)
|
|
||||||
|
|
||||||
fun copy(
|
fun copy(
|
||||||
status: Status = this.status,
|
status: Status = this.status,
|
||||||
mediaType: String? = this.mediaType,
|
mediaType: String? = this.mediaType,
|
||||||
|
|
@ -40,7 +38,7 @@ class Media(
|
||||||
)
|
)
|
||||||
|
|
||||||
enum class Status {
|
enum class Status {
|
||||||
UNKNOWN, ERROR, READY, UNSUPPORTED
|
UNKNOWN, ERROR, READY, UNSUPPORTED, OUTDATED
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun toString(): String =
|
override fun toString(): String =
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,13 @@ class BookLifecycle(
|
||||||
logger.error(ex) { "Error while analyzing book: $book" }
|
logger.error(ex) { "Error while analyzing book: $book" }
|
||||||
Media(status = Media.Status.ERROR, comment = ex.message)
|
Media(status = Media.Status.ERROR, comment = ex.message)
|
||||||
}.copy(bookId = book.id)
|
}.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)
|
mediaRepository.update(media)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ package org.gotson.komga.domain.service
|
||||||
|
|
||||||
import mu.KotlinLogging
|
import mu.KotlinLogging
|
||||||
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.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.SeriesRepository
|
import org.gotson.komga.domain.persistence.SeriesRepository
|
||||||
|
|
@ -74,7 +75,7 @@ class LibraryScanner(
|
||||||
fileSize = newBook.fileSize
|
fileSize = newBook.fileSize
|
||||||
)
|
)
|
||||||
mediaRepository.findById(existingBook.id).let {
|
mediaRepository.findById(existingBook.id).let {
|
||||||
mediaRepository.update(it.reset())
|
mediaRepository.update(it.copy(status = Media.Status.OUTDATED))
|
||||||
}
|
}
|
||||||
bookRepository.update(updatedBook)
|
bookRepository.update(updatedBook)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -222,10 +222,13 @@ class BookController(
|
||||||
if (!principal.user.canAccessBook(book)) throw ResponseStatusException(HttpStatus.UNAUTHORIZED)
|
if (!principal.user.canAccessBook(book)) throw ResponseStatusException(HttpStatus.UNAUTHORIZED)
|
||||||
|
|
||||||
val media = mediaRepository.findById(book.id)
|
val media = mediaRepository.findById(book.id)
|
||||||
if (media.status == Media.Status.UNKNOWN) throw ResponseStatusException(HttpStatus.NOT_FOUND, "Book has not been analyzed yet")
|
when (media.status) {
|
||||||
if (media.status in listOf(Media.Status.ERROR, Media.Status.UNSUPPORTED)) throw ResponseStatusException(HttpStatus.NOT_FOUND, "Book analysis failed")
|
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.pages.mapIndexed { index, s -> PageDto(index + 1, s.fileName, s.mediaType) }
|
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)
|
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
|
||||||
|
|
||||||
@ApiResponse(content = [Content(
|
@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
|
@Test
|
||||||
fun `given 2 libraries when deleting all books of one and scanning then the other library is kept intact`() {
|
fun `given 2 libraries when deleting all books of one and scanning then the other library is kept intact`() {
|
||||||
// given
|
// given
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue