mirror of
https://github.com/gotson/komga.git
synced 2025-12-24 09:24:36 +01:00
refactor: use AsyncOrchestrator for reAnalyzing of books
This commit is contained in:
parent
e72e2437f9
commit
f5e8f3e20b
3 changed files with 143 additions and 126 deletions
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue