diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/service/BookLifecycle.kt b/komga/src/main/kotlin/org/gotson/komga/domain/service/BookLifecycle.kt index c48ff5c68..b1ccbaa56 100644 --- a/komga/src/main/kotlin/org/gotson/komga/domain/service/BookLifecycle.kt +++ b/komga/src/main/kotlin/org/gotson/komga/domain/service/BookLifecycle.kt @@ -17,6 +17,7 @@ import org.gotson.komga.domain.persistence.ThumbnailBookRepository import org.gotson.komga.infrastructure.image.ImageConverter import org.gotson.komga.infrastructure.image.ImageType import org.springframework.stereotype.Service +import java.io.File import java.nio.file.Files import java.nio.file.Paths @@ -99,6 +100,17 @@ class BookLifecycle( return selected } + fun getThumbnailBytes(bookId: String): ByteArray? { + getThumbnail(bookId)?.let { + return when { + it.thumbnail != null -> it.thumbnail + it.url != null -> File(it.url.toURI()).readBytes() + else -> null + } + } + return null + } + private fun thumbnailsHouseKeeping(bookId: String) { logger.info { "House keeping thumbnails for book: $bookId" } val all = thumbnailBookRepository.findByBookId(bookId) diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/service/MetadataLifecycle.kt b/komga/src/main/kotlin/org/gotson/komga/domain/service/MetadataLifecycle.kt index b9a03f8ea..e2e0cec89 100644 --- a/komga/src/main/kotlin/org/gotson/komga/domain/service/MetadataLifecycle.kt +++ b/komga/src/main/kotlin/org/gotson/komga/domain/service/MetadataLifecycle.kt @@ -15,6 +15,7 @@ import org.gotson.komga.infrastructure.metadata.BookMetadataProvider import org.gotson.komga.infrastructure.metadata.SeriesMetadataProvider import org.gotson.komga.infrastructure.metadata.comicinfo.ComicInfoProvider import org.gotson.komga.infrastructure.metadata.epub.EpubMetadataProvider +import org.gotson.komga.infrastructure.metadata.localmediaassets.LocalMediaAssetsProvider import org.springframework.stereotype.Service private val logger = KotlinLogging.logger {} @@ -29,8 +30,10 @@ class MetadataLifecycle( private val seriesMetadataRepository: SeriesMetadataRepository, private val libraryRepository: LibraryRepository, private val bookRepository: BookRepository, + private val bookLifecycle: BookLifecycle, private val collectionRepository: SeriesCollectionRepository, - private val collectionLifecycle: SeriesCollectionLifecycle + private val collectionLifecycle: SeriesCollectionLifecycle, + private val localMediaAssetsProvider: LocalMediaAssetsProvider ) { fun refreshMetadata(book: Book) { @@ -58,6 +61,10 @@ class MetadataLifecycle( } } } + + localMediaAssetsProvider.getBookThumbnails(book).forEach { + bookLifecycle.addThumbnailForBook(it) + } } fun refreshMetadata(series: Series) { diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/service/SeriesCollectionLifecycle.kt b/komga/src/main/kotlin/org/gotson/komga/domain/service/SeriesCollectionLifecycle.kt index 9712ff535..abf92a361 100644 --- a/komga/src/main/kotlin/org/gotson/komga/domain/service/SeriesCollectionLifecycle.kt +++ b/komga/src/main/kotlin/org/gotson/komga/domain/service/SeriesCollectionLifecycle.kt @@ -4,13 +4,16 @@ import mu.KotlinLogging import org.gotson.komga.domain.model.DuplicateNameException import org.gotson.komga.domain.model.SeriesCollection import org.gotson.komga.domain.persistence.SeriesCollectionRepository +import org.gotson.komga.infrastructure.image.MosaicGenerator import org.springframework.stereotype.Service private val logger = KotlinLogging.logger {} @Service class SeriesCollectionLifecycle( - private val collectionRepository: SeriesCollectionRepository + private val collectionRepository: SeriesCollectionRepository, + private val seriesLifecycle: SeriesLifecycle, + private val mosaicGenerator: MosaicGenerator ) { @Throws( @@ -40,4 +43,16 @@ class SeriesCollectionLifecycle( fun deleteCollection(collectionId: String) { collectionRepository.delete(collectionId) } + + fun getThumbnailBytes(collection: SeriesCollection): ByteArray { + val ids = with(mutableListOf()) { + while (size < 4) { + this += collection.seriesIds.take(4) + } + this.take(4) + } + + val images = ids.mapNotNull { seriesLifecycle.getThumbnailBytes(it) } + return mosaicGenerator.createMosaic(images) + } } diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/service/SeriesLifecycle.kt b/komga/src/main/kotlin/org/gotson/komga/domain/service/SeriesLifecycle.kt index 3042716eb..fb23c5d53 100644 --- a/komga/src/main/kotlin/org/gotson/komga/domain/service/SeriesLifecycle.kt +++ b/komga/src/main/kotlin/org/gotson/komga/domain/service/SeriesLifecycle.kt @@ -15,7 +15,7 @@ import org.gotson.komga.domain.persistence.SeriesCollectionRepository import org.gotson.komga.domain.persistence.SeriesMetadataRepository import org.gotson.komga.domain.persistence.SeriesRepository import org.springframework.stereotype.Service -import java.util.* +import java.util.Comparator private val logger = KotlinLogging.logger {} private val natSortComparator: Comparator = CaseInsensitiveSimpleNaturalComparator.getInstance() @@ -110,4 +110,11 @@ class SeriesLifecycle( seriesRepository.deleteAll(seriesIds) } + + fun getThumbnailBytes(seriesId: String): ByteArray? { + bookRepository.findFirstIdInSeries(seriesId)?.let { bookId -> + return bookLifecycle.getThumbnailBytes(bookId) + } + return null + } } diff --git a/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/BookDtoDao.kt b/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/BookDtoDao.kt index 7abc2eaae..1c4110bd3 100644 --- a/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/BookDtoDao.kt +++ b/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/BookDtoDao.kt @@ -2,6 +2,7 @@ package org.gotson.komga.infrastructure.jooq import org.gotson.komga.domain.model.BookSearchWithReadProgress import org.gotson.komga.domain.model.ReadStatus +import org.gotson.komga.infrastructure.web.toFilePath import org.gotson.komga.interfaces.rest.dto.AuthorDto import org.gotson.komga.interfaces.rest.dto.BookDto import org.gotson.komga.interfaces.rest.dto.BookMetadataDto @@ -24,7 +25,6 @@ import org.springframework.data.domain.PageImpl import org.springframework.data.domain.PageRequest import org.springframework.data.domain.Pageable import org.springframework.stereotype.Component -import toFilePath import java.net.URL @Component diff --git a/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/SeriesDtoDao.kt b/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/SeriesDtoDao.kt index e3b138c7a..7bc23bacf 100644 --- a/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/SeriesDtoDao.kt +++ b/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/SeriesDtoDao.kt @@ -2,6 +2,7 @@ package org.gotson.komga.infrastructure.jooq import org.gotson.komga.domain.model.ReadStatus import org.gotson.komga.domain.model.SeriesSearchWithReadProgress +import org.gotson.komga.infrastructure.web.toFilePath import org.gotson.komga.interfaces.rest.dto.SeriesDto import org.gotson.komga.interfaces.rest.dto.SeriesMetadataDto import org.gotson.komga.interfaces.rest.persistence.SeriesDtoRepository @@ -22,7 +23,6 @@ import org.springframework.data.domain.PageRequest import org.springframework.data.domain.Pageable import org.springframework.data.domain.Sort import org.springframework.stereotype.Component -import toFilePath import java.math.BigDecimal import java.net.URL diff --git a/komga/src/main/kotlin/org/gotson/komga/infrastructure/metadata/localmediaassets/LocalMediaAssetsProvider.kt b/komga/src/main/kotlin/org/gotson/komga/infrastructure/metadata/localmediaassets/LocalMediaAssetsProvider.kt new file mode 100644 index 000000000..c091e8ea7 --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/infrastructure/metadata/localmediaassets/LocalMediaAssetsProvider.kt @@ -0,0 +1,47 @@ +package org.gotson.komga.infrastructure.metadata.localmediaassets + +import mu.KotlinLogging +import org.apache.commons.io.FilenameUtils +import org.gotson.komga.domain.model.Book +import org.gotson.komga.domain.model.ThumbnailBook +import org.gotson.komga.infrastructure.mediacontainer.ContentDetector +import org.springframework.stereotype.Service +import java.nio.file.Files +import kotlin.streams.asSequence + +private val logger = KotlinLogging.logger {} + +@Service +class LocalMediaAssetsProvider( + private val contentDetector: ContentDetector +) { + + val supportedExtensions = listOf("png", "jpeg", "jpg", "tbn") + + fun getBookThumbnails(book: Book): List { + logger.info { "Looking for local thumbnails for book: $book" } + val bookPath = book.path() + val baseName = FilenameUtils.getBaseName(bookPath.toString()) + + val regex = "${Regex.escape(baseName)}(-\\d+)?".toRegex(RegexOption.IGNORE_CASE) + + return Files.list(bookPath.parent).use { dirStream -> + dirStream.asSequence() + .filter { Files.isRegularFile(it) } + .filter { regex.matches(FilenameUtils.getBaseName(it.toString())) } + .filter { supportedExtensions.contains(FilenameUtils.getExtension(it.fileName.toString()).toLowerCase()) } + .filter { contentDetector.isImage(contentDetector.detectMediaType(it)) } + .mapIndexed { index, path -> + logger.info { "Found file: $path" } + ThumbnailBook( + url = path.toUri().toURL(), + type = ThumbnailBook.Type.SIDECAR, + bookId = book.id, + selected = index == 0 + ) + }.sortedBy { it.url.toString() } + .toList() + } + } + +} diff --git a/komga/src/main/kotlin/org/gotson/komga/infrastructure/web/Utils.kt b/komga/src/main/kotlin/org/gotson/komga/infrastructure/web/Utils.kt index 95376b319..e38a10113 100644 --- a/komga/src/main/kotlin/org/gotson/komga/infrastructure/web/Utils.kt +++ b/komga/src/main/kotlin/org/gotson/komga/infrastructure/web/Utils.kt @@ -1,8 +1,19 @@ +package org.gotson.komga.infrastructure.web + +import org.springframework.http.CacheControl +import org.springframework.http.ResponseEntity import java.net.URL import java.nio.file.Paths +import java.util.concurrent.TimeUnit fun URL.toFilePath(): String = Paths.get(this.toURI()).toString() fun filePathToUrl(filePath: String): URL = Paths.get(filePath).toUri().toURL() + +fun ResponseEntity.BodyBuilder.setCachePrivate() = + this.cacheControl(CacheControl.maxAge(0, TimeUnit.SECONDS) + .cachePrivate() + .mustRevalidate() + ) diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/BookController.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/BookController.kt index 9eb934fb6..eb5cb5495 100644 --- a/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/BookController.kt +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/BookController.kt @@ -25,6 +25,7 @@ import org.gotson.komga.infrastructure.image.ImageType import org.gotson.komga.infrastructure.security.KomgaPrincipal import org.gotson.komga.infrastructure.swagger.PageableAsQueryParam import org.gotson.komga.infrastructure.swagger.PageableWithoutSortAsQueryParam +import org.gotson.komga.infrastructure.web.setCachePrivate import org.gotson.komga.interfaces.rest.dto.BookDto import org.gotson.komga.interfaces.rest.dto.BookMetadataUpdateDto import org.gotson.komga.interfaces.rest.dto.PageDto @@ -36,7 +37,6 @@ import org.springframework.data.domain.Page import org.springframework.data.domain.PageRequest import org.springframework.data.domain.Pageable import org.springframework.data.domain.Sort -import org.springframework.http.CacheControl import org.springframework.http.ContentDisposition import org.springframework.http.HttpHeaders import org.springframework.http.HttpStatus @@ -61,7 +61,6 @@ import java.io.FileNotFoundException import java.io.OutputStream import java.nio.file.NoSuchFileException import java.time.ZoneOffset -import java.util.concurrent.TimeUnit import javax.validation.Valid private val logger = KotlinLogging.logger {} @@ -197,10 +196,10 @@ class BookController( if (!principal.user.canAccessLibrary(it)) throw ResponseStatusException(HttpStatus.FORBIDDEN) } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) - return bookLifecycle.getThumbnail(bookId)?.let { + return bookLifecycle.getThumbnailBytes(bookId)?.let { ResponseEntity.ok() - .setCachePrivate() //TODO: this won't work with changing covers - .body(it.thumbnail) + .setCachePrivate() + .body(it) } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) } @@ -449,13 +448,6 @@ class BookController( } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) } - - private fun ResponseEntity.BodyBuilder.setCachePrivate() = - this.cacheControl(CacheControl.maxAge(0, TimeUnit.SECONDS) - .cachePrivate() - .mustRevalidate() - ) - private fun ResponseEntity.BodyBuilder.setNotModified(media: Media) = this.setCachePrivate().lastModified(getBookLastModified(media)) diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/LibraryController.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/LibraryController.kt index 91c79c520..1f1dd38e4 100644 --- a/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/LibraryController.kt +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/LibraryController.kt @@ -1,6 +1,5 @@ package org.gotson.komga.interfaces.rest -import filePathToUrl import mu.KotlinLogging import org.gotson.komga.application.tasks.TaskReceiver import org.gotson.komga.domain.model.DirectoryNotFoundException @@ -12,6 +11,8 @@ import org.gotson.komga.domain.persistence.BookRepository import org.gotson.komga.domain.persistence.LibraryRepository import org.gotson.komga.domain.service.LibraryLifecycle import org.gotson.komga.infrastructure.security.KomgaPrincipal +import org.gotson.komga.infrastructure.web.filePathToUrl +import org.gotson.komga.infrastructure.web.toFilePath import org.springframework.http.HttpStatus import org.springframework.http.MediaType import org.springframework.security.access.prepost.PreAuthorize @@ -26,7 +27,6 @@ import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.ResponseStatus import org.springframework.web.bind.annotation.RestController import org.springframework.web.server.ResponseStatusException -import toFilePath import java.io.FileNotFoundException import javax.validation.Valid import javax.validation.constraints.NotBlank diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/SeriesCollectionController.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/SeriesCollectionController.kt index 1e3d83a12..fa60d50db 100644 --- a/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/SeriesCollectionController.kt +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/SeriesCollectionController.kt @@ -98,19 +98,9 @@ class SeriesCollectionController( @PathVariable id: String ): ResponseEntity { collectionRepository.findByIdOrNull(id, principal.user.getAuthorizedLibraryIds(null))?.let { - val ids = with(mutableListOf()) { - while (size < 4) { - this += it.seriesIds.take(4) - } - this.take(4) - } - - val images = ids.mapNotNull { seriesController.getSeriesThumbnail(principal, it).body } - val thumbnail = mosaicGenerator.createMosaic(images) - return ResponseEntity.ok() .cacheControl(CacheControl.maxAge(1, TimeUnit.HOURS).cachePrivate()) - .body(thumbnail) + .body(collectionLifecycle.getThumbnailBytes(it)) } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) } diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/SeriesController.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/SeriesController.kt index 52e4d95e3..5b7386616 100644 --- a/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/SeriesController.kt +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/SeriesController.kt @@ -18,10 +18,12 @@ import org.gotson.komga.domain.persistence.SeriesCollectionRepository import org.gotson.komga.domain.persistence.SeriesMetadataRepository import org.gotson.komga.domain.persistence.SeriesRepository import org.gotson.komga.domain.service.BookLifecycle +import org.gotson.komga.domain.service.SeriesLifecycle import org.gotson.komga.infrastructure.jooq.UnpagedSorted import org.gotson.komga.infrastructure.security.KomgaPrincipal import org.gotson.komga.infrastructure.swagger.PageableAsQueryParam import org.gotson.komga.infrastructure.swagger.PageableWithoutSortAsQueryParam +import org.gotson.komga.infrastructure.web.setCachePrivate import org.gotson.komga.interfaces.rest.dto.BookDto import org.gotson.komga.interfaces.rest.dto.CollectionDto import org.gotson.komga.interfaces.rest.dto.SeriesDto @@ -59,12 +61,12 @@ private val logger = KotlinLogging.logger {} class SeriesController( private val taskReceiver: TaskReceiver, private val seriesRepository: SeriesRepository, + private val seriesLifecycle: SeriesLifecycle, private val seriesMetadataRepository: SeriesMetadataRepository, private val seriesDtoRepository: SeriesDtoRepository, private val bookLifecycle: BookLifecycle, private val bookRepository: BookRepository, private val bookDtoRepository: BookDtoRepository, - private val bookController: BookController, private val collectionRepository: SeriesCollectionRepository ) { @@ -199,8 +201,10 @@ class SeriesController( if (!principal.user.canAccessLibrary(it)) throw ResponseStatusException(HttpStatus.FORBIDDEN) } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) - return bookRepository.findFirstIdInSeries(seriesId)?.let { - bookController.getBookThumbnail(principal, it) + return seriesLifecycle.getThumbnailBytes(seriesId)?.let { + ResponseEntity.ok() + .setCachePrivate() + .body(it) } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) } diff --git a/komga/src/test/kotlin/org/gotson/komga/infrastructure/metadata/localmediaassets/LocalMediaAssetsProviderTest.kt b/komga/src/test/kotlin/org/gotson/komga/infrastructure/metadata/localmediaassets/LocalMediaAssetsProviderTest.kt new file mode 100644 index 000000000..3503c2620 --- /dev/null +++ b/komga/src/test/kotlin/org/gotson/komga/infrastructure/metadata/localmediaassets/LocalMediaAssetsProviderTest.kt @@ -0,0 +1,64 @@ +package org.gotson.komga.infrastructure.metadata.localmediaassets + +import com.google.common.jimfs.Configuration +import com.google.common.jimfs.Jimfs +import io.mockk.every +import io.mockk.spyk +import org.apache.commons.io.FilenameUtils +import org.assertj.core.api.Assertions.assertThat +import org.gotson.komga.domain.model.Book +import org.gotson.komga.infrastructure.mediacontainer.ContentDetector +import org.gotson.komga.infrastructure.mediacontainer.TikaConfiguration +import org.junit.jupiter.api.Test +import java.nio.file.Files +import java.nio.file.Path +import java.time.LocalDateTime + +class LocalMediaAssetsProviderTest { + + private val contentDetector = spyk(ContentDetector(TikaConfiguration().tika())).also { + every { it.detectMediaType(any()) } answers { + when (FilenameUtils.getExtension(firstArg().toString().toLowerCase())) { + "jpg", "jpeg", "tbn" -> "image/jpeg" + "png" -> "image/png" + else -> "application/octet-stream" + } + } + } + + private val localMediaAssetsProvider = LocalMediaAssetsProvider(contentDetector) + + @Test + fun `given root directory with only files when scanning then return 1 series containing those files as books`() { + Jimfs.newFileSystem(Configuration.unix()).use { fs -> + // given + val root = fs.getPath("/root") + Files.createDirectory(root) + + val bookFile = Files.createFile(root.resolve("book(e).cbz")) + val thumbsFiles = listOf("bOOk(e).jpeg", "Book(e).tbn", "book(e).PNG", "book(e).jpeg") + val thumbsDashFiles = listOf("book(e)-1.jpeg", "book(e)-2.tbn", "book(e)-23.png", "book(e)-111.jpeg") + val invalidFiles = listOf("book12(e).jpeg", "book(e).gif", "cover.png", "other.jpeg") + + (thumbsFiles + thumbsDashFiles + invalidFiles).forEach { Files.createFile(root.resolve(it)) } + + val book = spyk(Book( + name = "Book", + url = bookFile.toUri().toURL(), + fileLastModified = LocalDateTime.now() + )) + every { book.path() } returns bookFile + + // when + val thumbnails = localMediaAssetsProvider.getBookThumbnails(book) + + // then + assertThat(thumbnails).hasSize(thumbsFiles.size + thumbsDashFiles.size) + assertThat(thumbnails.filter { it.selected }).hasSize(1) + assertThat(thumbnails.map { FilenameUtils.getName(it.url.toString()) }) + .containsAll(thumbsFiles) + .containsAll(thumbsDashFiles) + .doesNotContainAnyElementsOf(invalidFiles) + } + } +}