diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/service/AsyncOrchestrator.kt b/komga/src/main/kotlin/org/gotson/komga/domain/service/AsyncOrchestrator.kt index 26f432daa..ad1bdecd9 100644 --- a/komga/src/main/kotlin/org/gotson/komga/domain/service/AsyncOrchestrator.kt +++ b/komga/src/main/kotlin/org/gotson/komga/domain/service/AsyncOrchestrator.kt @@ -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) { + val loadedBooks = bookRepository.findAllById(books.map { it.id }) + loadedBooks.forEach { it.media.reset() } + bookRepository.saveAll(loadedBooks) + + loadedBooks.map { bookLifecycle.analyzeAndPersist(it) } + } } diff --git a/komga/src/main/kotlin/org/gotson/komga/infrastructure/async/AsyncConfiguration.kt b/komga/src/main/kotlin/org/gotson/komga/infrastructure/async/AsyncConfiguration.kt index e637a96f8..80aea1a14 100644 --- a/komga/src/main/kotlin/org/gotson/komga/infrastructure/async/AsyncConfiguration.kt +++ b/komga/src/main/kotlin/org/gotson/komga/infrastructure/async/AsyncConfiguration.kt @@ -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 + } } diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/web/rest/BookController.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/web/rest/BookController.kt index dc6488a30..64b201c95 100644 --- a/komga/src/main/kotlin/org/gotson/komga/interfaces/web/rest/BookController.kt +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/web/rest/BookController.kt @@ -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?, - page: Pageable + @AuthenticationPrincipal principal: KomgaPrincipal, + @RequestParam(name = "search", required = false) searchTerm: String?, + @RequestParam(name = "library_id", required = false) libraryIds: List?, + page: Pageable ): Page { 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>().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 { 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 = - 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 = - 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 = - 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 = - 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 {