mirror of
https://github.com/gotson/komga.git
synced 2025-12-29 03:43:43 +01:00
support for OPDS feed with OpenSearch and Page Streaming Extension (https://vaemendis.net/opds-pse/)
This commit is contained in:
parent
1024240990
commit
55fb8da568
10 changed files with 400 additions and 5 deletions
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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/"
|
||||
Loading…
Reference in a new issue