diff --git a/komga/src/flyway/resources/db/migration/sqlite/V20211206124920__collection_and_readlist_thumbnails.sql b/komga/src/flyway/resources/db/migration/sqlite/V20211206124920__collection_and_readlist_thumbnails.sql new file mode 100644 index 00000000..eeea1446 --- /dev/null +++ b/komga/src/flyway/resources/db/migration/sqlite/V20211206124920__collection_and_readlist_thumbnails.sql @@ -0,0 +1,23 @@ +CREATE TABLE THUMBNAIL_COLLECTION +( + ID varchar NOT NULL PRIMARY KEY, + SELECTED boolean NOT NULL DEFAULT 0, + THUMBNAIL blob NOT NULL, + TYPE varchar NOT NULL, + COLLECTION_ID varchar NOT NULL, + CREATED_DATE datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + LAST_MODIFIED_DATE datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (COLLECTION_ID) REFERENCES COLLECTION (ID) +); + +CREATE TABLE THUMBNAIL_READLIST +( + ID varchar NOT NULL PRIMARY KEY, + SELECTED boolean NOT NULL DEFAULT 0, + THUMBNAIL blob NOT NULL, + TYPE varchar NOT NULL, + READLIST_ID varchar NOT NULL, + CREATED_DATE datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + LAST_MODIFIED_DATE datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (READLIST_ID) REFERENCES READLIST (ID) +); diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/model/DomainEvent.kt b/komga/src/main/kotlin/org/gotson/komga/domain/model/DomainEvent.kt index 44eb95d4..ac647118 100644 --- a/komga/src/main/kotlin/org/gotson/komga/domain/model/DomainEvent.kt +++ b/komga/src/main/kotlin/org/gotson/komga/domain/model/DomainEvent.kt @@ -32,5 +32,14 @@ sealed class DomainEvent : Serializable { data class ReadProgressSeriesDeleted(val seriesId: String, val userId: String) : DomainEvent() data class ThumbnailBookAdded(val thumbnail: ThumbnailBook) : DomainEvent() + data class ThumbnailBookDeleted(val thumbnail: ThumbnailBook) : DomainEvent() + data class ThumbnailSeriesAdded(val thumbnail: ThumbnailSeries) : DomainEvent() + data class ThumbnailSeriesDeleted(val thumbnail: ThumbnailSeries) : DomainEvent() + + data class ThumbnailSeriesCollectionAdded(val thumbnail: ThumbnailSeriesCollection) : DomainEvent() + data class ThumbnailSeriesCollectionDeleted(val thumbnail: ThumbnailSeriesCollection) : DomainEvent() + + data class ThumbnailReadListAdded(val thumbnail: ThumbnailReadList) : DomainEvent() + data class ThumbnailReadListDeleted(val thumbnail: ThumbnailReadList) : DomainEvent() } diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/model/MarkSelectedPreference.kt b/komga/src/main/kotlin/org/gotson/komga/domain/model/MarkSelectedPreference.kt index ccf51a3c..541785e1 100644 --- a/komga/src/main/kotlin/org/gotson/komga/domain/model/MarkSelectedPreference.kt +++ b/komga/src/main/kotlin/org/gotson/komga/domain/model/MarkSelectedPreference.kt @@ -1,5 +1,5 @@ package org.gotson.komga.domain.model enum class MarkSelectedPreference { - NO, YES, IF_NONE_EXIST + NO, YES, IF_NONE_OR_GENERATED } diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/model/ThumbnailBook.kt b/komga/src/main/kotlin/org/gotson/komga/domain/model/ThumbnailBook.kt index 4999d3bb..51629de9 100644 --- a/komga/src/main/kotlin/org/gotson/komga/domain/model/ThumbnailBook.kt +++ b/komga/src/main/kotlin/org/gotson/komga/domain/model/ThumbnailBook.kt @@ -20,7 +20,7 @@ data class ThumbnailBook( override val lastModifiedDate: LocalDateTime = createdDate, ) : Auditable(), Serializable { enum class Type { - GENERATED, SIDECAR + GENERATED, SIDECAR, USER_UPLOADED } override fun equals(other: Any?): Boolean { diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/model/ThumbnailReadList.kt b/komga/src/main/kotlin/org/gotson/komga/domain/model/ThumbnailReadList.kt new file mode 100644 index 00000000..20516c7f --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/domain/model/ThumbnailReadList.kt @@ -0,0 +1,47 @@ +package org.gotson.komga.domain.model + +import com.github.f4b6a3.tsid.TsidCreator +import java.io.Serializable +import java.time.LocalDateTime + +data class ThumbnailReadList( + val thumbnail: ByteArray, + val selected: Boolean = false, + val type: Type, + + val id: String = TsidCreator.getTsid256().toString(), + val readListId: String = "", + + override val createdDate: LocalDateTime = LocalDateTime.now(), + override val lastModifiedDate: LocalDateTime = createdDate, +) : Auditable(), Serializable { + enum class Type { + USER_UPLOADED + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is ThumbnailReadList) return false + + if (!thumbnail.contentEquals(other.thumbnail)) return false + if (selected != other.selected) return false + if (type != other.type) return false + if (id != other.id) return false + if (readListId != other.readListId) return false + if (createdDate != other.createdDate) return false + if (lastModifiedDate != other.lastModifiedDate) return false + + return true + } + + override fun hashCode(): Int { + var result = thumbnail.contentHashCode() + result = 31 * result + selected.hashCode() + result = 31 * result + type.hashCode() + result = 31 * result + id.hashCode() + result = 31 * result + readListId.hashCode() + result = 31 * result + createdDate.hashCode() + result = 31 * result + lastModifiedDate.hashCode() + return result + } +} diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/model/ThumbnailSeriesCollection.kt b/komga/src/main/kotlin/org/gotson/komga/domain/model/ThumbnailSeriesCollection.kt new file mode 100644 index 00000000..77fb1632 --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/domain/model/ThumbnailSeriesCollection.kt @@ -0,0 +1,47 @@ +package org.gotson.komga.domain.model + +import com.github.f4b6a3.tsid.TsidCreator +import java.io.Serializable +import java.time.LocalDateTime + +data class ThumbnailSeriesCollection( + val thumbnail: ByteArray, + val selected: Boolean = false, + val type: Type, + + val id: String = TsidCreator.getTsid256().toString(), + val collectionId: String = "", + + override val createdDate: LocalDateTime = LocalDateTime.now(), + override val lastModifiedDate: LocalDateTime = createdDate, +) : Auditable(), Serializable { + enum class Type { + USER_UPLOADED + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is ThumbnailSeriesCollection) return false + + if (!thumbnail.contentEquals(other.thumbnail)) return false + if (selected != other.selected) return false + if (type != other.type) return false + if (id != other.id) return false + if (collectionId != other.collectionId) return false + if (createdDate != other.createdDate) return false + if (lastModifiedDate != other.lastModifiedDate) return false + + return true + } + + override fun hashCode(): Int { + var result = thumbnail.contentHashCode() + result = 31 * result + selected.hashCode() + result = 31 * result + type.hashCode() + result = 31 * result + id.hashCode() + result = 31 * result + collectionId.hashCode() + result = 31 * result + createdDate.hashCode() + result = 31 * result + lastModifiedDate.hashCode() + return result + } +} diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/persistence/ThumbnailBookRepository.kt b/komga/src/main/kotlin/org/gotson/komga/domain/persistence/ThumbnailBookRepository.kt index ac08783e..b1148ee1 100644 --- a/komga/src/main/kotlin/org/gotson/komga/domain/persistence/ThumbnailBookRepository.kt +++ b/komga/src/main/kotlin/org/gotson/komga/domain/persistence/ThumbnailBookRepository.kt @@ -3,6 +3,8 @@ package org.gotson.komga.domain.persistence import org.gotson.komga.domain.model.ThumbnailBook interface ThumbnailBookRepository { + fun findByIdOrNull(thumbnailId: String): ThumbnailBook? + fun findSelectedByBookIdOrNull(bookId: String): ThumbnailBook? fun findAllByBookId(bookId: String): Collection diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/persistence/ThumbnailReadListRepository.kt b/komga/src/main/kotlin/org/gotson/komga/domain/persistence/ThumbnailReadListRepository.kt new file mode 100644 index 00000000..d8bf7574 --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/domain/persistence/ThumbnailReadListRepository.kt @@ -0,0 +1,17 @@ +package org.gotson.komga.domain.persistence + +import org.gotson.komga.domain.model.ThumbnailReadList + +interface ThumbnailReadListRepository { + fun findByIdOrNull(thumbnailId: String): ThumbnailReadList? + fun findSelectedByReadListIdOrNull(readListId: String): ThumbnailReadList? + fun findAllByReadListId(readListId: String): Collection + + fun insert(thumbnail: ThumbnailReadList) + fun update(thumbnail: ThumbnailReadList) + fun markSelected(thumbnail: ThumbnailReadList) + + fun delete(thumbnailReadListId: String) + fun deleteByReadListId(readListId: String) + fun deleteByReadListIds(readListIds: Collection) +} diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/persistence/ThumbnailSeriesCollectionRepository.kt b/komga/src/main/kotlin/org/gotson/komga/domain/persistence/ThumbnailSeriesCollectionRepository.kt new file mode 100644 index 00000000..9b7b7735 --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/domain/persistence/ThumbnailSeriesCollectionRepository.kt @@ -0,0 +1,17 @@ +package org.gotson.komga.domain.persistence + +import org.gotson.komga.domain.model.ThumbnailSeriesCollection + +interface ThumbnailSeriesCollectionRepository { + fun findByIdOrNull(thumbnailId: String): ThumbnailSeriesCollection? + fun findSelectedByCollectionIdOrNull(collectionId: String): ThumbnailSeriesCollection? + fun findAllByCollectionId(collectionId: String): Collection + + fun insert(thumbnail: ThumbnailSeriesCollection) + fun update(thumbnail: ThumbnailSeriesCollection) + fun markSelected(thumbnail: ThumbnailSeriesCollection) + + fun delete(thumbnailCollectionId: String) + fun deleteByCollectionId(collectionId: String) + fun deleteByCollectionIds(collectionIds: 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 631f24c0..90dc3734 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 @@ -8,6 +8,7 @@ import org.gotson.komga.domain.model.BookWithMedia import org.gotson.komga.domain.model.DomainEvent import org.gotson.komga.domain.model.ImageConversionException import org.gotson.komga.domain.model.KomgaUser +import org.gotson.komga.domain.model.MarkSelectedPreference import org.gotson.komga.domain.model.Media import org.gotson.komga.domain.model.MediaNotReadyException import org.gotson.komga.domain.model.ReadProgress @@ -82,18 +83,18 @@ class BookLifecycle( fun generateThumbnailAndPersist(book: Book) { logger.info { "Generate thumbnail and persist for book: $book" } try { - addThumbnailForBook(bookAnalyzer.generateThumbnail(BookWithMedia(book, mediaRepository.findById(book.id)))) + addThumbnailForBook(bookAnalyzer.generateThumbnail(BookWithMedia(book, mediaRepository.findById(book.id))), MarkSelectedPreference.IF_NONE_OR_GENERATED) } catch (ex: Exception) { logger.error(ex) { "Error while creating thumbnail" } } } - fun addThumbnailForBook(thumbnail: ThumbnailBook) { + fun addThumbnailForBook(thumbnail: ThumbnailBook, markSelected: MarkSelectedPreference) { when (thumbnail.type) { ThumbnailBook.Type.GENERATED -> { // only one generated thumbnail is allowed thumbnailBookRepository.deleteByBookIdAndType(thumbnail.bookId, ThumbnailBook.Type.GENERATED) - thumbnailBookRepository.insert(thumbnail) + thumbnailBookRepository.insert(thumbnail.copy(selected = false)) } ThumbnailBook.Type.SIDECAR -> { // delete existing thumbnail with the same url @@ -102,16 +103,37 @@ class BookLifecycle( .forEach { thumbnailBookRepository.delete(it.id) } - thumbnailBookRepository.insert(thumbnail) + thumbnailBookRepository.insert(thumbnail.copy(selected = false)) + } + ThumbnailBook.Type.USER_UPLOADED -> { + thumbnailBookRepository.insert(thumbnail.copy(selected = false)) + } + } + + when (markSelected) { + MarkSelectedPreference.YES -> { + thumbnailBookRepository.markSelected(thumbnail) + } + MarkSelectedPreference.IF_NONE_OR_GENERATED -> { + val selectedThumbnail = thumbnailBookRepository.findSelectedByBookIdOrNull(thumbnail.bookId) + + if (selectedThumbnail == null || selectedThumbnail.type == ThumbnailBook.Type.GENERATED) + thumbnailBookRepository.markSelected(thumbnail) + else thumbnailsHouseKeeping(thumbnail.bookId) + } + MarkSelectedPreference.NO -> { + thumbnailsHouseKeeping(thumbnail.bookId) } } eventPublisher.publishEvent(DomainEvent.ThumbnailBookAdded(thumbnail)) + } - if (thumbnail.selected) - thumbnailBookRepository.markSelected(thumbnail) - else - thumbnailsHouseKeeping(thumbnail.bookId) + fun deleteThumbnailForBook(thumbnail: ThumbnailBook) { + require(thumbnail.type == ThumbnailBook.Type.USER_UPLOADED) { "Only uploaded thumbnails can be deleted" } + thumbnailBookRepository.delete(thumbnail.id) + thumbnailsHouseKeeping(thumbnail.bookId) + eventPublisher.publishEvent(DomainEvent.ThumbnailBookDeleted(thumbnail)) } fun getThumbnail(bookId: String): ThumbnailBook? { @@ -136,6 +158,18 @@ class BookLifecycle( return null } + fun getThumbnailBytesByThumbnailId(thumbnailId: String): ByteArray? = + thumbnailBookRepository.findByIdOrNull(thumbnailId)?.let { + getBytesFromThumbnailBook(it) + } + + private fun getBytesFromThumbnailBook(thumbnail: ThumbnailBook): ByteArray? = + when { + thumbnail.thumbnail != null -> thumbnail.thumbnail + thumbnail.url != null -> File(thumbnail.url.toURI()).readBytes() + else -> null + } + private fun thumbnailsHouseKeeping(bookId: String) { logger.info { "House keeping thumbnails for book: $bookId" } val all = thumbnailBookRepository.findAllByBookId(bookId) diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/service/LocalArtworkLifecycle.kt b/komga/src/main/kotlin/org/gotson/komga/domain/service/LocalArtworkLifecycle.kt index b68049f2..77ca97e4 100644 --- a/komga/src/main/kotlin/org/gotson/komga/domain/service/LocalArtworkLifecycle.kt +++ b/komga/src/main/kotlin/org/gotson/komga/domain/service/LocalArtworkLifecycle.kt @@ -24,7 +24,7 @@ class LocalArtworkLifecycle( if (library.importLocalArtwork) localArtworkProvider.getBookThumbnails(book).forEach { - bookLifecycle.addThumbnailForBook(it) + bookLifecycle.addThumbnailForBook(it, if (it.selected) MarkSelectedPreference.IF_NONE_OR_GENERATED else MarkSelectedPreference.NO) } else logger.info { "Library is not set to import local artwork, skipping" } @@ -36,7 +36,7 @@ class LocalArtworkLifecycle( if (library.importLocalArtwork) localArtworkProvider.getSeriesThumbnails(series).forEach { - seriesLifecycle.addThumbnailForSeries(it, if (it.selected) MarkSelectedPreference.IF_NONE_EXIST else MarkSelectedPreference.NO) + seriesLifecycle.addThumbnailForSeries(it, if (it.selected) MarkSelectedPreference.IF_NONE_OR_GENERATED else MarkSelectedPreference.NO) } else logger.info { "Library is not set to import local artwork, skipping" } diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/service/ReadListLifecycle.kt b/komga/src/main/kotlin/org/gotson/komga/domain/service/ReadListLifecycle.kt index 1a9c1631..9cc7ed39 100644 --- a/komga/src/main/kotlin/org/gotson/komga/domain/service/ReadListLifecycle.kt +++ b/komga/src/main/kotlin/org/gotson/komga/domain/service/ReadListLifecycle.kt @@ -6,21 +6,26 @@ import org.gotson.komga.domain.model.DomainEvent import org.gotson.komga.domain.model.DuplicateNameException import org.gotson.komga.domain.model.ReadList import org.gotson.komga.domain.model.ReadListRequestResult +import org.gotson.komga.domain.model.ThumbnailReadList import org.gotson.komga.domain.persistence.ReadListRepository +import org.gotson.komga.domain.persistence.ThumbnailReadListRepository import org.gotson.komga.infrastructure.image.MosaicGenerator import org.gotson.komga.infrastructure.metadata.comicrack.ReadListProvider import org.springframework.stereotype.Service +import org.springframework.transaction.support.TransactionTemplate private val logger = KotlinLogging.logger {} @Service class ReadListLifecycle( private val readListRepository: ReadListRepository, + private val thumbnailReadListRepository: ThumbnailReadListRepository, private val bookLifecycle: BookLifecycle, private val mosaicGenerator: MosaicGenerator, private val readListMatcher: ReadListMatcher, private val readListProvider: ReadListProvider, private val eventPublisher: EventPublisher, + private val transactionTemplate: TransactionTemplate, ) { @Throws( @@ -53,19 +58,57 @@ class ReadListLifecycle( } fun deleteReadList(readList: ReadList) { - readListRepository.delete(readList.id) + transactionTemplate.executeWithoutResult { + thumbnailReadListRepository.deleteByReadListId(readList.id) + readListRepository.delete(readList.id) + } eventPublisher.publishEvent(DomainEvent.ReadListDeleted(readList)) } fun deleteEmptyReadLists() { logger.info { "Deleting empty read lists" } - val toDelete = readListRepository.findAllEmpty() - readListRepository.delete(toDelete.map { it.id }) - toDelete.forEach { eventPublisher.publishEvent(DomainEvent.ReadListDeleted(it)) } + transactionTemplate.executeWithoutResult { + val toDelete = readListRepository.findAllEmpty() + readListRepository.delete(toDelete.map { it.id }) + thumbnailReadListRepository.deleteByReadListIds(toDelete.map { it.id }) + + toDelete.forEach { eventPublisher.publishEvent(DomainEvent.ReadListDeleted(it)) } + } } + fun addThumbnail(thumbnail: ThumbnailReadList) { + when (thumbnail.type) { + ThumbnailReadList.Type.USER_UPLOADED -> { + thumbnailReadListRepository.insert(thumbnail) + if (thumbnail.selected) { + thumbnailReadListRepository.markSelected(thumbnail) + } + } + } + + eventPublisher.publishEvent(DomainEvent.ThumbnailReadListAdded(thumbnail)) + } + + fun markSelectedThumbnail(thumbnail: ThumbnailReadList) { + thumbnailReadListRepository.markSelected(thumbnail) + eventPublisher.publishEvent(DomainEvent.ThumbnailReadListAdded(thumbnail)) + } + + fun deleteThumbnail(thumbnail: ThumbnailReadList) { + thumbnailReadListRepository.delete(thumbnail.id) + thumbnailsHouseKeeping(thumbnail.readListId) + eventPublisher.publishEvent(DomainEvent.ThumbnailReadListDeleted(thumbnail)) + } + + fun getThumbnailBytes(thumbnailId: String): ByteArray? = + thumbnailReadListRepository.findByIdOrNull(thumbnailId)?.thumbnail + fun getThumbnailBytes(readList: ReadList): ByteArray { + thumbnailReadListRepository.findSelectedByReadListIdOrNull(readList.id)?.let { + return it.thumbnail + } + val ids = with(mutableListOf()) { while (size < 4) { this += readList.bookIds.values.take(4) @@ -93,4 +136,21 @@ class ReadListLifecycle( else -> result } } + + private fun thumbnailsHouseKeeping(readListId: String) { + logger.info { "House keeping thumbnails for read list: $readListId" } + val all = thumbnailReadListRepository.findAllByReadListId(readListId) + + val selected = all.filter { it.selected } + when { + selected.size > 1 -> { + logger.info { "More than one thumbnail is selected, removing extra ones" } + thumbnailReadListRepository.markSelected(selected[0]) + } + selected.isEmpty() && all.isNotEmpty() -> { + logger.info { "Read list has no selected thumbnail, choosing one automatically" } + thumbnailReadListRepository.markSelected(all.first()) + } + } + } } diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/service/SeriesCollectionLifecycle.kt b/komga/src/main/kotlin/org/gotson/komga/domain/service/SeriesCollectionLifecycle.kt index aa2eb2dd..f74c50de 100644 --- a/komga/src/main/kotlin/org/gotson/komga/domain/service/SeriesCollectionLifecycle.kt +++ b/komga/src/main/kotlin/org/gotson/komga/domain/service/SeriesCollectionLifecycle.kt @@ -5,18 +5,23 @@ import org.gotson.komga.application.events.EventPublisher import org.gotson.komga.domain.model.DomainEvent import org.gotson.komga.domain.model.DuplicateNameException import org.gotson.komga.domain.model.SeriesCollection +import org.gotson.komga.domain.model.ThumbnailSeriesCollection import org.gotson.komga.domain.persistence.SeriesCollectionRepository +import org.gotson.komga.domain.persistence.ThumbnailSeriesCollectionRepository import org.gotson.komga.infrastructure.image.MosaicGenerator import org.springframework.stereotype.Service +import org.springframework.transaction.support.TransactionTemplate private val logger = KotlinLogging.logger {} @Service class SeriesCollectionLifecycle( private val collectionRepository: SeriesCollectionRepository, + private val thumbnailSeriesCollectionRepository: ThumbnailSeriesCollectionRepository, private val seriesLifecycle: SeriesLifecycle, private val mosaicGenerator: MosaicGenerator, private val eventPublisher: EventPublisher, + private val transactionTemplate: TransactionTemplate, ) { @Throws( @@ -50,18 +55,56 @@ class SeriesCollectionLifecycle( } fun deleteCollection(collection: SeriesCollection) { - collectionRepository.delete(collection.id) + transactionTemplate.executeWithoutResult { + thumbnailSeriesCollectionRepository.deleteByCollectionId(collection.id) + collectionRepository.delete(collection.id) + } eventPublisher.publishEvent(DomainEvent.CollectionDeleted(collection)) } fun deleteEmptyCollections() { logger.info { "Deleting empty collections" } - val toDelete = collectionRepository.findAllEmpty() - collectionRepository.delete(toDelete.map { it.id }) - toDelete.forEach { eventPublisher.publishEvent(DomainEvent.CollectionDeleted(it)) } + transactionTemplate.executeWithoutResult { + val toDelete = collectionRepository.findAllEmpty() + thumbnailSeriesCollectionRepository.deleteByCollectionIds(toDelete.map { it.id }) + collectionRepository.delete(toDelete.map { it.id }) + + toDelete.forEach { eventPublisher.publishEvent(DomainEvent.CollectionDeleted(it)) } + } } + fun addThumbnail(thumbnail: ThumbnailSeriesCollection) { + when (thumbnail.type) { + ThumbnailSeriesCollection.Type.USER_UPLOADED -> { + thumbnailSeriesCollectionRepository.insert(thumbnail) + if (thumbnail.selected) { + thumbnailSeriesCollectionRepository.markSelected(thumbnail) + } + } + } + + eventPublisher.publishEvent(DomainEvent.ThumbnailSeriesCollectionAdded(thumbnail)) + } + + fun markSelectedThumbnail(thumbnail: ThumbnailSeriesCollection) { + thumbnailSeriesCollectionRepository.markSelected(thumbnail) + eventPublisher.publishEvent(DomainEvent.ThumbnailSeriesCollectionAdded(thumbnail)) + } + + fun deleteThumbnail(thumbnail: ThumbnailSeriesCollection) { + thumbnailSeriesCollectionRepository.delete(thumbnail.id) + thumbnailsHouseKeeping(thumbnail.collectionId) + eventPublisher.publishEvent(DomainEvent.ThumbnailSeriesCollectionDeleted(thumbnail)) + } + + fun getThumbnailBytes(thumbnailId: String): ByteArray? = + thumbnailSeriesCollectionRepository.findByIdOrNull(thumbnailId)?.thumbnail + fun getThumbnailBytes(collection: SeriesCollection, userId: String): ByteArray { + thumbnailSeriesCollectionRepository.findSelectedByCollectionIdOrNull(collection.id)?.let { + return it.thumbnail + } + val ids = with(mutableListOf()) { while (size < 4) { this += collection.seriesIds.take(4) @@ -72,4 +115,21 @@ class SeriesCollectionLifecycle( val images = ids.mapNotNull { seriesLifecycle.getThumbnailBytes(it, userId) } return mosaicGenerator.createMosaic(images) } + + private fun thumbnailsHouseKeeping(collectionId: String) { + logger.info { "House keeping thumbnails for collection: $collectionId" } + val all = thumbnailSeriesCollectionRepository.findAllByCollectionId(collectionId) + + val selected = all.filter { it.selected } + when { + selected.size > 1 -> { + logger.info { "More than one thumbnail is selected, removing extra ones" } + thumbnailSeriesCollectionRepository.markSelected(selected[0]) + } + selected.isEmpty() && all.isNotEmpty() -> { + logger.info { "Collection has no selected thumbnail, choosing one automatically" } + thumbnailSeriesCollectionRepository.markSelected(all.first()) + } + } + } } 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 da9ac179..2780ed54 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 @@ -264,7 +264,7 @@ class SeriesLifecycle( if (markSelected == MarkSelectedPreference.YES || ( - markSelected == MarkSelectedPreference.IF_NONE_EXIST && + markSelected == MarkSelectedPreference.IF_NONE_OR_GENERATED && thumbnailsSeriesRepository.findSelectedBySeriesIdOrNull(thumbnail.seriesId) == null ) ) { @@ -276,6 +276,7 @@ class SeriesLifecycle( fun deleteThumbnailForSeries(thumbnail: ThumbnailSeries) { require(thumbnail.type == ThumbnailSeries.Type.USER_UPLOADED) { "Only uploaded thumbnails can be deleted" } thumbnailsSeriesRepository.delete(thumbnail.id) + eventPublisher.publishEvent(DomainEvent.ThumbnailSeriesDeleted(thumbnail)) } private fun thumbnailsHouseKeeping(seriesId: String) { diff --git a/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/ThumbnailBookDao.kt b/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/ThumbnailBookDao.kt index 06b580f0..66cb6592 100644 --- a/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/ThumbnailBookDao.kt +++ b/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/ThumbnailBookDao.kt @@ -30,6 +30,12 @@ class ThumbnailBookDao( .fetchInto(tb) .map { it.toDomain() } + override fun findByIdOrNull(thumbnailId: String): ThumbnailBook? = + dsl.selectFrom(tb) + .where(tb.ID.eq(thumbnailId)) + .fetchOneInto(tb) + ?.toDomain() + override fun findSelectedByBookIdOrNull(bookId: String): ThumbnailBook? = dsl.selectFrom(tb) .where(tb.BOOK_ID.eq(bookId)) diff --git a/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/ThumbnailReadListDao.kt b/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/ThumbnailReadListDao.kt new file mode 100644 index 00000000..7661c7a8 --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/ThumbnailReadListDao.kt @@ -0,0 +1,95 @@ +package org.gotson.komga.infrastructure.jooq + +import org.gotson.komga.domain.model.ThumbnailReadList +import org.gotson.komga.domain.persistence.ThumbnailReadListRepository +import org.gotson.komga.jooq.Tables +import org.gotson.komga.jooq.tables.records.ThumbnailReadlistRecord +import org.jooq.DSLContext +import org.springframework.stereotype.Component +import org.springframework.transaction.annotation.Transactional + +@Component +class ThumbnailReadListDao( + private val dsl: DSLContext +) : ThumbnailReadListRepository { + private val tr = Tables.THUMBNAIL_READLIST + + override fun findAllByReadListId(readListId: String): Collection = + dsl.selectFrom(tr) + .where(tr.READLIST_ID.eq(readListId)) + .fetchInto(tr) + .map { it.toDomain() } + + override fun findByIdOrNull(thumbnailId: String): ThumbnailReadList? = + dsl.selectFrom(tr) + .where(tr.ID.eq(thumbnailId)) + .fetchOneInto(tr) + ?.toDomain() + + override fun findSelectedByReadListIdOrNull(readListId: String): ThumbnailReadList? = + dsl.selectFrom(tr) + .where(tr.READLIST_ID.eq(readListId)) + .and(tr.SELECTED.isTrue) + .limit(1) + .fetchInto(tr) + .map { it.toDomain() } + .firstOrNull() + + override fun insert(thumbnail: ThumbnailReadList) { + dsl.insertInto(tr) + .set(tr.ID, thumbnail.id) + .set(tr.READLIST_ID, thumbnail.readListId) + .set(tr.THUMBNAIL, thumbnail.thumbnail) + .set(tr.SELECTED, thumbnail.selected) + .set(tr.TYPE, thumbnail.type.toString()) + .execute() + } + + override fun update(thumbnail: ThumbnailReadList) { + dsl.update(tr) + .set(tr.READLIST_ID, thumbnail.readListId) + .set(tr.THUMBNAIL, thumbnail.thumbnail) + .set(tr.SELECTED, thumbnail.selected) + .set(tr.TYPE, thumbnail.type.toString()) + .where(tr.ID.eq(thumbnail.id)) + .execute() + } + + @Transactional + override fun markSelected(thumbnail: ThumbnailReadList) { + dsl.update(tr) + .set(tr.SELECTED, false) + .where(tr.READLIST_ID.eq(thumbnail.readListId)) + .and(tr.ID.ne(thumbnail.id)) + .execute() + + dsl.update(tr) + .set(tr.SELECTED, true) + .where(tr.READLIST_ID.eq(thumbnail.readListId)) + .and(tr.ID.eq(thumbnail.id)) + .execute() + } + + override fun delete(thumbnailReadListId: String) { + dsl.deleteFrom(tr).where(tr.ID.eq(thumbnailReadListId)).execute() + } + + override fun deleteByReadListId(readListId: String) { + dsl.deleteFrom(tr).where(tr.READLIST_ID.eq(readListId)).execute() + } + + override fun deleteByReadListIds(readListIds: Collection) { + dsl.deleteFrom(tr).where(tr.READLIST_ID.`in`(readListIds)).execute() + } + + private fun ThumbnailReadlistRecord.toDomain() = + ThumbnailReadList( + thumbnail = thumbnail, + selected = selected, + type = ThumbnailReadList.Type.valueOf(type), + id = id, + readListId = readlistId, + createdDate = createdDate, + lastModifiedDate = lastModifiedDate + ) +} diff --git a/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/ThumbnailSeriesCollectionDao.kt b/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/ThumbnailSeriesCollectionDao.kt new file mode 100644 index 00000000..785ee90b --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/ThumbnailSeriesCollectionDao.kt @@ -0,0 +1,95 @@ +package org.gotson.komga.infrastructure.jooq + +import org.gotson.komga.domain.model.ThumbnailSeriesCollection +import org.gotson.komga.domain.persistence.ThumbnailSeriesCollectionRepository +import org.gotson.komga.jooq.Tables +import org.gotson.komga.jooq.tables.records.ThumbnailCollectionRecord +import org.jooq.DSLContext +import org.springframework.stereotype.Component +import org.springframework.transaction.annotation.Transactional + +@Component +class ThumbnailSeriesCollectionDao( + private val dsl: DSLContext +) : ThumbnailSeriesCollectionRepository { + private val tc = Tables.THUMBNAIL_COLLECTION + + override fun findByIdOrNull(thumbnailId: String): ThumbnailSeriesCollection? = + dsl.selectFrom(tc) + .where(tc.ID.eq(thumbnailId)) + .fetchOneInto(tc) + ?.toDomain() + + override fun findSelectedByCollectionIdOrNull(collectionId: String): ThumbnailSeriesCollection? = + dsl.selectFrom(tc) + .where(tc.COLLECTION_ID.eq(collectionId)) + .and(tc.SELECTED.isTrue) + .limit(1) + .fetchInto(tc) + .map { it.toDomain() } + .firstOrNull() + + override fun findAllByCollectionId(collectionId: String): Collection = + dsl.selectFrom(tc) + .where(tc.COLLECTION_ID.eq(collectionId)) + .fetchInto(tc) + .map { it.toDomain() } + + override fun insert(thumbnail: ThumbnailSeriesCollection) { + dsl.insertInto(tc) + .set(tc.ID, thumbnail.id) + .set(tc.COLLECTION_ID, thumbnail.collectionId) + .set(tc.THUMBNAIL, thumbnail.thumbnail) + .set(tc.SELECTED, thumbnail.selected) + .set(tc.TYPE, thumbnail.type.toString()) + .execute() + } + + override fun update(thumbnail: ThumbnailSeriesCollection) { + dsl.update(tc) + .set(tc.COLLECTION_ID, thumbnail.collectionId) + .set(tc.THUMBNAIL, thumbnail.thumbnail) + .set(tc.SELECTED, thumbnail.selected) + .set(tc.TYPE, thumbnail.type.toString()) + .where(tc.ID.eq(thumbnail.id)) + .execute() + } + + @Transactional + override fun markSelected(thumbnail: ThumbnailSeriesCollection) { + dsl.update(tc) + .set(tc.SELECTED, false) + .where(tc.COLLECTION_ID.eq(thumbnail.collectionId)) + .and(tc.ID.ne(thumbnail.id)) + .execute() + + dsl.update(tc) + .set(tc.SELECTED, true) + .where(tc.COLLECTION_ID.eq(thumbnail.collectionId)) + .and(tc.ID.eq(thumbnail.id)) + .execute() + } + + override fun delete(thumbnailCollectionId: String) { + dsl.deleteFrom(tc).where(tc.ID.eq(thumbnailCollectionId)).execute() + } + + override fun deleteByCollectionId(collectionId: String) { + dsl.deleteFrom(tc).where(tc.COLLECTION_ID.eq(collectionId)).execute() + } + + override fun deleteByCollectionIds(collectionIds: Collection) { + dsl.deleteFrom(tc).where(tc.COLLECTION_ID.`in`(collectionIds)).execute() + } + + private fun ThumbnailCollectionRecord.toDomain() = + ThumbnailSeriesCollection( + thumbnail = thumbnail, + selected = selected, + type = ThumbnailSeriesCollection.Type.valueOf(type), + id = id, + collectionId = collectionId, + createdDate = createdDate, + lastModifiedDate = lastModifiedDate + ) +} diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/BookController.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/BookController.kt index 91547902..bdbf6962 100644 --- a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/BookController.kt +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/BookController.kt @@ -14,16 +14,19 @@ import org.gotson.komga.application.tasks.TaskReceiver import org.gotson.komga.domain.model.BookSearchWithReadProgress import org.gotson.komga.domain.model.DomainEvent import org.gotson.komga.domain.model.ImageConversionException +import org.gotson.komga.domain.model.MarkSelectedPreference import org.gotson.komga.domain.model.Media import org.gotson.komga.domain.model.MediaNotReadyException import org.gotson.komga.domain.model.ROLE_ADMIN import org.gotson.komga.domain.model.ROLE_FILE_DOWNLOAD import org.gotson.komga.domain.model.ROLE_PAGE_STREAMING import org.gotson.komga.domain.model.ReadStatus +import org.gotson.komga.domain.model.ThumbnailBook 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.ReadListRepository +import org.gotson.komga.domain.persistence.ThumbnailBookRepository import org.gotson.komga.domain.service.BookLifecycle import org.gotson.komga.infrastructure.image.ImageType import org.gotson.komga.infrastructure.jooq.UnpagedSorted @@ -40,6 +43,7 @@ import org.gotson.komga.interfaces.api.rest.dto.BookMetadataUpdateDto import org.gotson.komga.interfaces.api.rest.dto.PageDto import org.gotson.komga.interfaces.api.rest.dto.ReadListDto import org.gotson.komga.interfaces.api.rest.dto.ReadProgressUpdateDto +import org.gotson.komga.interfaces.api.rest.dto.ThumbnailBookDto import org.gotson.komga.interfaces.api.rest.dto.patch import org.gotson.komga.interfaces.api.rest.dto.restrictUrl import org.gotson.komga.interfaces.api.rest.dto.toDto @@ -61,6 +65,7 @@ import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PatchMapping import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.PutMapping import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RequestParam @@ -68,6 +73,7 @@ import org.springframework.web.bind.annotation.ResponseStatus import org.springframework.web.bind.annotation.RestController import org.springframework.web.context.request.ServletWebRequest import org.springframework.web.context.request.WebRequest +import org.springframework.web.multipart.MultipartFile import org.springframework.web.server.ResponseStatusException import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody import java.io.FileNotFoundException @@ -92,6 +98,7 @@ class BookController( private val readListRepository: ReadListRepository, private val contentDetector: ContentDetector, private val eventPublisher: EventPublisher, + private val thumbnailBookRepository: ThumbnailBookRepository ) { @PageableAsQueryParam @@ -246,6 +253,99 @@ class BookController( return bookLifecycle.getThumbnailBytes(bookId) ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) } + @ApiResponse(content = [Content(schema = Schema(type = "string", format = "binary"))]) + @GetMapping(value = ["api/v1/books/{bookId}/thumbnails/{thumbnailId}"], produces = [MediaType.IMAGE_JPEG_VALUE]) + fun getBookThumbnailById( + @AuthenticationPrincipal principal: KomgaPrincipal, + @PathVariable(name = "bookId") bookId: String, + @PathVariable(name = "thumbnailId") thumbnailId: String + ): ByteArray { + bookRepository.getLibraryIdOrNull(bookId)?.let { + if (!principal.user.canAccessLibrary(it)) throw ResponseStatusException(HttpStatus.FORBIDDEN) + } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + + return bookLifecycle.getThumbnailBytesByThumbnailId(thumbnailId) + ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + } + + @GetMapping(value = ["api/v1/books/{bookId}/thumbnails"], produces = [MediaType.APPLICATION_JSON_VALUE]) + fun getBookThumbnails( + @AuthenticationPrincipal principal: KomgaPrincipal, + @PathVariable(name = "bookId") bookId: String, + ): Collection { + bookRepository.getLibraryIdOrNull(bookId)?.let { + if (!principal.user.canAccessLibrary(it)) throw ResponseStatusException(HttpStatus.FORBIDDEN) + } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + + return thumbnailBookRepository.findAllByBookId(bookId) + .map { it.toDto() } + } + + @PostMapping(value = ["api/v1/books/{bookId}/thumbnails"], consumes = [MediaType.MULTIPART_FORM_DATA_VALUE]) + @PreAuthorize("hasRole('$ROLE_ADMIN')") + @ResponseStatus(HttpStatus.ACCEPTED) + fun addUserUploadedBookThumbnail( + @AuthenticationPrincipal principal: KomgaPrincipal, + @PathVariable(name = "bookId") bookId: String, + @RequestParam("file") file: MultipartFile, + @RequestParam("selected") selected: Boolean = true, + ) { + val book = bookRepository.findByIdOrNull(bookId) ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + if (!principal.user.canAccessBook(book)) throw ResponseStatusException(HttpStatus.FORBIDDEN) + + if (!contentDetector.isImage(file.inputStream.buffered().use { contentDetector.detectMediaType(it) })) + throw ResponseStatusException(HttpStatus.UNSUPPORTED_MEDIA_TYPE) + + bookLifecycle.addThumbnailForBook( + ThumbnailBook( + bookId = book.id, + thumbnail = file.bytes, + type = ThumbnailBook.Type.USER_UPLOADED, + selected = selected + ), + if (selected) MarkSelectedPreference.YES else MarkSelectedPreference.NO + ) + } + + @PutMapping("api/v1/books/{bookId}/thumbnails/{thumbnailId}/selected") + @PreAuthorize("hasRole('$ROLE_ADMIN')") + @ResponseStatus(HttpStatus.ACCEPTED) + fun markSelectedBookThumbnail( + @AuthenticationPrincipal principal: KomgaPrincipal, + @PathVariable(name = "bookId") bookId: String, + @PathVariable(name = "thumbnailId") thumbnailId: String, + ) { + bookRepository.findByIdOrNull(bookId)?.let { book -> + if (!principal.user.canAccessBook(book)) throw ResponseStatusException(HttpStatus.FORBIDDEN) + } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + + thumbnailBookRepository.findByIdOrNull(thumbnailId)?.let { + thumbnailBookRepository.markSelected(it) + eventPublisher.publishEvent(DomainEvent.ThumbnailBookAdded(it)) + } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + } + + @DeleteMapping("api/v1/books/{bookId}/thumbnails/{thumbnailId}") + @PreAuthorize("hasRole('$ROLE_ADMIN')") + @ResponseStatus(HttpStatus.ACCEPTED) + fun deleteUserUploadedBookThumbnail( + @AuthenticationPrincipal principal: KomgaPrincipal, + @PathVariable(name = "bookId") bookId: String, + @PathVariable(name = "thumbnailId") thumbnailId: String, + ) { + bookRepository.findByIdOrNull(bookId)?.let { book -> + if (!principal.user.canAccessBook(book)) throw ResponseStatusException(HttpStatus.FORBIDDEN) + } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + + thumbnailBookRepository.findByIdOrNull(thumbnailId)?.let { + try { + bookLifecycle.deleteThumbnailForBook(it) + } catch (e: IllegalArgumentException) { + throw ResponseStatusException(HttpStatus.BAD_REQUEST, e.message) + } + } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + } + @Operation(description = "Download the book file.") @GetMapping( value = [ diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/ReadListController.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/ReadListController.kt index 5c6c32a4..1ffc015a 100644 --- a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/ReadListController.kt +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/ReadListController.kt @@ -16,12 +16,15 @@ import org.gotson.komga.domain.model.ROLE_ADMIN import org.gotson.komga.domain.model.ROLE_FILE_DOWNLOAD import org.gotson.komga.domain.model.ReadList import org.gotson.komga.domain.model.ReadStatus +import org.gotson.komga.domain.model.ThumbnailReadList import org.gotson.komga.domain.persistence.BookRepository import org.gotson.komga.domain.persistence.ReadListRepository +import org.gotson.komga.domain.persistence.ThumbnailReadListRepository import org.gotson.komga.domain.service.BookLifecycle import org.gotson.komga.domain.service.ReadListLifecycle import org.gotson.komga.infrastructure.jooq.UnpagedSorted import org.gotson.komga.infrastructure.language.toIndexedMap +import org.gotson.komga.infrastructure.mediacontainer.ContentDetector import org.gotson.komga.infrastructure.security.KomgaPrincipal import org.gotson.komga.infrastructure.swagger.AuthorsAsQueryParam import org.gotson.komga.infrastructure.swagger.PageableWithoutSortAsQueryParam @@ -35,6 +38,7 @@ import org.gotson.komga.interfaces.api.rest.dto.ReadListRequestResultDto import org.gotson.komga.interfaces.api.rest.dto.ReadListUpdateDto import org.gotson.komga.interfaces.api.rest.dto.TachiyomiReadProgressDto import org.gotson.komga.interfaces.api.rest.dto.TachiyomiReadProgressUpdateDto +import org.gotson.komga.interfaces.api.rest.dto.ThumbnailReadListDto import org.gotson.komga.interfaces.api.rest.dto.restrictUrl import org.gotson.komga.interfaces.api.rest.dto.toDto import org.springframework.core.io.FileSystemResource @@ -79,6 +83,8 @@ class ReadListController( private val bookDtoRepository: BookDtoRepository, private val bookRepository: BookRepository, private val readProgressDtoRepository: ReadProgressDtoRepository, + private val thumbnailReadListRepository: ThumbnailReadListRepository, + private val contentDetector: ContentDetector, private val bookLifecycle: BookLifecycle, ) { @@ -152,6 +158,84 @@ class ReadListController( } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) } + @ApiResponse(content = [Content(schema = Schema(type = "string", format = "binary"))]) + @GetMapping(value = ["{id}/thumbnails/{thumbnailId}"], produces = [MediaType.IMAGE_JPEG_VALUE]) + fun getReadListThumbnailById( + @AuthenticationPrincipal principal: KomgaPrincipal, + @PathVariable(name = "id") id: String, + @PathVariable(name = "thumbnailId") thumbnailId: String + ): ByteArray { + readListRepository.findByIdOrNull(id, principal.user.getAuthorizedLibraryIds(null))?.let { + return readListLifecycle.getThumbnailBytes(thumbnailId) + ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + } + + @GetMapping(value = ["{id}/thumbnails"], produces = [MediaType.APPLICATION_JSON_VALUE]) + fun getReadListThumbnails( + @AuthenticationPrincipal principal: KomgaPrincipal, + @PathVariable(name = "id") id: String, + ): Collection { + readListRepository.findByIdOrNull(id, principal.user.getAuthorizedLibraryIds(null))?.let { + return thumbnailReadListRepository.findAllByReadListId(id).map { it.toDto() } + } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + } + + @PostMapping(value = ["{id}/thumbnails"], consumes = [MediaType.MULTIPART_FORM_DATA_VALUE]) + @PreAuthorize("hasRole('$ROLE_ADMIN')") + @ResponseStatus(HttpStatus.ACCEPTED) + fun addUserUploadedReadListThumbnail( + @AuthenticationPrincipal principal: KomgaPrincipal, + @PathVariable(name = "id") id: String, + @RequestParam("file") file: MultipartFile, + @RequestParam("selected") selected: Boolean = true, + ) { + readListRepository.findByIdOrNull(id, principal.user.getAuthorizedLibraryIds(null))?.let { readList -> + + if (!contentDetector.isImage(file.inputStream.buffered().use { contentDetector.detectMediaType(it) })) + throw ResponseStatusException(HttpStatus.UNSUPPORTED_MEDIA_TYPE) + + readListLifecycle.addThumbnail( + ThumbnailReadList( + readListId = readList.id, + thumbnail = file.bytes, + type = ThumbnailReadList.Type.USER_UPLOADED, + selected = selected + ), + ) + } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + } + + @PutMapping("{id}/thumbnails/{thumbnailId}/selected") + @PreAuthorize("hasRole('$ROLE_ADMIN')") + @ResponseStatus(HttpStatus.ACCEPTED) + fun markSelectedReadListThumbnail( + @AuthenticationPrincipal principal: KomgaPrincipal, + @PathVariable(name = "id") id: String, + @PathVariable(name = "thumbnailId") thumbnailId: String, + ) { + readListRepository.findByIdOrNull(id, principal.user.getAuthorizedLibraryIds(null))?.let { readList -> + thumbnailReadListRepository.findByIdOrNull(thumbnailId)?.let { + readListLifecycle.markSelectedThumbnail(it) + } + } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + } + + @DeleteMapping("{id}/thumbnails/{thumbnailId}") + @PreAuthorize("hasRole('$ROLE_ADMIN')") + @ResponseStatus(HttpStatus.ACCEPTED) + fun deleteUserUploadedReadListThumbnail( + @AuthenticationPrincipal principal: KomgaPrincipal, + @PathVariable(name = "id") id: String, + @PathVariable(name = "thumbnailId") thumbnailId: String, + ) { + readListRepository.findByIdOrNull(id, principal.user.getAuthorizedLibraryIds(null))?.let { readList -> + thumbnailReadListRepository.findByIdOrNull(thumbnailId)?.let { + readListLifecycle.deleteThumbnail(it) + } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + } + @PostMapping @PreAuthorize("hasRole('$ROLE_ADMIN')") fun addOne( diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/SeriesCollectionController.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/SeriesCollectionController.kt index 498933e5..55d98022 100644 --- a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/SeriesCollectionController.kt +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/SeriesCollectionController.kt @@ -12,9 +12,12 @@ import org.gotson.komga.domain.model.ReadStatus import org.gotson.komga.domain.model.SeriesCollection import org.gotson.komga.domain.model.SeriesMetadata import org.gotson.komga.domain.model.SeriesSearchWithReadProgress +import org.gotson.komga.domain.model.ThumbnailSeriesCollection import org.gotson.komga.domain.persistence.SeriesCollectionRepository +import org.gotson.komga.domain.persistence.ThumbnailSeriesCollectionRepository import org.gotson.komga.domain.service.SeriesCollectionLifecycle import org.gotson.komga.infrastructure.jooq.UnpagedSorted +import org.gotson.komga.infrastructure.mediacontainer.ContentDetector import org.gotson.komga.infrastructure.security.KomgaPrincipal import org.gotson.komga.infrastructure.swagger.AuthorsAsQueryParam import org.gotson.komga.infrastructure.swagger.PageableWithoutSortAsQueryParam @@ -24,6 +27,7 @@ import org.gotson.komga.interfaces.api.rest.dto.CollectionCreationDto import org.gotson.komga.interfaces.api.rest.dto.CollectionDto import org.gotson.komga.interfaces.api.rest.dto.CollectionUpdateDto import org.gotson.komga.interfaces.api.rest.dto.SeriesDto +import org.gotson.komga.interfaces.api.rest.dto.ThumbnailSeriesCollectionDto import org.gotson.komga.interfaces.api.rest.dto.restrictUrl import org.gotson.komga.interfaces.api.rest.dto.toDto import org.springframework.data.domain.Page @@ -41,11 +45,13 @@ import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PatchMapping import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.PutMapping import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.ResponseStatus import org.springframework.web.bind.annotation.RestController +import org.springframework.web.multipart.MultipartFile import org.springframework.web.server.ResponseStatusException import java.util.concurrent.TimeUnit import javax.validation.Valid @@ -57,7 +63,9 @@ private val logger = KotlinLogging.logger {} class SeriesCollectionController( private val collectionRepository: SeriesCollectionRepository, private val collectionLifecycle: SeriesCollectionLifecycle, - private val seriesDtoRepository: SeriesDtoRepository + private val seriesDtoRepository: SeriesDtoRepository, + private val contentDetector: ContentDetector, + private val thumbnailSeriesCollectionRepository: ThumbnailSeriesCollectionRepository, ) { @PageableWithoutSortAsQueryParam @@ -112,6 +120,84 @@ class SeriesCollectionController( } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) } + @ApiResponse(content = [Content(schema = Schema(type = "string", format = "binary"))]) + @GetMapping(value = ["{id}/thumbnails/{thumbnailId}"], produces = [MediaType.IMAGE_JPEG_VALUE]) + fun getCollectionThumbnailById( + @AuthenticationPrincipal principal: KomgaPrincipal, + @PathVariable(name = "id") id: String, + @PathVariable(name = "thumbnailId") thumbnailId: String + ): ByteArray { + collectionRepository.findByIdOrNull(id, principal.user.getAuthorizedLibraryIds(null))?.let { + return collectionLifecycle.getThumbnailBytes(thumbnailId) + ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + } + + @GetMapping(value = ["{id}/thumbnails"], produces = [MediaType.APPLICATION_JSON_VALUE]) + fun getCollectionThumbnails( + @AuthenticationPrincipal principal: KomgaPrincipal, + @PathVariable(name = "id") id: String, + ): Collection { + collectionRepository.findByIdOrNull(id, principal.user.getAuthorizedLibraryIds(null))?.let { + return thumbnailSeriesCollectionRepository.findAllByCollectionId(id).map { it.toDto() } + } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + } + + @PostMapping(value = ["{id}/thumbnails"], consumes = [MediaType.MULTIPART_FORM_DATA_VALUE]) + @PreAuthorize("hasRole('$ROLE_ADMIN')") + @ResponseStatus(HttpStatus.ACCEPTED) + fun addUserUploadedCollectionThumbnail( + @AuthenticationPrincipal principal: KomgaPrincipal, + @PathVariable(name = "id") id: String, + @RequestParam("file") file: MultipartFile, + @RequestParam("selected") selected: Boolean = true, + ) { + collectionRepository.findByIdOrNull(id, principal.user.getAuthorizedLibraryIds(null))?.let { collection -> + + if (!contentDetector.isImage(file.inputStream.buffered().use { contentDetector.detectMediaType(it) })) + throw ResponseStatusException(HttpStatus.UNSUPPORTED_MEDIA_TYPE) + + collectionLifecycle.addThumbnail( + ThumbnailSeriesCollection( + collectionId = collection.id, + thumbnail = file.bytes, + type = ThumbnailSeriesCollection.Type.USER_UPLOADED, + selected = selected + ), + ) + } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + } + + @PutMapping("{id}/thumbnails/{thumbnailId}/selected") + @PreAuthorize("hasRole('$ROLE_ADMIN')") + @ResponseStatus(HttpStatus.ACCEPTED) + fun markSelectedCollectionThumbnail( + @AuthenticationPrincipal principal: KomgaPrincipal, + @PathVariable(name = "id") id: String, + @PathVariable(name = "thumbnailId") thumbnailId: String, + ) { + collectionRepository.findByIdOrNull(id, principal.user.getAuthorizedLibraryIds(null))?.let { + thumbnailSeriesCollectionRepository.findByIdOrNull(thumbnailId)?.let { + collectionLifecycle.markSelectedThumbnail(it) + } + } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + } + + @DeleteMapping("{id}/thumbnails/{thumbnailId}") + @PreAuthorize("hasRole('$ROLE_ADMIN')") + @ResponseStatus(HttpStatus.ACCEPTED) + fun deleteUserUploadedCollectionThumbnail( + @AuthenticationPrincipal principal: KomgaPrincipal, + @PathVariable(name = "id") id: String, + @PathVariable(name = "thumbnailId") thumbnailId: String, + ) { + collectionRepository.findByIdOrNull(id, principal.user.getAuthorizedLibraryIds(null))?.let { + thumbnailSeriesCollectionRepository.findByIdOrNull(thumbnailId)?.let { + collectionLifecycle.deleteThumbnail(it) + } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + } + @PostMapping @PreAuthorize("hasRole('$ROLE_ADMIN')") fun addOne( diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/ThumbnailBookDto.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/ThumbnailBookDto.kt new file mode 100644 index 00000000..bfeb9dc4 --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/ThumbnailBookDto.kt @@ -0,0 +1,18 @@ +package org.gotson.komga.interfaces.api.rest.dto + +import org.gotson.komga.domain.model.ThumbnailBook + +data class ThumbnailBookDto( + val id: String, + val bookId: String, + val type: String, + val selected: Boolean +) + +fun ThumbnailBook.toDto() = + ThumbnailBookDto( + id = id, + bookId = bookId, + type = type.toString(), + selected = selected + ) diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/ThumbnailReadListDto.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/ThumbnailReadListDto.kt new file mode 100644 index 00000000..a158f092 --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/ThumbnailReadListDto.kt @@ -0,0 +1,18 @@ +package org.gotson.komga.interfaces.api.rest.dto + +import org.gotson.komga.domain.model.ThumbnailReadList + +data class ThumbnailReadListDto( + val id: String, + val readListId: String, + val type: String, + val selected: Boolean +) + +fun ThumbnailReadList.toDto() = + ThumbnailReadListDto( + id = id, + readListId = readListId, + type = type.toString(), + selected = selected + ) diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/ThumbnailSeriesCollectionDto.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/ThumbnailSeriesCollectionDto.kt new file mode 100644 index 00000000..a898ffea --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/ThumbnailSeriesCollectionDto.kt @@ -0,0 +1,18 @@ +package org.gotson.komga.interfaces.api.rest.dto + +import org.gotson.komga.domain.model.ThumbnailSeriesCollection + +data class ThumbnailSeriesCollectionDto( + val id: String, + val collectionId: String, + val type: String, + val selected: Boolean +) + +fun ThumbnailSeriesCollection.toDto() = + ThumbnailSeriesCollectionDto( + id = id, + collectionId = collectionId, + type = type.toString(), + selected = selected + ) diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/sse/SseController.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/sse/SseController.kt index e17c6188..7082bb17 100644 --- a/komga/src/main/kotlin/org/gotson/komga/interfaces/sse/SseController.kt +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/sse/SseController.kt @@ -21,6 +21,8 @@ import org.gotson.komga.interfaces.sse.dto.ReadProgressSseDto import org.gotson.komga.interfaces.sse.dto.SeriesSseDto import org.gotson.komga.interfaces.sse.dto.TaskQueueSseDto import org.gotson.komga.interfaces.sse.dto.ThumbnailBookSseDto +import org.gotson.komga.interfaces.sse.dto.ThumbnailReadListSseDto +import org.gotson.komga.interfaces.sse.dto.ThumbnailSeriesCollectionSseDto import org.gotson.komga.interfaces.sse.dto.ThumbnailSeriesSseDto import org.springframework.http.MediaType import org.springframework.jms.annotation.JmsListener @@ -100,7 +102,13 @@ class SseController( is DomainEvent.ReadProgressSeriesDeleted -> emitSse("ReadProgressSeriesDeleted", ReadProgressSeriesSseDto(event.seriesId, event.userId), userIdOnly = event.userId) is DomainEvent.ThumbnailBookAdded -> emitSse("ThumbnailBookAdded", ThumbnailBookSseDto(event.thumbnail.bookId, bookRepository.getSeriesIdOrNull(event.thumbnail.bookId).orEmpty())) + is DomainEvent.ThumbnailBookDeleted -> emitSse("ThumbnailBookDeleted", ThumbnailBookSseDto(event.thumbnail.bookId, bookRepository.getSeriesIdOrNull(event.thumbnail.bookId).orEmpty())) is DomainEvent.ThumbnailSeriesAdded -> emitSse("ThumbnailSeriesAdded", ThumbnailSeriesSseDto(event.thumbnail.seriesId)) + is DomainEvent.ThumbnailSeriesDeleted -> emitSse("ThumbnailSeriesDeleted", ThumbnailSeriesSseDto(event.thumbnail.seriesId)) + is DomainEvent.ThumbnailSeriesCollectionAdded -> emitSse("ThumbnailSeriesCollectionAdded", ThumbnailSeriesCollectionSseDto(event.thumbnail.collectionId)) + is DomainEvent.ThumbnailSeriesCollectionDeleted -> emitSse("ThumbnailSeriesCollectionDeleted", ThumbnailSeriesCollectionSseDto(event.thumbnail.collectionId)) + is DomainEvent.ThumbnailReadListAdded -> emitSse("ThumbnailReadListAdded", ThumbnailReadListSseDto(event.thumbnail.readListId)) + is DomainEvent.ThumbnailReadListDeleted -> emitSse("ThumbnailReadListDeleted", ThumbnailReadListSseDto(event.thumbnail.readListId)) } } diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/sse/dto/ThumbnailReadListSseDto.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/sse/dto/ThumbnailReadListSseDto.kt new file mode 100644 index 00000000..5a87ef66 --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/sse/dto/ThumbnailReadListSseDto.kt @@ -0,0 +1,5 @@ +package org.gotson.komga.interfaces.sse.dto + +data class ThumbnailReadListSseDto( + val readListId: String, +) diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/sse/dto/ThumbnailSeriesCollectionSseDto.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/sse/dto/ThumbnailSeriesCollectionSseDto.kt new file mode 100644 index 00000000..52f5725e --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/sse/dto/ThumbnailSeriesCollectionSseDto.kt @@ -0,0 +1,5 @@ +package org.gotson.komga.interfaces.sse.dto + +data class ThumbnailSeriesCollectionSseDto( + val collectionId: String, +) diff --git a/komga/src/test/kotlin/org/gotson/komga/domain/service/LibraryContentLifecycleTest.kt b/komga/src/test/kotlin/org/gotson/komga/domain/service/LibraryContentLifecycleTest.kt index 60e5fe23..7876abcb 100644 --- a/komga/src/test/kotlin/org/gotson/komga/domain/service/LibraryContentLifecycleTest.kt +++ b/komga/src/test/kotlin/org/gotson/komga/domain/service/LibraryContentLifecycleTest.kt @@ -13,6 +13,7 @@ import org.gotson.komga.domain.model.Book import org.gotson.komga.domain.model.BookMetadataPatchCapability import org.gotson.komga.domain.model.DirectoryNotFoundException import org.gotson.komga.domain.model.KomgaUser +import org.gotson.komga.domain.model.MarkSelectedPreference import org.gotson.komga.domain.model.Media import org.gotson.komga.domain.model.ReadList import org.gotson.komga.domain.model.ScanResult @@ -577,8 +578,8 @@ class LibraryContentLifecycleTest( bookRepository.findByIdOrNull(book.id)?.let { bookRepository.update(it.copy(fileHash = "sameHash")) mediaRepository.update(mediaRepository.findById(it.id).copy(status = Media.Status.READY)) - bookLifecycle.addThumbnailForBook(ThumbnailBook(thumbnail = ByteArray(0), type = ThumbnailBook.Type.GENERATED, bookId = book.id)) - bookLifecycle.addThumbnailForBook(ThumbnailBook(url = URL("file:/sidecar"), type = ThumbnailBook.Type.SIDECAR, bookId = book.id)) + bookLifecycle.addThumbnailForBook(ThumbnailBook(thumbnail = ByteArray(0), type = ThumbnailBook.Type.GENERATED, bookId = book.id), MarkSelectedPreference.NO) + bookLifecycle.addThumbnailForBook(ThumbnailBook(url = URL("file:/sidecar"), type = ThumbnailBook.Type.SIDECAR, bookId = book.id), MarkSelectedPreference.NO) } every { mockHasher.computeHash(any()) } returns "sameHash" @@ -623,8 +624,8 @@ class LibraryContentLifecycleTest( bookRepository.findByIdOrNull(book.id)?.let { bookRepository.update(it.copy(fileHash = "sameHash")) mediaRepository.update(mediaRepository.findById(it.id).copy(status = Media.Status.READY)) - bookLifecycle.addThumbnailForBook(ThumbnailBook(thumbnail = ByteArray(0), type = ThumbnailBook.Type.GENERATED, bookId = book.id)) - bookLifecycle.addThumbnailForBook(ThumbnailBook(url = URL("file:/sidecar"), type = ThumbnailBook.Type.SIDECAR, bookId = book.id)) + bookLifecycle.addThumbnailForBook(ThumbnailBook(thumbnail = ByteArray(0), type = ThumbnailBook.Type.GENERATED, bookId = book.id), MarkSelectedPreference.NO) + bookLifecycle.addThumbnailForBook(ThumbnailBook(url = URL("file:/sidecar"), type = ThumbnailBook.Type.SIDECAR, bookId = book.id), MarkSelectedPreference.NO) } every { mockHasher.computeHash(any()) } returns "sameHash" diff --git a/komga/src/test/kotlin/org/gotson/komga/interfaces/api/rest/BookControllerTest.kt b/komga/src/test/kotlin/org/gotson/komga/interfaces/api/rest/BookControllerTest.kt index 8b276451..29969756 100644 --- a/komga/src/test/kotlin/org/gotson/komga/interfaces/api/rest/BookControllerTest.kt +++ b/komga/src/test/kotlin/org/gotson/komga/interfaces/api/rest/BookControllerTest.kt @@ -5,6 +5,7 @@ import org.assertj.core.groups.Tuple.tuple import org.gotson.komga.domain.model.Author import org.gotson.komga.domain.model.BookPage import org.gotson.komga.domain.model.KomgaUser +import org.gotson.komga.domain.model.MarkSelectedPreference import org.gotson.komga.domain.model.Media import org.gotson.komga.domain.model.ROLE_ADMIN import org.gotson.komga.domain.model.ThumbnailBook @@ -474,7 +475,8 @@ class BookControllerTest( thumbnail = Random.nextBytes(100), bookId = book.id, type = ThumbnailBook.Type.GENERATED - ) + ), + MarkSelectedPreference.YES ) val url = "/api/v1/books/${book.id}/thumbnail" @@ -533,7 +535,8 @@ class BookControllerTest( thumbnail = Random.nextBytes(1), bookId = book.id, type = ThumbnailBook.Type.GENERATED - ) + ), + MarkSelectedPreference.YES ) val url = "/api/v1/books/${book.id}/thumbnail" @@ -546,7 +549,8 @@ class BookControllerTest( thumbnail = Random.nextBytes(1), bookId = book.id, type = ThumbnailBook.Type.GENERATED - ) + ), + MarkSelectedPreference.YES ) mockMvc.get(url) { diff --git a/komga/src/test/kotlin/org/gotson/komga/interfaces/api/rest/SeriesControllerTest.kt b/komga/src/test/kotlin/org/gotson/komga/interfaces/api/rest/SeriesControllerTest.kt index 8b64b519..a4686bce 100644 --- a/komga/src/test/kotlin/org/gotson/komga/interfaces/api/rest/SeriesControllerTest.kt +++ b/komga/src/test/kotlin/org/gotson/komga/interfaces/api/rest/SeriesControllerTest.kt @@ -3,6 +3,7 @@ package org.gotson.komga.interfaces.api.rest import org.assertj.core.api.Assertions.assertThat import org.gotson.komga.domain.model.BookPage import org.gotson.komga.domain.model.KomgaUser +import org.gotson.komga.domain.model.MarkSelectedPreference import org.gotson.komga.domain.model.Media import org.gotson.komga.domain.model.ROLE_ADMIN import org.gotson.komga.domain.model.SeriesMetadata @@ -580,7 +581,8 @@ class SeriesControllerTest( thumbnail = Random.nextBytes(1), bookId = book.id, type = ThumbnailBook.Type.GENERATED - ) + ), + MarkSelectedPreference.YES ) } @@ -614,7 +616,8 @@ class SeriesControllerTest( thumbnail = Random.nextBytes(1), bookId = book.id, type = ThumbnailBook.Type.GENERATED - ) + ), + MarkSelectedPreference.YES ) }