feat(opds): opds v2 support

This commit is contained in:
Gauthier Roebroeck 2023-09-04 15:41:53 +08:00
parent 8bdc4d8cad
commit d1cb58b21b
16 changed files with 1210 additions and 99 deletions

View file

@ -27,6 +27,9 @@ class ImageConverter {
private val supportsTransparency = listOf("png")
fun canConvertMediaType(from: String, to: String) =
supportedReadMediaTypes.contains(from) && supportedWriteMediaTypes.contains(to)
fun convertImage(imageBytes: ByteArray, format: String): ByteArray =
ByteArrayOutputStream().use { baos ->
val image = ImageIO.read(imageBytes.inputStream())

View file

@ -1,6 +1,8 @@
package org.gotson.komga.interfaces.api
import org.gotson.komga.domain.model.KomgaUser
import org.gotson.komga.domain.persistence.BookRepository
import org.gotson.komga.domain.persistence.SeriesMetadataRepository
import org.gotson.komga.interfaces.api.rest.dto.SeriesDto
import org.springframework.http.HttpStatus
import org.springframework.web.server.ResponseStatusException
@ -14,3 +16,22 @@ fun KomgaUser.checkContentRestriction(series: SeriesDto) {
if (!canAccessLibrary(series.libraryId)) throw ResponseStatusException(HttpStatus.FORBIDDEN)
if (!isContentAllowed(series.metadata.ageRating, series.metadata.sharingLabels)) throw ResponseStatusException(HttpStatus.FORBIDDEN)
}
/**
* Convenience function to check for content restriction.
* This will retrieve data from repositories if needed.
*
* @throws[ResponseStatusException] if the user cannot access the content
*/
fun KomgaUser.checkContentRestriction(bookId: String, bookRepository: BookRepository, seriesMetadataRepository: SeriesMetadataRepository) {
if (!sharedAllLibraries) {
bookRepository.getLibraryIdOrNull(bookId)?.let {
if (!canAccessLibrary(it)) throw ResponseStatusException(HttpStatus.FORBIDDEN)
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
}
if (restrictions.isRestricted) bookRepository.getSeriesIdOrNull(bookId)?.let { seriesId ->
seriesMetadataRepository.findById(seriesId).let {
if (!isContentAllowed(it.ageRating, it.sharingLabels)) throw ResponseStatusException(HttpStatus.FORBIDDEN)
}
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
}

View file

@ -0,0 +1,16 @@
package org.gotson.komga.interfaces.api.dto
class OpdsLinkRel {
companion object {
const val SEARCH = "search"
const val COVER = "cover"
const val SELF = "self"
const val START = "start"
const val PREVIOUS = "previous"
const val NEXT = "next"
const val SUBSECTION = "subsection"
const val SORT_NEW = "http://opds-spec.org/sort/new"
const val SORT_POPULAR = "http://opds-spec.org/sort/popular"
const val ACQUISITION = "http://opds-spec.org/acquisition"
}
}

View file

@ -0,0 +1,92 @@
package org.gotson.komga.interfaces.api.dto
import com.fasterxml.jackson.annotation.JsonAlias
import com.fasterxml.jackson.annotation.JsonInclude
import com.fasterxml.jackson.annotation.JsonProperty
import jakarta.validation.constraints.Positive
import java.time.LocalDate
import java.time.ZonedDateTime
@JsonInclude(JsonInclude.Include.NON_EMPTY)
data class WPLinkDto(
val title: String? = null,
val rel: String? = null,
val href: String? = null,
val type: String? = null,
val templated: Boolean? = null,
@Positive
val width: Int? = null,
@Positive
val height: Int? = null,
val alternate: List<WPLinkDto> = emptyList(),
)
@JsonInclude(JsonInclude.Include.NON_EMPTY)
data class WPPublicationDto(
val metadata: WPMetadataDto,
val links: List<WPLinkDto>,
val images: List<WPLinkDto>,
val readingOrder: List<WPLinkDto>,
val resources: List<WPLinkDto> = emptyList(),
val toc: List<WPLinkDto> = emptyList(),
)
@JsonInclude(JsonInclude.Include.NON_EMPTY)
data class WPMetadataDto(
val title: String,
val identifier: String? = null,
@JsonAlias("@type")
val type: String? = null,
val conformsTo: String? = null,
val sortAs: String? = null,
val subtitle: String? = null,
val modified: ZonedDateTime? = null,
val published: LocalDate? = null,
val language: String? = null,
val author: List<String> = emptyList(),
val translator: List<String> = emptyList(),
val editor: List<String> = emptyList(),
val artist: List<String> = emptyList(),
val illustrator: List<String> = emptyList(),
val letterer: List<String> = emptyList(),
val penciler: List<String> = emptyList(),
val colorist: List<String> = emptyList(),
val inker: List<String> = emptyList(),
val contributor: List<String> = emptyList(),
val publisher: List<String> = emptyList(),
val subject: List<String> = emptyList(),
val readingProgression: WPReadingProgressionDto? = null,
val description: String? = null,
@Positive
val numberOfPages: Int? = null,
val belongsTo: WPBelongsToDto? = null,
)
@JsonInclude(JsonInclude.Include.NON_EMPTY)
data class WPBelongsToDto(
val series: List<WPContributorDto> = emptyList(),
val collection: List<WPContributorDto> = emptyList(),
)
@JsonInclude(JsonInclude.Include.NON_EMPTY)
data class WPContributorDto(
val name: String,
val position: Float? = null,
)
enum class WPReadingProgressionDto {
@JsonProperty("rtl")
RTL,
@JsonProperty("ltr")
LTR,
@JsonProperty("ttb")
TTB,
@JsonProperty("btt")
BTT,
@JsonProperty("auto")
AUTO,
}

View file

@ -0,0 +1,108 @@
package org.gotson.komga.interfaces.api.dto
import org.gotson.komga.domain.model.BookPage
import org.gotson.komga.domain.model.Media
import org.gotson.komga.domain.model.SeriesMetadata
import org.gotson.komga.domain.model.SeriesMetadata.ReadingDirection.LEFT_TO_RIGHT
import org.gotson.komga.domain.model.SeriesMetadata.ReadingDirection.RIGHT_TO_LEFT
import org.gotson.komga.domain.model.SeriesMetadata.ReadingDirection.VERTICAL
import org.gotson.komga.domain.model.SeriesMetadata.ReadingDirection.WEBTOON
import org.gotson.komga.domain.model.SeriesMetadata.ReadingDirection.valueOf
import org.gotson.komga.infrastructure.jooq.toCurrentTimeZone
import org.gotson.komga.interfaces.api.rest.dto.AuthorDto
import org.gotson.komga.interfaces.api.rest.dto.BookDto
import org.gotson.komga.interfaces.api.rest.dto.SeriesDto
import org.springframework.http.MediaType
import org.springframework.web.servlet.support.ServletUriComponentsBuilder
import java.time.ZoneId
import java.time.ZonedDateTime
val MEDIATYPE_DIVINA_JSON = MediaType("application", "webpub+json")
const val MEDIATYPE_WEBPUB_JSON_VALUE = "application/webpub+json"
const val MEDIATYPE_DIVINA_JSON_VALUE = "application/divina+json"
const val PROFILE_DIVINA = "https://readium.org/webpub-manifest/profiles/divina"
val wpKnownRoles = listOf(
"author",
"translator",
"editor",
"artist",
"illustrator",
"letterer",
"penciler",
"penciller",
"colorist",
"inker",
)
fun WPMetadataDto.withAuthors(authors: List<AuthorDto>): WPMetadataDto {
val groups = authors.groupBy({ it.role }, { it.name })
return copy(
author = groups["author"].orEmpty(),
translator = groups["translator"].orEmpty(),
editor = groups["editor"].orEmpty(),
artist = groups["artist"].orEmpty(),
illustrator = groups["illustrator"].orEmpty(),
letterer = groups["letterer"].orEmpty(),
penciler = groups["penciler"].orEmpty() + groups["penciller"].orEmpty(),
colorist = groups["colorist"].orEmpty(),
inker = groups["inker"].orEmpty(),
// use contributor role for all roles not mentioned above
contributor = authors.filterNot { wpKnownRoles.contains(it.role) }.map { it.name },
)
}
val recommendedImageMediaTypes = listOf("image/jpeg", "image/png", "image/gif")
fun BookDto.toWPPublicationDto(canConvertMediaType: (String, String) -> Boolean, media: Media, seriesMetadata: SeriesMetadata? = null, seriesDto: SeriesDto? = null): WPPublicationDto {
val builder = ServletUriComponentsBuilder.fromCurrentContextPath().pathSegment("api", "v1")
return WPPublicationDto(
metadata = WPMetadataDto(
title = metadata.title,
description = metadata.summary,
numberOfPages = media.pages.size,
conformsTo = PROFILE_DIVINA,
modified = lastModified.toCurrentTimeZone().atZone(ZoneId.systemDefault()) ?: ZonedDateTime.now(),
published = metadata.releaseDate,
subject = metadata.tags.toList(),
identifier = if (metadata.isbn.isNotBlank()) "urn:isbn:${metadata.isbn}" else null,
language = seriesMetadata?.language ?: seriesDto?.metadata?.language,
readingProgression = (seriesMetadata?.readingDirection ?: seriesDto?.metadata?.readingDirection?.let { valueOf(it) })?.let {
when (it) {
LEFT_TO_RIGHT -> WPReadingProgressionDto.LTR
RIGHT_TO_LEFT -> WPReadingProgressionDto.RTL
VERTICAL -> WPReadingProgressionDto.TTB
WEBTOON -> WPReadingProgressionDto.TTB
}
},
belongsTo = (seriesMetadata?.title ?: seriesDto?.metadata?.title)?.let { WPBelongsToDto(series = listOf(WPContributorDto(it, metadata.numberSort))) },
).withAuthors(metadata.authors),
links = listOf(
WPLinkDto(rel = OpdsLinkRel.SELF, href = builder.cloneBuilder().path("books/$id/manifest").toUriString(), type = MEDIATYPE_DIVINA_JSON_VALUE),
WPLinkDto(rel = OpdsLinkRel.ACQUISITION, type = media.mediaType, href = builder.cloneBuilder().path("books/$id/file").toUriString()),
),
images = listOf(
WPLinkDto(
href = builder.cloneBuilder().path("books/$id/thumbnail").toUriString(),
type = MediaType.IMAGE_JPEG_VALUE,
),
),
readingOrder =
media.pages.mapIndexed { index: Int, page: BookPage ->
WPLinkDto(
href = builder.cloneBuilder().path("books/$id/pages/${index + 1}").toUriString(),
type = page.mediaType,
width = page.dimension?.width,
height = page.dimension?.height,
alternate = if (!recommendedImageMediaTypes.contains(page.mediaType) && canConvertMediaType(page.mediaType, MediaType.IMAGE_JPEG_VALUE)) listOf(
WPLinkDto(
href = builder.cloneBuilder().path("books/$id/pages/${index + 1}").queryParam("convert", "jpeg").toUriString(),
type = MediaType.IMAGE_JPEG_VALUE,
width = page.dimension?.width,
height = page.dimension?.height,
),
) else emptyList(),
)
},
)
}

View file

@ -0,0 +1,48 @@
package org.gotson.komga.interfaces.api.opds
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 org.gotson.komga.domain.model.ThumbnailBook
import org.gotson.komga.domain.persistence.BookRepository
import org.gotson.komga.domain.persistence.SeriesMetadataRepository
import org.gotson.komga.domain.service.BookLifecycle
import org.gotson.komga.infrastructure.image.ImageType
import org.gotson.komga.infrastructure.security.KomgaPrincipal
import org.gotson.komga.interfaces.api.checkContentRestriction
import org.springframework.http.HttpStatus
import org.springframework.http.MediaType
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.server.ResponseStatusException
@RestController
class OpdsCommonController(
private val seriesMetadataRepository: SeriesMetadataRepository,
private val bookRepository: BookRepository,
private val bookLifecycle: BookLifecycle,
) {
@ApiResponse(content = [Content(schema = Schema(type = "string", format = "binary"))])
@GetMapping(
value = [
"/opds/v1.2/books/{bookId}/thumbnail",
"/opds/v2/books/{bookId}/thumbnail",
],
produces = [MediaType.IMAGE_JPEG_VALUE],
)
fun getBookThumbnail(
@AuthenticationPrincipal principal: KomgaPrincipal,
@PathVariable bookId: String,
): ByteArray {
principal.user.checkContentRestriction(bookId, bookRepository, seriesMetadataRepository)
val thumbnail = bookLifecycle.getThumbnail(bookId) ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
return if (thumbnail.type == ThumbnailBook.Type.GENERATED) {
bookLifecycle.getBookPage(bookRepository.findByIdOrNull(bookId)!!, 1, ImageType.JPEG).content
} else {
bookLifecycle.getThumbnailBytes(bookId) ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
}
}
}

View file

@ -1,4 +1,4 @@
package org.gotson.komga.interfaces.api.opds
package org.gotson.komga.interfaces.api.opds.v1
import io.swagger.v3.oas.annotations.Parameter
import io.swagger.v3.oas.annotations.media.Content
@ -8,7 +8,6 @@ import jakarta.servlet.ServletContext
import mu.KotlinLogging
import org.apache.commons.io.FilenameUtils
import org.gotson.komga.domain.model.BookSearchWithReadProgress
import org.gotson.komga.domain.model.KomgaUser
import org.gotson.komga.domain.model.Library
import org.gotson.komga.domain.model.Media
import org.gotson.komga.domain.model.ReadList
@ -24,26 +23,25 @@ import org.gotson.komga.domain.persistence.ReferentialRepository
import org.gotson.komga.domain.persistence.SeriesCollectionRepository
import org.gotson.komga.domain.persistence.SeriesMetadataRepository
import org.gotson.komga.domain.service.BookLifecycle
import org.gotson.komga.infrastructure.image.ImageType
import org.gotson.komga.infrastructure.jooq.toCurrentTimeZone
import org.gotson.komga.infrastructure.security.KomgaPrincipal
import org.gotson.komga.infrastructure.swagger.PageAsQueryParam
import org.gotson.komga.interfaces.api.checkContentRestriction
import org.gotson.komga.interfaces.api.opds.dto.OpdsAuthor
import org.gotson.komga.interfaces.api.opds.dto.OpdsEntryAcquisition
import org.gotson.komga.interfaces.api.opds.dto.OpdsEntryNavigation
import org.gotson.komga.interfaces.api.opds.dto.OpdsFeed
import org.gotson.komga.interfaces.api.opds.dto.OpdsFeedAcquisition
import org.gotson.komga.interfaces.api.opds.dto.OpdsFeedNavigation
import org.gotson.komga.interfaces.api.opds.dto.OpdsLink
import org.gotson.komga.interfaces.api.opds.dto.OpdsLinkFeedNavigation
import org.gotson.komga.interfaces.api.opds.dto.OpdsLinkFileAcquisition
import org.gotson.komga.interfaces.api.opds.dto.OpdsLinkImage
import org.gotson.komga.interfaces.api.opds.dto.OpdsLinkImageThumbnail
import org.gotson.komga.interfaces.api.opds.dto.OpdsLinkPageStreaming
import org.gotson.komga.interfaces.api.opds.dto.OpdsLinkRel
import org.gotson.komga.interfaces.api.opds.dto.OpdsLinkSearch
import org.gotson.komga.interfaces.api.opds.dto.OpenSearchDescription
import org.gotson.komga.interfaces.api.dto.OpdsLinkRel
import org.gotson.komga.interfaces.api.opds.v1.dto.OpdsAuthor
import org.gotson.komga.interfaces.api.opds.v1.dto.OpdsEntryAcquisition
import org.gotson.komga.interfaces.api.opds.v1.dto.OpdsEntryNavigation
import org.gotson.komga.interfaces.api.opds.v1.dto.OpdsFeed
import org.gotson.komga.interfaces.api.opds.v1.dto.OpdsFeedAcquisition
import org.gotson.komga.interfaces.api.opds.v1.dto.OpdsFeedNavigation
import org.gotson.komga.interfaces.api.opds.v1.dto.OpdsLink
import org.gotson.komga.interfaces.api.opds.v1.dto.OpdsLinkFeedNavigation
import org.gotson.komga.interfaces.api.opds.v1.dto.OpdsLinkFileAcquisition
import org.gotson.komga.interfaces.api.opds.v1.dto.OpdsLinkImage
import org.gotson.komga.interfaces.api.opds.v1.dto.OpdsLinkImageThumbnail
import org.gotson.komga.interfaces.api.opds.v1.dto.OpdsLinkPageStreaming
import org.gotson.komga.interfaces.api.opds.v1.dto.OpdsLinkSearch
import org.gotson.komga.interfaces.api.opds.v1.dto.OpenSearchDescription
import org.gotson.komga.interfaces.api.persistence.BookDtoRepository
import org.gotson.komga.interfaces.api.persistence.SeriesDtoRepository
import org.gotson.komga.interfaces.api.rest.dto.BookDto
@ -673,24 +671,6 @@ class OpdsController(
)
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
@ApiResponse(content = [Content(schema = Schema(type = "string", format = "binary"))])
@GetMapping(
value = ["books/{bookId}/thumbnail"],
produces = [MediaType.IMAGE_JPEG_VALUE],
)
fun getBookThumbnail(
@AuthenticationPrincipal principal: KomgaPrincipal,
@PathVariable bookId: String,
): ByteArray {
principal.user.checkContentRestriction(bookId)
val thumbnail = bookLifecycle.getThumbnail(bookId) ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
return if (thumbnail.type == ThumbnailBook.Type.GENERATED) {
bookLifecycle.getBookPage(bookRepository.findByIdOrNull(bookId)!!, 1, ImageType.JPEG).content
} else {
bookLifecycle.getThumbnailBytes(bookId) ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
}
}
@ApiResponse(content = [Content(schema = Schema(type = "string", format = "binary"))])
@GetMapping(
value = ["books/{bookId}/thumbnail/small"],
@ -700,7 +680,7 @@ class OpdsController(
@AuthenticationPrincipal principal: KomgaPrincipal,
@PathVariable bookId: String,
): ByteArray {
principal.user.checkContentRestriction(bookId)
principal.user.checkContentRestriction(bookId, bookRepository, seriesMetadataRepository)
val thumbnail = bookLifecycle.getThumbnail(bookId) ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
return bookLifecycle.getThumbnailBytes(bookId, if (thumbnail.type == ThumbnailBook.Type.GENERATED) null else 300)
@ -784,23 +764,4 @@ class OpdsController(
private fun sanitize(fileName: String): String =
fileName.replace(";", "")
/**
* Convenience function to check for content restriction.
* This will retrieve data from repositories if needed.
*
* @throws[ResponseStatusException] if the user cannot access the content
*/
private fun KomgaUser.checkContentRestriction(bookId: String) {
if (!sharedAllLibraries) {
bookRepository.getLibraryIdOrNull(bookId)?.let {
if (!canAccessLibrary(it)) throw ResponseStatusException(HttpStatus.FORBIDDEN)
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
}
if (restrictions.isRestricted) bookRepository.getSeriesIdOrNull(bookId)?.let { seriesId ->
seriesMetadataRepository.findById(seriesId).let {
if (!isContentAllowed(it.ageRating, it.sharingLabels)) throw ResponseStatusException(HttpStatus.FORBIDDEN)
}
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
}
}

View file

@ -1,4 +1,4 @@
package org.gotson.komga.interfaces.api.opds.dto
package org.gotson.komga.interfaces.api.opds.v1.dto
import com.fasterxml.jackson.annotation.JsonInclude
import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlProperty

View file

@ -1,4 +1,4 @@
package org.gotson.komga.interfaces.api.opds.dto
package org.gotson.komga.interfaces.api.opds.v1.dto
import com.fasterxml.jackson.annotation.JsonInclude
import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlElementWrapper

View file

@ -1,4 +1,4 @@
package org.gotson.komga.interfaces.api.opds.dto
package org.gotson.komga.interfaces.api.opds.v1.dto
import com.fasterxml.jackson.databind.annotation.JsonSerialize
import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlElementWrapper

View file

@ -1,4 +1,4 @@
package org.gotson.komga.interfaces.api.opds.dto
package org.gotson.komga.interfaces.api.opds.v1.dto
import com.fasterxml.jackson.annotation.JsonFormat
import com.fasterxml.jackson.databind.annotation.JsonSerialize
@ -76,15 +76,3 @@ class OpdsLinkPageStreaming(
rel = "http://vaemendis.net/opds-pse/stream",
href = href,
)
class OpdsLinkRel {
companion object {
const val SELF = "self"
const val START = "start"
const val PREVIOUS = "previous"
const val NEXT = "next"
const val SUBSECTION = "subsection"
const val SORT_NEW = "http://opds-spec.org/sort/new"
const val SORT_POPULAR = "http://opds-spec.org/sort/popular"
}
}

View file

@ -1,4 +1,4 @@
package org.gotson.komga.interfaces.api.opds.dto
package org.gotson.komga.interfaces.api.opds.v1.dto
import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlProperty
import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlRootElement

View file

@ -1,4 +1,4 @@
package org.gotson.komga.interfaces.api.opds.dto
package org.gotson.komga.interfaces.api.opds.v1.dto
const val ATOM = "http://www.w3.org/2005/Atom"
const val OPDS_PSE = "http://vaemendis.net/opds-pse/ns"

View file

@ -0,0 +1,804 @@
package org.gotson.komga.interfaces.api.opds.v2
import io.swagger.v3.oas.annotations.Parameter
import org.gotson.komga.domain.model.BookSearchWithReadProgress
import org.gotson.komga.domain.model.KomgaUser
import org.gotson.komga.domain.model.Library
import org.gotson.komga.domain.model.Media
import org.gotson.komga.domain.model.ReadList
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.persistence.LibraryRepository
import org.gotson.komga.domain.persistence.MediaRepository
import org.gotson.komga.domain.persistence.ReadListRepository
import org.gotson.komga.domain.persistence.ReferentialRepository
import org.gotson.komga.domain.persistence.SeriesCollectionRepository
import org.gotson.komga.domain.persistence.SeriesMetadataRepository
import org.gotson.komga.infrastructure.image.ImageConverter
import org.gotson.komga.infrastructure.jooq.toCurrentTimeZone
import org.gotson.komga.infrastructure.security.KomgaPrincipal
import org.gotson.komga.infrastructure.swagger.PageAsQueryParam
import org.gotson.komga.interfaces.api.checkContentRestriction
import org.gotson.komga.interfaces.api.dto.OpdsLinkRel
import org.gotson.komga.interfaces.api.dto.WPLinkDto
import org.gotson.komga.interfaces.api.dto.WPPublicationDto
import org.gotson.komga.interfaces.api.dto.toWPPublicationDto
import org.gotson.komga.interfaces.api.opds.v2.dto.FacetDto
import org.gotson.komga.interfaces.api.opds.v2.dto.FeedDto
import org.gotson.komga.interfaces.api.opds.v2.dto.FeedGroupDto
import org.gotson.komga.interfaces.api.opds.v2.dto.FeedMetadataDto
import org.gotson.komga.interfaces.api.persistence.BookDtoRepository
import org.gotson.komga.interfaces.api.persistence.SeriesDtoRepository
import org.gotson.komga.interfaces.api.rest.dto.BookDto
import org.gotson.komga.interfaces.api.rest.dto.SeriesDto
import org.springframework.data.domain.Page
import org.springframework.data.domain.PageRequest
import org.springframework.data.domain.Pageable
import org.springframework.data.domain.Sort
import org.springframework.http.HttpStatus
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.server.ResponseStatusException
import org.springframework.web.servlet.support.ServletUriComponentsBuilder
import org.springframework.web.util.UriComponentsBuilder
import java.time.ZoneId
import java.time.ZonedDateTime
private const val MEDIATYPE_OPDS_JSON = "application/opds+json"
private const val ROUTE_BASE = "/opds/v2/"
private const val ROUTE_CATALOG = "catalog"
private const val RECOMMENDED_ITEMS_NUMBER = 5
@RestController
@RequestMapping(value = [ROUTE_BASE], produces = [MEDIATYPE_OPDS_JSON])
class Opds2Controller(
private val imageConverter: ImageConverter,
private val libraryRepository: LibraryRepository,
private val collectionRepository: SeriesCollectionRepository,
private val readListRepository: ReadListRepository,
private val seriesDtoRepository: SeriesDtoRepository,
private val seriesMetadataRepository: SeriesMetadataRepository,
private val bookDtoRepository: BookDtoRepository,
private val mediaRepository: MediaRepository,
private val referentialRepository: ReferentialRepository,
) {
private fun linkStart() = WPLinkDto(
title = "Home",
rel = OpdsLinkRel.START,
type = MEDIATYPE_OPDS_JSON,
href = uriBuilder(ROUTE_CATALOG).toUriString(),
)
private fun linkSearch() = WPLinkDto(
title = "Search",
rel = OpdsLinkRel.SEARCH,
type = MEDIATYPE_OPDS_JSON,
href = uriBuilder("search").toUriString() + "{?query}",
templated = true,
)
private fun uriBuilder(path: String) =
ServletUriComponentsBuilder.fromCurrentContextPath().pathSegment("opds", "v2", path)
private fun linkPage(uriBuilder: UriComponentsBuilder, page: Page<*>): List<WPLinkDto> {
return listOfNotNull(
if (!page.isFirst) WPLinkDto(
rel = OpdsLinkRel.PREVIOUS,
href = uriBuilder.cloneBuilder().queryParam("page", page.pageable.previousOrFirst().pageNumber).toUriString(),
)
else null,
if (!page.isLast) WPLinkDto(
rel = OpdsLinkRel.NEXT,
href = uriBuilder.cloneBuilder().queryParam("page", page.pageable.next().pageNumber).toUriString(),
)
else null,
)
}
private fun linkSelf(path: String, type: String? = null) =
linkSelf(uriBuilder(path), type)
private fun linkSelf(uriBuilder: UriComponentsBuilder, type: String? = null) =
WPLinkDto(
rel = OpdsLinkRel.SELF,
href = uriBuilder.toUriString(),
type = type,
)
private fun getLibrariesFeedGroup(
principal: KomgaPrincipal,
): FeedGroupDto {
val libraries =
if (principal.user.sharedAllLibraries) {
libraryRepository.findAll()
} else {
libraryRepository.findAllByIds(principal.user.sharedLibrariesIds)
}
return FeedGroupDto(
metadata = FeedMetadataDto(
title = "Libraries",
),
links = listOf(linkSelf("libraries")),
navigation = libraries.map { it.toWPLinkDto() },
)
}
private fun getLibraryNavigation(user: KomgaUser, libraryId: String?): List<WPLinkDto> {
val uriBuilder = uriBuilder("libraries${if (libraryId != null) "/$libraryId" else ""}")
val collections = collectionRepository.findAll(libraryId?.let { listOf(it) }, restrictions = user.restrictions, pageable = Pageable.ofSize(1))
val readLists = readListRepository.findAll(libraryId?.let { listOf(it) }, restrictions = user.restrictions, pageable = Pageable.ofSize(1))
return listOfNotNull(
WPLinkDto("Recommended", OpdsLinkRel.SUBSECTION, href = uriBuilder.toUriString(), type = MEDIATYPE_OPDS_JSON),
WPLinkDto("Browse", OpdsLinkRel.SUBSECTION, href = uriBuilder.cloneBuilder().pathSegment("browse").toUriString(), type = MEDIATYPE_OPDS_JSON),
if (collections.isEmpty) null
else WPLinkDto("Collections", OpdsLinkRel.SUBSECTION, href = uriBuilder.cloneBuilder().pathSegment("collections").toUriString(), type = MEDIATYPE_OPDS_JSON),
if (readLists.isEmpty) null
else WPLinkDto("Read lists", OpdsLinkRel.SUBSECTION, href = uriBuilder.cloneBuilder().pathSegment("readlists").toUriString(), type = MEDIATYPE_OPDS_JSON),
)
}
private fun quickCache() = Pair(mutableMapOf<String, Media>(), mutableMapOf<String, SeriesMetadata>())
private fun mapBooks(page: Page<BookDto>, mediaCache: MutableMap<String, Media>, seriesCache: MutableMap<String, SeriesMetadata>): List<WPPublicationDto> =
page.content.map { bookDto ->
bookDto.toPublicationDto(
mediaCache.getOrPut(bookDto.id) { mediaRepository.findById(bookDto.id) },
seriesCache.getOrPut(bookDto.seriesId) { seriesMetadataRepository.findById(bookDto.seriesId) },
)
}
@GetMapping(value = [ROUTE_CATALOG, "libraries", "libraries/{id}"])
fun getLibrariesRecommended(
@AuthenticationPrincipal principal: KomgaPrincipal,
@PathVariable(name = "id", required = false) libraryId: String?,
): FeedDto {
val (library, authorizedLibraryIds) = checkLibraryAccess(libraryId, principal)
// quick memoization
val (mediaCache, seriesCache) = quickCache()
val keepReading = bookDtoRepository.findAll(
BookSearchWithReadProgress(
libraryIds = authorizedLibraryIds,
readStatus = setOf(ReadStatus.IN_PROGRESS),
mediaStatus = setOf(Media.Status.READY),
deleted = false,
),
principal.user.id,
PageRequest.of(0, RECOMMENDED_ITEMS_NUMBER, Sort.by(Sort.Order.desc("readProgress.readDate"))),
principal.user.restrictions,
)
val onDeck = bookDtoRepository.findAllOnDeck(
principal.user.id,
authorizedLibraryIds,
Pageable.ofSize(RECOMMENDED_ITEMS_NUMBER),
principal.user.restrictions,
)
val latestBooks = bookDtoRepository.findAll(
BookSearchWithReadProgress(
libraryIds = authorizedLibraryIds,
mediaStatus = setOf(Media.Status.READY),
deleted = false,
),
principal.user.id,
PageRequest.of(0, RECOMMENDED_ITEMS_NUMBER, Sort.by(Sort.Order.desc("createdDate"))),
principal.user.restrictions,
)
val latestSeries = seriesDtoRepository.findAll(
SeriesSearchWithReadProgress(
libraryIds = authorizedLibraryIds,
deleted = false,
oneshot = false,
),
principal.user.id,
PageRequest.of(0, RECOMMENDED_ITEMS_NUMBER, Sort.by(Sort.Order.desc("lastModified"))),
principal.user.restrictions,
).map { it.toWPLinkDto() }
val uriBuilder = uriBuilder("libraries${if (library != null) "/${library.id}" else ""}")
return FeedDto(
metadata = FeedMetadataDto(
title = (library?.name ?: "All libraries") + " - Recommended",
modified = library?.lastModifiedDate?.atZone(ZoneId.systemDefault()) ?: ZonedDateTime.now(),
),
links = listOf(
linkSelf(uriBuilder),
linkStart(),
linkSearch(),
),
navigation = getLibraryNavigation(principal.user, libraryId),
groups = listOfNotNull(
if (library == null) getLibrariesFeedGroup(principal) else null,
if (!keepReading.isEmpty) FeedGroupDto(
FeedMetadataDto("Keep Reading", page = keepReading),
links = listOf(WPLinkDto("Keep Reading", OpdsLinkRel.SELF, uriBuilder.cloneBuilder().pathSegment("keep-reading").toUriString(), MEDIATYPE_OPDS_JSON)),
publications = mapBooks(keepReading, mediaCache, seriesCache),
) else null,
if (!onDeck.isEmpty) FeedGroupDto(
FeedMetadataDto("On Deck", page = onDeck),
links = listOf(WPLinkDto("On Deck", OpdsLinkRel.SELF, uriBuilder.cloneBuilder().pathSegment("on-deck").toUriString(), MEDIATYPE_OPDS_JSON)),
publications = mapBooks(onDeck, mediaCache, seriesCache),
) else null,
if (!latestBooks.isEmpty) FeedGroupDto(
FeedMetadataDto("Latest Books", page = latestBooks),
links = listOf(WPLinkDto("Latest Books", OpdsLinkRel.SELF, uriBuilder.cloneBuilder().pathSegment("books", "latest").toUriString(), MEDIATYPE_OPDS_JSON)),
publications = mapBooks(latestBooks, mediaCache, seriesCache),
) else null,
if (!latestSeries.isEmpty) FeedGroupDto(
FeedMetadataDto("Latest Series", page = latestSeries),
links = listOf(WPLinkDto("Latest Series", OpdsLinkRel.SELF, uriBuilder.cloneBuilder().pathSegment("series", "latest").toUriString(), MEDIATYPE_OPDS_JSON)),
navigation = latestSeries.content,
) else null,
),
)
}
@PageAsQueryParam
@GetMapping(value = ["libraries/keep-reading", "libraries/{id}/keep-reading"])
fun getKeepReading(
@AuthenticationPrincipal principal: KomgaPrincipal,
@PathVariable(name = "id", required = false) libraryId: String?,
@Parameter(hidden = true) page: Pageable,
): FeedDto {
val (library, authorizedLibraryIds) = checkLibraryAccess(libraryId, principal)
// quick memoization
val (mediaCache, seriesCache) = quickCache()
val entries = bookDtoRepository.findAll(
BookSearchWithReadProgress(
libraryIds = authorizedLibraryIds,
readStatus = setOf(ReadStatus.IN_PROGRESS),
mediaStatus = setOf(Media.Status.READY),
deleted = false,
),
principal.user.id,
PageRequest.of(page.pageNumber, page.pageSize, Sort.by(Sort.Order.desc("readProgress.readDate"))),
principal.user.restrictions,
)
val uriBuilder = uriBuilder("libraries${if (library != null) "/${library.id}" else ""}/keep-reading")
return FeedDto(
metadata = FeedMetadataDto(
title = (library?.name ?: "All libraries") + " - Keep Reading",
modified = library?.lastModifiedDate?.atZone(ZoneId.systemDefault()) ?: ZonedDateTime.now(),
page = entries,
),
links = listOf(
linkSelf(uriBuilder),
linkStart(),
linkSearch(),
*linkPage(uriBuilder, entries).toTypedArray(),
),
publications = mapBooks(entries, mediaCache, seriesCache),
)
}
@PageAsQueryParam
@GetMapping(value = ["libraries/on-deck", "libraries/{id}/on-deck"])
fun getOnDeck(
@AuthenticationPrincipal principal: KomgaPrincipal,
@PathVariable(name = "id", required = false) libraryId: String?,
@Parameter(hidden = true) page: Pageable,
): FeedDto {
val (library, authorizedLibraryIds) = checkLibraryAccess(libraryId, principal)
// quick memoization
val (mediaCache, seriesCache) = quickCache()
val entries = bookDtoRepository.findAllOnDeck(
principal.user.id,
authorizedLibraryIds,
page,
principal.user.restrictions,
)
val uriBuilder = uriBuilder("libraries${if (library != null) "/${library.id}" else ""}/on-deck")
return FeedDto(
metadata = FeedMetadataDto(
title = (library?.name ?: "All libraries") + " - On Deck",
modified = library?.lastModifiedDate?.atZone(ZoneId.systemDefault()) ?: ZonedDateTime.now(),
page = entries,
),
links = listOf(
linkSelf(uriBuilder),
linkStart(),
linkSearch(),
*linkPage(uriBuilder, entries).toTypedArray(),
),
publications = mapBooks(entries, mediaCache, seriesCache),
)
}
@PageAsQueryParam
@GetMapping(value = ["libraries/books/latest", "libraries/{id}/books/latest"])
fun getLatestBooks(
@AuthenticationPrincipal principal: KomgaPrincipal,
@PathVariable(name = "id", required = false) libraryId: String?,
@Parameter(hidden = true) page: Pageable,
): FeedDto {
val (library, authorizedLibraryIds) = checkLibraryAccess(libraryId, principal)
// quick memoization
val (mediaCache, seriesCache) = quickCache()
val entries = bookDtoRepository.findAll(
BookSearchWithReadProgress(
libraryIds = authorizedLibraryIds,
mediaStatus = setOf(Media.Status.READY),
deleted = false,
),
principal.user.id,
PageRequest.of(page.pageNumber, page.pageSize, Sort.by(Sort.Order.desc("createdDate"))),
principal.user.restrictions,
)
val uriBuilder = uriBuilder("libraries${if (library != null) "/${library.id}" else ""}/books/latest")
return FeedDto(
metadata = FeedMetadataDto(
title = (library?.name ?: "All libraries") + " - Latest Books",
modified = library?.lastModifiedDate?.atZone(ZoneId.systemDefault()) ?: ZonedDateTime.now(),
page = entries,
),
links = listOf(
linkSelf(uriBuilder),
linkStart(),
linkSearch(),
*linkPage(uriBuilder, entries).toTypedArray(),
),
publications = mapBooks(entries, mediaCache, seriesCache),
)
}
@PageAsQueryParam
@GetMapping(value = ["libraries/series/latest", "libraries/{id}/series/latest"])
fun getLatestSeries(
@AuthenticationPrincipal principal: KomgaPrincipal,
@PathVariable(name = "id", required = false) libraryId: String?,
@Parameter(hidden = true) page: Pageable,
): FeedDto {
val (library, authorizedLibraryIds) = checkLibraryAccess(libraryId, principal)
val entries = seriesDtoRepository.findAll(
SeriesSearchWithReadProgress(
libraryIds = authorizedLibraryIds,
deleted = false,
oneshot = false,
),
principal.user.id,
PageRequest.of(page.pageNumber, page.pageSize, Sort.by(Sort.Order.desc("lastModified"))),
principal.user.restrictions,
).map { it.toWPLinkDto() }
val uriBuilder = uriBuilder("libraries${if (library != null) "/${library.id}" else ""}/books/latest")
return FeedDto(
metadata = FeedMetadataDto(
title = (library?.name ?: "All libraries") + " - Latest Series",
modified = library?.lastModifiedDate?.atZone(ZoneId.systemDefault()) ?: ZonedDateTime.now(),
page = entries,
),
links = listOf(
linkSelf(uriBuilder),
linkStart(),
linkSearch(),
*linkPage(uriBuilder, entries).toTypedArray(),
),
navigation = entries.content,
)
}
@PageAsQueryParam
@GetMapping(value = ["libraries/browse", "libraries/{id}/browse"])
fun getLibrariesBrowse(
@AuthenticationPrincipal principal: KomgaPrincipal,
@PathVariable(name = "id", required = false) libraryId: String?,
@RequestParam(name = "publisher", required = false) publishers: List<String>? = null,
@Parameter(hidden = true) page: Pageable,
): FeedDto {
val (library, authorizedLibraryIds) = checkLibraryAccess(libraryId, principal)
val seriesSearch = SeriesSearchWithReadProgress(
libraryIds = authorizedLibraryIds,
publishers = publishers,
deleted = false,
)
val pageable = PageRequest.of(page.pageNumber, page.pageSize, Sort.by(Sort.Order.asc("metadata.titleSort")))
val entries = seriesDtoRepository
.findAll(seriesSearch, principal.user.id, pageable, principal.user.restrictions)
.map { it.toWPLinkDto() }
val uriBuilder = uriBuilder("libraries${if (library != null) "/${library.id}" else ""}/browse")
val publisherLinks = referentialRepository.findAllPublishers(authorizedLibraryIds)
.map {
WPLinkDto(
title = it,
href = uriBuilder.cloneBuilder().queryParam("publisher", it).toUriString(),
type = MEDIATYPE_OPDS_JSON,
)
}
return FeedDto(
metadata = FeedMetadataDto(
title = library?.name ?: "All libraries",
modified = library?.lastModifiedDate?.atZone(ZoneId.systemDefault()) ?: ZonedDateTime.now(),
page = entries,
),
links = listOf(
linkSelf(uriBuilder),
linkStart(),
linkSearch(),
*linkPage(uriBuilder, entries).toTypedArray(),
),
navigation = getLibraryNavigation(principal.user, libraryId),
groups = listOfNotNull(
FeedGroupDto(FeedMetadataDto("Series"), navigation = entries.content),
if (publisherLinks.isNotEmpty()) FeedGroupDto(FeedMetadataDto("Publisher"), navigation = publisherLinks) else null,
),
)
}
@PageAsQueryParam
@GetMapping(value = ["libraries/collections", "libraries/{id}/collections"])
fun getLibrariesCollections(
@AuthenticationPrincipal principal: KomgaPrincipal,
@PathVariable(name = "id", required = false) libraryId: String?,
@Parameter(hidden = true) page: Pageable,
): FeedDto {
val (library, authorizedLibraryIds) = checkLibraryAccess(libraryId, principal)
val pageable = PageRequest.of(page.pageNumber, page.pageSize, Sort.by(Sort.Order.asc("name")))
val entries = collectionRepository.findAll(
authorizedLibraryIds,
authorizedLibraryIds,
pageable = pageable,
).map { it.toWPLinkDto() }
val uriBuilder = uriBuilder("libraries${if (library != null) "/${library.id}" else ""}/collections")
return FeedDto(
metadata = FeedMetadataDto(
title = (library?.name ?: "All libraries") + " - Collections",
modified = library?.lastModifiedDate?.atZone(ZoneId.systemDefault()) ?: ZonedDateTime.now(),
page = entries,
),
links = listOf(
linkSelf(uriBuilder),
linkStart(),
linkSearch(),
*linkPage(uriBuilder, entries).toTypedArray(),
),
navigation = getLibraryNavigation(principal.user, libraryId),
groups = listOfNotNull(
FeedGroupDto(FeedMetadataDto("Collections"), navigation = entries.content),
),
)
}
@PageAsQueryParam
@GetMapping("collections/{id}")
fun getOneCollection(
@AuthenticationPrincipal principal: KomgaPrincipal,
@PathVariable id: String,
@Parameter(hidden = true) page: Pageable,
): FeedDto =
collectionRepository.findByIdOrNull(id, principal.user.getAuthorizedLibraryIds(null))?.let { collection ->
val sort =
if (collection.ordered) Sort.by(Sort.Order.asc("collection.number"))
else Sort.by(Sort.Order.asc("metadata.titleSort"))
val pageable = PageRequest.of(page.pageNumber, page.pageSize, sort)
val seriesSearch = SeriesSearchWithReadProgress(
libraryIds = principal.user.getAuthorizedLibraryIds(null),
deleted = false,
)
val entries = seriesDtoRepository.findAllByCollectionId(collection.id, seriesSearch, principal.user.id, pageable, principal.user.restrictions)
.map { it.toWPLinkDto() }
val uriBuilder = uriBuilder("collections/$id")
FeedDto(
FeedMetadataDto(
title = collection.name,
modified = collection.lastModifiedDate.atZone(ZoneId.systemDefault()) ?: ZonedDateTime.now(),
page = entries,
),
links = listOf(
linkSelf(uriBuilder),
linkStart(),
linkSearch(),
*linkPage(uriBuilder, entries).toTypedArray(),
),
navigation = entries.content,
)
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
@PageAsQueryParam
@GetMapping(value = ["libraries/readlists", "libraries/{id}/readlists"])
fun getLibrariesReadLists(
@AuthenticationPrincipal principal: KomgaPrincipal,
@PathVariable(name = "id", required = false) libraryId: String?,
@Parameter(hidden = true) page: Pageable,
): FeedDto {
val (library, authorizedLibraryIds) = checkLibraryAccess(libraryId, principal)
val pageable = PageRequest.of(page.pageNumber, page.pageSize, Sort.by(Sort.Order.asc("name")))
val entries = readListRepository.findAll(
authorizedLibraryIds,
authorizedLibraryIds,
pageable = pageable,
).map { it.toWPLinkDto() }
val uriBuilder = uriBuilder("libraries${if (library != null) "/${library.id}" else ""}/readlists")
return FeedDto(
metadata = FeedMetadataDto(
title = (library?.name ?: "All libraries") + " - Read Lists",
modified = library?.lastModifiedDate?.atZone(ZoneId.systemDefault()) ?: ZonedDateTime.now(),
page = entries,
),
links = listOf(
linkSelf(uriBuilder),
linkStart(),
linkSearch(),
*linkPage(uriBuilder, entries).toTypedArray(),
),
navigation = getLibraryNavigation(principal.user, libraryId),
groups = listOfNotNull(
FeedGroupDto(FeedMetadataDto("Read Lists"), navigation = entries.content),
),
)
}
@PageAsQueryParam
@GetMapping("readlists/{id}")
fun getOneReadList(
@AuthenticationPrincipal principal: KomgaPrincipal,
@PathVariable id: String,
@Parameter(hidden = true) page: Pageable,
): FeedDto =
readListRepository.findByIdOrNull(id, principal.user.getAuthorizedLibraryIds(null))?.let { readList ->
val sort =
if (readList.ordered) Sort.by(Sort.Order.asc("readList.number"))
else Sort.by(Sort.Order.asc("metadata.releaseDate"))
val pageable = PageRequest.of(page.pageNumber, page.pageSize, sort)
val bookSearch = BookSearchWithReadProgress(
mediaStatus = setOf(Media.Status.READY),
deleted = false,
)
val booksPage = bookDtoRepository.findAllByReadListId(
readList.id,
principal.user.id,
principal.user.getAuthorizedLibraryIds(null),
bookSearch,
pageable,
principal.user.restrictions,
)
// quick memoization
val seriesCache = mutableMapOf<String, SeriesMetadata>()
val entries = booksPage.map { bookDto ->
bookDto.toPublicationDto(
mediaRepository.findById(bookDto.id),
seriesCache.getOrPut(bookDto.seriesId) { seriesMetadataRepository.findById(bookDto.seriesId) },
)
}
val uriBuilder = uriBuilder("readlists/$id")
FeedDto(
FeedMetadataDto(
title = readList.name,
modified = readList.lastModifiedDate.atZone(ZoneId.systemDefault()) ?: ZonedDateTime.now(),
page = entries,
),
links = listOf(
linkSelf(uriBuilder),
linkStart(),
linkSearch(),
*linkPage(uriBuilder, booksPage).toTypedArray(),
),
publications = entries.content,
)
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
private fun checkLibraryAccess(libraryId: String?, principal: KomgaPrincipal): Pair<Library?, Collection<String>?> {
val library = if (libraryId != null)
libraryRepository.findByIdOrNull(libraryId)?.let { library ->
if (!principal.user.canAccessLibrary(library)) throw ResponseStatusException(HttpStatus.FORBIDDEN)
library
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
else null
val libraryIds = principal.user.getAuthorizedLibraryIds(if (libraryId != null) listOf(libraryId) else null)
return Pair(library, libraryIds)
}
@PageAsQueryParam
@GetMapping("series/{id}")
fun getOneSeries(
@AuthenticationPrincipal principal: KomgaPrincipal,
@PathVariable id: String,
@RequestParam(name = "tag", required = false) tag: String? = null,
@Parameter(hidden = true) page: Pageable,
): FeedDto =
seriesDtoRepository.findByIdOrNull(id, "principal.user.id")?.let { series ->
principal.user.checkContentRestriction(series)
val bookSearch = BookSearchWithReadProgress(
seriesIds = listOf(id),
mediaStatus = setOf(Media.Status.READY),
tags = if (tag != null) listOf(tag) else null,
deleted = false,
)
val pageable = PageRequest.of(page.pageNumber, page.pageSize, Sort.by(Sort.Order.asc("metadata.numberSort")))
val entries = bookDtoRepository.findAll(bookSearch, principal.user.id, pageable, principal.user.restrictions)
.map { bookDto -> bookDto.toPublicationDto(mediaRepository.findById(bookDto.id), series) }
val uriBuilder = uriBuilder("series/$id")
val tagLinks = referentialRepository.findAllBookTagsBySeries(series.id, null)
.map {
WPLinkDto(
title = it,
href = uriBuilder.cloneBuilder().queryParam("tag", it).toUriString(),
type = MEDIATYPE_OPDS_JSON,
rel = if (it == tag) OpdsLinkRel.SELF else null,
)
}
FeedDto(
metadata = FeedMetadataDto(
title = series.metadata.title,
modified = series.lastModified.toCurrentTimeZone().atZone(ZoneId.systemDefault()) ?: ZonedDateTime.now(),
description = series.metadata.summary.ifBlank { series.booksMetadata.summary },
page = entries,
),
links = listOf(
linkSelf(uriBuilder),
linkStart(),
linkSearch(),
*linkPage(uriBuilder, entries).toTypedArray(),
),
publications = entries.content,
facets = listOfNotNull(
if (tagLinks.isNotEmpty()) FacetDto(FeedMetadataDto("Tag"), tagLinks) else null,
),
)
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
@GetMapping("search")
fun getSearchResults(
@AuthenticationPrincipal principal: KomgaPrincipal,
@RequestParam(name = "query", required = false) query: String? = null,
): FeedDto {
val pageable = PageRequest.of(0, 20, Sort.by("relevance"))
val (mediaCache, seriesCache) = quickCache()
val resultsSeries = seriesDtoRepository
.findAll(
SeriesSearchWithReadProgress(
libraryIds = principal.user.getAuthorizedLibraryIds(null),
searchTerm = query,
oneshot = false,
deleted = false,
),
principal.user.id,
pageable,
principal.user.restrictions,
)
.map { it.toWPLinkDto() }
val resultsBooks = bookDtoRepository
.findAll(
BookSearchWithReadProgress(
libraryIds = principal.user.getAuthorizedLibraryIds(null),
searchTerm = query,
deleted = false,
),
principal.user.id,
pageable,
principal.user.restrictions,
)
val resultsCollections = collectionRepository.findAll(
principal.user.getAuthorizedLibraryIds(null),
principal.user.getAuthorizedLibraryIds(null),
query,
pageable,
principal.user.restrictions,
).map { it.toWPLinkDto() }
val resultsReadLists = readListRepository.findAll(
principal.user.getAuthorizedLibraryIds(null),
principal.user.getAuthorizedLibraryIds(null),
query,
pageable,
principal.user.restrictions,
).map { it.toWPLinkDto() }
return FeedDto(
metadata = FeedMetadataDto(
title = "Search results",
modified = ZonedDateTime.now(),
),
links = listOf(
linkStart(),
linkSearch(),
),
groups = listOfNotNull(
if (!resultsSeries.isEmpty) FeedGroupDto(FeedMetadataDto("Series"), navigation = resultsSeries.content) else null,
if (!resultsBooks.isEmpty) FeedGroupDto(FeedMetadataDto("Books"), publications = mapBooks(resultsBooks, mediaCache, seriesCache)) else null,
if (!resultsCollections.isEmpty) FeedGroupDto(FeedMetadataDto("Collections"), navigation = resultsCollections.content) else null,
if (!resultsReadLists.isEmpty) FeedGroupDto(FeedMetadataDto("Read Lists"), navigation = resultsReadLists.content) else null,
),
)
}
private fun Library.toWPLinkDto(): WPLinkDto =
WPLinkDto(
title = name,
href = uriBuilder("libraries/$id").toUriString(),
type = MEDIATYPE_OPDS_JSON,
)
private fun SeriesDto.toWPLinkDto(): WPLinkDto =
WPLinkDto(
title = metadata.title,
href = uriBuilder("series/$id").toUriString(),
type = MEDIATYPE_OPDS_JSON,
)
private fun SeriesCollection.toWPLinkDto(): WPLinkDto =
WPLinkDto(
title = name,
href = uriBuilder("collections/$id").toUriString(),
type = MEDIATYPE_OPDS_JSON,
)
private fun ReadList.toWPLinkDto(): WPLinkDto =
WPLinkDto(
title = name,
href = uriBuilder("readlists/$id").toUriString(),
type = MEDIATYPE_OPDS_JSON,
)
private fun BookDto.toPublicationDto(media: Media, seriesMetadata: SeriesMetadata?): WPPublicationDto =
toWPPublicationDto(imageConverter::canConvertMediaType, media, seriesMetadata)
private fun BookDto.toPublicationDto(media: Media, seriesDto: SeriesDto?): WPPublicationDto =
toWPPublicationDto(imageConverter::canConvertMediaType, media, seriesDto = seriesDto)
}

View file

@ -0,0 +1,54 @@
package org.gotson.komga.interfaces.api.opds.v2.dto
import com.fasterxml.jackson.annotation.JsonAlias
import com.fasterxml.jackson.annotation.JsonIgnore
import com.fasterxml.jackson.annotation.JsonInclude
import jakarta.validation.constraints.Positive
import jakarta.validation.constraints.PositiveOrZero
import org.gotson.komga.interfaces.api.dto.WPLinkDto
import org.gotson.komga.interfaces.api.dto.WPPublicationDto
import org.springframework.data.domain.Page
import java.time.ZonedDateTime
@JsonInclude(JsonInclude.Include.NON_EMPTY)
data class FeedDto(
val metadata: FeedMetadataDto,
val links: List<WPLinkDto> = emptyList(),
val navigation: List<WPLinkDto> = emptyList(),
val facets: List<FacetDto> = emptyList(),
val groups: List<FeedGroupDto> = emptyList(),
val publications: List<WPPublicationDto> = emptyList(),
)
@JsonInclude(JsonInclude.Include.NON_EMPTY)
data class FacetDto(
val metadata: FeedMetadataDto,
val links: List<WPLinkDto> = emptyList(),
)
@JsonInclude(JsonInclude.Include.NON_EMPTY)
data class FeedGroupDto(
val metadata: FeedMetadataDto,
val links: List<WPLinkDto> = emptyList(),
val navigation: List<WPLinkDto> = emptyList(),
val publications: List<WPPublicationDto> = emptyList(),
)
@JsonInclude(JsonInclude.Include.NON_EMPTY)
data class FeedMetadataDto(
val title: String,
val subTitle: String? = null,
@JsonAlias("@type")
val type: String? = null,
val identifier: String? = null,
val modified: ZonedDateTime? = null,
val description: String? = null,
@JsonIgnore
val page: Page<*>? = null,
@Positive
val itemsPerPage: Int? = page?.size,
@Positive
val currentPage: Int? = page?.number?.plus(1),
@PositiveOrZero
val numberOfItems: Long? = page?.totalElements,
)

View file

@ -32,6 +32,7 @@ import org.gotson.komga.domain.persistence.ReadListRepository
import org.gotson.komga.domain.persistence.SeriesMetadataRepository
import org.gotson.komga.domain.persistence.ThumbnailBookRepository
import org.gotson.komga.domain.service.BookLifecycle
import org.gotson.komga.infrastructure.image.ImageConverter
import org.gotson.komga.infrastructure.image.ImageType
import org.gotson.komga.infrastructure.jooq.UnpagedSorted
import org.gotson.komga.infrastructure.mediacontainer.ContentDetector
@ -40,6 +41,12 @@ import org.gotson.komga.infrastructure.swagger.PageableAsQueryParam
import org.gotson.komga.infrastructure.swagger.PageableWithoutSortAsQueryParam
import org.gotson.komga.infrastructure.web.getMediaTypeOrDefault
import org.gotson.komga.infrastructure.web.setCachePrivate
import org.gotson.komga.interfaces.api.checkContentRestriction
import org.gotson.komga.interfaces.api.dto.MEDIATYPE_DIVINA_JSON
import org.gotson.komga.interfaces.api.dto.MEDIATYPE_DIVINA_JSON_VALUE
import org.gotson.komga.interfaces.api.dto.MEDIATYPE_WEBPUB_JSON_VALUE
import org.gotson.komga.interfaces.api.dto.WPPublicationDto
import org.gotson.komga.interfaces.api.dto.toWPPublicationDto
import org.gotson.komga.interfaces.api.persistence.BookDtoRepository
import org.gotson.komga.interfaces.api.rest.dto.BookDto
import org.gotson.komga.interfaces.api.rest.dto.BookImportBatchDto
@ -104,6 +111,7 @@ class BookController(
private val contentDetector: ContentDetector,
private val eventPublisher: EventPublisher,
private val thumbnailBookRepository: ThumbnailBookRepository,
private val imageConverter: ImageConverter,
) {
@PageableAsQueryParam
@ -233,7 +241,7 @@ class BookController(
@AuthenticationPrincipal principal: KomgaPrincipal,
@PathVariable bookId: String,
): BookDto {
principal.user.checkContentRestriction(bookId)
principal.user.checkContentRestriction(bookId, bookRepository, seriesMetadataRepository)
return bookDtoRepository.findPreviousInSeriesOrNull(bookId, principal.user.id)
?.restrictUrl(!principal.user.roleAdmin)
@ -245,7 +253,7 @@ class BookController(
@AuthenticationPrincipal principal: KomgaPrincipal,
@PathVariable bookId: String,
): BookDto {
principal.user.checkContentRestriction(bookId)
principal.user.checkContentRestriction(bookId, bookRepository, seriesMetadataRepository)
return bookDtoRepository.findNextInSeriesOrNull(bookId, principal.user.id)
?.restrictUrl(!principal.user.roleAdmin)
@ -257,7 +265,7 @@ class BookController(
@AuthenticationPrincipal principal: KomgaPrincipal,
@PathVariable(name = "bookId") bookId: String,
): List<ReadListDto> {
principal.user.checkContentRestriction(bookId)
principal.user.checkContentRestriction(bookId, bookRepository, seriesMetadataRepository)
return readListRepository.findAllContainingBookId(bookId, principal.user.getAuthorizedLibraryIds(null), principal.user.restrictions)
.map { it.toDto() }
@ -272,7 +280,7 @@ class BookController(
@AuthenticationPrincipal principal: KomgaPrincipal,
@PathVariable bookId: String,
): ByteArray {
principal.user.checkContentRestriction(bookId)
principal.user.checkContentRestriction(bookId, bookRepository, seriesMetadataRepository)
return bookLifecycle.getThumbnailBytes(bookId) ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
}
@ -284,7 +292,7 @@ class BookController(
@PathVariable(name = "bookId") bookId: String,
@PathVariable(name = "thumbnailId") thumbnailId: String,
): ByteArray {
principal.user.checkContentRestriction(bookId)
principal.user.checkContentRestriction(bookId, bookRepository, seriesMetadataRepository)
return bookLifecycle.getThumbnailBytesByThumbnailId(thumbnailId)
?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
@ -295,7 +303,7 @@ class BookController(
@AuthenticationPrincipal principal: KomgaPrincipal,
@PathVariable(name = "bookId") bookId: String,
): Collection<ThumbnailBookDto> {
principal.user.checkContentRestriction(bookId)
principal.user.checkContentRestriction(bookId, bookRepository, seriesMetadataRepository)
return thumbnailBookRepository.findAllByBookId(bookId)
.map { it.toDto() }
@ -362,6 +370,7 @@ class BookController(
"api/v1/books/{bookId}/file",
"api/v1/books/{bookId}/file/*",
"opds/v1.2/books/{bookId}/file/*",
"opds/v2/books/{bookId}/file",
],
produces = [MediaType.APPLICATION_OCTET_STREAM_VALUE],
)
@ -443,6 +452,7 @@ class BookController(
value = [
"api/v1/books/{bookId}/pages/{pageNumber}",
"opds/v1.2/books/{bookId}/pages/{pageNumber}",
"opds/v2/books/{bookId}/pages/{pageNumber}",
],
produces = [MediaType.ALL_VALUE],
)
@ -551,6 +561,31 @@ class BookController(
}
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
@GetMapping(
value = [
"api/v1/books/{bookId}/manifest",
"opds/v2/books/{bookId}/manifest",
],
produces = [MEDIATYPE_WEBPUB_JSON_VALUE, MEDIATYPE_DIVINA_JSON_VALUE],
)
@PreAuthorize("hasRole('$ROLE_FILE_DOWNLOAD')")
fun getWebPubManifest(
@AuthenticationPrincipal principal: KomgaPrincipal,
@PathVariable bookId: String,
): ResponseEntity<WPPublicationDto> {
return bookDtoRepository.findByIdOrNull(bookId, principal.user.id)?.let { bookDto ->
principal.user.checkContentRestriction(bookDto)
val manifest = bookDto.toWPPublicationDto(
imageConverter::canConvertMediaType,
mediaRepository.findById(bookDto.id),
seriesMetadataRepository.findById(bookDto.seriesId),
)
ResponseEntity.ok()
.contentType(MEDIATYPE_DIVINA_JSON)
.body(manifest)
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
}
@PostMapping("api/v1/books/{bookId}/analyze")
@PreAuthorize("hasRole('$ROLE_ADMIN')")
@ResponseStatus(HttpStatus.ACCEPTED)
@ -716,23 +751,4 @@ class BookController(
if (!isContentAllowed(it.ageRating, it.sharingLabels)) throw ResponseStatusException(HttpStatus.FORBIDDEN)
}
}
/**
* Convenience function to check for content restriction.
* This will retrieve data from repositories if needed.
*
* @throws[ResponseStatusException] if the user cannot access the content
*/
private fun KomgaUser.checkContentRestriction(bookId: String) {
if (!sharedAllLibraries) {
bookRepository.getLibraryIdOrNull(bookId)?.let {
if (!canAccessLibrary(it)) throw ResponseStatusException(HttpStatus.FORBIDDEN)
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
}
if (restrictions.isRestricted) bookRepository.getSeriesIdOrNull(bookId)?.let { seriesId ->
seriesMetadataRepository.findById(seriesId).let {
if (!isContentAllowed(it.ageRating, it.sharingLabels)) throw ResponseStatusException(HttpStatus.FORBIDDEN)
}
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
}
}