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:
Gauthier Roebroeck 2020-08-12 10:19:50 +08:00
parent 59a9060831
commit d01b29f280
13 changed files with 182 additions and 33 deletions

View file

@ -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)

View file

@ -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) {

View file

@ -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)
}
}

View file

@ -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
}
}

View file

@ -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

View file

@ -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

View file

@ -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()
}
}
}

View file

@ -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()
)

View file

@ -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))

View file

@ -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

View file

@ -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)
}

View file

@ -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)
}

View file

@ -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)
}
}
}