Skip to content

Commit

Permalink
feat(analysis): handle read progress during book analysis
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
gotson committed Jun 4, 2020
1 parent 31e21fe commit 1fc893e
Show file tree
Hide file tree
Showing 9 changed files with 190 additions and 12 deletions.
3 changes: 2 additions & 1 deletion komga-webui/src/types/enum-books.ts
Expand Up @@ -9,7 +9,8 @@ export enum MediaStatus {
READY = 'READY',
UNKNOWN = 'UNKNOWN',
ERROR = 'ERROR',
UNSUPPORTED = 'UNSUPPORTED'
UNSUPPORTED = 'UNSUPPORTED',
OUTDATED = 'OUTDATED'
}

export enum ReadProgress {
Expand Down
Expand Up @@ -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 ->
Expand Down
Expand Up @@ -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))
}
Expand Down
4 changes: 1 addition & 3 deletions komga/src/main/kotlin/org/gotson/komga/domain/model/Media.kt
Expand Up @@ -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,
Expand All @@ -40,7 +38,7 @@ class Media(
)

enum class Status {
UNKNOWN, ERROR, READY, UNSUPPORTED
UNKNOWN, ERROR, READY, UNSUPPORTED, OUTDATED
}

override fun toString(): String =
Expand Down
Expand Up @@ -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)
}

Expand Down
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
Expand Down
Expand Up @@ -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(
Expand Down
@@ -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)
}
}
Expand Up @@ -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
Expand Down

0 comments on commit 1fc893e

Please sign in to comment.