feat(api): cover upload for books, read lists and collections

Co-authored-by: Gauthier Roebroeck <gauthier.roebroeck@gmail.com>
This commit is contained in:
Snd-R 2021-12-22 05:02:11 +03:00 committed by GitHub
parent ff358da598
commit 31ad351144
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
29 changed files with 894 additions and 31 deletions

View file

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

View file

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

View file

@ -1,5 +1,5 @@
package org.gotson.komga.domain.model
enum class MarkSelectedPreference {
NO, YES, IF_NONE_EXIST
NO, YES, IF_NONE_OR_GENERATED
}

View file

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

View file

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

View file

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

View file

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

View file

@ -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<ThumbnailReadList>
fun insert(thumbnail: ThumbnailReadList)
fun update(thumbnail: ThumbnailReadList)
fun markSelected(thumbnail: ThumbnailReadList)
fun delete(thumbnailReadListId: String)
fun deleteByReadListId(readListId: String)
fun deleteByReadListIds(readListIds: Collection<String>)
}

View file

@ -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<ThumbnailSeriesCollection>
fun insert(thumbnail: ThumbnailSeriesCollection)
fun update(thumbnail: ThumbnailSeriesCollection)
fun markSelected(thumbnail: ThumbnailSeriesCollection)
fun delete(thumbnailCollectionId: String)
fun deleteByCollectionId(collectionId: String)
fun deleteByCollectionIds(collectionIds: Collection<String>)
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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<ThumbnailReadList> =
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<String>) {
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
)
}

View file

@ -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<ThumbnailSeriesCollection> =
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<String>) {
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
)
}

View file

@ -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<ThumbnailBookDto> {
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 = [

View file

@ -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<ThumbnailReadListDto> {
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(

View file

@ -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<ThumbnailSeriesCollectionDto> {
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(

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,5 @@
package org.gotson.komga.interfaces.sse.dto
data class ThumbnailReadListSseDto(
val readListId: String,
)

View file

@ -0,0 +1,5 @@
package org.gotson.komga.interfaces.sse.dto
data class ThumbnailSeriesCollectionSseDto(
val collectionId: String,
)

View file

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

View file

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

View file

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