diff --git a/komga/src/flyway/resources/db/migration/sqlite/V20200812161631__thumbnails_series.sql b/komga/src/flyway/resources/db/migration/sqlite/V20200812161631__thumbnails_series.sql new file mode 100644 index 000000000..da14b642f --- /dev/null +++ b/komga/src/flyway/resources/db/migration/sqlite/V20200812161631__thumbnails_series.sql @@ -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) +); diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/model/Series.kt b/komga/src/main/kotlin/org/gotson/komga/domain/model/Series.kt index 46fc4ff95..5aa2c9a74 100644 --- a/komga/src/main/kotlin/org/gotson/komga/domain/model/Series.kt +++ b/komga/src/main/kotlin/org/gotson/komga/domain/model/Series.kt @@ -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()) +} diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/model/ThumbnailSeries.kt b/komga/src/main/kotlin/org/gotson/komga/domain/model/ThumbnailSeries.kt new file mode 100644 index 000000000..2e9822173 --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/domain/model/ThumbnailSeries.kt @@ -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() diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/persistence/ThumbnailSeriesRepository.kt b/komga/src/main/kotlin/org/gotson/komga/domain/persistence/ThumbnailSeriesRepository.kt new file mode 100644 index 000000000..bf40da5d1 --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/domain/persistence/ThumbnailSeriesRepository.kt @@ -0,0 +1,15 @@ +package org.gotson.komga.domain.persistence + +import org.gotson.komga.domain.model.ThumbnailSeries + +interface ThumbnailSeriesRepository { + fun findBySeriesId(seriesId: String): Collection + 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) +} 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 b1ccbaa56..2fd95ba4a 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 @@ -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 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 e2e0cec89..b2da81172 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,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 Iterable.uniqueOrNull(transform: (T) -> R?): R? { 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 fb23c5d53..acd33e1dd 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 @@ -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())) } diff --git a/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/ThumbnailSeriesDao.kt b/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/ThumbnailSeriesDao.kt new file mode 100644 index 000000000..cb2d06909 --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/ThumbnailSeriesDao.kt @@ -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 = + 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) { + 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 + ) +} 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/localartwork/LocalArtworkProvider.kt similarity index 57% rename from komga/src/main/kotlin/org/gotson/komga/infrastructure/metadata/localmediaassets/LocalMediaAssetsProvider.kt rename to komga/src/main/kotlin/org/gotson/komga/infrastructure/metadata/localartwork/LocalArtworkProvider.kt index c091e8ea7..130bfc09e 100644 --- a/komga/src/main/kotlin/org/gotson/komga/infrastructure/metadata/localmediaassets/LocalMediaAssetsProvider.kt +++ b/komga/src/main/kotlin/org/gotson/komga/infrastructure/metadata/localartwork/LocalArtworkProvider.kt @@ -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 { 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 { + 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() } } 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/localartwork/LocalArtworkProviderTest.kt similarity index 59% rename from komga/src/test/kotlin/org/gotson/komga/infrastructure/metadata/localmediaassets/LocalMediaAssetsProviderTest.kt rename to komga/src/test/kotlin/org/gotson/komga/infrastructure/metadata/localartwork/LocalArtworkProviderTest.kt index 3503c2620..df0dabf03 100644 --- a/komga/src/test/kotlin/org/gotson/komga/infrastructure/metadata/localmediaassets/LocalMediaAssetsProviderTest.kt +++ b/komga/src/test/kotlin/org/gotson/komga/infrastructure/metadata/localartwork/LocalArtworkProviderTest.kt @@ -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()) } 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) + } + } }