feat(api): filter series and books by authors

closes #339
This commit is contained in:
Gauthier Roebroeck 2021-02-26 16:23:50 +08:00
parent f549067a8a
commit bd64381a8e
10 changed files with 136 additions and 12 deletions

View file

@ -13,5 +13,6 @@ class BookSearchWithReadProgress(
searchTerm: String? = null,
mediaStatus: Collection<Media.Status>? = null,
val tags: Collection<String>? = null,
val readStatus: Collection<ReadStatus>? = null
val readStatus: Collection<ReadStatus>? = null,
val authors: Collection<Author>? = null,
) : BookSearch(libraryIds, seriesIds, searchTerm, mediaStatus)

View file

@ -19,7 +19,8 @@ class SeriesSearchWithReadProgress(
val tags: Collection<String>? = null,
val ageRatings: Collection<Int?>? = null,
val releaseYears: Collection<String>? = null,
val readStatus: Collection<ReadStatus>? = null
val readStatus: Collection<ReadStatus>? = null,
val authors: Collection<Author>? = null,
) : SeriesSearch(
libraryIds = libraryIds,
collectionIds = collectionIds,

View file

@ -92,6 +92,7 @@ class BookDtoDao(
.apply { filterOnLibraryIds?.let { and(b.LIBRARY_ID.`in`(it)) } }
.apply { if (joinConditions.tag) leftJoin(bt).on(b.ID.eq(bt.BOOK_ID)) }
.apply { if (joinConditions.selectReadListNumber) leftJoin(rlb).on(b.ID.eq(rlb.BOOK_ID)) }
.apply { if(joinConditions.author) leftJoin(a).on(b.ID.eq(a.BOOK_ID)) }
.where(conditions)
.groupBy(b.ID)
.fetch()
@ -236,6 +237,7 @@ class BookDtoDao(
.leftJoin(r).on(b.ID.eq(r.BOOK_ID)).and(readProgressCondition(userId))
.apply { if (joinConditions.tag) leftJoin(bt).on(b.ID.eq(bt.BOOK_ID)) }
.apply { if (joinConditions.selectReadListNumber) leftJoin(rlb).on(b.ID.eq(rlb.BOOK_ID)) }
.apply { if(joinConditions.author) leftJoin(a).on(b.ID.eq(a.BOOK_ID)) }
private fun ResultQuery<Record>.fetchAndMap() =
fetch()
@ -280,17 +282,27 @@ class BookDtoDao(
c = c.and(cr)
}
if (!authors.isNullOrEmpty()) {
var ca: Condition = DSL.falseCondition()
authors.forEach {
ca = ca.or(a.NAME.equalIgnoreCase(it.name).and(a.ROLE.equalIgnoreCase(it.role)))
}
c = c.and(ca)
}
return c
}
private fun BookSearchWithReadProgress.toJoinConditions() =
JoinConditions(
tag = !tags.isNullOrEmpty()
tag = !tags.isNullOrEmpty(),
author = !authors.isNullOrEmpty(),
)
private data class JoinConditions(
val selectReadListNumber: Boolean = false,
val tag: Boolean = false
val tag: Boolean = false,
val author: Boolean = false,
)
private fun BookRecord.toDto(media: MediaDto, metadata: BookMetadataDto, readProgress: ReadProgressDto?) =

View file

@ -79,7 +79,12 @@ class SeriesDtoDao(
return findAll(conditions, having, userId, pageable, search.toJoinConditions())
}
override fun findByCollectionId(collectionId: String, search: SeriesSearchWithReadProgress, userId: String, pageable: Pageable): Page<SeriesDto> {
override fun findByCollectionId(
collectionId: String,
search: SeriesSearchWithReadProgress,
userId: String,
pageable: Pageable
): Page<SeriesDto> {
val conditions = search.toCondition().and(cs.COLLECTION_ID.eq(collectionId))
val having = search.readStatus?.toCondition() ?: DSL.trueCondition()
val joinConditions = search.toJoinConditions().copy(selectCollectionNumber = true, collection = true)
@ -87,7 +92,11 @@ class SeriesDtoDao(
return findAll(conditions, having, userId, pageable, joinConditions)
}
override fun findRecentlyUpdated(search: SeriesSearchWithReadProgress, userId: String, pageable: Pageable): Page<SeriesDto> {
override fun findRecentlyUpdated(
search: SeriesSearchWithReadProgress,
userId: String,
pageable: Pageable
): Page<SeriesDto> {
val conditions = search.toCondition()
.and(s.CREATED_DATE.ne(s.LAST_MODIFIED_DATE))
@ -118,6 +127,7 @@ class SeriesDtoDao(
.apply { if (joinConditions.genre) leftJoin(g).on(s.ID.eq(g.SERIES_ID)) }
.apply { if (joinConditions.tag) leftJoin(st).on(s.ID.eq(st.SERIES_ID)) }
.apply { if (joinConditions.collection) leftJoin(cs).on(s.ID.eq(cs.SERIES_ID)) }
.apply { if (joinConditions.aggregationAuthor) leftJoin(bmaa).on(s.ID.eq(bmaa.SERIES_ID)) }
private fun findAll(
conditions: Condition,
@ -135,6 +145,7 @@ class SeriesDtoDao(
.apply { if (joinConditions.genre) leftJoin(g).on(s.ID.eq(g.SERIES_ID)) }
.apply { if (joinConditions.tag) leftJoin(st).on(s.ID.eq(st.SERIES_ID)) }
.apply { if (joinConditions.collection) leftJoin(cs).on(s.ID.eq(cs.SERIES_ID)) }
.apply { if (joinConditions.aggregationAuthor) leftJoin(bmaa).on(s.ID.eq(bmaa.SERIES_ID)) }
.where(conditions)
.groupBy(s.ID)
.having(having)
@ -200,7 +211,14 @@ class SeriesDtoDao(
.filter { it.name != null }
.map { AuthorDto(it.name, it.role) }
sr.toDto(booksCount, booksReadCount, booksUnreadCount, booksInProgressCount, dr.toDto(genres, tags), bmar.toDto(aggregatedAuthors))
sr.toDto(
booksCount,
booksReadCount,
booksUnreadCount,
booksInProgressCount,
dr.toDto(genres, tags),
bmar.toDto(aggregatedAuthors)
)
}
private fun SeriesSearchWithReadProgress.toCondition(): Condition {
@ -216,11 +234,20 @@ class SeriesDtoDao(
if (!tags.isNullOrEmpty()) c = c.and(lower(st.TAG).`in`(tags.map { it.toLowerCase() }))
if (!ageRatings.isNullOrEmpty()) {
val c1 = if (ageRatings.contains(null)) d.AGE_RATING.isNull else DSL.falseCondition()
val c2 = if (ageRatings.filterNotNull().isNotEmpty()) d.AGE_RATING.`in`(ageRatings.filterNotNull()) else DSL.falseCondition()
val c2 = if (ageRatings.filterNotNull()
.isNotEmpty()
) d.AGE_RATING.`in`(ageRatings.filterNotNull()) else DSL.falseCondition()
c = c.and(c1.or(c2))
}
// cast to String is necessary for SQLite, else the years in the IN block are coerced to Int, even though YEAR for SQLite uses strftime (string)
if (!releaseYears.isNullOrEmpty()) c = c.and(DSL.year(bma.RELEASE_DATE).cast(String::class.java).`in`(releaseYears))
if (!authors.isNullOrEmpty()) {
var ca: Condition = DSL.falseCondition()
authors.forEach {
ca = ca.or(bmaa.NAME.equalIgnoreCase(it.name).and(bmaa.ROLE.equalIgnoreCase(it.role)))
}
c = c.and(ca)
}
return c
}
@ -229,14 +256,16 @@ class SeriesDtoDao(
JoinConditions(
genre = !genres.isNullOrEmpty(),
tag = !tags.isNullOrEmpty(),
collection = !collectionIds.isNullOrEmpty()
collection = !collectionIds.isNullOrEmpty(),
aggregationAuthor = !authors.isNullOrEmpty(),
)
private data class JoinConditions(
val selectCollectionNumber: Boolean = false,
val genre: Boolean = false,
val tag: Boolean = false,
val collection: Boolean = false
val collection: Boolean = false,
val aggregationAuthor: Boolean = false,
)
private fun Collection<ReadStatus>.toCondition(): Condition =
@ -248,7 +277,14 @@ class SeriesDtoDao(
}
}.reduce { acc, condition -> acc.or(condition) }
private fun SeriesRecord.toDto(booksCount: Int, booksReadCount: Int, booksUnreadCount: Int, booksInProgressCount: Int, metadata: SeriesMetadataDto, booksMetadata: BookMetadataAggregationDto) =
private fun SeriesRecord.toDto(
booksCount: Int,
booksReadCount: Int,
booksUnreadCount: Int,
booksInProgressCount: Int,
metadata: SeriesMetadataDto,
booksMetadata: BookMetadataAggregationDto
) =
SeriesDto(
id = id,
libraryId = libraryId,

View file

@ -0,0 +1,17 @@
package org.gotson.komga.infrastructure.swagger
import io.swagger.v3.oas.annotations.Parameter
import io.swagger.v3.oas.annotations.Parameters
import io.swagger.v3.oas.annotations.enums.ParameterIn
import io.swagger.v3.oas.annotations.media.ArraySchema
import io.swagger.v3.oas.annotations.media.Content
import io.swagger.v3.oas.annotations.media.Schema
@Target(AnnotationTarget.ANNOTATION_CLASS, AnnotationTarget.FUNCTION)
@Parameters(
Parameter(
description = "Author criteria in the format: name,role. Multiple author criteria are supported.",
`in` = ParameterIn.QUERY, name = "author", content = [Content(array = ArraySchema(schema = Schema(type = "string")))]
)
)
annotation class AuthorsAsQueryParam

View file

@ -0,0 +1,5 @@
package org.gotson.komga.infrastructure.web
@Retention(AnnotationRetention.RUNTIME)
@Target(AnnotationTarget.VALUE_PARAMETER)
annotation class Authors()

View file

@ -0,0 +1,32 @@
package org.gotson.komga.infrastructure.web
import org.gotson.komga.domain.model.Author
import org.springframework.core.MethodParameter
import org.springframework.web.bind.support.WebDataBinderFactory
import org.springframework.web.context.request.NativeWebRequest
import org.springframework.web.method.support.HandlerMethodArgumentResolver
import org.springframework.web.method.support.ModelAndViewContainer
class AuthorsHandlerMethodArgumentResolver : HandlerMethodArgumentResolver {
override fun supportsParameter(parameter: MethodParameter): Boolean =
parameter.getParameterAnnotation(Authors::class.java) != null
override fun resolveArgument(
parameter: MethodParameter,
mavContainer: ModelAndViewContainer?,
webRequest: NativeWebRequest,
binderFactory: WebDataBinderFactory?
): Any? {
val param = webRequest.getParameterValues("author") ?: return null
// Single empty parameter, e.g "author="
if (param.size == 1 && param[0].isNullOrBlank()) return null
return parseParameterIntoAuthors(param.toList())
}
private fun parseParameterIntoAuthors(source: List<String>, delimiter: String = ","): List<Author> =
source
.filter { it.contains(delimiter) }
.map { Author(name = it.substringBeforeLast(delimiter), role = it.substringAfterLast(delimiter)) }
}

View file

@ -5,6 +5,7 @@ import org.springframework.http.CacheControl
import org.springframework.stereotype.Component
import org.springframework.web.bind.annotation.ControllerAdvice
import org.springframework.web.bind.annotation.ExceptionHandler
import org.springframework.web.method.support.HandlerMethodArgumentResolver
import org.springframework.web.servlet.NoHandlerFoundException
import org.springframework.web.servlet.config.annotation.InterceptorRegistry
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry
@ -63,6 +64,10 @@ class WebMvcConfiguration : WebMvcConfigurer {
}
)
}
override fun addArgumentResolvers(resolvers: MutableList<HandlerMethodArgumentResolver>) {
resolvers.add(AuthorsHandlerMethodArgumentResolver())
}
}
@Component

View file

@ -5,6 +5,7 @@ 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.Author
import org.gotson.komga.domain.model.DuplicateNameException
import org.gotson.komga.domain.model.ROLE_ADMIN
import org.gotson.komga.domain.model.ReadStatus
@ -15,7 +16,9 @@ import org.gotson.komga.domain.persistence.SeriesCollectionRepository
import org.gotson.komga.domain.service.SeriesCollectionLifecycle
import org.gotson.komga.infrastructure.jooq.UnpagedSorted
import org.gotson.komga.infrastructure.security.KomgaPrincipal
import org.gotson.komga.infrastructure.swagger.AuthorsAsQueryParam
import org.gotson.komga.infrastructure.swagger.PageableWithoutSortAsQueryParam
import org.gotson.komga.infrastructure.web.Authors
import org.gotson.komga.interfaces.rest.dto.CollectionCreationDto
import org.gotson.komga.interfaces.rest.dto.CollectionDto
import org.gotson.komga.interfaces.rest.dto.CollectionUpdateDto
@ -154,6 +157,7 @@ class SeriesCollectionController(
}
@PageableWithoutSortAsQueryParam
@AuthorsAsQueryParam
@GetMapping("{id}/series")
fun getSeriesForCollection(
@PathVariable id: String,
@ -168,6 +172,7 @@ class SeriesCollectionController(
@RequestParam(name = "age_rating", required = false) ageRatings: List<String>?,
@RequestParam(name = "release_year", required = false) release_years: List<String>?,
@RequestParam(name = "unpaged", required = false) unpaged: Boolean = false,
@Parameter(hidden = true) @Authors authors: List<Author>?,
@Parameter(hidden = true) page: Pageable
): Page<SeriesDto> =
collectionRepository.findByIdOrNull(id, principal.user.getAuthorizedLibraryIds(null))?.let { collection ->
@ -193,6 +198,7 @@ class SeriesCollectionController(
tags = tags,
ageRatings = ageRatings?.map { it.toIntOrNull() },
releaseYears = release_years,
authors = authors
)
seriesDtoRepository.findByCollectionId(collection.id, seriesSearch, principal.user.id, pageRequest)

View file

@ -10,6 +10,7 @@ import org.apache.commons.compress.archivers.zip.ZipArchiveEntry
import org.apache.commons.compress.archivers.zip.ZipArchiveOutputStream
import org.apache.commons.io.IOUtils
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.Media
import org.gotson.komga.domain.model.ROLE_ADMIN
@ -25,8 +26,10 @@ 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.security.KomgaPrincipal
import org.gotson.komga.infrastructure.swagger.AuthorsAsQueryParam
import org.gotson.komga.infrastructure.swagger.PageableAsQueryParam
import org.gotson.komga.infrastructure.swagger.PageableWithoutSortAsQueryParam
import org.gotson.komga.infrastructure.web.Authors
import org.gotson.komga.interfaces.rest.dto.BookDto
import org.gotson.komga.interfaces.rest.dto.CollectionDto
import org.gotson.komga.interfaces.rest.dto.SeriesDto
@ -80,6 +83,7 @@ class SeriesController(
) {
@PageableAsQueryParam
@AuthorsAsQueryParam
@GetMapping
fun getAllSeries(
@AuthenticationPrincipal principal: KomgaPrincipal,
@ -95,6 +99,7 @@ class SeriesController(
@RequestParam(name = "age_rating", required = false) ageRatings: List<String>?,
@RequestParam(name = "release_year", required = false) release_years: List<String>?,
@RequestParam(name = "unpaged", required = false) unpaged: Boolean = false,
@Parameter(hidden = true) @Authors authors: List<Author>?,
@Parameter(hidden = true) page: Pageable
): Page<SeriesDto> {
val sort =
@ -121,6 +126,7 @@ class SeriesController(
tags = tags,
ageRatings = ageRatings?.map { it.toIntOrNull() },
releaseYears = release_years,
authors = authors
)
return seriesDtoRepository.findAll(seriesSearch, principal.user.id, pageRequest)
@ -229,6 +235,7 @@ class SeriesController(
}
@PageableAsQueryParam
@AuthorsAsQueryParam
@GetMapping("{seriesId}/books")
fun getAllBooksBySeries(
@AuthenticationPrincipal principal: KomgaPrincipal,
@ -237,6 +244,7 @@ class SeriesController(
@RequestParam(name = "read_status", required = false) readStatus: List<ReadStatus>?,
@RequestParam(name = "tag", required = false) tags: List<String>?,
@RequestParam(name = "unpaged", required = false) unpaged: Boolean = false,
@Parameter(hidden = true) @Authors authors: List<Author>?,
@Parameter(hidden = true) page: Pageable
): Page<BookDto> {
seriesRepository.getLibraryId(seriesId)?.let {
@ -259,7 +267,8 @@ class SeriesController(
seriesIds = listOf(seriesId),
mediaStatus = mediaStatus,
readStatus = readStatus,
tags = tags
tags = tags,
authors = authors,
),
principal.user.id,
pageRequest