refactor: use AsyncOrchestrator for reAnalyzing of books

This commit is contained in:
Gauthier Roebroeck 2020-01-06 11:01:47 +08:00
parent e72e2437f9
commit f5e8f3e20b
3 changed files with 143 additions and 126 deletions

View file

@ -7,16 +7,17 @@ import org.gotson.komga.domain.persistence.BookRepository
import org.gotson.komga.domain.persistence.LibraryRepository
import org.springframework.scheduling.annotation.Async
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import kotlin.system.measureTimeMillis
private val logger = KotlinLogging.logger {}
@Service
class AsyncOrchestrator(
private val libraryScanner: LibraryScanner,
private val libraryRepository: LibraryRepository,
private val bookRepository: BookRepository,
private val bookLifecycle: BookLifecycle
private val libraryScanner: LibraryScanner,
private val libraryRepository: LibraryRepository,
private val bookRepository: BookRepository,
private val bookLifecycle: BookLifecycle
) {
@Async("periodicScanTaskExecutor")
@ -52,17 +53,27 @@ class AsyncOrchestrator(
var sumOfTasksTime = 0L
measureTimeMillis {
sumOfTasksTime = books
.map { bookLifecycle.regenerateThumbnailAndPersist(it) }
.map {
try {
it.get()
} catch (ex: Exception) {
0L
}
.map { bookLifecycle.regenerateThumbnailAndPersist(it) }
.map {
try {
it.get()
} catch (ex: Exception) {
0L
}
.sum()
}
.sum()
}.also {
logger.info { "Generated ${books.size} thumbnails in ${DurationFormatUtils.formatDurationHMS(it)} (virtual: ${DurationFormatUtils.formatDurationHMS(sumOfTasksTime)})" }
}
}
@Async("reAnalyzeBooksTaskExecutor")
@Transactional
fun reAnalyzeBooks(books: List<Book>) {
val loadedBooks = bookRepository.findAllById(books.map { it.id })
loadedBooks.forEach { it.media.reset() }
bookRepository.saveAll(loadedBooks)
loadedBooks.map { bookLifecycle.analyzeAndPersist(it) }
}
}

View file

@ -29,9 +29,15 @@ class AsyncConfiguration(
@Bean("regenerateThumbnailsTaskExecutor")
fun regenerateThumbnailsTaskExecutor(): Executor =
ThreadPoolTaskExecutor().apply {
corePoolSize = 1
maxPoolSize = 1
setQueueCapacity(0)
}
ThreadPoolTaskExecutor().apply {
corePoolSize = 1
maxPoolSize = 1
setQueueCapacity(0)
}
@Bean("reAnalyzeBooksTaskExecutor")
fun reAnalyzeBooksTaskExecutor(): Executor =
ThreadPoolTaskExecutor().apply {
corePoolSize = 1
}
}

View file

@ -7,8 +7,8 @@ import org.gotson.komga.domain.model.Book
import org.gotson.komga.domain.model.Media
import org.gotson.komga.domain.model.MediaNotReadyException
import org.gotson.komga.domain.persistence.BookRepository
import org.gotson.komga.domain.persistence.LibraryRepository
import org.gotson.komga.domain.persistence.SeriesRepository
import org.gotson.komga.domain.service.AsyncOrchestrator
import org.gotson.komga.domain.service.BookLifecycle
import org.gotson.komga.infrastructure.image.ImageType
import org.gotson.komga.infrastructure.security.KomgaPrincipal
@ -47,24 +47,24 @@ private val logger = KotlinLogging.logger {}
@RestController
@RequestMapping(produces = [MediaType.APPLICATION_JSON_VALUE])
class BookController(
private val libraryRepository: LibraryRepository,
private val seriesRepository: SeriesRepository,
private val bookRepository: BookRepository,
private val bookLifecycle: BookLifecycle
private val seriesRepository: SeriesRepository,
private val bookRepository: BookRepository,
private val bookLifecycle: BookLifecycle,
private val asyncOrchestrator: AsyncOrchestrator
) {
@GetMapping("api/v1/books")
fun getAllBooks(
@AuthenticationPrincipal principal: KomgaPrincipal,
@RequestParam(name = "search", required = false) searchTerm: String?,
@RequestParam(name = "library_id", required = false) libraryIds: List<Long>?,
page: Pageable
@AuthenticationPrincipal principal: KomgaPrincipal,
@RequestParam(name = "search", required = false) searchTerm: String?,
@RequestParam(name = "library_id", required = false) libraryIds: List<Long>?,
page: Pageable
): Page<BookDto> {
val pageRequest = PageRequest.of(
page.pageNumber,
page.pageSize,
if (page.sort.isSorted) page.sort
else Sort.by(Sort.Order.asc("name").ignoreCase())
page.pageNumber,
page.pageSize,
if (page.sort.isSorted) page.sort
else Sort.by(Sort.Order.asc("name").ignoreCase())
)
return mutableListOf<Specification<Book>>().let { specs ->
@ -97,13 +97,13 @@ class BookController(
@GetMapping("api/v1/books/latest")
fun getLatestSeries(
@AuthenticationPrincipal principal: KomgaPrincipal,
page: Pageable
@AuthenticationPrincipal principal: KomgaPrincipal,
page: Pageable
): Page<BookDto> {
val pageRequest = PageRequest.of(
page.pageNumber,
page.pageSize,
Sort.by(Sort.Direction.DESC, "lastModifiedDate")
page.pageNumber,
page.pageSize,
Sort.by(Sort.Direction.DESC, "lastModifiedDate")
)
return if (principal.user.sharedAllLibraries) {
@ -116,13 +116,13 @@ class BookController(
@GetMapping("api/v1/books/{bookId}")
fun getOneBook(
@AuthenticationPrincipal principal: KomgaPrincipal,
@PathVariable bookId: Long
@AuthenticationPrincipal principal: KomgaPrincipal,
@PathVariable bookId: Long
): BookDto =
bookRepository.findByIdOrNull(bookId)?.let {
if (!principal.user.canAccessBook(it)) throw ResponseStatusException(HttpStatus.UNAUTHORIZED)
it.toDto(includeFullUrl = principal.user.isAdmin())
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
bookRepository.findByIdOrNull(bookId)?.let {
if (!principal.user.canAccessBook(it)) throw ResponseStatusException(HttpStatus.UNAUTHORIZED)
it.toDto(includeFullUrl = principal.user.isAdmin())
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
@GetMapping(value = [
@ -130,24 +130,24 @@ class BookController(
"opds/v1.2/books/{bookId}/thumbnail"
], produces = [MediaType.IMAGE_PNG_VALUE])
fun getBookThumbnail(
@AuthenticationPrincipal principal: KomgaPrincipal,
request: WebRequest,
@PathVariable bookId: Long
@AuthenticationPrincipal principal: KomgaPrincipal,
request: WebRequest,
@PathVariable bookId: Long
): ResponseEntity<ByteArray> =
bookRepository.findByIdOrNull(bookId)?.let { book ->
if (request.checkNotModified(getBookLastModified(book))) {
return@let ResponseEntity
.status(HttpStatus.NOT_MODIFIED)
.setNotModified(book)
.body(ByteArray(0))
}
if (!principal.user.canAccessBook(book)) throw ResponseStatusException(HttpStatus.UNAUTHORIZED)
if (book.media.thumbnail != null) {
ResponseEntity.ok()
.setNotModified(book)
.body(book.media.thumbnail)
} else throw ResponseStatusException(HttpStatus.NOT_FOUND)
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
bookRepository.findByIdOrNull(bookId)?.let { book ->
if (request.checkNotModified(getBookLastModified(book))) {
return@let ResponseEntity
.status(HttpStatus.NOT_MODIFIED)
.setNotModified(book)
.body(ByteArray(0))
}
if (!principal.user.canAccessBook(book)) throw ResponseStatusException(HttpStatus.UNAUTHORIZED)
if (book.media.thumbnail != null) {
ResponseEntity.ok()
.setNotModified(book)
.body(book.media.thumbnail)
} else throw ResponseStatusException(HttpStatus.NOT_FOUND)
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
@GetMapping(value = [
"api/v1/books/{bookId}/file",
@ -155,93 +155,93 @@ class BookController(
"opds/v1.2/books/{bookId}/file/*"
])
fun getBookFile(
@AuthenticationPrincipal principal: KomgaPrincipal,
@PathVariable bookId: Long
@AuthenticationPrincipal principal: KomgaPrincipal,
@PathVariable bookId: Long
): ResponseEntity<ByteArray> =
bookRepository.findByIdOrNull(bookId)?.let { book ->
if (!principal.user.canAccessBook(book)) throw ResponseStatusException(HttpStatus.UNAUTHORIZED)
try {
ResponseEntity.ok()
.headers(HttpHeaders().apply {
contentDisposition = ContentDisposition.builder("attachment")
.filename(book.fileName())
.build()
})
.contentType(getMediaTypeOrDefault(book.media.mediaType))
.body(File(book.url.toURI()).readBytes())
} catch (ex: FileNotFoundException) {
logger.warn(ex) { "File not found: $book" }
throw ResponseStatusException(HttpStatus.NOT_FOUND, "File not found, it may have moved")
}
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
bookRepository.findByIdOrNull(bookId)?.let { book ->
if (!principal.user.canAccessBook(book)) throw ResponseStatusException(HttpStatus.UNAUTHORIZED)
try {
ResponseEntity.ok()
.headers(HttpHeaders().apply {
contentDisposition = ContentDisposition.builder("attachment")
.filename(book.fileName())
.build()
})
.contentType(getMediaTypeOrDefault(book.media.mediaType))
.body(File(book.url.toURI()).readBytes())
} catch (ex: FileNotFoundException) {
logger.warn(ex) { "File not found: $book" }
throw ResponseStatusException(HttpStatus.NOT_FOUND, "File not found, it may have moved")
}
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
@GetMapping("api/v1/books/{bookId}/pages")
fun getBookPages(
@AuthenticationPrincipal principal: KomgaPrincipal,
@PathVariable bookId: Long
@AuthenticationPrincipal principal: KomgaPrincipal,
@PathVariable bookId: Long
): List<PageDto> =
bookRepository.findByIdOrNull((bookId))?.let {
if (!principal.user.canAccessBook(it)) throw ResponseStatusException(HttpStatus.UNAUTHORIZED)
if (it.media.status == Media.Status.UNKNOWN) throw ResponseStatusException(HttpStatus.NOT_FOUND, "Book has not been analyzed yet")
if (it.media.status in listOf(Media.Status.ERROR, Media.Status.UNSUPPORTED)) throw ResponseStatusException(HttpStatus.NOT_FOUND, "Book analysis failed")
bookRepository.findByIdOrNull((bookId))?.let {
if (!principal.user.canAccessBook(it)) throw ResponseStatusException(HttpStatus.UNAUTHORIZED)
if (it.media.status == Media.Status.UNKNOWN) throw ResponseStatusException(HttpStatus.NOT_FOUND, "Book has not been analyzed yet")
if (it.media.status in listOf(Media.Status.ERROR, Media.Status.UNSUPPORTED)) throw ResponseStatusException(HttpStatus.NOT_FOUND, "Book analysis failed")
it.media.pages.mapIndexed { index, s -> PageDto(index + 1, s.fileName, s.mediaType) }
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
it.media.pages.mapIndexed { index, s -> PageDto(index + 1, s.fileName, s.mediaType) }
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
@GetMapping(value = [
"api/v1/books/{bookId}/pages/{pageNumber}",
"opds/v1.2/books/{bookId}/pages/{pageNumber}"
])
fun getBookPage(
@AuthenticationPrincipal principal: KomgaPrincipal,
request: WebRequest,
@PathVariable bookId: Long,
@PathVariable pageNumber: Int,
@RequestParam(value = "convert", required = false) convertTo: String?,
@RequestParam(value = "zero_based", defaultValue = "false") zeroBasedIndex: Boolean
@AuthenticationPrincipal principal: KomgaPrincipal,
request: WebRequest,
@PathVariable bookId: Long,
@PathVariable pageNumber: Int,
@RequestParam(value = "convert", required = false) convertTo: String?,
@RequestParam(value = "zero_based", defaultValue = "false") zeroBasedIndex: Boolean
): ResponseEntity<ByteArray> =
bookRepository.findByIdOrNull((bookId))?.let { book ->
if (request.checkNotModified(getBookLastModified(book))) {
return@let ResponseEntity
.status(HttpStatus.NOT_MODIFIED)
.setNotModified(book)
.body(ByteArray(0))
bookRepository.findByIdOrNull((bookId))?.let { book ->
if (request.checkNotModified(getBookLastModified(book))) {
return@let ResponseEntity
.status(HttpStatus.NOT_MODIFIED)
.setNotModified(book)
.body(ByteArray(0))
}
if (!principal.user.canAccessBook(book)) throw ResponseStatusException(HttpStatus.UNAUTHORIZED)
try {
val convertFormat = when (convertTo?.toLowerCase()) {
"jpeg" -> ImageType.JPEG
"png" -> ImageType.PNG
"", null -> null
else -> throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid conversion format: $convertTo")
}
if (!principal.user.canAccessBook(book)) throw ResponseStatusException(HttpStatus.UNAUTHORIZED)
try {
val convertFormat = when (convertTo?.toLowerCase()) {
"jpeg" -> ImageType.JPEG
"png" -> ImageType.PNG
"", null -> null
else -> throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid conversion format: $convertTo")
}
val pageNum = if (zeroBasedIndex) pageNumber + 1 else pageNumber
val pageNum = if (zeroBasedIndex) pageNumber + 1 else pageNumber
val pageContent = bookLifecycle.getBookPage(book, pageNum, convertFormat)
val pageContent = bookLifecycle.getBookPage(book, pageNum, convertFormat)
ResponseEntity.ok()
.contentType(getMediaTypeOrDefault(pageContent.mediaType))
.setNotModified(book)
.body(pageContent.content)
} catch (ex: IndexOutOfBoundsException) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Page number does not exist")
} catch (ex: MediaNotReadyException) {
throw ResponseStatusException(HttpStatus.NOT_FOUND, "Book analysis failed")
} catch (ex: NoSuchFileException) {
logger.warn(ex) { "File not found: $book" }
throw ResponseStatusException(HttpStatus.NOT_FOUND, "File not found, it may have moved")
}
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
ResponseEntity.ok()
.contentType(getMediaTypeOrDefault(pageContent.mediaType))
.setNotModified(book)
.body(pageContent.content)
} catch (ex: IndexOutOfBoundsException) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Page number does not exist")
} catch (ex: MediaNotReadyException) {
throw ResponseStatusException(HttpStatus.NOT_FOUND, "Book analysis failed")
} catch (ex: NoSuchFileException) {
logger.warn(ex) { "File not found: $book" }
throw ResponseStatusException(HttpStatus.NOT_FOUND, "File not found, it may have moved")
}
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
@PostMapping("api/v1/books/{bookId}/analyze")
@PreAuthorize("hasRole('ROLE_ADMIN')")
@ResponseStatus(HttpStatus.ACCEPTED)
fun analyze(@PathVariable bookId: Long) {
bookRepository.findByIdOrNull((bookId))?.let { book ->
bookRepository.findByIdOrNull(bookId)?.let { book ->
try {
bookLifecycle.analyzeAndPersist(book)
asyncOrchestrator.reAnalyzeBooks(listOf(book))
} catch (e: RejectedExecutionException) {
throw ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "Another book analysis task is already running")
}
@ -249,13 +249,13 @@ class BookController(
}
private fun ResponseEntity.BodyBuilder.setNotModified(book: Book) =
this.cacheControl(CacheControl.maxAge(0, TimeUnit.SECONDS)
.cachePrivate()
.mustRevalidate()
).lastModified(getBookLastModified(book))
this.cacheControl(CacheControl.maxAge(0, TimeUnit.SECONDS)
.cachePrivate()
.mustRevalidate()
).lastModified(getBookLastModified(book))
private fun getBookLastModified(book: Book) =
book.media.lastModifiedDate!!.toInstant(ZoneOffset.UTC).toEpochMilli()
book.media.lastModifiedDate!!.toInstant(ZoneOffset.UTC).toEpochMilli()
private fun getMediaTypeOrDefault(mediaTypeString: String?): MediaType {