feat: custom thumbnails for series

closes #63
This commit is contained in:
Gauthier Roebroeck 2020-08-12 17:08:16 +08:00
parent f0854a89e0
commit f5f423f05f
10 changed files with 263 additions and 18 deletions

View file

@ -0,0 +1,10 @@
CREATE TABLE THUMBNAIL_SERIES
(
ID varchar NOT NULL PRIMARY KEY,
URL varchar NOT NULL,
SELECTED boolean NOT NULL DEFAULT 0,
CREATED_DATE datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
LAST_MODIFIED_DATE datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
SERIES_ID varchar NOT NULL,
FOREIGN KEY (SERIES_ID) REFERENCES SERIES (ID)
);

View file

@ -2,6 +2,8 @@ package org.gotson.komga.domain.model
import com.github.f4b6a3.tsid.TsidCreator
import java.net.URL
import java.nio.file.Path
import java.nio.file.Paths
import java.time.LocalDateTime
data class Series(
@ -14,4 +16,7 @@ data class Series(
override val createdDate: LocalDateTime = LocalDateTime.now(),
override val lastModifiedDate: LocalDateTime = LocalDateTime.now()
) : Auditable()
) : Auditable() {
fun path(): Path = Paths.get(this.url.toURI())
}

View file

@ -0,0 +1,16 @@
package org.gotson.komga.domain.model
import com.github.f4b6a3.tsid.TsidCreator
import java.net.URL
import java.time.LocalDateTime
data class ThumbnailSeries(
val url: URL,
val selected: Boolean = false,
val id: String = TsidCreator.getTsidString256(),
val seriesId: String = "",
override val createdDate: LocalDateTime = LocalDateTime.now(),
override val lastModifiedDate: LocalDateTime = LocalDateTime.now()
) : Auditable()

View file

@ -0,0 +1,15 @@
package org.gotson.komga.domain.persistence
import org.gotson.komga.domain.model.ThumbnailSeries
interface ThumbnailSeriesRepository {
fun findBySeriesId(seriesId: String): Collection<ThumbnailSeries>
fun findSelectedBySeriesId(seriesId: String): ThumbnailSeries?
fun insert(thumbnail: ThumbnailSeries)
fun markSelected(thumbnail: ThumbnailSeries)
fun delete(thumbnailSeriesId: String)
fun deleteBySeriesId(seriesId: String)
fun deleteBySeriesIds(seriesIds: Collection<String>)
}

View file

@ -92,7 +92,7 @@ class BookLifecycle(
fun getThumbnail(bookId: String): ThumbnailBook? {
val selected = thumbnailBookRepository.findSelectedByBookId(bookId)
if (selected == null || !thumbnailExists(selected)) {
if (selected == null || !selected.exists()) {
thumbnailsHouseKeeping(bookId)
return thumbnailBookRepository.findSelectedByBookId(bookId)
}
@ -115,7 +115,7 @@ class BookLifecycle(
logger.info { "House keeping thumbnails for book: $bookId" }
val all = thumbnailBookRepository.findByBookId(bookId)
.mapNotNull {
if (!thumbnailExists(it)) {
if (!it.exists()) {
logger.warn { "Thumbnail doesn't exist, removing entry" }
thumbnailBookRepository.delete(it.id)
null
@ -135,10 +135,10 @@ class BookLifecycle(
}
}
private fun thumbnailExists(thumbnailBook: ThumbnailBook): Boolean {
if (thumbnailBook.type == ThumbnailBook.Type.SIDECAR) {
if (thumbnailBook.url != null)
return Files.exists(Paths.get(thumbnailBook.url.toURI()))
private fun ThumbnailBook.exists(): Boolean {
if (type == ThumbnailBook.Type.SIDECAR) {
if (url != null)
return Files.exists(Paths.get(url.toURI()))
return false
}
return true

View file

@ -15,7 +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.gotson.komga.infrastructure.metadata.localartwork.LocalArtworkProvider
import org.springframework.stereotype.Service
private val logger = KotlinLogging.logger {}
@ -31,9 +31,10 @@ class MetadataLifecycle(
private val libraryRepository: LibraryRepository,
private val bookRepository: BookRepository,
private val bookLifecycle: BookLifecycle,
private val seriesLifecycle: SeriesLifecycle,
private val collectionRepository: SeriesCollectionRepository,
private val collectionLifecycle: SeriesCollectionLifecycle,
private val localMediaAssetsProvider: LocalMediaAssetsProvider
private val localArtworkProvider: LocalArtworkProvider
) {
fun refreshMetadata(book: Book) {
@ -62,7 +63,7 @@ class MetadataLifecycle(
}
}
localMediaAssetsProvider.getBookThumbnails(book).forEach {
localArtworkProvider.getBookThumbnails(book).forEach {
bookLifecycle.addThumbnailForBook(it)
}
}
@ -131,6 +132,10 @@ class MetadataLifecycle(
}
}
}
localArtworkProvider.getSeriesThumbnails(series).forEach {
seriesLifecycle.addThumbnailForSeries(it)
}
}
private fun <T, R : Any> Iterable<T>.uniqueOrNull(transform: (T) -> R?): R? {

View file

@ -8,13 +8,18 @@ import org.gotson.komga.domain.model.BookMetadata
import org.gotson.komga.domain.model.Media
import org.gotson.komga.domain.model.Series
import org.gotson.komga.domain.model.SeriesMetadata
import org.gotson.komga.domain.model.ThumbnailSeries
import org.gotson.komga.domain.persistence.BookMetadataRepository
import org.gotson.komga.domain.persistence.BookRepository
import org.gotson.komga.domain.persistence.MediaRepository
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.persistence.ThumbnailSeriesRepository
import org.springframework.stereotype.Service
import java.io.File
import java.nio.file.Files
import java.nio.file.Paths
import java.util.Comparator
private val logger = KotlinLogging.logger {}
@ -27,6 +32,7 @@ class SeriesLifecycle(
private val mediaRepository: MediaRepository,
private val bookMetadataRepository: BookMetadataRepository,
private val seriesRepository: SeriesRepository,
private val thumbnailsSeriesRepository: ThumbnailSeriesRepository,
private val seriesMetadataRepository: SeriesMetadataRepository,
private val collectionRepository: SeriesCollectionRepository
) {
@ -96,6 +102,7 @@ class SeriesLifecycle(
bookLifecycle.deleteMany(bookIds)
collectionRepository.removeSeriesFromAll(seriesId)
thumbnailsSeriesRepository.deleteBySeriesId(seriesId)
seriesRepository.delete(seriesId)
}
@ -107,14 +114,69 @@ class SeriesLifecycle(
bookLifecycle.deleteMany(bookIds)
collectionRepository.removeSeriesFromAll(seriesIds)
thumbnailsSeriesRepository.deleteBySeriesIds(seriesIds)
seriesRepository.deleteAll(seriesIds)
}
fun getThumbnail(seriesId: String): ThumbnailSeries? {
val selected = thumbnailsSeriesRepository.findSelectedBySeriesId(seriesId)
if (selected == null || !selected.exists()) {
thumbnailsHouseKeeping(seriesId)
return thumbnailsSeriesRepository.findSelectedBySeriesId(seriesId)
}
return selected
}
fun getThumbnailBytes(seriesId: String): ByteArray? {
getThumbnail(seriesId)?.let {
return File(it.url.toURI()).readBytes()
}
bookRepository.findFirstIdInSeries(seriesId)?.let { bookId ->
return bookLifecycle.getThumbnailBytes(bookId)
}
return null
}
fun addThumbnailForSeries(thumbnail: ThumbnailSeries) {
// delete existing thumbnail with the same url
thumbnailsSeriesRepository.findBySeriesId(thumbnail.seriesId)
.filter { it.url == thumbnail.url }
.forEach {
thumbnailsSeriesRepository.delete(it.id)
}
thumbnailsSeriesRepository.insert(thumbnail)
if (thumbnail.selected)
thumbnailsSeriesRepository.markSelected(thumbnail)
}
private fun thumbnailsHouseKeeping(seriesId: String) {
logger.info { "House keeping thumbnails for series: $seriesId" }
val all = thumbnailsSeriesRepository.findBySeriesId(seriesId)
.mapNotNull {
if (!it.exists()) {
logger.warn { "Thumbnail doesn't exist, removing entry" }
thumbnailsSeriesRepository.delete(it.id)
null
} else it
}
val selected = all.filter { it.selected }
when {
selected.size > 1 -> {
logger.info { "More than one thumbnail is selected, removing extra ones" }
thumbnailsSeriesRepository.markSelected(selected[0])
}
selected.isEmpty() && all.isNotEmpty() -> {
logger.info { "Series has bo selected thumbnail, choosing one automatically" }
thumbnailsSeriesRepository.markSelected(all.first())
}
}
}
private fun ThumbnailSeries.exists(): Boolean = Files.exists(Paths.get(url.toURI()))
}

View file

@ -0,0 +1,78 @@
package org.gotson.komga.infrastructure.jooq
import org.gotson.komga.domain.model.ThumbnailSeries
import org.gotson.komga.domain.persistence.ThumbnailSeriesRepository
import org.gotson.komga.jooq.Tables
import org.gotson.komga.jooq.tables.records.ThumbnailSeriesRecord
import org.jooq.DSLContext
import org.springframework.stereotype.Component
import java.net.URL
@Component
class ThumbnailSeriesDao(
private val dsl: DSLContext
) : ThumbnailSeriesRepository {
private val ts = Tables.THUMBNAIL_SERIES
override fun findBySeriesId(seriesId: String): Collection<ThumbnailSeries> =
dsl.selectFrom(ts)
.where(ts.SERIES_ID.eq(seriesId))
.fetchInto(ts)
.map { it.toDomain() }
override fun findSelectedBySeriesId(seriesId: String): ThumbnailSeries? =
dsl.selectFrom(ts)
.where(ts.SERIES_ID.eq(seriesId))
.and(ts.SELECTED.isTrue)
.limit(1)
.fetchInto(ts)
.map { it.toDomain() }
.firstOrNull()
override fun insert(thumbnail: ThumbnailSeries) {
dsl.insertInto(ts)
.set(ts.ID, thumbnail.id)
.set(ts.SERIES_ID, thumbnail.seriesId)
.set(ts.URL, thumbnail.url.toString())
.set(ts.SELECTED, thumbnail.selected)
.execute()
}
override fun markSelected(thumbnail: ThumbnailSeries) {
dsl.transaction { config ->
config.dsl().update(ts)
.set(ts.SELECTED, false)
.where(ts.SERIES_ID.eq(thumbnail.seriesId))
.and(ts.ID.ne(thumbnail.id))
.execute()
config.dsl().update(ts)
.set(ts.SELECTED, true)
.where(ts.SERIES_ID.eq(thumbnail.seriesId))
.and(ts.ID.eq(thumbnail.id))
.execute()
}
}
override fun delete(thumbnailSeriesId: String) {
dsl.deleteFrom(ts).where(ts.ID.eq(thumbnailSeriesId)).execute()
}
override fun deleteBySeriesId(seriesId: String) {
dsl.deleteFrom(ts).where(ts.SERIES_ID.eq(seriesId)).execute()
}
override fun deleteBySeriesIds(seriesIds: Collection<String>) {
dsl.deleteFrom(ts).where(ts.SERIES_ID.`in`(seriesIds)).execute()
}
private fun ThumbnailSeriesRecord.toDomain() =
ThumbnailSeries(
url = URL(url),
selected = selected,
id = id,
seriesId = seriesId,
createdDate = createdDate,
lastModifiedDate = lastModifiedDate
)
}

View file

@ -1,9 +1,11 @@
package org.gotson.komga.infrastructure.metadata.localmediaassets
package org.gotson.komga.infrastructure.metadata.localartwork
import mu.KotlinLogging
import org.apache.commons.io.FilenameUtils
import org.gotson.komga.domain.model.Book
import org.gotson.komga.domain.model.Series
import org.gotson.komga.domain.model.ThumbnailBook
import org.gotson.komga.domain.model.ThumbnailSeries
import org.gotson.komga.infrastructure.mediacontainer.ContentDetector
import org.springframework.stereotype.Service
import java.nio.file.Files
@ -12,11 +14,12 @@ import kotlin.streams.asSequence
private val logger = KotlinLogging.logger {}
@Service
class LocalMediaAssetsProvider(
class LocalArtworkProvider(
private val contentDetector: ContentDetector
) {
val supportedExtensions = listOf("png", "jpeg", "jpg", "tbn")
val supportedSeriesFiles = listOf("cover", "default", "folder", "poster", "series")
fun getBookThumbnails(book: Book): List<ThumbnailBook> {
logger.info { "Looking for local thumbnails for book: $book" }
@ -39,8 +42,27 @@ class LocalMediaAssetsProvider(
bookId = book.id,
selected = index == 0
)
}.sortedBy { it.url.toString() }
.toList()
}.toList()
}
}
fun getSeriesThumbnails(series: Series): List<ThumbnailSeries> {
logger.info { "Looking for local thumbnails for series: $series" }
return Files.list(series.path()).use { dirStream ->
dirStream.asSequence()
.filter { Files.isRegularFile(it) }
.filter { supportedSeriesFiles.contains(FilenameUtils.getBaseName(it.toString().toLowerCase())) }
.filter { supportedExtensions.contains(FilenameUtils.getExtension(it.fileName.toString()).toLowerCase()) }
.filter { contentDetector.isImage(contentDetector.detectMediaType(it)) }
.mapIndexed { index, path ->
logger.info { "Found file: $path" }
ThumbnailSeries(
url = path.toUri().toURL(),
seriesId = series.id,
selected = index == 0
)
}.toList()
}
}

View file

@ -1,4 +1,4 @@
package org.gotson.komga.infrastructure.metadata.localmediaassets
package org.gotson.komga.infrastructure.metadata.localartwork
import com.google.common.jimfs.Configuration
import com.google.common.jimfs.Jimfs
@ -7,6 +7,7 @@ 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.domain.model.Series
import org.gotson.komga.infrastructure.mediacontainer.ContentDetector
import org.gotson.komga.infrastructure.mediacontainer.TikaConfiguration
import org.junit.jupiter.api.Test
@ -14,7 +15,7 @@ import java.nio.file.Files
import java.nio.file.Path
import java.time.LocalDateTime
class LocalMediaAssetsProviderTest {
class LocalArtworkProviderTest {
private val contentDetector = spyk(ContentDetector(TikaConfiguration().tika())).also {
every { it.detectMediaType(any<Path>()) } answers {
@ -26,10 +27,10 @@ class LocalMediaAssetsProviderTest {
}
}
private val localMediaAssetsProvider = LocalMediaAssetsProvider(contentDetector)
private val localMediaAssetsProvider = LocalArtworkProvider(contentDetector)
@Test
fun `given root directory with only files when scanning then return 1 series containing those files as books`() {
fun `given book with sidecar files when getting thumbnails then return valid ones`() {
Jimfs.newFileSystem(Configuration.unix()).use { fs ->
// given
val root = fs.getPath("/root")
@ -61,4 +62,35 @@ class LocalMediaAssetsProviderTest {
.doesNotContainAnyElementsOf(invalidFiles)
}
}
@Test
fun `given series with sidecar files when getting thumbnails then return valid ones`() {
Jimfs.newFileSystem(Configuration.unix()).use { fs ->
// given
val seriesPath = fs.getPath("/series")
val seriesFile = Files.createDirectory(seriesPath)
val thumbsFiles = listOf("CoVeR.jpeg", "DefauLt.tbn", "POSter.PNG", "FoLDer.jpeg", "serIES.TBN")
val invalidFiles = listOf("cover.gif", "artwork.jpg", "other.jpeg")
(thumbsFiles + invalidFiles).forEach { Files.createFile(seriesPath.resolve(it)) }
val series = spyk(Series(
name = "Series",
url = seriesFile.toUri().toURL(),
fileLastModified = LocalDateTime.now()
))
every { series.path() } returns seriesFile
// when
val thumbnails = localMediaAssetsProvider.getSeriesThumbnails(series)
// then
assertThat(thumbnails).hasSize(thumbsFiles.size)
assertThat(thumbnails.filter { it.selected }).hasSize(1)
assertThat(thumbnails.map { FilenameUtils.getName(it.url.toString()) })
.containsAll(thumbsFiles)
.doesNotContainAnyElementsOf(invalidFiles)
}
}
}