From 55fb8da568cd5ecaa41b2d603206cb723d79ba67 Mon Sep 17 00:00:00 2001 From: Gauthier Roebroeck Date: Wed, 11 Sep 2019 15:19:25 +0800 Subject: [PATCH] support for OPDS feed with OpenSearch and Page Streaming Extension (https://vaemendis.net/opds-pse/) --- komga/build.gradle.kts | 3 +- .../security/SecurityConfiguration.kt | 5 +- .../komga/interfaces/web/SerieController.kt | 9 +- .../interfaces/web/opds/OpdsController.kt | 164 ++++++++++++++++++ .../interfaces/web/opds/dto/OpdsAuthor.kt | 12 ++ .../interfaces/web/opds/dto/OpdsEntry.kt | 45 +++++ .../komga/interfaces/web/opds/dto/OpdsFeed.kt | 50 ++++++ .../komga/interfaces/web/opds/dto/OpdsLink.kt | 79 +++++++++ .../web/opds/dto/OpenSearchDescription.kt | 33 ++++ .../interfaces/web/opds/dto/XmlNamespaces.kt | 5 + 10 files changed, 400 insertions(+), 5 deletions(-) create mode 100644 komga/src/main/kotlin/org/gotson/komga/interfaces/web/opds/OpdsController.kt create mode 100644 komga/src/main/kotlin/org/gotson/komga/interfaces/web/opds/dto/OpdsAuthor.kt create mode 100644 komga/src/main/kotlin/org/gotson/komga/interfaces/web/opds/dto/OpdsEntry.kt create mode 100644 komga/src/main/kotlin/org/gotson/komga/interfaces/web/opds/dto/OpdsFeed.kt create mode 100644 komga/src/main/kotlin/org/gotson/komga/interfaces/web/opds/dto/OpdsLink.kt create mode 100644 komga/src/main/kotlin/org/gotson/komga/interfaces/web/opds/dto/OpenSearchDescription.kt create mode 100644 komga/src/main/kotlin/org/gotson/komga/interfaces/web/opds/dto/XmlNamespaces.kt diff --git a/komga/build.gradle.kts b/komga/build.gradle.kts index 62951fe41..881770369 100644 --- a/komga/build.gradle.kts +++ b/komga/build.gradle.kts @@ -53,6 +53,7 @@ dependencies { } implementation("com.fasterxml.jackson.module:jackson-module-kotlin:2.9.9") + implementation("com.fasterxml.jackson.dataformat:jackson-dataformat-xml:2.9.9") implementation("com.github.klinq:klinq-jpaspec:0.8") @@ -126,4 +127,4 @@ githubRelease { } tasks.withType { dependsOn(tasks.bootJar) -} +} \ No newline at end of file diff --git a/komga/src/main/kotlin/org/gotson/komga/infrastructure/security/SecurityConfiguration.kt b/komga/src/main/kotlin/org/gotson/komga/infrastructure/security/SecurityConfiguration.kt index 1cceb6a3a..eb7e64c44 100644 --- a/komga/src/main/kotlin/org/gotson/komga/infrastructure/security/SecurityConfiguration.kt +++ b/komga/src/main/kotlin/org/gotson/komga/infrastructure/security/SecurityConfiguration.kt @@ -35,7 +35,10 @@ class SecurityConfiguration( .requestMatchers(PathRequest.toH2Console()).hasRole("ADMIN") // all other endpoints are restricted to authenticated users - .antMatchers("/api/**").hasRole("USER") + .antMatchers( + "/api/**", + "/opds/**" + ).hasRole("USER") // authorize frames for H2 console .and().headers().frameOptions().sameOrigin() diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/web/SerieController.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/web/SerieController.kt index 6ef4d6174..0dad060eb 100644 --- a/komga/src/main/kotlin/org/gotson/komga/interfaces/web/SerieController.kt +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/web/SerieController.kt @@ -39,7 +39,7 @@ import java.time.ZoneOffset private val logger = KotlinLogging.logger {} @RestController -@RequestMapping("api/v1/series") +@RequestMapping("api/v1/series", produces = [MediaType.APPLICATION_JSON_VALUE]) class SerieController( private val serieRepository: SerieRepository, private val bookRepository: BookRepository, @@ -172,7 +172,8 @@ class SerieController( @PathVariable serieId: Long, @PathVariable bookId: Long, @PathVariable pageNumber: Int, - @RequestParam(value = "convert") convertTo: String? + @RequestParam(value = "convert") convertTo: String?, + @RequestParam(value = "zerobased", defaultValue = "false") zeroBasedIndex: Boolean ): ResponseEntity { if (!serieRepository.existsById(serieId)) throw ResponseStatusException(HttpStatus.NOT_FOUND) @@ -185,8 +186,10 @@ class SerieController( else -> throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid conversion format: $convertTo") } + val pageNum = if (zeroBasedIndex) pageNumber + 1 else pageNumber + val pageContent = try { - bookManager.getBookPage(book, pageNumber, convertFormat) + bookManager.getBookPage(book, pageNum, convertFormat) } catch (e: UnsupportedMediaTypeException) { throw ResponseStatusException(HttpStatus.BAD_REQUEST, e.message) } catch (e: Exception) { diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/web/opds/OpdsController.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/web/opds/OpdsController.kt new file mode 100644 index 000000000..a8b13caa8 --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/web/opds/OpdsController.kt @@ -0,0 +1,164 @@ +package org.gotson.komga.interfaces.web.opds + +import com.github.klinq.jpaspec.likeLower +import org.gotson.komga.domain.model.Book +import org.gotson.komga.domain.model.Serie +import org.gotson.komga.domain.persistence.SerieRepository +import org.gotson.komga.interfaces.web.opds.dto.OpdsAuthor +import org.gotson.komga.interfaces.web.opds.dto.OpdsEntryAcquisition +import org.gotson.komga.interfaces.web.opds.dto.OpdsEntryNavigation +import org.gotson.komga.interfaces.web.opds.dto.OpdsFeed +import org.gotson.komga.interfaces.web.opds.dto.OpdsFeedAcquisition +import org.gotson.komga.interfaces.web.opds.dto.OpdsFeedNavigation +import org.gotson.komga.interfaces.web.opds.dto.OpdsLinkFeedNavigation +import org.gotson.komga.interfaces.web.opds.dto.OpdsLinkFileAcquisition +import org.gotson.komga.interfaces.web.opds.dto.OpdsLinkImage +import org.gotson.komga.interfaces.web.opds.dto.OpdsLinkImageThumbnail +import org.gotson.komga.interfaces.web.opds.dto.OpdsLinkPageStreaming +import org.gotson.komga.interfaces.web.opds.dto.OpdsLinkRel +import org.gotson.komga.interfaces.web.opds.dto.OpdsLinkSearch +import org.gotson.komga.interfaces.web.opds.dto.OpenSearchDescription +import org.springframework.data.domain.Sort +import org.springframework.data.repository.findByIdOrNull +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType +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 java.net.URI +import java.time.ZoneId +import java.time.ZonedDateTime + +private const val ROUTE_BASE = "/opds/v1.2/" +private const val ROUTE_CATALOG = "catalog" +private const val ROUTE_SERIES_ALL = "series" +private const val ROUTE_SERIES_LATEST = "series/latest" +private const val ROUTE_SEARCH = "search" + +private const val ID_SERIES_ALL = "allSeries" +private const val ID_SERIES_LATEST = "latestSeries" + +@RestController +@RequestMapping(value = [ROUTE_BASE], produces = [MediaType.TEXT_XML_VALUE]) +class OpdsController( + private val serieRepository: SerieRepository +) { + + private val komgaAuthor = OpdsAuthor("Komga", URI("https://github.com/gotson/komga")) + private val linkStart = OpdsLinkFeedNavigation(OpdsLinkRel.START, "${ROUTE_BASE}${ROUTE_CATALOG}") + private val linkSearch = OpdsLinkSearch("${ROUTE_BASE}${ROUTE_SEARCH}") + + private val feedCatalog = OpdsFeedNavigation( + id = "root", + title = "Komga OPDS catalog", + updated = ZonedDateTime.now(), + author = komgaAuthor, + links = listOf( + OpdsLinkFeedNavigation(OpdsLinkRel.SELF, "${ROUTE_BASE}${ROUTE_CATALOG}"), + linkStart, + linkSearch + ), + entries = listOf( + OpdsEntryNavigation("All series", ZonedDateTime.now(), ID_SERIES_ALL, "All series.", OpdsLinkFeedNavigation(OpdsLinkRel.SUBSECTION, "${ROUTE_BASE}${ROUTE_SERIES_ALL}")), + OpdsEntryNavigation("Latest series", ZonedDateTime.now(), ID_SERIES_LATEST, "Latest series.", OpdsLinkFeedNavigation(OpdsLinkRel.SUBSECTION, "${ROUTE_BASE}${ROUTE_SERIES_LATEST}")) + ) + ) + + private val openSearchDescription = OpenSearchDescription( + shortName = "Search", + description = "Search for series", + url = OpenSearchDescription.OpenSearchUrl("${ROUTE_BASE}${ROUTE_SERIES_ALL}?search={searchTerms}") + ) + + @GetMapping(ROUTE_CATALOG) + fun getCatalog(): OpdsFeed = feedCatalog + + @GetMapping(ROUTE_SEARCH) + fun getSearch(): OpenSearchDescription = openSearchDescription + + @GetMapping(ROUTE_SERIES_ALL) + fun getAllSeries( + @RequestParam("search") + searchTerm: String? + ): OpdsFeed { + val series = if (!searchTerm.isNullOrEmpty()) { + val spec = Serie::name.likeLower("%$searchTerm%") + serieRepository.findAll(spec) + } else { + serieRepository.findAll() + } + + return OpdsFeedNavigation( + id = ID_SERIES_ALL, + title = "All series", + updated = ZonedDateTime.now(), + author = komgaAuthor, + links = listOf( + OpdsLinkFeedNavigation(OpdsLinkRel.SELF, "${ROUTE_BASE}${ROUTE_SERIES_ALL}"), + linkStart + ), + entries = series.map { it.toOpdsEntry() } + ) + } + + @GetMapping(ROUTE_SERIES_LATEST) + fun getLatestSeries(): OpdsFeed { + val series = serieRepository.findAll(Sort(Sort.Direction.DESC, "lastModifiedDate")) + return OpdsFeedNavigation( + id = ID_SERIES_LATEST, + title = "Latest series", + updated = ZonedDateTime.now(), + author = komgaAuthor, + links = listOf( + OpdsLinkFeedNavigation(OpdsLinkRel.SELF, "${ROUTE_BASE}${ROUTE_SERIES_LATEST}"), + linkStart + ), + entries = series.map { it.toOpdsEntry() } + ) + } + + @GetMapping("series/{id}") + fun getOneSerie( + @PathVariable id: Long + ): OpdsFeed = + serieRepository.findByIdOrNull(id)?.let { + OpdsFeedAcquisition( + id = it.id.toString(), + title = it.name, + updated = it.lastModifiedDate?.atZone(ZoneId.systemDefault()) ?: ZonedDateTime.now(), + author = komgaAuthor, + links = listOf( + OpdsLinkFeedNavigation(OpdsLinkRel.SELF, "${ROUTE_BASE}series/$id"), + linkStart + ), + entries = it.books.map { it.toOpdsEntry() } + ) + } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + + private fun Serie.toOpdsEntry() = + OpdsEntryNavigation( + title = name, + updated = lastModifiedDate?.atZone(ZoneId.systemDefault()) ?: ZonedDateTime.now(), + id = id.toString(), + content = "", + link = OpdsLinkFeedNavigation(OpdsLinkRel.SUBSECTION, "${ROUTE_BASE}series/$id") + ) + + private fun Book.toOpdsEntry() = + OpdsEntryAcquisition( + title = name, + updated = lastModifiedDate?.atZone(ZoneId.systemDefault()) ?: ZonedDateTime.now(), + id = id.toString(), + content = "", + links = listOf( + OpdsLinkImageThumbnail("image/png", "/api/v1/series/${serie.id}/books/$id/thumbnail"), + OpdsLinkImage(metadata.pages[0].mediaType, "/api/v1/series/${serie.id}/books/$id/pages/1"), + OpdsLinkFileAcquisition(metadata.mediaType + ?: "application/octet-stream", "/api/v1/series/${serie.id}/books/$id/file"), + OpdsLinkPageStreaming("image/jpeg", "/api/v1/series/${serie.id}/books/$id/pages/{pageNumber}?convert=jpeg&zerobased=true", metadata.pages.size) + ) + ) +} \ No newline at end of file diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/web/opds/dto/OpdsAuthor.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/web/opds/dto/OpdsAuthor.kt new file mode 100644 index 000000000..0511bd836 --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/web/opds/dto/OpdsAuthor.kt @@ -0,0 +1,12 @@ +package org.gotson.komga.interfaces.web.opds.dto + +import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlProperty +import java.net.URI + +data class OpdsAuthor( + @JacksonXmlProperty(namespace = ATOM) + val name: String, + + @JacksonXmlProperty(namespace = ATOM) + val uri: URI +) \ No newline at end of file diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/web/opds/dto/OpdsEntry.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/web/opds/dto/OpdsEntry.kt new file mode 100644 index 000000000..2aa4177af --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/web/opds/dto/OpdsEntry.kt @@ -0,0 +1,45 @@ +package org.gotson.komga.interfaces.web.opds.dto + +import com.fasterxml.jackson.annotation.JsonInclude +import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlElementWrapper +import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlProperty +import java.time.ZonedDateTime + +@JsonInclude(JsonInclude.Include.NON_NULL) +abstract class OpdsEntry( + @get:JacksonXmlProperty(namespace = ATOM) + val title: String, + + @get:JacksonXmlProperty(namespace = ATOM) + val updated: ZonedDateTime, + + @get:JacksonXmlProperty(namespace = ATOM) + val id: String, + + @get:JacksonXmlProperty(namespace = ATOM) + val content: String +) + +class OpdsEntryNavigation( + title: String, + updated: ZonedDateTime, + id: String, + content: String, + + @JacksonXmlProperty(namespace = ATOM) + val link: OpdsLink +) : OpdsEntry(title, updated, id, content) + +class OpdsEntryAcquisition( + title: String, + updated: ZonedDateTime, + id: String, + content: String, + + @JacksonXmlProperty(namespace = ATOM) + val author: OpdsAuthor? = null, + + @JacksonXmlElementWrapper(useWrapping = false) + @JacksonXmlProperty(localName = "link", namespace = ATOM) + val links: List +) : OpdsEntry(title, updated, id, content) \ No newline at end of file diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/web/opds/dto/OpdsFeed.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/web/opds/dto/OpdsFeed.kt new file mode 100644 index 000000000..4b6d845f3 --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/web/opds/dto/OpdsFeed.kt @@ -0,0 +1,50 @@ +package org.gotson.komga.interfaces.web.opds.dto + +import com.fasterxml.jackson.databind.annotation.JsonSerialize +import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlElementWrapper +import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlProperty +import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlRootElement +import java.time.ZonedDateTime + +@JacksonXmlRootElement(localName = "feed", namespace = ATOM) +abstract class OpdsFeed( + @JacksonXmlProperty(namespace = ATOM) + val id: String, + + @JacksonXmlProperty(namespace = ATOM) + val title: String, + + @JacksonXmlProperty(namespace = ATOM) + val updated: ZonedDateTime, + + @JacksonXmlProperty(namespace = ATOM) + val author: OpdsAuthor, + + @JacksonXmlElementWrapper(useWrapping = false) + @JacksonXmlProperty(localName = "link", namespace = ATOM) + val links: List, + + @JacksonXmlElementWrapper(useWrapping = false) + @JacksonXmlProperty(localName = "entry", namespace = ATOM) + val entries: List +) + +@JsonSerialize(`as` = OpdsFeed::class) +class OpdsFeedNavigation( + id: String, + title: String, + updated: ZonedDateTime, + author: OpdsAuthor, + links: List, + entries: List +) : OpdsFeed(id, title, updated, author, links, entries) + +@JsonSerialize(`as` = OpdsFeed::class) +class OpdsFeedAcquisition( + id: String, + title: String, + updated: ZonedDateTime, + author: OpdsAuthor, + links: List, + entries: List +) : OpdsFeed(id, title, updated, author, links, entries) \ No newline at end of file diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/web/opds/dto/OpdsLink.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/web/opds/dto/OpdsLink.kt new file mode 100644 index 000000000..c0c66f4d6 --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/web/opds/dto/OpdsLink.kt @@ -0,0 +1,79 @@ +package org.gotson.komga.interfaces.web.opds.dto + +import com.fasterxml.jackson.databind.annotation.JsonSerialize +import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlProperty + +open class OpdsLink( + @get:JacksonXmlProperty(isAttribute = true) + val type: String, + + @get:JacksonXmlProperty(isAttribute = true) + val rel: String, + + @get:JacksonXmlProperty(isAttribute = true) + val href: String +) + +@JsonSerialize(`as` = OpdsLink::class) +class OpdsLinkFeedNavigation(rel: String, href: String) : OpdsLink( + type = "application/atom+xml;profile=opds-catalog;kind=navigation", + rel = rel, + href = href +) + +@JsonSerialize(`as` = OpdsLink::class) +class OpdsLinkFeedAcquisition(rel: String, href: String) : OpdsLink( + type = "application/atom+xml;profile=opds-catalog;kind=acquisition", + rel = rel, + href = href +) + +@JsonSerialize(`as` = OpdsLink::class) +class OpdsLinkImage(mediaType: String, href: String) : OpdsLink( + type = mediaType, + rel = "http://opds-spec.org/image", + href = href +) + +@JsonSerialize(`as` = OpdsLink::class) +class OpdsLinkImageThumbnail(mediaType: String, href: String) : OpdsLink( + type = mediaType, + rel = "http://opds-spec.org/image/thumbnail", + href = href +) + +@JsonSerialize(`as` = OpdsLink::class) +class OpdsLinkFileAcquisition(mediaType: String, href: String) : OpdsLink( + type = mediaType, + rel = "http://opds-spec.org/acquisition", + href = href +) + +@JsonSerialize(`as` = OpdsLink::class) +class OpdsLinkSearch(href: String) : OpdsLink( + type = "application/opensearchdescription+xml", + rel = "search", + href = href +) + +class OpdsLinkPageStreaming( + mediaType: String, + href: String, + + @get:JacksonXmlProperty(isAttribute = true, namespace = OPDS_PSE) + val count: Int +) : OpdsLink( + type = mediaType, + rel = "http://vaemendis.net/opds-pse/stream", + href = href +) + +class OpdsLinkRel { + companion object { + val SELF = "self" + val START = "start" + val SUBSECTION = "subsection" + val SORT_NEW = "http://opds-spec.org/sort/new" + val SORT_POPULAR = "http://opds-spec.org/sort/popular" + } +} \ No newline at end of file diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/web/opds/dto/OpenSearchDescription.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/web/opds/dto/OpenSearchDescription.kt new file mode 100644 index 000000000..842836509 --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/web/opds/dto/OpenSearchDescription.kt @@ -0,0 +1,33 @@ +package org.gotson.komga.interfaces.web.opds.dto + +import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlProperty +import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlRootElement +import javax.validation.constraints.Size + +@JacksonXmlRootElement(localName = "OpenSearchDescription", namespace = OPENSEARCH) +class OpenSearchDescription( + @JacksonXmlProperty(localName = "ShortName", namespace = OPENSEARCH) + @Size(min = 1, max = 16) + val shortName: String, + + @JacksonXmlProperty(localName = "Description", namespace = OPENSEARCH) + @Size(min = 1, max = 1024) + val description: String, + + @JacksonXmlProperty(localName = "InputEncoding", namespace = OPENSEARCH) + val inputEncoding: String = "UTF-8", + + @JacksonXmlProperty(localName = "OutputEncoding", namespace = OPENSEARCH) + val outputEncoding: String = "UTF-8", + + @JacksonXmlProperty(localName = "Url", namespace = OPENSEARCH) + val url: OpenSearchUrl +) { + class OpenSearchUrl( + @JacksonXmlProperty(isAttribute = true) + val template: String + ) { + @JacksonXmlProperty(isAttribute = true) + val type = "application/atom+xml;profile=opds-catalog;kind=acquisition" + } +} \ No newline at end of file diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/web/opds/dto/XmlNamespaces.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/web/opds/dto/XmlNamespaces.kt new file mode 100644 index 000000000..7affe775a --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/web/opds/dto/XmlNamespaces.kt @@ -0,0 +1,5 @@ +package org.gotson.komga.interfaces.web.opds.dto + +const val ATOM = "http://www.w3.org/2005/Atom" +const val OPDS_PSE = "http://vaemendis.net/opds-pse/ns" +const val OPENSEARCH = "http://a9.com/-/spec/opensearch/1.1/" \ No newline at end of file