mirror of
https://github.com/gotson/komga.git
synced 2025-12-22 00:13:30 +01:00
feat: sidecar thumbnails for books
thumbnails can be added next to the book file sidecar thumbnails will be loaded during refresh metadata
This commit is contained in:
parent
59a9060831
commit
d01b29f280
13 changed files with 182 additions and 33 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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<String>()) {
|
||||
while (size < 4) {
|
||||
this += collection.seriesIds.take(4)
|
||||
}
|
||||
this.take(4)
|
||||
}
|
||||
|
||||
val images = ids.mapNotNull { seriesLifecycle.getThumbnailBytes(it) }
|
||||
return mosaicGenerator.createMosaic(images)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<String> = 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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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<ThumbnailBook> {
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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()
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -98,19 +98,9 @@ class SeriesCollectionController(
|
|||
@PathVariable id: String
|
||||
): ResponseEntity<ByteArray> {
|
||||
collectionRepository.findByIdOrNull(id, principal.user.getAuthorizedLibraryIds(null))?.let {
|
||||
val ids = with(mutableListOf<String>()) {
|
||||
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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<Path>()) } answers {
|
||||
when (FilenameUtils.getExtension(firstArg<Path>().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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue