mirror of
https://github.com/gotson/komga.git
synced 2025-12-22 00:13:30 +01:00
parent
f0854a89e0
commit
f5f423f05f
10 changed files with 263 additions and 18 deletions
|
|
@ -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)
|
||||
);
|
||||
|
|
@ -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())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -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>)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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? {
|
||||
|
|
|
|||
|
|
@ -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()))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue