feat(opds): discoverable Progression API, used by Aldiko/Cantook

Refs: https://github.com/opds-community/drafts/discussions/67#discussioncomment-6414507
This commit is contained in:
Gauthier Roebroeck 2024-04-18 13:24:28 +08:00
parent 14bee1732a
commit 443d8a70b4
5 changed files with 75 additions and 41 deletions

View file

@ -13,10 +13,13 @@ import org.gotson.komga.domain.model.Media
import org.gotson.komga.domain.model.MediaNotReadyException
import org.gotson.komga.domain.model.MediaProfile
import org.gotson.komga.domain.model.MediaUnsupportedException
import org.gotson.komga.domain.model.R2Progression
import org.gotson.komga.domain.model.ROLE_FILE_DOWNLOAD
import org.gotson.komga.domain.model.ROLE_PAGE_STREAMING
import org.gotson.komga.domain.model.toR2Progression
import org.gotson.komga.domain.persistence.BookRepository
import org.gotson.komga.domain.persistence.MediaRepository
import org.gotson.komga.domain.persistence.ReadProgressRepository
import org.gotson.komga.domain.persistence.SeriesMetadataRepository
import org.gotson.komga.domain.service.BookAnalyzer
import org.gotson.komga.domain.service.BookLifecycle
@ -24,6 +27,7 @@ import org.gotson.komga.infrastructure.image.ImageType
import org.gotson.komga.infrastructure.mediacontainer.ContentDetector
import org.gotson.komga.infrastructure.security.KomgaPrincipal
import org.gotson.komga.infrastructure.web.getMediaTypeOrDefault
import org.gotson.komga.interfaces.api.dto.MEDIATYPE_PROGRESSION_JSON_VALUE
import org.gotson.komga.interfaces.api.persistence.BookDtoRepository
import org.springframework.core.io.FileSystemResource
import org.springframework.http.ContentDisposition
@ -36,7 +40,10 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.util.MimeTypeUtils
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PutMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.ResponseStatus
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.context.request.ServletWebRequest
import org.springframework.web.server.ResponseStatusException
@ -61,6 +68,7 @@ class CommonBookController(
private val bookAnalyzer: BookAnalyzer,
private val contentRestrictionChecker: ContentRestrictionChecker,
private val contentDetector: ContentDetector,
private val readProgressRepository: ReadProgressRepository,
) {
fun getWebPubManifestInternal(
principal: KomgaPrincipal,
@ -345,4 +353,48 @@ class CommonBookController(
throw ResponseStatusException(HttpStatus.NOT_FOUND, "File not found, it may have moved")
}
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
@GetMapping(
value = [
"api/v1/books/{bookId}/progression",
"opds/v2/books/{bookId}/progression",
],
produces = [MEDIATYPE_PROGRESSION_JSON_VALUE],
)
fun getProgression(
@AuthenticationPrincipal principal: KomgaPrincipal,
@PathVariable bookId: String,
): ResponseEntity<R2Progression> =
bookRepository.findByIdOrNull(bookId)?.let { book ->
contentRestrictionChecker.checkContentRestriction(principal.user, book)
readProgressRepository.findByBookIdAndUserIdOrNull(bookId, principal.user.id)?.let {
ResponseEntity.ok(it.toR2Progression())
} ?: ResponseEntity.noContent().build()
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
@PutMapping(
value = [
"api/v1/books/{bookId}/progression",
"opds/v2/books/{bookId}/progression",
],
)
@ResponseStatus(HttpStatus.NO_CONTENT)
fun markProgression(
@AuthenticationPrincipal principal: KomgaPrincipal,
@PathVariable bookId: String,
@RequestBody progression: R2Progression,
) {
bookRepository.findByIdOrNull(bookId)?.let { book ->
contentRestrictionChecker.checkContentRestriction(principal.user, book)
try {
bookLifecycle.markProgression(book, principal.user, progression)
} catch (e: IllegalStateException) {
throw ResponseStatusException(HttpStatus.CONFLICT, e.message)
} catch (e: IllegalArgumentException) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, e.message)
}
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
}
}

View file

@ -7,6 +7,8 @@ import org.gotson.komga.infrastructure.image.ImageType
import org.gotson.komga.interfaces.api.dto.MEDIATYPE_OPDS_AUTHENTICATION_JSON_VALUE
import org.gotson.komga.interfaces.api.dto.MEDIATYPE_OPDS_JSON_VALUE
import org.gotson.komga.interfaces.api.dto.MEDIATYPE_OPDS_PUBLICATION_JSON
import org.gotson.komga.interfaces.api.dto.MEDIATYPE_PROGRESSION_JSON_VALUE
import org.gotson.komga.interfaces.api.dto.REL_PROGRESSION_API
import org.gotson.komga.interfaces.api.dto.WPLinkDto
import org.gotson.komga.interfaces.api.dto.WPPublicationDto
import org.gotson.komga.interfaces.api.opds.v2.ROUTE_AUTH
@ -46,14 +48,27 @@ class OpdsGenerator(
mapOf(
"authenticate" to
mapOf(
"href" to ServletUriComponentsBuilder.fromCurrentContextPath().pathSegment("opds", "v2").path(ROUTE_AUTH).toUriString(),
"href" to ServletUriComponentsBuilder.fromCurrentContextPath().pathSegment(*pathSegments.toTypedArray()).path(ROUTE_AUTH).toUriString(),
"type" to MEDIATYPE_OPDS_AUTHENTICATION_JSON_VALUE,
),
)
override fun getExtraLinks(bookId: String): List<WPLinkDto> {
return buildList {
add(
WPLinkDto(
type = MEDIATYPE_PROGRESSION_JSON_VALUE,
rel = REL_PROGRESSION_API,
href = ServletUriComponentsBuilder.fromCurrentContextPath().pathSegment(*pathSegments.toTypedArray()).path("books/$bookId/progression").toUriString(),
properties = getExtraLinkProperties(),
),
)
}
}
fun generateOpdsAuthDocument() =
AuthenticationDocumentDto(
id = ServletUriComponentsBuilder.fromCurrentContextPath().pathSegment("opds", "v2").path(ROUTE_AUTH).toUriString(),
id = ServletUriComponentsBuilder.fromCurrentContextPath().pathSegment(*pathSegments.toTypedArray()).path(ROUTE_AUTH).toUriString(),
title = "Komga",
description = "Enter your email and password to authenticate.",
links =

View file

@ -240,6 +240,8 @@ class WebPubGenerator(
protected fun getExtraLinkProperties(): Map<String, Map<String, Any>> = emptyMap()
protected fun getExtraLinks(bookId: String): List<WPLinkDto> = emptyList()
private fun BookDto.toWPLinkDtos(uriBuilder: UriComponentsBuilder): List<WPLinkDto> {
val komgaMediaType = KomgaMediaType.fromMediaType(media.mediaType)
return buildList {
@ -250,6 +252,8 @@ class WebPubGenerator(
add(WPLinkDto(href = uriBuilder.cloneBuilder().path("books/$id/manifest/divina").toUriString(), type = MEDIATYPE_DIVINA_JSON_VALUE, properties = getExtraLinkProperties()))
// main acquisition link
add(WPLinkDto(rel = OpdsLinkRel.ACQUISITION, type = komgaMediaType?.exportType ?: media.mediaType, href = uriBuilder.cloneBuilder().path("books/$id/file").toUriString(), properties = getExtraLinkProperties()))
// extra links
addAll(getExtraLinks(id))
}
}

View file

@ -14,6 +14,8 @@ const val PROFILE_DIVINA = "https://readium.org/webpub-manifest/profiles/divina"
const val PROFILE_EPUB = "https://readium.org/webpub-manifest/profiles/epub"
const val PROFILE_PDF = "https://readium.org/webpub-manifest/profiles/pdf"
const val REL_PROGRESSION_API = "http://www.cantook.com/api/progression"
val MEDIATYPE_OPDS_PUBLICATION_JSON = MediaType("application", "opds-publication+json")
val MEDIATYPE_DIVINA_JSON = MediaType("application", "divina+json")
val MEDIATYPE_WEBPUB_JSON = MediaType("application", "webpub+json")

View file

@ -21,12 +21,10 @@ import org.gotson.komga.domain.model.Media
import org.gotson.komga.domain.model.MediaExtensionEpub
import org.gotson.komga.domain.model.MediaNotReadyException
import org.gotson.komga.domain.model.MediaProfile
import org.gotson.komga.domain.model.R2Progression
import org.gotson.komga.domain.model.ROLE_ADMIN
import org.gotson.komga.domain.model.ROLE_PAGE_STREAMING
import org.gotson.komga.domain.model.ReadStatus
import org.gotson.komga.domain.model.ThumbnailBook
import org.gotson.komga.domain.model.toR2Progression
import org.gotson.komga.domain.persistence.BookMetadataRepository
import org.gotson.komga.domain.persistence.BookRepository
import org.gotson.komga.domain.persistence.MediaRepository
@ -48,7 +46,6 @@ import org.gotson.komga.interfaces.api.WebPubGenerator
import org.gotson.komga.interfaces.api.dto.MEDIATYPE_DIVINA_JSON_VALUE
import org.gotson.komga.interfaces.api.dto.MEDIATYPE_POSITION_LIST_JSON
import org.gotson.komga.interfaces.api.dto.MEDIATYPE_POSITION_LIST_JSON_VALUE
import org.gotson.komga.interfaces.api.dto.MEDIATYPE_PROGRESSION_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.getBookLastModified
@ -527,42 +524,6 @@ class BookController(
.body(R2Positions(extension.positions.size, extension.positions))
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
@GetMapping(
value = ["api/v1/books/{bookId}/progression"],
produces = [MEDIATYPE_PROGRESSION_JSON_VALUE],
)
fun getProgression(
@AuthenticationPrincipal principal: KomgaPrincipal,
@PathVariable bookId: String,
): ResponseEntity<R2Progression> =
bookRepository.findByIdOrNull(bookId)?.let { book ->
contentRestrictionChecker.checkContentRestriction(principal.user, book)
readProgressRepository.findByBookIdAndUserIdOrNull(bookId, principal.user.id)?.let {
ResponseEntity.ok(it.toR2Progression())
} ?: ResponseEntity.noContent().build()
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
@PutMapping("api/v1/books/{bookId}/progression")
@ResponseStatus(HttpStatus.NO_CONTENT)
fun markProgression(
@AuthenticationPrincipal principal: KomgaPrincipal,
@PathVariable bookId: String,
@RequestBody progression: R2Progression,
) {
bookRepository.findByIdOrNull(bookId)?.let { book ->
contentRestrictionChecker.checkContentRestriction(principal.user, book)
try {
bookLifecycle.markProgression(book, principal.user, progression)
} catch (e: IllegalStateException) {
throw ResponseStatusException(HttpStatus.CONFLICT, e.message)
} catch (e: IllegalArgumentException) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, e.message)
}
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
}
@GetMapping(
value = ["api/v1/books/{bookId}/manifest/epub"],
produces = [MEDIATYPE_WEBPUB_JSON_VALUE],