mirror of
https://github.com/gotson/komga.git
synced 2026-04-16 20:11:30 +02:00
feat(opds): opds v2 support
This commit is contained in:
parent
8bdc4d8cad
commit
d1cb58b21b
16 changed files with 1210 additions and 99 deletions
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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(),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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"
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue