feat(api): collections management

related to #30
This commit is contained in:
Gauthier Roebroeck 2020-06-19 17:26:31 +08:00
parent 1650aec75b
commit c2f940336a
19 changed files with 1268 additions and 3 deletions

View file

@ -0,0 +1,23 @@
create table collection
(
id bigint not null,
name varchar not null,
ordered boolean not null default false,
series_count int not null,
created_date timestamp not null default now(),
last_modified_date timestamp not null default now(),
primary key (id)
);
create table collection_series
(
collection_id bigint not null,
series_id bigint not null,
number integer not null
);
alter table collection_series
add constraint fk_collection_series_collection_collection_id foreign key (collection_id) references collection (id);
alter table collection_series
add constraint fk_collection_series_series_series_id foreign key (series_id) references series (id);

View file

@ -0,0 +1,20 @@
package org.gotson.komga.domain.model
import java.time.LocalDateTime
data class SeriesCollection(
val name: String,
val ordered: Boolean = false,
val seriesIds: List<Long> = emptyList(),
val id: Long = 0,
override val createdDate: LocalDateTime = LocalDateTime.now(),
override val lastModifiedDate: LocalDateTime = LocalDateTime.now(),
/**
* Indicates that the seriesIds have been filtered and is not exhaustive.
*/
val filtered: Boolean = false
) : Auditable()

View file

@ -0,0 +1,36 @@
package org.gotson.komga.domain.persistence
import org.gotson.komga.domain.model.SeriesCollection
interface SeriesCollectionRepository {
fun findByIdOrNull(collectionId: Long): SeriesCollection?
fun findAll(): Collection<SeriesCollection>
/**
* Find one SeriesCollection by collectionId,
* optionally with only seriesId filtered by the provided filterOnLibraryIds.
*/
fun findByIdOrNull(collectionId: Long, filterOnLibraryIds: Collection<Long>?): SeriesCollection?
/**
* Find all SeriesCollection with at least one Series belonging to the provided belongsToLibraryIds,
* optionally with only seriesId filtered by the provided filterOnLibraryIds.
*/
fun findAllByLibraries(belongsToLibraryIds: Collection<Long>, filterOnLibraryIds: Collection<Long>?): Collection<SeriesCollection>
/**
* Find all SeriesCollection that contains the provided containsSeriesId,
* optionally with only seriesId filtered by the provided filterOnLibraryIds.
*/
fun findAllBySeries(containsSeriesId: Long, filterOnLibraryIds: Collection<Long>?): Collection<SeriesCollection>
fun insert(collection: SeriesCollection): SeriesCollection
fun update(collection: SeriesCollection)
fun removeSeriesFromAll(seriesId: Long)
fun delete(collectionId: Long)
fun deleteAll()
fun existsByName(name: String): Boolean
}

View file

@ -0,0 +1,41 @@
package org.gotson.komga.domain.service
import mu.KotlinLogging
import org.gotson.komga.domain.model.DuplicateNameException
import org.gotson.komga.domain.model.SeriesCollection
import org.gotson.komga.domain.persistence.SeriesCollectionRepository
import org.springframework.stereotype.Service
private val logger = KotlinLogging.logger {}
@Service
class SeriesCollectionLifecycle(
private val collectionRepository: SeriesCollectionRepository
) {
@Throws(
DuplicateNameException::class
)
fun addCollection(collection: SeriesCollection): SeriesCollection {
logger.info { "Adding new collection: $collection" }
if (collectionRepository.existsByName(collection.name))
throw DuplicateNameException("Collection name already exists")
return collectionRepository.insert(collection)
}
fun updateCollection(toUpdate: SeriesCollection) {
val existing = collectionRepository.findByIdOrNull(toUpdate.id)
?: throw IllegalArgumentException("Cannot update collection that does not exist")
if (existing.name != toUpdate.name && collectionRepository.existsByName(toUpdate.name))
throw DuplicateNameException("Collection name already exists")
collectionRepository.update(toUpdate)
}
fun deleteCollection(collectionId: Long) {
collectionRepository.delete(collectionId)
}
}

View file

@ -11,6 +11,7 @@ import org.gotson.komga.domain.model.SeriesMetadata
import org.gotson.komga.domain.persistence.BookMetadataRepository
import org.gotson.komga.domain.persistence.BookRepository
import org.gotson.komga.domain.persistence.MediaRepository
import org.gotson.komga.domain.persistence.SeriesCollectionRepository
import org.gotson.komga.domain.persistence.SeriesMetadataRepository
import org.gotson.komga.domain.persistence.SeriesRepository
import org.springframework.stereotype.Service
@ -26,7 +27,8 @@ class SeriesLifecycle(
private val mediaRepository: MediaRepository,
private val bookMetadataRepository: BookMetadataRepository,
private val seriesRepository: SeriesRepository,
private val seriesMetadataRepository: SeriesMetadataRepository
private val seriesMetadataRepository: SeriesMetadataRepository,
private val collectionRepository: SeriesCollectionRepository
) {
fun sortBooks(series: Series) {
@ -90,6 +92,8 @@ class SeriesLifecycle(
bookLifecycle.delete(it.id)
}
collectionRepository.removeSeriesFromAll(seriesId)
seriesRepository.delete(seriesId)
}
}

View file

@ -0,0 +1,40 @@
package org.gotson.komga.infrastructure.image
import net.coobird.thumbnailator.Thumbnails
import org.springframework.stereotype.Service
import java.awt.image.BufferedImage
import java.io.ByteArrayOutputStream
import javax.imageio.ImageIO
@Service
class MosaicGenerator {
fun createMosaic(images: List<ByteArray>): ByteArray {
val thumbs = images.map { resize(it, 150) }
return ByteArrayOutputStream().use { baos ->
val mosaic = BufferedImage(212, 300, BufferedImage.TYPE_INT_RGB)
mosaic.createGraphics().apply {
listOf(
0 to 0,
106 to 0,
0 to 150,
106 to 150
).forEachIndexed { index, (x, y) ->
thumbs.getOrNull(index)?.let { drawImage(it, x, y, null) }
}
}
ImageIO.write(mosaic, "jpeg", baos)
baos.toByteArray()
}
}
private fun resize(imageBytes: ByteArray, size: Int) =
Thumbnails.of(imageBytes.inputStream())
.size(size, size)
.outputFormat("jpeg")
.asBufferedImage()
}

View file

@ -0,0 +1,188 @@
package org.gotson.komga.infrastructure.jooq
import org.gotson.komga.domain.model.SeriesCollection
import org.gotson.komga.domain.persistence.SeriesCollectionRepository
import org.gotson.komga.jooq.Sequences
import org.gotson.komga.jooq.Tables
import org.gotson.komga.jooq.tables.records.CollectionRecord
import org.jooq.DSLContext
import org.jooq.Record
import org.jooq.ResultQuery
import org.springframework.stereotype.Component
import java.time.LocalDateTime
@Component
class SeriesCollectionDao(
private val dsl: DSLContext
) : SeriesCollectionRepository {
private val c = Tables.COLLECTION
private val cs = Tables.COLLECTION_SERIES
private val s = Tables.SERIES
private val groupFields = arrayOf(*c.fields(), *cs.fields())
override fun findByIdOrNull(collectionId: Long): SeriesCollection? =
selectBase()
.where(c.ID.eq(collectionId))
.groupBy(*groupFields)
.orderBy(cs.NUMBER.asc())
.fetchAndMap()
.firstOrNull()
override fun findByIdOrNull(collectionId: Long, filterOnLibraryIds: Collection<Long>?): SeriesCollection? =
selectBase()
.where(c.ID.eq(collectionId))
.also { step ->
filterOnLibraryIds?.let { step.and(s.LIBRARY_ID.`in`(it)) }
}
.groupBy(*groupFields)
.orderBy(cs.NUMBER.asc())
.fetchAndMap()
.firstOrNull()
override fun findAll(): Collection<SeriesCollection> =
selectBase()
.groupBy(*groupFields)
.orderBy(cs.NUMBER.asc())
.fetchAndMap()
override fun findAllByLibraries(belongsToLibraryIds: Collection<Long>, filterOnLibraryIds: Collection<Long>?): Collection<SeriesCollection> {
val ids = dsl.select(c.ID)
.from(c)
.leftJoin(cs).on(c.ID.eq(cs.COLLECTION_ID))
.leftJoin(s).on(cs.SERIES_ID.eq(s.ID))
.where(s.LIBRARY_ID.`in`(belongsToLibraryIds))
.fetch(0, Long::class.java)
return selectBase()
.where(c.ID.`in`(ids))
.also { step ->
filterOnLibraryIds?.let { step.and(s.LIBRARY_ID.`in`(it)) }
}
.groupBy(*groupFields)
.orderBy(cs.NUMBER.asc())
.fetchAndMap()
}
override fun findAllBySeries(containsSeriesId: Long, filterOnLibraryIds: Collection<Long>?): Collection<SeriesCollection> {
val ids = dsl.select(c.ID)
.from(c)
.leftJoin(cs).on(c.ID.eq(cs.COLLECTION_ID))
.where(cs.SERIES_ID.eq(containsSeriesId))
.fetch(0, Long::class.java)
return selectBase()
.where(c.ID.`in`(ids))
.also { step ->
filterOnLibraryIds?.let { step.and(s.LIBRARY_ID.`in`(it)) }
}
.groupBy(*groupFields)
.orderBy(cs.NUMBER.asc())
.fetchAndMap()
}
private fun selectBase() =
dsl.select(*groupFields)
.from(c)
.leftJoin(cs).on(c.ID.eq(cs.COLLECTION_ID))
.leftJoin(s).on(cs.SERIES_ID.eq(s.ID))
private fun ResultQuery<Record>.fetchAndMap() =
fetchGroups({ it.into(c) }, { it.into(cs) })
.map { (cr, csr) ->
val seriesIds = csr.map { it.seriesId }
cr.toDomain(seriesIds)
}
override fun insert(collection: SeriesCollection): SeriesCollection {
val id = dsl.nextval(Sequences.HIBERNATE_SEQUENCE)
val insert = collection.copy(id = id)
dsl.insertInto(c)
.set(c.ID, insert.id)
.set(c.NAME, insert.name)
.set(c.ORDERED, insert.ordered)
.set(c.SERIES_COUNT, collection.seriesIds.size)
.execute()
insertSeries(insert)
return findByIdOrNull(id)!!
}
private fun insertSeries(collection: SeriesCollection) {
collection.seriesIds.forEachIndexed { index, id ->
dsl.insertInto(cs)
.set(cs.COLLECTION_ID, collection.id)
.set(cs.SERIES_ID, id)
.set(cs.NUMBER, index)
.execute()
}
}
override fun update(collection: SeriesCollection) {
dsl.transaction { config ->
with(config.dsl())
{
update(c)
.set(c.NAME, collection.name)
.set(c.ORDERED, collection.ordered)
.set(c.SERIES_COUNT, collection.seriesIds.size)
.set(c.LAST_MODIFIED_DATE, LocalDateTime.now())
.where(c.ID.eq(collection.id))
.execute()
deleteFrom(cs).where(cs.COLLECTION_ID.eq(collection.id)).execute()
insertSeries(collection)
}
}
}
override fun removeSeriesFromAll(seriesId: Long) {
dsl.deleteFrom(cs)
.where(cs.SERIES_ID.eq(seriesId))
.execute()
}
override fun delete(collectionId: Long) {
dsl.transaction { config ->
with(config.dsl())
{
deleteFrom(cs).where(cs.COLLECTION_ID.eq(collectionId)).execute()
deleteFrom(c).where(c.ID.eq(collectionId)).execute()
}
}
}
override fun deleteAll() {
dsl.transaction { config ->
with(config.dsl())
{
deleteFrom(cs).execute()
deleteFrom(c).execute()
}
}
}
override fun existsByName(name: String): Boolean =
dsl.fetchExists(
dsl.selectFrom(c)
.where(c.NAME.equalIgnoreCase(name))
)
private fun CollectionRecord.toDomain(seriesIds: List<Long>) =
SeriesCollection(
name = name,
ordered = ordered,
seriesIds = seriesIds,
id = id,
createdDate = createdDate,
lastModifiedDate = lastModifiedDate,
filtered = seriesCount != seriesIds.size
)
}

View file

@ -82,6 +82,12 @@ class SeriesDtoDao(
.fetchAndMap()
.firstOrNull()
override fun findByIds(seriesIds: Collection<Long>, userId: Long): List<SeriesDto> =
selectBase(userId)
.where(s.ID.`in`(seriesIds))
.groupBy(*groupFields)
.fetchAndMap()
private fun findAll(conditions: Condition, having: Condition, userId: Long, pageable: Pageable): Page<SeriesDto> {
val count = dsl.select(s.ID)

View file

@ -0,0 +1,21 @@
package org.gotson.komga.infrastructure.validation
import org.hibernate.validator.constraints.CompositionType
import org.hibernate.validator.constraints.ConstraintComposition
import javax.validation.Constraint
import javax.validation.constraints.NotEmpty
import javax.validation.constraints.Null
import kotlin.reflect.KClass
@ConstraintComposition(CompositionType.OR)
@Constraint(validatedBy = [])
@Null
@NotEmpty
@Target(AnnotationTarget.VALUE_PARAMETER, AnnotationTarget.FIELD, AnnotationTarget.PROPERTY_GETTER)
@Retention(AnnotationRetention.RUNTIME)
annotation class NullOrNotEmpty(
val message: String = "Must be null or not empty",
val groups: Array<KClass<out Any>> = [],
val payload: Array<KClass<out Any>> = []
)

View file

@ -0,0 +1,23 @@
package org.gotson.komga.infrastructure.validation
import javax.validation.Constraint
import javax.validation.ConstraintValidator
import javax.validation.ConstraintValidatorContext
import kotlin.reflect.KClass
@Constraint(validatedBy = [UniqueValidator::class])
@Target(AnnotationTarget.VALUE_PARAMETER, AnnotationTarget.FIELD, AnnotationTarget.PROPERTY_GETTER)
@Retention(AnnotationRetention.RUNTIME)
annotation class Unique(
val message: String = "Must contain unique values",
val groups: Array<KClass<out Any>> = [],
val payload: Array<KClass<out Any>> = []
)
class UniqueValidator : ConstraintValidator<Unique, List<*>> {
override fun isValid(value: List<*>?, context: ConstraintValidatorContext?): Boolean {
if (value == null) return true
return value.distinct().size == value.size
}
}

View file

@ -0,0 +1,164 @@
package org.gotson.komga.interfaces.rest
import io.swagger.v3.oas.annotations.Parameter
import io.swagger.v3.oas.annotations.media.Content
import io.swagger.v3.oas.annotations.media.Schema
import io.swagger.v3.oas.annotations.responses.ApiResponse
import mu.KotlinLogging
import org.gotson.komga.domain.model.DuplicateNameException
import org.gotson.komga.domain.model.ROLE_ADMIN
import org.gotson.komga.domain.model.SeriesCollection
import org.gotson.komga.domain.persistence.SeriesCollectionRepository
import org.gotson.komga.domain.service.SeriesCollectionLifecycle
import org.gotson.komga.infrastructure.image.MosaicGenerator
import org.gotson.komga.infrastructure.security.KomgaPrincipal
import org.gotson.komga.infrastructure.swagger.PageableAsQueryParam
import org.gotson.komga.interfaces.rest.dto.CollectionCreationDto
import org.gotson.komga.interfaces.rest.dto.CollectionDto
import org.gotson.komga.interfaces.rest.dto.CollectionUpdateDto
import org.gotson.komga.interfaces.rest.dto.SeriesDto
import org.gotson.komga.interfaces.rest.dto.restrictUrl
import org.gotson.komga.interfaces.rest.dto.toDto
import org.gotson.komga.interfaces.rest.persistence.SeriesDtoRepository
import org.springframework.data.domain.Pageable
import org.springframework.http.CacheControl
import org.springframework.http.HttpStatus
import org.springframework.http.MediaType
import org.springframework.http.ResponseEntity
import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.web.bind.annotation.DeleteMapping
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.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.server.ResponseStatusException
import java.util.concurrent.TimeUnit
import javax.validation.Valid
private val logger = KotlinLogging.logger {}
@RestController
@RequestMapping("api/v1/collections", produces = [MediaType.APPLICATION_JSON_VALUE])
class SeriesCollectionController(
private val collectionRepository: SeriesCollectionRepository,
private val collectionLifecycle: SeriesCollectionLifecycle,
private val seriesDtoRepository: SeriesDtoRepository,
private val seriesController: SeriesController,
private val mosaicGenerator: MosaicGenerator
) {
@GetMapping
fun getAll(
@AuthenticationPrincipal principal: KomgaPrincipal,
@RequestParam(name = "library_id", required = false) libraryIds: List<Long>?
): List<CollectionDto> =
when {
principal.user.sharedAllLibraries && libraryIds == null -> collectionRepository.findAll()
principal.user.sharedAllLibraries && libraryIds != null -> collectionRepository.findAllByLibraries(libraryIds, null)
!principal.user.sharedAllLibraries && libraryIds != null -> collectionRepository.findAllByLibraries(libraryIds, principal.user.sharedLibrariesIds)
else -> collectionRepository.findAllByLibraries(principal.user.sharedLibrariesIds, principal.user.sharedLibrariesIds)
}.sortedBy { it.name.toLowerCase() }.map { it.toDto() }
@GetMapping("{id}")
fun getOne(
@AuthenticationPrincipal principal: KomgaPrincipal,
@PathVariable id: Long
): CollectionDto =
collectionRepository.findByIdOrNull(id, principal.user.getAuthorizedLibraryIds(null))
?.toDto()
?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
@ApiResponse(content = [Content(schema = Schema(type = "string", format = "binary"))])
@GetMapping(value = ["{id}/thumbnail"], produces = [MediaType.IMAGE_JPEG_VALUE])
fun getCollectionThumbnail(
@AuthenticationPrincipal principal: KomgaPrincipal,
@PathVariable id: Long
): ResponseEntity<ByteArray> {
collectionRepository.findByIdOrNull(id, principal.user.getAuthorizedLibraryIds(null))?.let {
val ids = with(mutableListOf<Long>()) {
while (size < 4) {
this += it.seriesIds.take(4)
}
this.take(4)
}
val images = ids.mapNotNull { seriesController.getSeriesThumbnail(principal, it).body }
val thumbnail = mosaicGenerator.createMosaic(images)
return ResponseEntity.ok()
.cacheControl(CacheControl.maxAge(1, TimeUnit.HOURS).cachePrivate())
.body(thumbnail)
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
}
@PostMapping
@PreAuthorize("hasRole('$ROLE_ADMIN')")
fun addOne(
@Valid @RequestBody collection: CollectionCreationDto
): CollectionDto =
try {
collectionLifecycle.addCollection(SeriesCollection(
name = collection.name,
ordered = collection.ordered,
seriesIds = collection.seriesIds
)).toDto()
} catch (e: DuplicateNameException) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, e.message)
}
@PatchMapping("{id}")
@PreAuthorize("hasRole('$ROLE_ADMIN')")
@ResponseStatus(HttpStatus.NO_CONTENT)
fun updateOne(
@PathVariable id: Long,
@Valid @RequestBody collection: CollectionUpdateDto
) {
collectionRepository.findByIdOrNull(id)?.let { existing ->
val updated = existing.copy(
name = collection.name ?: existing.name,
ordered = collection.ordered ?: existing.ordered,
seriesIds = collection.seriesIds ?: existing.seriesIds
)
try {
collectionLifecycle.updateCollection(updated)
} catch (e: DuplicateNameException) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, e.message)
}
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
}
@DeleteMapping("{id}")
@PreAuthorize("hasRole('$ROLE_ADMIN')")
@ResponseStatus(HttpStatus.NO_CONTENT)
fun deleteOne(
@PathVariable id: Long
) {
collectionRepository.findByIdOrNull(id)?.let {
collectionLifecycle.deleteCollection(it.id)
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
}
@PageableAsQueryParam
@GetMapping("{id}/series")
fun getSeriesForCollection(
@PathVariable id: Long,
@AuthenticationPrincipal principal: KomgaPrincipal,
@Parameter(hidden = true) page: Pageable
): List<SeriesDto> =
collectionRepository.findByIdOrNull(id, principal.user.getAuthorizedLibraryIds(null))?.let { collection ->
if (collection.ordered) {
// use map to ensure the order is conserved
collection.seriesIds.mapNotNull { seriesDtoRepository.findByIdOrNull(it, principal.user.id) }
} else {
seriesDtoRepository.findByIds(collection.seriesIds, principal.user.id)
.sortedBy { it.metadata.titleSort }
}.map { it.restrictUrl(!principal.user.roleAdmin) }
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
}

View file

@ -14,6 +14,7 @@ import org.gotson.komga.domain.model.ReadStatus
import org.gotson.komga.domain.model.SeriesMetadata
import org.gotson.komga.domain.model.SeriesSearchWithReadProgress
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.service.BookLifecycle
@ -21,9 +22,11 @@ import org.gotson.komga.infrastructure.security.KomgaPrincipal
import org.gotson.komga.infrastructure.swagger.PageableAsQueryParam
import org.gotson.komga.infrastructure.swagger.PageableWithoutSortAsQueryParam
import org.gotson.komga.interfaces.rest.dto.BookDto
import org.gotson.komga.interfaces.rest.dto.CollectionDto
import org.gotson.komga.interfaces.rest.dto.SeriesDto
import org.gotson.komga.interfaces.rest.dto.SeriesMetadataUpdateDto
import org.gotson.komga.interfaces.rest.dto.restrictUrl
import org.gotson.komga.interfaces.rest.dto.toDto
import org.gotson.komga.interfaces.rest.persistence.BookDtoRepository
import org.gotson.komga.interfaces.rest.persistence.SeriesDtoRepository
import org.springframework.data.domain.Page
@ -60,7 +63,8 @@ class SeriesController(
private val bookLifecycle: BookLifecycle,
private val bookRepository: BookRepository,
private val bookDtoRepository: BookDtoRepository,
private val bookController: BookController
private val bookController: BookController,
private val collectionRepository: SeriesCollectionRepository
) {
@PageableAsQueryParam
@ -207,6 +211,19 @@ class SeriesController(
).map { it.restrictUrl(!principal.user.roleAdmin) }
}
@GetMapping("{seriesId}/collections")
fun getAllCollectionsBySeries(
@AuthenticationPrincipal principal: KomgaPrincipal,
@PathVariable(name = "seriesId") seriesId: Long
): List<CollectionDto> {
seriesRepository.getLibraryId(seriesId)?.let {
if (!principal.user.canAccessLibrary(it)) throw ResponseStatusException(HttpStatus.FORBIDDEN)
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
return collectionRepository.findAllBySeries(seriesId, principal.user.getAuthorizedLibraryIds(null))
.map { it.toDto() }
}
@PostMapping("{seriesId}/analyze")
@PreAuthorize("hasRole('$ROLE_ADMIN')")
@ResponseStatus(HttpStatus.ACCEPTED)

View file

@ -0,0 +1,11 @@
package org.gotson.komga.interfaces.rest.dto
import org.gotson.komga.infrastructure.validation.Unique
import javax.validation.constraints.NotBlank
import javax.validation.constraints.NotEmpty
data class CollectionCreationDto(
@get:NotBlank val name: String,
val ordered: Boolean,
@get:NotEmpty @get:Unique val seriesIds: List<Long>
)

View file

@ -0,0 +1,31 @@
package org.gotson.komga.interfaces.rest.dto
import com.fasterxml.jackson.annotation.JsonFormat
import org.gotson.komga.domain.model.SeriesCollection
import java.time.LocalDateTime
data class CollectionDto(
val id: Long,
val name: String,
val ordered: Boolean,
val seriesIds: List<Long>,
@JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss")
val createdDate: LocalDateTime,
@JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss")
val lastModifiedDate: LocalDateTime,
val filtered: Boolean
)
fun SeriesCollection.toDto() =
CollectionDto(
id = id,
name = name,
ordered = ordered,
seriesIds = seriesIds,
createdDate = createdDate.toUTC(),
lastModifiedDate = lastModifiedDate.toUTC(),
filtered = filtered
)

View file

@ -0,0 +1,11 @@
package org.gotson.komga.interfaces.rest.dto
import org.gotson.komga.infrastructure.validation.NullOrNotBlank
import org.gotson.komga.infrastructure.validation.NullOrNotEmpty
import org.gotson.komga.infrastructure.validation.Unique
data class CollectionUpdateDto(
@get:NullOrNotBlank val name: String?,
val ordered: Boolean?,
@get:NullOrNotEmpty @get:Unique val seriesIds: List<Long>?
)

View file

@ -9,4 +9,5 @@ interface SeriesDtoRepository {
fun findAll(search: SeriesSearchWithReadProgress, userId: Long, pageable: Pageable): Page<SeriesDto>
fun findRecentlyUpdated(search: SeriesSearchWithReadProgress, userId: Long, pageable: Pageable): Page<SeriesDto>
fun findByIdOrNull(seriesId: Long, userId: Long): SeriesDto?
fun findByIds(seriesIds: Collection<Long>, userId: Long): List<SeriesDto>
}

View file

@ -0,0 +1,219 @@
package org.gotson.komga.infrastructure.jooq
import org.assertj.core.api.Assertions.assertThat
import org.gotson.komga.domain.model.SeriesCollection
import org.gotson.komga.domain.model.makeLibrary
import org.gotson.komga.domain.model.makeSeries
import org.gotson.komga.domain.persistence.LibraryRepository
import org.gotson.komga.domain.persistence.SeriesRepository
import org.junit.jupiter.api.AfterAll
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeAll
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.test.context.junit.jupiter.SpringExtension
import java.time.LocalDateTime
@ExtendWith(SpringExtension::class)
@SpringBootTest
@AutoConfigureTestDatabase
class SeriesCollectionDaoTest(
@Autowired private val collectionDao: SeriesCollectionDao,
@Autowired private val seriesRepository: SeriesRepository,
@Autowired private val libraryRepository: LibraryRepository
) {
private var library = makeLibrary()
private var library2 = makeLibrary("library2")
@BeforeAll
fun setup() {
library = libraryRepository.insert(library)
library2 = libraryRepository.insert(library2)
}
@AfterEach
fun deleteSeries() {
collectionDao.deleteAll()
seriesRepository.deleteAll()
}
@AfterAll
fun tearDown() {
libraryRepository.deleteAll()
}
@Test
fun `given collection with series when inserting then it is persisted`() {
// given
val series = (1..10)
.map { makeSeries("Series $it", library.id) }
.map { seriesRepository.insert(it) }
val collection = SeriesCollection(
name = "MyCollection",
seriesIds = series.map { it.id }
)
// when
val now = LocalDateTime.now()
val created = collectionDao.insert(collection)
// then
assertThat(created.name).isEqualTo(collection.name)
assertThat(created.ordered).isEqualTo(collection.ordered)
assertThat(created.createdDate)
.isEqualTo(created.lastModifiedDate)
.isAfterOrEqualTo(now)
assertThat(created.seriesIds).containsExactlyElementsOf(series.map { it.id })
}
@Test
fun `given collection with updated series when updating then it is persisted`() {
// given
val series = (1..10)
.map { makeSeries("Series $it", library.id) }
.map { seriesRepository.insert(it) }
val collection = SeriesCollection(
name = "MyCollection",
seriesIds = series.map { it.id }
)
val created = collectionDao.insert(collection)
// when
val updatedCollection = created.copy(
name = "UpdatedCollection",
ordered = true,
seriesIds = created.seriesIds.take(5)
)
val now = LocalDateTime.now()
collectionDao.update(updatedCollection)
val updated = collectionDao.findByIdOrNull(updatedCollection.id)!!
// then
assertThat(updated.name).isEqualTo(updatedCollection.name)
assertThat(updated.ordered).isEqualTo(updatedCollection.ordered)
assertThat(updated.createdDate).isNotEqualTo(updated.lastModifiedDate)
assertThat(updated.lastModifiedDate).isAfterOrEqualTo(now)
assertThat(updated.seriesIds)
.hasSize(5)
.containsExactlyElementsOf(series.map { it.id }.take(5))
}
@Test
fun `given collections with series when removing one series from all then it is removed from all`() {
// given
val series = (1..10)
.map { makeSeries("Series $it", library.id) }
.map { seriesRepository.insert(it) }
val collection1 = collectionDao.insert(
SeriesCollection(
name = "MyCollection",
seriesIds = series.map { it.id }
)
)
val collection2 = collectionDao.insert(
SeriesCollection(
name = "MyCollection2",
seriesIds = series.map { it.id }.take(5)
)
)
// when
collectionDao.removeSeriesFromAll(series.first().id)
// then
val col1 = collectionDao.findByIdOrNull(collection1.id)!!
assertThat(col1.seriesIds)
.hasSize(9)
.doesNotContain(series.first().id)
val col2 = collectionDao.findByIdOrNull(collection2.id)!!
assertThat(col2.seriesIds)
.hasSize(4)
.doesNotContain(series.first().id)
}
@Test
fun `given collections spanning different libraries when finding by library then only matching collections are returned`() {
// given
val seriesLibrary1 = seriesRepository.insert(makeSeries("Series1", library.id))
val seriesLibrary2 = seriesRepository.insert(makeSeries("Series2", library2.id))
collectionDao.insert(SeriesCollection(
name = "collectionLibrary1",
seriesIds = listOf(seriesLibrary1.id)
))
collectionDao.insert(SeriesCollection(
name = "collectionLibrary2",
seriesIds = listOf(seriesLibrary2.id)
))
collectionDao.insert(SeriesCollection(
name = "collectionLibraryBoth",
seriesIds = listOf(seriesLibrary1.id, seriesLibrary2.id)
))
// when
val foundLibrary1Filtered = collectionDao.findAllByLibraries(listOf(library.id), listOf(library.id))
val foundLibrary1Unfiltered = collectionDao.findAllByLibraries(listOf(library.id), null)
val foundLibrary2Filtered = collectionDao.findAllByLibraries(listOf(library2.id), listOf(library2.id))
val foundLibrary2Unfiltered = collectionDao.findAllByLibraries(listOf(library2.id), null)
val foundBothUnfiltered = collectionDao.findAllByLibraries(listOf(library.id, library2.id), null)
// then
assertThat(foundLibrary1Filtered).hasSize(2)
assertThat(foundLibrary1Filtered.map { it.name }).containsExactly("collectionLibrary1", "collectionLibraryBoth")
with(foundLibrary1Filtered.find { it.name == "collectionLibraryBoth" }!!) {
assertThat(seriesIds)
.hasSize(1)
.containsExactly(seriesLibrary1.id)
assertThat(filtered).isTrue()
}
assertThat(foundLibrary1Unfiltered).hasSize(2)
assertThat(foundLibrary1Unfiltered.map { it.name }).containsExactly("collectionLibrary1", "collectionLibraryBoth")
with(foundLibrary1Unfiltered.find { it.name == "collectionLibraryBoth" }!!) {
assertThat(seriesIds)
.hasSize(2)
.containsExactly(seriesLibrary1.id, seriesLibrary2.id)
assertThat(filtered).isFalse()
}
assertThat(foundLibrary2Filtered).hasSize(2)
assertThat(foundLibrary2Filtered.map { it.name }).containsExactly("collectionLibrary2", "collectionLibraryBoth")
with(foundLibrary2Filtered.find { it.name == "collectionLibraryBoth" }!!) {
assertThat(seriesIds)
.hasSize(1)
.containsExactly(seriesLibrary2.id)
assertThat(filtered).isTrue()
}
assertThat(foundLibrary2Unfiltered).hasSize(2)
assertThat(foundLibrary2Unfiltered.map { it.name }).containsExactly("collectionLibrary2", "collectionLibraryBoth")
with(foundLibrary2Unfiltered.find { it.name == "collectionLibraryBoth" }!!) {
assertThat(seriesIds)
.hasSize(2)
.containsExactly(seriesLibrary1.id, seriesLibrary2.id)
assertThat(filtered).isFalse()
}
assertThat(foundBothUnfiltered).hasSize(3)
assertThat(foundBothUnfiltered.map { it.name }).containsExactly("collectionLibrary1", "collectionLibrary2", "collectionLibraryBoth")
with(foundBothUnfiltered.find { it.name == "collectionLibraryBoth" }!!) {
assertThat(seriesIds)
.hasSize(2)
.containsExactly(seriesLibrary1.id, seriesLibrary2.id)
assertThat(filtered).isFalse()
}
}
}

View file

@ -10,30 +10,45 @@ import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.http.MediaType
import org.springframework.jdbc.core.JdbcTemplate
import org.springframework.security.test.context.support.WithAnonymousUser
import org.springframework.security.test.context.support.WithMockUser
import org.springframework.test.context.junit.jupiter.SpringExtension
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.get
import org.springframework.test.web.servlet.post
import javax.sql.DataSource
@ExtendWith(SpringExtension::class)
@SpringBootTest
@AutoConfigureMockMvc(printOnlyOnFailure = false)
@AutoConfigureTestDatabase
class LibraryControllerTest(
@Autowired private val mockMvc: MockMvc,
@Autowired private val libraryRepository: LibraryRepository
) {
lateinit var jdbcTemplate: JdbcTemplate
@Autowired
fun setDataSource(dataSource: DataSource) {
jdbcTemplate = JdbcTemplate(dataSource)
}
private val route = "/api/v1/libraries"
private var library = makeLibrary(url = "file:/library1")
@BeforeAll
fun `setup library`() {
library = libraryRepository.insert(library)
jdbcTemplate.execute("ALTER SEQUENCE hibernate_sequence RESTART WITH 1")
library = libraryRepository.insert(library) // id = 1
}
@AfterAll

View file

@ -0,0 +1,394 @@
package org.gotson.komga.interfaces.rest
import org.gotson.komga.domain.model.ROLE_ADMIN
import org.gotson.komga.domain.model.Series
import org.gotson.komga.domain.model.SeriesCollection
import org.gotson.komga.domain.model.makeLibrary
import org.gotson.komga.domain.model.makeSeries
import org.gotson.komga.domain.persistence.LibraryRepository
import org.gotson.komga.domain.persistence.SeriesCollectionRepository
import org.gotson.komga.domain.service.LibraryLifecycle
import org.gotson.komga.domain.service.SeriesCollectionLifecycle
import org.gotson.komga.domain.service.SeriesLifecycle
import org.junit.jupiter.api.AfterAll
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeAll
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.http.MediaType
import org.springframework.jdbc.core.JdbcTemplate
import org.springframework.test.context.junit.jupiter.SpringExtension
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.delete
import org.springframework.test.web.servlet.get
import org.springframework.test.web.servlet.patch
import org.springframework.test.web.servlet.post
import javax.sql.DataSource
@ExtendWith(SpringExtension::class)
@SpringBootTest
@AutoConfigureMockMvc(printOnlyOnFailure = false)
@AutoConfigureTestDatabase
class SeriesCollectionControllerTest(
@Autowired private val mockMvc: MockMvc,
@Autowired private val collectionLifecycle: SeriesCollectionLifecycle,
@Autowired private val collectionRepository: SeriesCollectionRepository,
@Autowired private val libraryLifecycle: LibraryLifecycle,
@Autowired private val libraryRepository: LibraryRepository,
@Autowired private val seriesLifecycle: SeriesLifecycle
) {
lateinit var jdbcTemplate: JdbcTemplate
@Autowired
fun setDataSource(dataSource: DataSource) {
jdbcTemplate = JdbcTemplate(dataSource)
}
private var library1 = makeLibrary("Library1")
private var library2 = makeLibrary("Library2")
private lateinit var seriesLibrary1: List<Series>
private lateinit var seriesLibrary2: List<Series>
private lateinit var colLib1: SeriesCollection
private lateinit var colLib2: SeriesCollection
private lateinit var colLibBoth: SeriesCollection
@BeforeAll
fun setup() {
jdbcTemplate.execute("ALTER SEQUENCE hibernate_sequence RESTART WITH 1")
library1 = libraryRepository.insert(library1) // id = 1
library2 = libraryRepository.insert(library2) // id = 2
seriesLibrary1 = (1..5)
.map { makeSeries("Series_$it", library1.id) }
.map { seriesLifecycle.createSeries(it) }
seriesLibrary2 = (6..10)
.map { makeSeries("Series_$it", library2.id) }
.map { seriesLifecycle.createSeries(it) }
}
@AfterAll
fun teardown() {
libraryRepository.findAll().forEach {
libraryLifecycle.deleteLibrary(it)
}
}
@AfterEach
fun clear() {
collectionRepository.deleteAll()
}
private fun makeCollections() {
colLib1 = collectionLifecycle.addCollection(SeriesCollection(
name = "Lib1",
seriesIds = seriesLibrary1.map { it.id }
))
colLib2 = collectionLifecycle.addCollection(SeriesCollection(
name = "Lib2",
seriesIds = seriesLibrary2.map { it.id }
))
colLibBoth = collectionLifecycle.addCollection(SeriesCollection(
name = "Lib1+2",
seriesIds = (seriesLibrary1 + seriesLibrary2).map { it.id }
))
}
@Nested
inner class GetAndFilter {
@Test
@WithMockCustomUser
fun `given user with access to all libraries when getting collections then get all collections`() {
makeCollections()
mockMvc.get("/api/v1/collections")
.andExpect {
status { isOk }
jsonPath("$.length()") { value(3) }
jsonPath("$[?(@.name == 'Lib1')].filtered") { value(false) }
jsonPath("$[?(@.name == 'Lib2')].filtered") { value(false) }
jsonPath("$[?(@.name == 'Lib1+2')].filtered") { value(false) }
}
}
@Test
@WithMockCustomUser(sharedAllLibraries = false, sharedLibraries = [1])
fun `given user with access to a single library when getting collections then only get collections from this library`() {
makeCollections()
mockMvc.get("/api/v1/collections")
.andExpect {
status { isOk }
jsonPath("$.length()") { value(2) }
jsonPath("$[?(@.name == 'Lib1')].filtered") { value(false) }
jsonPath("$[?(@.name == 'Lib1+2')].filtered") { value(true) }
}
}
@Test
@WithMockCustomUser
fun `given user with access to all libraries when getting single collection then it is not filtered`() {
makeCollections()
mockMvc.get("/api/v1/collections/${colLibBoth.id}")
.andExpect {
status { isOk }
jsonPath("$.seriesIds.length()") { value(10) }
jsonPath("$.filtered") { value(false) }
}
}
@Test
@WithMockCustomUser(sharedAllLibraries = false, sharedLibraries = [1])
fun `given user with access to a single library when getting single collection with items from 2 libraries then it is filtered`() {
makeCollections()
mockMvc.get("/api/v1/collections/${colLibBoth.id}")
.andExpect {
status { isOk }
jsonPath("$.seriesIds.length()") { value(5) }
jsonPath("$.filtered") { value(true) }
}
}
@Test
@WithMockCustomUser(sharedAllLibraries = false, sharedLibraries = [1])
fun `given user with access to a single library when getting single collection from another library then return not found`() {
makeCollections()
mockMvc.get("/api/v1/collections/${colLib2.id}")
.andExpect {
status { isNotFound }
}
}
}
@Nested
inner class Creation {
@Test
@WithMockCustomUser
fun `given non-admin user when creating collection then return forbidden`() {
val jsonString = """
{"name":"collection","ordered":false,"seriesIds":[3]}
""".trimIndent()
mockMvc.post("/api/v1/collections") {
contentType = MediaType.APPLICATION_JSON
content = jsonString
}.andExpect {
status { isForbidden }
}
}
@Test
@WithMockCustomUser(roles = [ROLE_ADMIN])
fun `given admin user when creating collection then return ok`() {
val jsonString = """
{"name":"collection","ordered":false,"seriesIds":[${seriesLibrary1.first().id}]}
""".trimIndent()
mockMvc.post("/api/v1/collections") {
contentType = MediaType.APPLICATION_JSON
content = jsonString
}.andExpect {
status { isOk }
jsonPath("$.seriesIds.length()") { value(1) }
jsonPath("$.name") { value("collection") }
jsonPath("$.ordered") { value(false) }
}
}
@Test
@WithMockCustomUser(roles = [ROLE_ADMIN])
fun `given existing collections when creating collection with existing name then return bad request`() {
makeCollections()
val jsonString = """
{"name":"Lib1","ordered":false,"seriesIds":[${seriesLibrary1.first().id}]}
""".trimIndent()
mockMvc.post("/api/v1/collections") {
contentType = MediaType.APPLICATION_JSON
content = jsonString
}.andExpect {
status { isBadRequest }
}
}
@Test
@WithMockCustomUser(roles = [ROLE_ADMIN])
fun `given collection with duplicate seriesIds when creating collection then return bad request`() {
makeCollections()
val jsonString = """
{"name":"Lib1","ordered":false,"seriesIds":[${seriesLibrary1.first().id},${seriesLibrary1.first().id}]}
""".trimIndent()
mockMvc.post("/api/v1/collections") {
contentType = MediaType.APPLICATION_JSON
content = jsonString
}.andExpect {
status { isBadRequest }
}
}
}
@Nested
inner class Update {
@Test
@WithMockCustomUser
fun `given non-admin user when updating collection then return forbidden`() {
val jsonString = """
{"name":"collection","ordered":false,"seriesIds":[3]}
""".trimIndent()
mockMvc.patch("/api/v1/collections/5") {
contentType = MediaType.APPLICATION_JSON
content = jsonString
}.andExpect {
status { isForbidden }
}
}
@Test
@WithMockCustomUser(roles = [ROLE_ADMIN])
fun `given admin user when updating collection then return no content`() {
makeCollections()
val jsonString = """
{"name":"updated","ordered":true,"seriesIds":[${seriesLibrary1.first().id}]}
""".trimIndent()
mockMvc.patch("/api/v1/collections/${colLib1.id}") {
contentType = MediaType.APPLICATION_JSON
content = jsonString
}.andExpect {
status { isNoContent }
}
mockMvc.get("/api/v1/collections/${colLib1.id}")
.andExpect {
status { isOk }
jsonPath("$.name") { value("updated") }
jsonPath("$.ordered") { value(true) }
jsonPath("$.seriesIds.length()") { value(1) }
jsonPath("$.filtered") { value(false) }
}
}
@Test
@WithMockCustomUser(roles = [ROLE_ADMIN])
fun `given existing collections when updating collection with existing name then return bad request`() {
makeCollections()
val jsonString = """{"name":"Lib2"}"""
mockMvc.patch("/api/v1/collections/${colLib1.id}") {
contentType = MediaType.APPLICATION_JSON
content = jsonString
}.andExpect {
status { isBadRequest }
}
}
@Test
@WithMockCustomUser(roles = [ROLE_ADMIN])
fun `given existing collection when updating collection with duplicate seriesIds then return bad request`() {
makeCollections()
val jsonString = """{"seriesIds":[${seriesLibrary1.first().id},${seriesLibrary1.first().id}]}"""
mockMvc.patch("/api/v1/collections/${colLib1.id}") {
contentType = MediaType.APPLICATION_JSON
content = jsonString
}.andExpect {
status { isBadRequest }
}
}
@Test
@WithMockCustomUser(roles = [ROLE_ADMIN])
fun `given admin user when updating collection then only updated fields are modified`() {
makeCollections()
mockMvc.patch("/api/v1/collections/${colLib1.id}") {
contentType = MediaType.APPLICATION_JSON
content = """{"ordered":true}"""
}
mockMvc.get("/api/v1/collections/${colLib1.id}")
.andExpect {
status { isOk }
jsonPath("$.name") { value("Lib1") }
jsonPath("$.ordered") { value(true) }
jsonPath("$.seriesIds.length()") { value(5) }
}
mockMvc.patch("/api/v1/collections/${colLib2.id}") {
contentType = MediaType.APPLICATION_JSON
content = """{"name":"newName"}"""
}
mockMvc.get("/api/v1/collections/${colLib2.id}")
.andExpect {
status { isOk }
jsonPath("$.name") { value("newName") }
jsonPath("$.ordered") { value(false) }
jsonPath("$.seriesIds.length()") { value(5) }
}
mockMvc.patch("/api/v1/collections/${colLibBoth.id}") {
contentType = MediaType.APPLICATION_JSON
content = """{"seriesIds":[${seriesLibrary1.first().id}]}"""
}
mockMvc.get("/api/v1/collections/${colLibBoth.id}")
.andExpect {
status { isOk }
jsonPath("$.name") { value("Lib1+2") }
jsonPath("$.ordered") { value(false) }
jsonPath("$.seriesIds.length()") { value(1) }
}
}
}
@Nested
inner class Delete {
@Test
@WithMockCustomUser
fun `given non-admin user when deleting collection then return forbidden`() {
mockMvc.delete("/api/v1/collections/5")
.andExpect {
status { isForbidden }
}
}
@Test
@WithMockCustomUser(roles = [ROLE_ADMIN])
fun `given admin user when deleting collection then return no content`() {
makeCollections()
mockMvc.delete("/api/v1/collections/${colLib1.id}")
.andExpect {
status { isNoContent }
}
mockMvc.get("/api/v1/collections/${colLib1.id}")
.andExpect {
status { isNotFound }
}
}
}
}