support for OPDS feed with OpenSearch and Page Streaming Extension (https://vaemendis.net/opds-pse/)

This commit is contained in:
Gauthier Roebroeck 2019-09-11 15:19:25 +08:00
parent 1024240990
commit 55fb8da568
10 changed files with 400 additions and 5 deletions

View file

@ -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<GithubReleaseTask> {
dependsOn(tasks.bootJar)
}
}

View file

@ -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()

View file

@ -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<ByteArray> {
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) {

View file

@ -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&amp;zerobased=true", metadata.pages.size)
)
)
}

View file

@ -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
)

View file

@ -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<OpdsLink>
) : OpdsEntry(title, updated, id, content)

View file

@ -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<OpdsLink>,
@JacksonXmlElementWrapper(useWrapping = false)
@JacksonXmlProperty(localName = "entry", namespace = ATOM)
val entries: List<OpdsEntry>
)
@JsonSerialize(`as` = OpdsFeed::class)
class OpdsFeedNavigation(
id: String,
title: String,
updated: ZonedDateTime,
author: OpdsAuthor,
links: List<OpdsLink>,
entries: List<OpdsEntryNavigation>
) : OpdsFeed(id, title, updated, author, links, entries)
@JsonSerialize(`as` = OpdsFeed::class)
class OpdsFeedAcquisition(
id: String,
title: String,
updated: ZonedDateTime,
author: OpdsAuthor,
links: List<OpdsLink>,
entries: List<OpdsEntryAcquisition>
) : OpdsFeed(id, title, updated, author, links, entries)

View file

@ -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"
}
}

View file

@ -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"
}
}

View file

@ -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/"