feat(api): support custom covers for series

Co-authored-by: Gauthier <gotson@users.noreply.github.com>
Co-authored-by: Gauthier Roebroeck <gauthier.roebroeck@gmail.com>
This commit is contained in:
Andreas 2021-09-06 03:22:45 +02:00 committed by GitHub
parent 1ba6822fd5
commit d7470dd7db
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 253 additions and 38 deletions

View file

@ -267,7 +267,7 @@ export default Vue.extend({
}
},
thumbnailSeriesAdded(event: ThumbnailSeriesSseDto) {
if (this.thumbnailError && (this.computedItem.type() === ItemTypes.SERIES && event.seriesId === this.item.id)) {
if (this.computedItem.type() === ItemTypes.SERIES && event.seriesId === this.item.id) {
this.thumbnailCacheBust = '?' + this.$_.random(1000)
}
},

View file

@ -0,0 +1,20 @@
ALTER TABLE THUMBNAIL_SERIES RENAME TO TMP_THUMBNAIL_SERIES;
CREATE TABLE THUMBNAIL_SERIES
(
ID varchar NOT NULL PRIMARY KEY,
URL varchar NULL DEFAULT NULL,
SELECTED boolean NOT NULL DEFAULT 0,
THUMBNAIL blob NULL DEFAULT NULL,
TYPE varchar not null,
CREATED_DATE datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
LAST_MODIFIED_DATE datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
SERIES_ID varchar NOT NULL,
FOREIGN KEY (SERIES_ID) REFERENCES SERIES (ID)
);
INSERT INTO THUMBNAIL_SERIES(ID, URL, SELECTED, CREATED_DATE, LAST_MODIFIED_DATE, SERIES_ID, TYPE)
SELECT ID, URL, SELECTED, CREATED_DATE, LAST_MODIFIED_DATE, SERIES_ID, "SIDECAR" AS TYPE
FROM TMP_THUMBNAIL_SERIES;
DROP TABLE TMP_THUMBNAIL_SERIES;

View file

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

View file

@ -3,6 +3,8 @@ package org.gotson.komga.domain.model
import com.github.f4b6a3.tsid.TsidCreator
import java.io.Serializable
import java.net.URL
import java.nio.file.Files
import java.nio.file.Paths
import java.time.LocalDateTime
data class ThumbnailBook(
@ -51,4 +53,9 @@ data class ThumbnailBook(
result = 31 * result + lastModifiedDate.hashCode()
return result
}
fun exists(): Boolean {
if (url != null) return Files.exists(Paths.get(url.toURI()))
return thumbnail != null
}
}

View file

@ -3,15 +3,59 @@ package org.gotson.komga.domain.model
import com.github.f4b6a3.tsid.TsidCreator
import java.io.Serializable
import java.net.URL
import java.nio.file.Files
import java.nio.file.Paths
import java.time.LocalDateTime
data class ThumbnailSeries(
val url: URL,
val thumbnail: ByteArray? = null,
val url: URL? = null,
val selected: Boolean = false,
val type: Type,
val id: String = TsidCreator.getTsid256().toString(),
val seriesId: String = "",
override val createdDate: LocalDateTime = LocalDateTime.now(),
override val lastModifiedDate: LocalDateTime = LocalDateTime.now()
) : Auditable(), Serializable
) : Auditable(), Serializable {
enum class Type {
SIDECAR, USER_UPLOADED
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is ThumbnailSeries) return false
if (thumbnail != null) {
if (other.thumbnail == null) return false
if (!thumbnail.contentEquals(other.thumbnail)) return false
} else if (other.thumbnail != null) return false
if (url != other.url) return false
if (selected != other.selected) return false
if (type != other.type) return false
if (id != other.id) return false
if (seriesId != other.seriesId) return false
if (createdDate != other.createdDate) return false
if (lastModifiedDate != other.lastModifiedDate) return false
return true
}
override fun hashCode(): Int {
var result = thumbnail?.contentHashCode() ?: 0
result = 31 * result + (url?.hashCode() ?: 0)
result = 31 * result + selected.hashCode()
result = 31 * result + type.hashCode()
result = 31 * result + id.hashCode()
result = 31 * result + seriesId.hashCode()
result = 31 * result + createdDate.hashCode()
result = 31 * result + lastModifiedDate.hashCode()
return result
}
fun exists(): Boolean {
if (url != null) return Files.exists(Paths.get(url.toURI()))
return thumbnail != null
}
}

View file

@ -3,6 +3,8 @@ package org.gotson.komga.domain.persistence
import org.gotson.komga.domain.model.ThumbnailSeries
interface ThumbnailSeriesRepository {
fun findByIdOrNull(thumbnailId: String): ThumbnailSeries?
fun findSelectedBySeriesIdOrNull(seriesId: String): ThumbnailSeries?
fun findAllBySeriesId(seriesId: String): Collection<ThumbnailSeries>

View file

@ -25,8 +25,6 @@ import org.gotson.komga.infrastructure.image.ImageType
import org.springframework.stereotype.Service
import org.springframework.transaction.support.TransactionTemplate
import java.io.File
import java.nio.file.Files
import java.nio.file.Paths
import java.time.LocalDateTime
private val logger = KotlinLogging.logger {}
@ -161,15 +159,6 @@ class BookLifecycle(
}
}
private fun ThumbnailBook.exists(): Boolean {
if (type == ThumbnailBook.Type.SIDECAR) {
if (url != null)
return Files.exists(Paths.get(url.toURI()))
return false
}
return true
}
@Throws(
ImageConversionException::class,
MediaNotReadyException::class,

View file

@ -2,6 +2,7 @@ package org.gotson.komga.domain.service
import mu.KotlinLogging
import org.gotson.komga.domain.model.Book
import org.gotson.komga.domain.model.MarkSelectedPreference
import org.gotson.komga.domain.model.Series
import org.gotson.komga.domain.persistence.LibraryRepository
import org.gotson.komga.infrastructure.metadata.localartwork.LocalArtworkProvider
@ -35,7 +36,7 @@ class LocalArtworkLifecycle(
if (library.importLocalArtwork)
localArtworkProvider.getSeriesThumbnails(series).forEach {
seriesLifecycle.addThumbnailForSeries(it)
seriesLifecycle.addThumbnailForSeries(it, if (it.selected) MarkSelectedPreference.IF_NONE_EXIST else MarkSelectedPreference.NO)
}
else
logger.info { "Library is not set to import local artwork, skipping" }

View file

@ -12,6 +12,7 @@ import org.gotson.komga.domain.model.BookMetadataPatchCapability
import org.gotson.komga.domain.model.DomainEvent
import org.gotson.komga.domain.model.KomgaUser
import org.gotson.komga.domain.model.Library
import org.gotson.komga.domain.model.MarkSelectedPreference
import org.gotson.komga.domain.model.Media
import org.gotson.komga.domain.model.ReadProgress
import org.gotson.komga.domain.model.Series
@ -30,8 +31,6 @@ import org.gotson.komga.domain.persistence.ThumbnailSeriesRepository
import org.springframework.stereotype.Service
import org.springframework.transaction.support.TransactionTemplate
import java.io.File
import java.nio.file.Files
import java.nio.file.Paths
import java.time.LocalDateTime
private val logger = KotlinLogging.logger {}
@ -194,10 +193,10 @@ class SeriesLifecycle(
eventPublisher.publishEvent(DomainEvent.ReadProgressSeriesDeleted(seriesId, user.id))
}
fun getThumbnail(seriesId: String): ThumbnailSeries? {
fun getSelectedThumbnail(seriesId: String): ThumbnailSeries? {
val selected = thumbnailsSeriesRepository.findSelectedBySeriesIdOrNull(seriesId)
if (selected == null || !selected.exists()) {
if (selected == null || (selected.type == ThumbnailSeries.Type.SIDECAR && !selected.exists())) {
thumbnailsHouseKeeping(seriesId)
return thumbnailsSeriesRepository.findSelectedBySeriesIdOrNull(seriesId)
}
@ -205,9 +204,21 @@ class SeriesLifecycle(
return selected
}
private fun getBytesFromThumbnailSeries(thumbnail: ThumbnailSeries): ByteArray? =
when {
thumbnail.thumbnail != null -> thumbnail.thumbnail
thumbnail.url != null -> File(thumbnail.url.toURI()).readBytes()
else -> null
}
fun getThumbnailBytesByThumbnailId(thumbnailId: String): ByteArray? =
thumbnailsSeriesRepository.findByIdOrNull(thumbnailId)?.let {
getBytesFromThumbnailSeries(it)
}
fun getThumbnailBytes(seriesId: String, userId: String): ByteArray? {
getThumbnail(seriesId)?.let {
return File(it.url.toURI()).readBytes()
getSelectedThumbnail(seriesId)?.let {
return getBytesFromThumbnailSeries(it)
}
seriesRepository.findByIdOrNull(seriesId)?.let { series ->
@ -225,19 +236,31 @@ class SeriesLifecycle(
return null
}
fun addThumbnailForSeries(thumbnail: ThumbnailSeries) {
fun addThumbnailForSeries(thumbnail: ThumbnailSeries, markSelected: MarkSelectedPreference) {
// delete existing thumbnail with the same url
thumbnailsSeriesRepository.findAllBySeriesId(thumbnail.seriesId)
.filter { it.url == thumbnail.url }
.forEach {
thumbnailsSeriesRepository.delete(it.id)
}
thumbnailsSeriesRepository.insert(thumbnail)
if (thumbnail.url != null) {
thumbnailsSeriesRepository.findAllBySeriesId(thumbnail.seriesId)
.filter { it.url == thumbnail.url }
.forEach {
thumbnailsSeriesRepository.delete(it.id)
}
}
thumbnailsSeriesRepository.insert(thumbnail.copy(selected = false))
eventPublisher.publishEvent(DomainEvent.ThumbnailSeriesAdded(thumbnail))
if (thumbnail.selected)
if (markSelected == MarkSelectedPreference.YES ||
(
markSelected == MarkSelectedPreference.IF_NONE_EXIST &&
thumbnailsSeriesRepository.findSelectedBySeriesIdOrNull(thumbnail.seriesId) == null
)
) {
thumbnailsSeriesRepository.markSelected(thumbnail)
eventPublisher.publishEvent(DomainEvent.ThumbnailSeriesAdded(thumbnail))
}
}
fun deleteThumbnailForSeries(thumbnail: ThumbnailSeries) {
require(thumbnail.type == ThumbnailSeries.Type.USER_UPLOADED) { "Only uploaded thumbnails can be deleted" }
thumbnailsSeriesRepository.delete(thumbnail.id)
}
private fun thumbnailsHouseKeeping(seriesId: String) {
@ -263,6 +286,4 @@ class SeriesLifecycle(
}
}
}
private fun ThumbnailSeries.exists(): Boolean = Files.exists(Paths.get(url.toURI()))
}

View file

@ -15,6 +15,12 @@ class ThumbnailSeriesDao(
) : ThumbnailSeriesRepository {
private val ts = Tables.THUMBNAIL_SERIES
override fun findByIdOrNull(thumbnailId: String): ThumbnailSeries? =
dsl.selectFrom(ts)
.where(ts.ID.eq(thumbnailId))
.fetchOneInto(ts)
?.toDomain()
override fun findAllBySeriesId(seriesId: String): Collection<ThumbnailSeries> =
dsl.selectFrom(ts)
.where(ts.SERIES_ID.eq(seriesId))
@ -34,7 +40,9 @@ class ThumbnailSeriesDao(
dsl.insertInto(ts)
.set(ts.ID, thumbnail.id)
.set(ts.SERIES_ID, thumbnail.seriesId)
.set(ts.URL, thumbnail.url.toString())
.set(ts.URL, thumbnail.url?.toString())
.set(ts.THUMBNAIL, thumbnail.thumbnail)
.set(ts.TYPE, thumbnail.type.toString())
.set(ts.SELECTED, thumbnail.selected)
.execute()
}
@ -68,8 +76,10 @@ class ThumbnailSeriesDao(
private fun ThumbnailSeriesRecord.toDomain() =
ThumbnailSeries(
url = URL(url),
thumbnail = thumbnail,
url = url?.let { URL(it) },
selected = selected,
type = ThumbnailSeries.Type.valueOf(type),
id = id,
seriesId = seriesId,
createdDate = createdDate,

View file

@ -65,7 +65,8 @@ class LocalArtworkProvider(
ThumbnailSeries(
url = path.toUri().toURL(),
seriesId = series.id,
selected = index == 0
selected = index == 0,
type = ThumbnailSeries.Type.SIDECAR
)
}.toList()
}

View file

@ -17,6 +17,7 @@ import org.gotson.komga.application.tasks.TaskReceiver
import org.gotson.komga.domain.model.Author
import org.gotson.komga.domain.model.BookSearchWithReadProgress
import org.gotson.komga.domain.model.DomainEvent
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.ROLE_FILE_DOWNLOAD
@ -24,13 +25,16 @@ import org.gotson.komga.domain.model.ReadStatus
import org.gotson.komga.domain.model.SeriesMetadata
import org.gotson.komga.domain.model.SeriesSearch
import org.gotson.komga.domain.model.SeriesSearchWithReadProgress
import org.gotson.komga.domain.model.ThumbnailSeries
import org.gotson.komga.domain.persistence.BookRepository
import org.gotson.komga.domain.persistence.SeriesCollectionRepository
import org.gotson.komga.domain.persistence.SeriesMetadataRepository
import org.gotson.komga.domain.persistence.SeriesRepository
import org.gotson.komga.domain.persistence.ThumbnailSeriesRepository
import org.gotson.komga.domain.service.BookLifecycle
import org.gotson.komga.domain.service.SeriesLifecycle
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.PageableAsQueryParam
@ -42,6 +46,7 @@ import org.gotson.komga.interfaces.rest.dto.CollectionDto
import org.gotson.komga.interfaces.rest.dto.GroupCountDto
import org.gotson.komga.interfaces.rest.dto.SeriesDto
import org.gotson.komga.interfaces.rest.dto.SeriesMetadataUpdateDto
import org.gotson.komga.interfaces.rest.dto.SeriesThumbnailDto
import org.gotson.komga.interfaces.rest.dto.TachiyomiReadProgressDto
import org.gotson.komga.interfaces.rest.dto.TachiyomiReadProgressUpdateDto
import org.gotson.komga.interfaces.rest.dto.TachiyomiReadProgressUpdateV2Dto
@ -74,6 +79,7 @@ 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 org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody
import java.io.OutputStream
@ -96,6 +102,8 @@ class SeriesController(
private val collectionRepository: SeriesCollectionRepository,
private val readProgressDtoRepository: ReadProgressDtoRepository,
private val eventPublisher: EventPublisher,
private val contentDetector: ContentDetector,
private val thumbnailsSeriesRepository: ThumbnailSeriesRepository,
) {
@PageableAsQueryParam
@ -322,9 +330,9 @@ class SeriesController(
@ApiResponse(content = [Content(schema = Schema(type = "string", format = "binary"))])
@GetMapping(value = ["v1/series/{seriesId}/thumbnail"], produces = [MediaType.IMAGE_JPEG_VALUE])
fun getSeriesThumbnail(
fun getSeriesDefaultThumbnail(
@AuthenticationPrincipal principal: KomgaPrincipal,
@PathVariable(name = "seriesId") seriesId: String
@PathVariable(name = "seriesId") seriesId: String,
): ByteArray {
seriesRepository.getLibraryId(seriesId)?.let {
if (!principal.user.canAccessLibrary(it)) throw ResponseStatusException(HttpStatus.FORBIDDEN)
@ -334,6 +342,85 @@ class SeriesController(
?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
}
@ApiResponse(content = [Content(schema = Schema(type = "string", format = "binary"))])
@GetMapping(value = ["v1/series/{seriesId}/thumbnails/{thumbnailId}"], produces = [MediaType.IMAGE_JPEG_VALUE])
fun getSeriesThumbnailById(
@AuthenticationPrincipal principal: KomgaPrincipal,
@PathVariable(name = "seriesId") seriesId: String,
@PathVariable(name = "thumbnailId") thumbnailId: String
): ByteArray {
seriesRepository.getLibraryId(seriesId)?.let {
if (!principal.user.canAccessLibrary(it)) throw ResponseStatusException(HttpStatus.FORBIDDEN)
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
return seriesLifecycle.getThumbnailBytesByThumbnailId(thumbnailId)
?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
}
@GetMapping(value = ["v1/series/{seriesId}/thumbnails"], produces = [MediaType.APPLICATION_JSON_VALUE])
fun getSeriesThumbnails(
@AuthenticationPrincipal principal: KomgaPrincipal,
@PathVariable(name = "seriesId") seriesId: String
): Collection<SeriesThumbnailDto> {
seriesRepository.getLibraryId(seriesId)?.let {
if (!principal.user.canAccessLibrary(it)) throw ResponseStatusException(HttpStatus.FORBIDDEN)
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
return thumbnailsSeriesRepository.findAllBySeriesId(seriesId)
.map { it.toDto() }
}
@PostMapping(value = ["v1/series/{seriesId}/thumbnails"], consumes = [MediaType.MULTIPART_FORM_DATA_VALUE])
@PreAuthorize("hasRole('$ROLE_ADMIN')")
@ResponseStatus(HttpStatus.ACCEPTED)
fun postUserUploadedSeriesThumbnail(
@PathVariable(name = "seriesId") seriesId: String,
@RequestParam("file") file: MultipartFile
) {
val series = seriesRepository.findByIdOrNull(seriesId) ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
if (!contentDetector.isImage(file.inputStream.buffered().use { contentDetector.detectMediaType(it) }))
throw ResponseStatusException(HttpStatus.UNSUPPORTED_MEDIA_TYPE)
seriesLifecycle.addThumbnailForSeries(
ThumbnailSeries(
seriesId = series.id,
thumbnail = file.bytes,
type = ThumbnailSeries.Type.USER_UPLOADED
),
MarkSelectedPreference.YES
)
}
@PutMapping("v1/series/{seriesId}/thumbnails/{thumbnailId}/selected")
@PreAuthorize("hasRole('$ROLE_ADMIN')")
@ResponseStatus(HttpStatus.ACCEPTED)
fun postMarkSelectedSeriesThumbnail(
@PathVariable(name = "seriesId") seriesId: String,
@PathVariable(name = "thumbnailId") thumbnailId: String,
) {
seriesRepository.findByIdOrNull(seriesId) ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
thumbnailsSeriesRepository.findByIdOrNull(thumbnailId)?.let {
thumbnailsSeriesRepository.markSelected(it)
eventPublisher.publishEvent(DomainEvent.ThumbnailSeriesAdded(it))
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
}
@DeleteMapping("v1/series/{seriesId}/thumbnails/{thumbnailId}")
@PreAuthorize("hasRole('$ROLE_ADMIN')")
@ResponseStatus(HttpStatus.ACCEPTED)
fun deleteUserUploadedSeriesThumbnail(
@PathVariable(name = "seriesId") seriesId: String,
@PathVariable(name = "thumbnailId") thumbnailId: String,
) {
seriesRepository.findByIdOrNull(seriesId) ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
thumbnailsSeriesRepository.findByIdOrNull(thumbnailId)?.let {
try {
seriesLifecycle.deleteThumbnailForSeries(it)
} catch (e: IllegalArgumentException) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, e.message)
}
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
}
@PageableAsQueryParam
@AuthorsAsQueryParam
@GetMapping("v1/series/{seriesId}/books")

View file

@ -0,0 +1,18 @@
package org.gotson.komga.interfaces.rest.dto
import org.gotson.komga.domain.model.ThumbnailSeries
data class SeriesThumbnailDto(
val id: String,
val seriesId: String,
val type: String,
val selected: Boolean
)
fun ThumbnailSeries.toDto() =
SeriesThumbnailDto(
id = id,
seriesId = seriesId,
type = type.toString(),
selected = selected
)

View file

@ -6,6 +6,7 @@ import org.assertj.core.api.Assertions.assertThat
import org.assertj.core.api.Assertions.catchThrowable
import org.gotson.komga.domain.model.BookMetadata
import org.gotson.komga.domain.model.Media
import org.gotson.komga.domain.model.ThumbnailSeries
import org.gotson.komga.domain.model.makeBook
import org.gotson.komga.domain.model.makeLibrary
import org.gotson.komga.domain.model.makeSeries
@ -253,4 +254,13 @@ class SeriesLifecycleTest(
assertThat(bookRepository.count()).isEqualTo(0)
}
}
@Test
fun `given a sidecar thumbnail when deleting then IllegarlArgumentException is thrown`() {
val thumbnail = ThumbnailSeries(type = ThumbnailSeries.Type.SIDECAR)
val thrown = catchThrowable { seriesLifecycle.deleteThumbnailForSeries(thumbnail) }
assertThat(thrown).isInstanceOf(IllegalArgumentException::class.java)
}
}