feat(opds2): generate PDF profile webpub manifest

This commit is contained in:
Gauthier Roebroeck 2023-09-05 15:00:13 +08:00
parent 2c33b3e0f1
commit 7205b1372d
6 changed files with 96 additions and 7 deletions

View file

@ -9,6 +9,6 @@ enum class MediaType(val type: String, val fileExtension: String, val exportType
;
companion object {
fun fromMediaType(mediaType: String): MediaType? = values().firstOrNull { it.type == mediaType }
fun fromMediaType(mediaType: String?): MediaType? = values().firstOrNull { it.type == mediaType }
}
}

View file

@ -4,6 +4,7 @@ import org.gotson.komga.domain.model.Media
interface MediaRepository {
fun findById(bookId: String): Media
fun findByIdOrNull(bookId: String): Media?
fun findAllBookAndSeriesIdsByLibraryIdAndMediaTypeAndWithMissingPageHash(libraryId: String, mediaTypes: Collection<String>, pageHashing: Int): Collection<Pair<String, String>>

View file

@ -29,6 +29,9 @@ class MediaDao(
private val groupFields = arrayOf(*m.fields(), *p.fields())
override fun findById(bookId: String): Media =
find(dsl, bookId)!!
override fun findByIdOrNull(bookId: String): Media? =
find(dsl, bookId)
override fun findAllBookAndSeriesIdsByLibraryIdAndMediaTypeAndWithMissingPageHash(libraryId: String, mediaTypes: Collection<String>, pageHashing: Int): Collection<Pair<String, String>> {
@ -64,7 +67,7 @@ class MediaDao(
.fetch()
.map { Pair(it[m.BOOK_ID], it[m.PAGE_COUNT]) }
private fun find(dsl: DSLContext, bookId: String): Media =
private fun find(dsl: DSLContext, bookId: String): Media? =
dsl.select(*groupFields)
.from(m)
.leftJoin(p).on(m.BOOK_ID.eq(p.BOOK_ID))
@ -81,7 +84,7 @@ class MediaDao(
.map { it.fileName }
mr.toDomain(pr.filterNot { it.bookId == null }.map { it.toDomain() }, files)
}.first()
}.firstOrNull()
@Transactional
override fun insert(media: Media) {

View file

@ -4,8 +4,11 @@ import org.springframework.http.MediaType
const val MEDIATYPE_OPDS_JSON_VALUE = "application/opds+json"
const val MEDIATYPE_DIVINA_JSON_VALUE = "application/divina+json"
const val MEDIATYPE_WEBPUB_JSON_VALUE = "application/webpub+json"
const val PROFILE_DIVINA = "https://readium.org/webpub-manifest/profiles/divina"
const val PROFILE_PDF = "https://readium.org/webpub-manifest/profiles/pdf"
val MEDIATYPE_OPDS_PUBLICATION_JSON = MediaType("application", "opds-publication+json")
val MEDIATYPE_DIVINA_JSON = MediaType("application", "webpub+json")
val MEDIATYPE_DIVINA_JSON = MediaType("application", "divina+json")
val MEDIATYPE_WEBPUB_JSON = MediaType("application", "webpub+json")

View file

@ -2,6 +2,7 @@ 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.MediaType.PDF
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
@ -15,6 +16,7 @@ import org.springframework.web.servlet.support.ServletUriComponentsBuilder
import org.springframework.web.util.UriComponentsBuilder
import java.time.ZoneId
import java.time.ZonedDateTime
import org.gotson.komga.domain.model.MediaType as KMediaType
import org.gotson.komga.domain.model.MediaType.Companion as KomgaMediaType
val wpKnownRoles = listOf(
@ -76,6 +78,24 @@ fun BookDto.toManifestDivina(canConvertMediaType: (String, String) -> Boolean, m
}
}
fun BookDto.toManifestPdf(media: Media, seriesMetadata: SeriesMetadata): WPPublicationDto {
val uriBuilder = ServletUriComponentsBuilder.fromCurrentContextPath().pathSegment("api", "v1")
return toOpdsPublicationDto().let {
it.copy(
mediaType = MEDIATYPE_WEBPUB_JSON,
metadata = it.metadata
.withSeriesMetadata(seriesMetadata)
.copy(conformsTo = PROFILE_PDF),
readingOrder = List(media.pages.size) { index: Int ->
WPLinkDto(
href = uriBuilder.cloneBuilder().path("books/$id/pages/${index + 1}/raw").toUriString(),
type = PDF.type,
)
},
)
}
}
private fun BookDto.toWPMetadataDto(includeOpdsLinks: Boolean = false) = WPMetadataDto(
title = metadata.title,
description = metadata.summary,
@ -130,10 +150,26 @@ private fun WPMetadataDto.withAuthors(authors: List<AuthorDto>): WPMetadataDto {
}
private fun BookDto.toWPLinkDtos(uriBuilder: UriComponentsBuilder): List<WPLinkDto> {
val komgaMediaType = KomgaMediaType.fromMediaType(media.mediaType)
val download = WPLinkDto(rel = OpdsLinkRel.ACQUISITION, type = media.mediaType, href = uriBuilder.cloneBuilder().path("books/$id/file").toUriString())
return listOfNotNull(
WPLinkDto(rel = OpdsLinkRel.SELF, href = uriBuilder.cloneBuilder().path("books/$id/manifest/divina").toUriString(), type = MEDIATYPE_DIVINA_JSON_VALUE),
// most appropriate manifest
WPLinkDto(rel = OpdsLinkRel.SELF, href = uriBuilder.cloneBuilder().path("books/$id/manifest").toUriString(), type = mediaTypeToWebPub(komgaMediaType)),
// PDF is also available under the Divina profile
if (komgaMediaType == PDF) WPLinkDto(href = uriBuilder.cloneBuilder().path("books/$id/manifest/divina").toUriString(), type = MEDIATYPE_DIVINA_JSON_VALUE) else null,
// main acquisition link
download,
KomgaMediaType.fromMediaType(media.mediaType)?.let { download.copy(type = it.exportType) },
// extra acquisition link with a different export type, useful for CBR/CBZ
komgaMediaType?.let { download.copy(type = it.exportType) },
).distinct()
}
private fun mediaTypeToWebPub(mediaType: KMediaType?): String = when (mediaType) {
KMediaType.ZIP -> MEDIATYPE_DIVINA_JSON_VALUE
KMediaType.RAR_GENERIC -> MEDIATYPE_DIVINA_JSON_VALUE
KMediaType.RAR_4 -> MEDIATYPE_DIVINA_JSON_VALUE
KMediaType.EPUB -> MEDIATYPE_DIVINA_JSON_VALUE
PDF -> MEDIATYPE_WEBPUB_JSON_VALUE
null -> MEDIATYPE_WEBPUB_JSON_VALUE
}

View file

@ -21,6 +21,11 @@ import org.gotson.komga.domain.model.KomgaUser
import org.gotson.komga.domain.model.MarkSelectedPreference
import org.gotson.komga.domain.model.Media
import org.gotson.komga.domain.model.MediaNotReadyException
import org.gotson.komga.domain.model.MediaType.EPUB
import org.gotson.komga.domain.model.MediaType.PDF
import org.gotson.komga.domain.model.MediaType.RAR_4
import org.gotson.komga.domain.model.MediaType.RAR_GENERIC
import org.gotson.komga.domain.model.MediaType.ZIP
import org.gotson.komga.domain.model.MediaUnsupportedException
import org.gotson.komga.domain.model.ROLE_ADMIN
import org.gotson.komga.domain.model.ROLE_FILE_DOWNLOAD
@ -46,8 +51,10 @@ 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_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.toManifestDivina
import org.gotson.komga.interfaces.api.dto.toManifestPdf
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
@ -95,6 +102,7 @@ import java.nio.file.NoSuchFileException
import java.time.LocalDate
import java.time.ZoneOffset
import kotlin.io.path.name
import org.gotson.komga.domain.model.MediaType as KomgaMediaType
private val logger = KotlinLogging.logger {}
@ -611,10 +619,48 @@ class BookController(
}
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
@GetMapping(
value = ["api/v1/books/{bookId}/manifest"],
produces = [MEDIATYPE_WEBPUB_JSON_VALUE, MEDIATYPE_DIVINA_JSON_VALUE],
)
fun getWebPubManifest(
@AuthenticationPrincipal principal: KomgaPrincipal,
@PathVariable bookId: String,
): ResponseEntity<WPPublicationDto> =
mediaRepository.findByIdOrNull(bookId)?.let { media ->
when (KomgaMediaType.fromMediaType(media.mediaType)) {
ZIP -> getWebPubManifestDivina(principal, bookId)
RAR_GENERIC -> getWebPubManifestDivina(principal, bookId)
RAR_4 -> getWebPubManifestDivina(principal, bookId)
EPUB -> getWebPubManifestDivina(principal, bookId)
PDF -> getWebPubManifestPdf(principal, bookId)
null -> throw ResponseStatusException(HttpStatus.NOT_FOUND, "Book analysis failed")
}
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
@GetMapping(
value = ["api/v1/books/{bookId}/manifest/pdf"],
produces = [MEDIATYPE_WEBPUB_JSON_VALUE],
)
fun getWebPubManifestPdf(
@AuthenticationPrincipal principal: KomgaPrincipal,
@PathVariable bookId: String,
): ResponseEntity<WPPublicationDto> =
bookDtoRepository.findByIdOrNull(bookId, principal.user.id)?.let { bookDto ->
if (bookDto.media.mediaType != KomgaMediaType.PDF.type) throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Book media type '${bookDto.media.mediaType}' not compatible with requested profile")
principal.user.checkContentRestriction(bookDto)
val manifest = bookDto.toManifestPdf(
mediaRepository.findById(bookDto.id),
seriesMetadataRepository.findById(bookDto.seriesId),
)
ResponseEntity.ok()
.contentType(manifest.mediaType)
.body(manifest)
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
@GetMapping(
value = [
"api/v1/books/{bookId}/manifest/divina",
"api/v1/books/{bookId}/manifest",
],
produces = [MEDIATYPE_DIVINA_JSON_VALUE],
)