diff --git a/komga/src/main/kotlin/org/gotson/komga/infrastructure/image/ImageConverter.kt b/komga/src/main/kotlin/org/gotson/komga/infrastructure/image/ImageConverter.kt index 6b90e3a7..43813190 100644 --- a/komga/src/main/kotlin/org/gotson/komga/infrastructure/image/ImageConverter.kt +++ b/komga/src/main/kotlin/org/gotson/komga/infrastructure/image/ImageConverter.kt @@ -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()) diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/Utils.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/Utils.kt index b3e60c2b..96387f04 100644 --- a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/Utils.kt +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/Utils.kt @@ -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) +} diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/dto/OpdsLinkRel.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/dto/OpdsLinkRel.kt new file mode 100644 index 00000000..e402fe35 --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/dto/OpdsLinkRel.kt @@ -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" + } +} diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/dto/WepPub.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/dto/WepPub.kt new file mode 100644 index 00000000..aa57489e --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/dto/WepPub.kt @@ -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 = emptyList(), +) + +@JsonInclude(JsonInclude.Include.NON_EMPTY) +data class WPPublicationDto( + val metadata: WPMetadataDto, + val links: List, + val images: List, + val readingOrder: List, + val resources: List = emptyList(), + val toc: List = 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 = emptyList(), + val translator: List = emptyList(), + val editor: List = emptyList(), + val artist: List = emptyList(), + val illustrator: List = emptyList(), + val letterer: List = emptyList(), + val penciler: List = emptyList(), + val colorist: List = emptyList(), + val inker: List = emptyList(), + val contributor: List = emptyList(), + val publisher: List = emptyList(), + val subject: List = 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 = emptyList(), + val collection: List = 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, +} diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/dto/WepPubHelper.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/dto/WepPubHelper.kt new file mode 100644 index 00000000..932eabc4 --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/dto/WepPubHelper.kt @@ -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): 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(), + ) + }, + ) +} diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/opds/OpdsCommonController.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/opds/OpdsCommonController.kt new file mode 100644 index 00000000..64c81fef --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/opds/OpdsCommonController.kt @@ -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) + } + } +} diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/opds/OpdsController.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/opds/v1/OpdsController.kt similarity index 91% rename from komga/src/main/kotlin/org/gotson/komga/interfaces/api/opds/OpdsController.kt rename to komga/src/main/kotlin/org/gotson/komga/interfaces/api/opds/v1/OpdsController.kt index 0835eebb..7d7df325 100644 --- a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/opds/OpdsController.kt +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/opds/v1/OpdsController.kt @@ -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) - } } diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/opds/dto/OpdsAuthor.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/opds/v1/dto/OpdsAuthor.kt similarity index 86% rename from komga/src/main/kotlin/org/gotson/komga/interfaces/api/opds/dto/OpdsAuthor.kt rename to komga/src/main/kotlin/org/gotson/komga/interfaces/api/opds/v1/dto/OpdsAuthor.kt index 647c56ee..04dcc42f 100644 --- a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/opds/dto/OpdsAuthor.kt +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/opds/v1/dto/OpdsAuthor.kt @@ -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 diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/opds/dto/OpdsEntry.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/opds/v1/dto/OpdsEntry.kt similarity index 96% rename from komga/src/main/kotlin/org/gotson/komga/interfaces/api/opds/dto/OpdsEntry.kt rename to komga/src/main/kotlin/org/gotson/komga/interfaces/api/opds/v1/dto/OpdsEntry.kt index a1e78b64..b21105a3 100644 --- a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/opds/dto/OpdsEntry.kt +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/opds/v1/dto/OpdsEntry.kt @@ -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 diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/opds/dto/OpdsFeed.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/opds/v1/dto/OpdsFeed.kt similarity index 96% rename from komga/src/main/kotlin/org/gotson/komga/interfaces/api/opds/dto/OpdsFeed.kt rename to komga/src/main/kotlin/org/gotson/komga/interfaces/api/opds/v1/dto/OpdsFeed.kt index 976c81cf..242fedcc 100644 --- a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/opds/dto/OpdsFeed.kt +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/opds/v1/dto/OpdsFeed.kt @@ -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 diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/opds/dto/OpdsLink.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/opds/v1/dto/OpdsLink.kt similarity index 84% rename from komga/src/main/kotlin/org/gotson/komga/interfaces/api/opds/dto/OpdsLink.kt rename to komga/src/main/kotlin/org/gotson/komga/interfaces/api/opds/v1/dto/OpdsLink.kt index 7c449506..03ad0745 100644 --- a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/opds/dto/OpdsLink.kt +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/opds/v1/dto/OpdsLink.kt @@ -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" - } -} diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/opds/dto/OpenSearchDescription.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/opds/v1/dto/OpenSearchDescription.kt similarity index 95% rename from komga/src/main/kotlin/org/gotson/komga/interfaces/api/opds/dto/OpenSearchDescription.kt rename to komga/src/main/kotlin/org/gotson/komga/interfaces/api/opds/v1/dto/OpenSearchDescription.kt index 4bbb0266..40312734 100644 --- a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/opds/dto/OpenSearchDescription.kt +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/opds/v1/dto/OpenSearchDescription.kt @@ -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 diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/opds/dto/XmlNamespaces.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/opds/v1/dto/XmlNamespaces.kt similarity index 76% rename from komga/src/main/kotlin/org/gotson/komga/interfaces/api/opds/dto/XmlNamespaces.kt rename to komga/src/main/kotlin/org/gotson/komga/interfaces/api/opds/v1/dto/XmlNamespaces.kt index ad85080d..146fe03d 100644 --- a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/opds/dto/XmlNamespaces.kt +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/opds/v1/dto/XmlNamespaces.kt @@ -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" diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/opds/v2/Opds2Controller.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/opds/v2/Opds2Controller.kt new file mode 100644 index 00000000..f1d3e68c --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/opds/v2/Opds2Controller.kt @@ -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 { + 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 { + 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(), mutableMapOf()) + + private fun mapBooks(page: Page, mediaCache: MutableMap, seriesCache: MutableMap): List = + 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? = 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() + + 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?> { + 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) +} diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/opds/v2/dto/Opds2Dto.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/opds/v2/dto/Opds2Dto.kt new file mode 100644 index 00000000..ae848c68 --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/opds/v2/dto/Opds2Dto.kt @@ -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 = emptyList(), + val navigation: List = emptyList(), + val facets: List = emptyList(), + val groups: List = emptyList(), + val publications: List = emptyList(), +) + +@JsonInclude(JsonInclude.Include.NON_EMPTY) +data class FacetDto( + val metadata: FeedMetadataDto, + val links: List = emptyList(), +) + +@JsonInclude(JsonInclude.Include.NON_EMPTY) +data class FeedGroupDto( + val metadata: FeedMetadataDto, + val links: List = emptyList(), + val navigation: List = emptyList(), + val publications: List = 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, +) diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/BookController.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/BookController.kt index b943346b..fe01d858 100644 --- a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/BookController.kt +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/BookController.kt @@ -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 { - 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 { - 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 { + 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) - } }