mirror of
https://github.com/gotson/komga.git
synced 2026-01-03 06:16:34 +01:00
feat(api): add positions endpoint to get pre-computed positions of epub books
This commit is contained in:
parent
834b51d744
commit
eb8a644234
15 changed files with 435 additions and 60 deletions
|
|
@ -0,0 +1,13 @@
|
|||
update MEDIA
|
||||
set EXTENSION_VALUE = null;
|
||||
|
||||
update media
|
||||
set STATUS = 'OUTDATED'
|
||||
where MEDIA_TYPE = 'application/epub+zip'
|
||||
and STATUS = 'READY';
|
||||
|
||||
ALTER TABLE MEDIA
|
||||
RENAME COLUMN EXTENSION_VALUE to _UNUSED;
|
||||
|
||||
alter table MEDIA
|
||||
add column EXTENSION_VALUE_BLOB blob NULL;
|
||||
|
|
@ -1,9 +1,31 @@
|
|||
package org.gotson.komga.domain.model
|
||||
|
||||
import kotlin.reflect.KClass
|
||||
import kotlin.reflect.full.isSubclassOf
|
||||
|
||||
interface MediaExtension
|
||||
|
||||
class ProxyExtension private constructor(
|
||||
val extensionClassName: String,
|
||||
) : MediaExtension {
|
||||
|
||||
companion object {
|
||||
fun of(extensionClass: String?): ProxyExtension? =
|
||||
extensionClass?.let {
|
||||
val kClass = Class.forName(extensionClass).kotlin
|
||||
if (kClass.qualifiedName != MediaExtension::class.qualifiedName && kClass.isSubclassOf(MediaExtension::class)) ProxyExtension(extensionClass)
|
||||
else null
|
||||
}
|
||||
}
|
||||
|
||||
inline fun <reified T> proxyForType(): Boolean = T::class.qualifiedName == extensionClassName
|
||||
fun proxyForType(clazz: KClass<out Any>): Boolean = clazz.qualifiedName == extensionClassName
|
||||
}
|
||||
|
||||
data class MediaExtensionEpub(
|
||||
val toc: List<EpubTocEntry> = emptyList(),
|
||||
val landmarks: List<EpubTocEntry> = emptyList(),
|
||||
val pageList: List<EpubTocEntry> = emptyList(),
|
||||
val isFixedLayout: Boolean = false,
|
||||
val positions: List<R2Locator> = emptyList(),
|
||||
) : MediaExtension
|
||||
|
|
|
|||
|
|
@ -0,0 +1,88 @@
|
|||
package org.gotson.komga.domain.model
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude
|
||||
|
||||
/**
|
||||
* Locators are meant to provide a precise location in a publication in a format that can be stored and shared.
|
||||
*
|
||||
* There are many different use cases for locators:
|
||||
* - reporting and saving the current progression
|
||||
* - bookmarks
|
||||
* - highlights & annotations
|
||||
* - search results
|
||||
* - human-readable (as-in shareable) references
|
||||
* - jumping to a location
|
||||
* - enhancing a table of contents
|
||||
*
|
||||
* Each locator must contain a reference to a resource in a publication (href and type). href must not point to the fragment of a resource.
|
||||
*
|
||||
* It may also contain:
|
||||
* - a title (`title`)
|
||||
* - one or more locations in a resource (grouped together in locations)
|
||||
* - one or more text references, if the resource is a document (`text`)
|
||||
*/
|
||||
@JsonInclude(JsonInclude.Include.NON_EMPTY)
|
||||
data class R2Locator(
|
||||
/**
|
||||
* The URI of the resource that the Locator Object points to.
|
||||
*/
|
||||
val href: String,
|
||||
/**
|
||||
* The media type of the resource that the Locator Object points to.
|
||||
*/
|
||||
val type: String,
|
||||
/**
|
||||
* The title of the chapter or section which is more relevant in the context of this locator.
|
||||
*/
|
||||
val title: String? = null,
|
||||
/**
|
||||
* One or more alternative expressions of the location.
|
||||
*/
|
||||
val locations: Location? = null,
|
||||
/**
|
||||
* Textual context of the locator.
|
||||
*/
|
||||
val text: Text? = null,
|
||||
) {
|
||||
|
||||
@JsonInclude(JsonInclude.Include.NON_EMPTY)
|
||||
data class Location(
|
||||
/**
|
||||
* Contains one or more fragment in the resource referenced by the Locator Object.
|
||||
*/
|
||||
val fragments: List<String> = emptyList(),
|
||||
/**
|
||||
* Progression in the resource expressed as a percentage.
|
||||
* Between 0 and 1.
|
||||
*/
|
||||
val progression: Float? = null,
|
||||
/**
|
||||
* An index in the publication.
|
||||
*/
|
||||
val position: Int? = null,
|
||||
/**
|
||||
* Progression in the publication expressed as a percentage.
|
||||
* Between 0 and 1.
|
||||
*/
|
||||
val totalProgression: Float? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* A Locator Text Object contains multiple text fragments, useful to give a context to the Locator or for highlights.
|
||||
*/
|
||||
@JsonInclude(JsonInclude.Include.NON_EMPTY)
|
||||
data class Text(
|
||||
/**
|
||||
* The text after the locator.
|
||||
*/
|
||||
val after: String? = null,
|
||||
/**
|
||||
* The text before the locator.
|
||||
*/
|
||||
val before: String? = null,
|
||||
/**
|
||||
* The text at the locator.
|
||||
*/
|
||||
val highlight: String? = null,
|
||||
)
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
package org.gotson.komga.domain.persistence
|
||||
|
||||
import org.gotson.komga.domain.model.Media
|
||||
import org.gotson.komga.domain.model.MediaExtension
|
||||
|
||||
interface MediaRepository {
|
||||
fun findById(bookId: String): Media
|
||||
|
|
@ -10,6 +11,7 @@ interface MediaRepository {
|
|||
|
||||
fun getPagesSize(bookId: String): Int
|
||||
fun getPagesSizes(bookIds: Collection<String>): Collection<Pair<String, Int>>
|
||||
fun findExtensionByIdOrNull(bookId: String): MediaExtension?
|
||||
|
||||
fun insert(media: Media)
|
||||
fun insert(medias: Collection<Media>)
|
||||
|
|
|
|||
|
|
@ -124,7 +124,18 @@ class BookAnalyzer(
|
|||
|
||||
private fun analyzeEpub(book: Book): Media {
|
||||
val manifest = epubExtractor.getManifest(book.path)
|
||||
return Media(status = Media.Status.READY, files = manifest.resources, pageCount = manifest.pageCount, extension = MediaExtensionEpub(toc = manifest.toc, landmarks = manifest.landmarks, pageList = manifest.pageList))
|
||||
return Media(
|
||||
status = Media.Status.READY,
|
||||
files = manifest.resources,
|
||||
pageCount = manifest.pageCount,
|
||||
extension = MediaExtensionEpub(
|
||||
toc = manifest.toc,
|
||||
landmarks = manifest.landmarks,
|
||||
pageList = manifest.pageList,
|
||||
isFixedLayout = manifest.isFixedLayout,
|
||||
positions = manifest.positions,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun analyzePdf(book: Book, analyzeDimensions: Boolean): Media {
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import org.gotson.komga.domain.model.Dimension
|
|||
import org.gotson.komga.domain.model.Media
|
||||
import org.gotson.komga.domain.model.MediaExtension
|
||||
import org.gotson.komga.domain.model.MediaFile
|
||||
import org.gotson.komga.domain.model.ProxyExtension
|
||||
import org.gotson.komga.domain.persistence.MediaRepository
|
||||
import org.gotson.komga.infrastructure.jooq.insertTempStrings
|
||||
import org.gotson.komga.infrastructure.jooq.selectTempStrings
|
||||
|
|
@ -20,8 +21,11 @@ import org.jooq.impl.DSL
|
|||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.stereotype.Component
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.time.LocalDateTime
|
||||
import java.time.ZoneId
|
||||
import java.util.zip.GZIPInputStream
|
||||
import java.util.zip.GZIPOutputStream
|
||||
|
||||
private val logger = KotlinLogging.logger {}
|
||||
|
||||
|
|
@ -37,7 +41,17 @@ class MediaDao(
|
|||
private val f = Tables.MEDIA_FILE
|
||||
private val b = Tables.BOOK
|
||||
|
||||
private val groupFields = arrayOf(*m.fields(), *p.fields())
|
||||
private val groupFields = arrayOf(
|
||||
m.BOOK_ID,
|
||||
m.MEDIA_TYPE,
|
||||
m.STATUS,
|
||||
m.CREATED_DATE,
|
||||
m.LAST_MODIFIED_DATE,
|
||||
m.COMMENT,
|
||||
m.PAGE_COUNT,
|
||||
m.EXTENSION_CLASS,
|
||||
*p.fields(),
|
||||
)
|
||||
|
||||
override fun findById(bookId: String): Media =
|
||||
find(dsl, bookId)!!
|
||||
|
|
@ -45,6 +59,13 @@ class MediaDao(
|
|||
override fun findByIdOrNull(bookId: String): Media? =
|
||||
find(dsl, bookId)
|
||||
|
||||
override fun findExtensionByIdOrNull(bookId: String): MediaExtension? =
|
||||
dsl.select(m.EXTENSION_CLASS, m.EXTENSION_VALUE_BLOB)
|
||||
.from(m)
|
||||
.where(m.BOOK_ID.eq(bookId))
|
||||
.fetchOne()
|
||||
?.map { deserializeExtension(it.get(m.EXTENSION_CLASS), it.get(m.EXTENSION_VALUE_BLOB)) }
|
||||
|
||||
override fun findAllBookIdsByLibraryIdAndMediaTypeAndWithMissingPageHash(libraryId: String, mediaTypes: Collection<String>, pageHashing: Int): Collection<String> {
|
||||
val pagesCount = DSL.count(p.BOOK_ID)
|
||||
val hashedCount = DSL.sum(DSL.`when`(p.FILE_HASH.eq(""), 0).otherwise(1)).cast(Int::class.java)
|
||||
|
|
@ -114,7 +135,7 @@ class MediaDao(
|
|||
m.COMMENT,
|
||||
m.PAGE_COUNT,
|
||||
m.EXTENSION_CLASS,
|
||||
m.EXTENSION_VALUE,
|
||||
m.EXTENSION_VALUE_BLOB,
|
||||
).values(null as String?, null, null, null, null, null, null),
|
||||
).also { step ->
|
||||
chunk.forEach { media ->
|
||||
|
|
@ -124,8 +145,8 @@ class MediaDao(
|
|||
media.mediaType,
|
||||
media.comment,
|
||||
media.pageCount,
|
||||
media.extension?.let { it::class.qualifiedName },
|
||||
media.extension?.let { mapper.writeValueAsString(it) },
|
||||
media.extension?.let { if (it is ProxyExtension) null else it::class.qualifiedName },
|
||||
media.extension?.let { if (it is ProxyExtension) null else serializeExtension(it) },
|
||||
)
|
||||
}
|
||||
}.execute()
|
||||
|
|
@ -207,8 +228,12 @@ class MediaDao(
|
|||
.set(m.MEDIA_TYPE, media.mediaType)
|
||||
.set(m.COMMENT, media.comment)
|
||||
.set(m.PAGE_COUNT, media.pageCount)
|
||||
.set(m.EXTENSION_CLASS, media.extension?.let { it::class.qualifiedName })
|
||||
.set(m.EXTENSION_VALUE, media.extension?.let { mapper.writeValueAsString(it) })
|
||||
.apply {
|
||||
if (media.extension != null && media.extension !is ProxyExtension) {
|
||||
set(m.EXTENSION_CLASS, media.extension::class.qualifiedName)
|
||||
set(m.EXTENSION_VALUE_BLOB, serializeExtension(media.extension))
|
||||
}
|
||||
}
|
||||
.set(m.LAST_MODIFIED_DATE, LocalDateTime.now(ZoneId.of("Z")))
|
||||
.where(m.BOOK_ID.eq(media.bookId))
|
||||
.execute()
|
||||
|
|
@ -250,19 +275,33 @@ class MediaDao(
|
|||
pages = pages,
|
||||
pageCount = pageCount,
|
||||
files = files,
|
||||
extension = deserializeExtension(extensionClass, extensionValue),
|
||||
extension = ProxyExtension.of(extensionClass),
|
||||
comment = comment,
|
||||
bookId = bookId,
|
||||
createdDate = createdDate.toCurrentTimeZone(),
|
||||
lastModifiedDate = lastModifiedDate.toCurrentTimeZone(),
|
||||
)
|
||||
|
||||
private fun deserializeExtension(extensionClass: String?, extensionValue: String?): MediaExtension? {
|
||||
if (extensionClass == null && extensionValue == null) return null
|
||||
return try {
|
||||
mapper.readValue(extensionValue, Class.forName(extensionClass)) as MediaExtension
|
||||
fun serializeExtension(extension: MediaExtension): ByteArray? =
|
||||
try {
|
||||
ByteArrayOutputStream().use { baos ->
|
||||
GZIPOutputStream(baos).use { gz ->
|
||||
mapper.writeValue(gz, extension)
|
||||
baos.toByteArray()
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.error(e) { "Could not deserialize media extension class: $extensionClass, value: $extensionValue" }
|
||||
logger.error(e) { "Could not serialize media extension" }
|
||||
null
|
||||
}
|
||||
|
||||
fun deserializeExtension(extensionClass: String?, extensionBlob: ByteArray?): MediaExtension? {
|
||||
if (extensionClass == null || extensionBlob == null) return null
|
||||
return try {
|
||||
GZIPInputStream(extensionBlob.inputStream()).use { gz ->
|
||||
mapper.readValue(gz, Class.forName(extensionClass)) as MediaExtension
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.error(e) { "Could not deserialize media extension class: $extensionClass" }
|
||||
null
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import org.apache.commons.compress.archivers.zip.ZipFile
|
|||
import org.gotson.komga.domain.model.MediaUnsupportedException
|
||||
import org.jsoup.Jsoup
|
||||
import org.jsoup.nodes.Document
|
||||
import org.jsoup.parser.Parser
|
||||
import java.nio.file.Path
|
||||
import java.nio.file.Paths
|
||||
|
||||
|
|
@ -17,7 +18,7 @@ data class EpubPackage(
|
|||
inline fun <R> Path.epub(block: (EpubPackage) -> R): R =
|
||||
ZipFile(this.toFile()).use { zip ->
|
||||
val opfFile = zip.getPackagePath()
|
||||
val opfDoc = zip.getInputStream(zip.getEntry(opfFile)).use { Jsoup.parse(it, null, "") }
|
||||
val opfDoc = zip.getInputStream(zip.getEntry(opfFile)).use { Jsoup.parse(it, null, "", Parser.xmlParser()) }
|
||||
val opfDir = Paths.get(opfFile).parent
|
||||
block(EpubPackage(zip, opfDoc, opfDir, opfDoc.getManifest()))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,10 +4,12 @@ import org.apache.commons.compress.archivers.ArchiveEntry
|
|||
import org.apache.commons.compress.archivers.zip.ZipFile
|
||||
import org.gotson.komga.domain.model.EpubTocEntry
|
||||
import org.gotson.komga.domain.model.MediaFile
|
||||
import org.gotson.komga.domain.model.R2Locator
|
||||
import org.gotson.komga.domain.model.TypedBytes
|
||||
import org.springframework.stereotype.Service
|
||||
import java.nio.file.Path
|
||||
import kotlin.math.ceil
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@Service
|
||||
class EpubExtractor {
|
||||
|
|
@ -44,12 +46,16 @@ class EpubExtractor {
|
|||
|
||||
fun getManifest(path: Path): EpubManifest =
|
||||
path.epub { epub ->
|
||||
val resources = getResources(epub)
|
||||
val isFixedLayout = isFixedLayout(epub)
|
||||
EpubManifest(
|
||||
resources = getResources(epub),
|
||||
resources = resources,
|
||||
toc = getToc(epub),
|
||||
landmarks = getLandmarks(epub),
|
||||
pageList = getPageList(epub),
|
||||
pageCount = computePageCount(epub),
|
||||
isFixedLayout = isFixedLayout,
|
||||
positions = computePositions(resources, isFixedLayout),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -86,6 +92,40 @@ class EpubExtractor {
|
|||
return epub.zip.entries.toList().filter { it.name in spine }.sumOf { ceil(it.compressedSize / 1024.0).toInt() }
|
||||
}
|
||||
|
||||
private fun isFixedLayout(epub: EpubPackage) =
|
||||
epub.opfDoc.selectFirst("metadata > *|meta[property=rendition:layout]")?.text()?.ifBlank { null } == "pre-paginated"
|
||||
|
||||
private fun computePositions(resources: List<MediaFile>, isFixedLayout: Boolean): List<R2Locator> {
|
||||
val readingOrder = resources.filter { it.subType == MediaFile.SubType.EPUB_PAGE }
|
||||
|
||||
var startPosition = 1
|
||||
val positions = if (isFixedLayout) {
|
||||
readingOrder.map {
|
||||
R2Locator(
|
||||
href = it.fileName,
|
||||
type = it.mediaType!!,
|
||||
locations = R2Locator.Location(progression = 0F, position = startPosition++),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
readingOrder.flatMap { file ->
|
||||
val positionCount = maxOf(1, ceil(file.fileSize!! / 1024.0).roundToInt())
|
||||
(0 until positionCount).map { p ->
|
||||
R2Locator(
|
||||
href = file.fileName,
|
||||
type = file.mediaType!!,
|
||||
locations = R2Locator.Location(progression = p.toFloat() / positionCount, position = startPosition++),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return positions.map { locator ->
|
||||
val totalProgression = locator.locations?.position?.let { it.toFloat() / positions.size }
|
||||
locator.copy(locations = locator.locations?.copy(totalProgression = totalProgression))
|
||||
}
|
||||
}
|
||||
|
||||
private fun getToc(epub: EpubPackage): List<EpubTocEntry> {
|
||||
// Epub 3
|
||||
epub.getNavResource()?.let { return processNav(it, Epub3Nav.TOC) }
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package org.gotson.komga.infrastructure.mediacontainer.epub
|
|||
|
||||
import org.gotson.komga.domain.model.EpubTocEntry
|
||||
import org.gotson.komga.domain.model.MediaFile
|
||||
import org.gotson.komga.domain.model.R2Locator
|
||||
|
||||
data class EpubManifest(
|
||||
val resources: List<MediaFile>,
|
||||
|
|
@ -9,4 +10,6 @@ data class EpubManifest(
|
|||
val landmarks: List<EpubTocEntry>,
|
||||
val pageList: List<EpubTocEntry>,
|
||||
val pageCount: Int,
|
||||
val isFixedLayout: Boolean,
|
||||
val positions: List<R2Locator>,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -6,7 +6,9 @@ import org.gotson.komga.domain.model.Media
|
|||
import org.gotson.komga.domain.model.MediaExtensionEpub
|
||||
import org.gotson.komga.domain.model.MediaFile
|
||||
import org.gotson.komga.domain.model.MediaProfile
|
||||
import org.gotson.komga.domain.model.ProxyExtension
|
||||
import org.gotson.komga.domain.model.SeriesMetadata
|
||||
import org.gotson.komga.domain.persistence.MediaRepository
|
||||
import org.gotson.komga.domain.service.BookAnalyzer
|
||||
import org.gotson.komga.infrastructure.image.ImageConverter
|
||||
import org.gotson.komga.infrastructure.image.ImageType
|
||||
|
|
@ -40,10 +42,10 @@ import org.gotson.komga.domain.model.MediaType as KomgaMediaType
|
|||
|
||||
@Service
|
||||
class WebPubGenerator(
|
||||
@Qualifier("thumbnailType")
|
||||
private val thumbnailType: ImageType,
|
||||
@Qualifier("thumbnailType") private val thumbnailType: ImageType,
|
||||
private val imageConverter: ImageConverter,
|
||||
private val bookAnalyzer: BookAnalyzer,
|
||||
private val mediaRepository: MediaRepository,
|
||||
) {
|
||||
private val wpKnownRoles = listOf(
|
||||
"author",
|
||||
|
|
@ -87,9 +89,7 @@ class WebPubGenerator(
|
|||
val pages = if (media.profile == MediaProfile.PDF) bookAnalyzer.getPdfPagesDynamic(media) else media.pages
|
||||
it.copy(
|
||||
mediaType = MEDIATYPE_DIVINA_JSON,
|
||||
metadata = it.metadata
|
||||
.withSeriesMetadata(seriesMetadata)
|
||||
.copy(conformsTo = PROFILE_DIVINA),
|
||||
metadata = it.metadata.withSeriesMetadata(seriesMetadata).copy(conformsTo = PROFILE_DIVINA),
|
||||
readingOrder = pages.mapIndexed { index: Int, page: BookPage ->
|
||||
WPLinkDto(
|
||||
href = uriBuilder.cloneBuilder().path("books/${bookDto.id}/pages/${index + 1}").toUriString(),
|
||||
|
|
@ -116,9 +116,7 @@ class WebPubGenerator(
|
|||
return bookDto.toBasePublicationDto().let {
|
||||
it.copy(
|
||||
mediaType = MEDIATYPE_WEBPUB_JSON,
|
||||
metadata = it.metadata
|
||||
.withSeriesMetadata(seriesMetadata)
|
||||
.copy(conformsTo = PROFILE_PDF),
|
||||
metadata = it.metadata.withSeriesMetadata(seriesMetadata).copy(conformsTo = PROFILE_PDF),
|
||||
readingOrder = List(media.pageCount) { index: Int ->
|
||||
WPLinkDto(
|
||||
href = uriBuilder.cloneBuilder().path("books/${bookDto.id}/pages/${index + 1}/raw").toUriString(),
|
||||
|
|
@ -132,26 +130,27 @@ class WebPubGenerator(
|
|||
|
||||
fun toManifestEpub(bookDto: BookDto, media: Media, seriesMetadata: SeriesMetadata): WPPublicationDto {
|
||||
val uriBuilder = ServletUriComponentsBuilder.fromCurrentContextPath().pathSegment("api", "v1")
|
||||
val extension = media.extension as MediaExtensionEpub?
|
||||
val extension = when {
|
||||
media.extension is ProxyExtension && media.extension.proxyForType<MediaExtensionEpub>() -> mediaRepository.findExtensionByIdOrNull(media.bookId) as? MediaExtensionEpub
|
||||
media.extension is MediaExtensionEpub -> media.extension
|
||||
else -> null
|
||||
}
|
||||
return bookDto.toBasePublicationDto().let { publication ->
|
||||
publication.copy(
|
||||
mediaType = MEDIATYPE_WEBPUB_JSON,
|
||||
metadata = publication.metadata
|
||||
.withSeriesMetadata(seriesMetadata)
|
||||
.copy(conformsTo = PROFILE_EPUB),
|
||||
metadata = publication.metadata.withSeriesMetadata(seriesMetadata).copy(conformsTo = PROFILE_EPUB),
|
||||
readingOrder = media.files.filter { it.subType == MediaFile.SubType.EPUB_PAGE }.map {
|
||||
WPLinkDto(
|
||||
href = uriBuilder.cloneBuilder().path("books/${bookDto.id}/resource/").path(it.fileName).toUriString(),
|
||||
type = it.mediaType,
|
||||
)
|
||||
},
|
||||
resources = buildThumbnailLinkDtos(bookDto.id) +
|
||||
media.files.filter { it.subType == MediaFile.SubType.EPUB_ASSET }.map {
|
||||
WPLinkDto(
|
||||
href = uriBuilder.cloneBuilder().path("books/${bookDto.id}/resource/").path(it.fileName).toUriString(),
|
||||
type = it.mediaType,
|
||||
)
|
||||
},
|
||||
resources = buildThumbnailLinkDtos(bookDto.id) + media.files.filter { it.subType == MediaFile.SubType.EPUB_ASSET }.map {
|
||||
WPLinkDto(
|
||||
href = uriBuilder.cloneBuilder().path("books/${bookDto.id}/resource/").path(it.fileName).toUriString(),
|
||||
type = it.mediaType,
|
||||
)
|
||||
},
|
||||
toc = extension?.toc?.map { it.toWPLinkDto(uriBuilder.cloneBuilder().path("books/${bookDto.id}/resource/")) } ?: emptyList(),
|
||||
landmarks = extension?.landmarks?.map { it.toWPLinkDto(uriBuilder.cloneBuilder().path("books/${bookDto.id}/resource/")) } ?: emptyList(),
|
||||
pageList = extension?.pageList?.map { it.toWPLinkDto(uriBuilder.cloneBuilder().path("books/${bookDto.id}/resource/")) } ?: emptyList(),
|
||||
|
|
@ -193,17 +192,16 @@ class WebPubGenerator(
|
|||
),
|
||||
)
|
||||
|
||||
private fun WPMetadataDto.withSeriesMetadata(seriesMetadata: SeriesMetadata) =
|
||||
copy(
|
||||
language = seriesMetadata.language,
|
||||
readingProgression = when (seriesMetadata.readingDirection) {
|
||||
SeriesMetadata.ReadingDirection.LEFT_TO_RIGHT -> WPReadingProgressionDto.LTR
|
||||
SeriesMetadata.ReadingDirection.RIGHT_TO_LEFT -> WPReadingProgressionDto.RTL
|
||||
SeriesMetadata.ReadingDirection.VERTICAL -> WPReadingProgressionDto.TTB
|
||||
SeriesMetadata.ReadingDirection.WEBTOON -> WPReadingProgressionDto.TTB
|
||||
null -> null
|
||||
},
|
||||
)
|
||||
private fun WPMetadataDto.withSeriesMetadata(seriesMetadata: SeriesMetadata) = copy(
|
||||
language = seriesMetadata.language,
|
||||
readingProgression = when (seriesMetadata.readingDirection) {
|
||||
SeriesMetadata.ReadingDirection.LEFT_TO_RIGHT -> WPReadingProgressionDto.LTR
|
||||
SeriesMetadata.ReadingDirection.RIGHT_TO_LEFT -> WPReadingProgressionDto.RTL
|
||||
SeriesMetadata.ReadingDirection.VERTICAL -> WPReadingProgressionDto.TTB
|
||||
SeriesMetadata.ReadingDirection.WEBTOON -> WPReadingProgressionDto.TTB
|
||||
null -> null
|
||||
},
|
||||
)
|
||||
|
||||
private fun WPMetadataDto.withAuthors(authors: List<AuthorDto>): WPMetadataDto {
|
||||
val groups = authors.groupBy({ it.role }, { it.name })
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ 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 MEDIATYPE_POSITION_LIST_JSON_VALUE = "application/vnd.readium.position-list+json"
|
||||
|
||||
const val PROFILE_DIVINA = "https://readium.org/webpub-manifest/profiles/divina"
|
||||
const val PROFILE_EPUB = "https://readium.org/webpub-manifest/profiles/epub"
|
||||
|
|
@ -13,3 +14,4 @@ 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", "divina+json")
|
||||
val MEDIATYPE_WEBPUB_JSON = MediaType("application", "webpub+json")
|
||||
val MEDIATYPE_POSITION_LIST_JSON = MediaType("application", "vnd.readium.position-list+json")
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import org.gotson.komga.domain.model.ImageConversionException
|
|||
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.MediaExtensionEpub
|
||||
import org.gotson.komga.domain.model.MediaNotReadyException
|
||||
import org.gotson.komga.domain.model.MediaProfile
|
||||
import org.gotson.komga.domain.model.MediaUnsupportedException
|
||||
|
|
@ -51,6 +52,8 @@ import org.gotson.komga.infrastructure.web.setCachePrivate
|
|||
import org.gotson.komga.interfaces.api.WebPubGenerator
|
||||
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_POSITION_LIST_JSON
|
||||
import org.gotson.komga.interfaces.api.dto.MEDIATYPE_POSITION_LIST_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.persistence.BookDtoRepository
|
||||
|
|
@ -58,6 +61,7 @@ import org.gotson.komga.interfaces.api.rest.dto.BookDto
|
|||
import org.gotson.komga.interfaces.api.rest.dto.BookImportBatchDto
|
||||
import org.gotson.komga.interfaces.api.rest.dto.BookMetadataUpdateDto
|
||||
import org.gotson.komga.interfaces.api.rest.dto.PageDto
|
||||
import org.gotson.komga.interfaces.api.rest.dto.R2Positions
|
||||
import org.gotson.komga.interfaces.api.rest.dto.ReadListDto
|
||||
import org.gotson.komga.interfaces.api.rest.dto.ReadProgressUpdateDto
|
||||
import org.gotson.komga.interfaces.api.rest.dto.ThumbnailBookDto
|
||||
|
|
@ -707,6 +711,36 @@ class BookController(
|
|||
.body(bytes)
|
||||
}
|
||||
|
||||
@GetMapping(
|
||||
value = ["api/v1/books/{bookId}/positions"],
|
||||
produces = [MEDIATYPE_POSITION_LIST_JSON_VALUE],
|
||||
)
|
||||
fun getPositions(
|
||||
request: HttpServletRequest,
|
||||
@AuthenticationPrincipal principal: KomgaPrincipal,
|
||||
@PathVariable bookId: String,
|
||||
): ResponseEntity<R2Positions> =
|
||||
bookRepository.findByIdOrNull(bookId)?.let { book ->
|
||||
val media = mediaRepository.findById(book.id)
|
||||
|
||||
if (ServletWebRequest(request).checkNotModified(getBookLastModified(media))) {
|
||||
return ResponseEntity
|
||||
.status(HttpStatus.NOT_MODIFIED)
|
||||
.setNotModified(media)
|
||||
.body(null)
|
||||
}
|
||||
|
||||
principal.user.checkContentRestriction(book)
|
||||
|
||||
val extension = mediaRepository.findExtensionByIdOrNull(book.id) as? MediaExtensionEpub
|
||||
?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
|
||||
|
||||
ResponseEntity.ok()
|
||||
.contentType(MEDIATYPE_POSITION_LIST_JSON)
|
||||
.setNotModified(media)
|
||||
.body(R2Positions(extension.positions.size, extension.positions))
|
||||
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
|
||||
|
||||
@GetMapping(
|
||||
value = ["api/v1/books/{bookId}/manifest/epub"],
|
||||
produces = [MEDIATYPE_WEBPUB_JSON_VALUE],
|
||||
|
|
|
|||
|
|
@ -0,0 +1,8 @@
|
|||
package org.gotson.komga.interfaces.api.rest.dto
|
||||
|
||||
import org.gotson.komga.domain.model.R2Locator
|
||||
|
||||
data class R2Positions(
|
||||
val total: Int,
|
||||
val positions: List<R2Locator>,
|
||||
)
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
package org.gotson.komga.domain.model
|
||||
|
||||
import org.assertj.core.api.Assertions.assertThat
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class ProxyExtensionTest {
|
||||
|
||||
@Test
|
||||
fun `when creating proxy of MediaExtension class then it is created`() {
|
||||
val proxy = ProxyExtension.of(MediaExtensionEpub::class.qualifiedName)
|
||||
|
||||
assertThat(proxy).isNotNull
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when creating proxy of MediaExtension interface then it is null`() {
|
||||
val proxy = ProxyExtension.of(MediaExtension::class.qualifiedName)
|
||||
|
||||
assertThat(proxy).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `given proxy extension of class when checking against the class type then it returns true`() {
|
||||
val proxy = ProxyExtension.of(MediaExtensionEpub::class.qualifiedName)!!
|
||||
|
||||
assertThat(proxy.proxyForType<MediaExtensionEpub>()).isTrue()
|
||||
assertThat(proxy.proxyForType<MediaExtension>()).isFalse()
|
||||
assertThat(proxy.proxyForType<Media>()).isFalse()
|
||||
|
||||
assertThat(proxy.proxyForType(MediaExtensionEpub::class)).isTrue()
|
||||
assertThat(proxy.proxyForType(MediaExtension::class)).isFalse()
|
||||
assertThat(proxy.proxyForType(Media::class)).isFalse()
|
||||
}
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@ import org.gotson.komga.domain.model.Media
|
|||
import org.gotson.komga.domain.model.MediaExtensionEpub
|
||||
import org.gotson.komga.domain.model.MediaFile
|
||||
import org.gotson.komga.domain.model.MediaType
|
||||
import org.gotson.komga.domain.model.ProxyExtension
|
||||
import org.gotson.komga.domain.model.makeBook
|
||||
import org.gotson.komga.domain.model.makeLibrary
|
||||
import org.gotson.komga.domain.model.makeSeries
|
||||
|
|
@ -77,10 +78,6 @@ class MediaDaoTest(
|
|||
),
|
||||
),
|
||||
files = listOf(MediaFile("ComicInfo.xml", "application/xml", MediaFile.SubType.EPUB_ASSET, 3)),
|
||||
extension = MediaExtensionEpub(
|
||||
toc = listOf(EpubTocEntry("title", "href", listOf(EpubTocEntry("subtitle", "subhref")))),
|
||||
landmarks = listOf(EpubTocEntry("title2", "href2", listOf(EpubTocEntry("subtitle2", "subhref2")))),
|
||||
),
|
||||
comment = "comment",
|
||||
bookId = book.id,
|
||||
)
|
||||
|
|
@ -109,9 +106,6 @@ class MediaDaoTest(
|
|||
assertThat(subType).isEqualTo(media.files.first().subType)
|
||||
assertThat(fileSize).isEqualTo(media.files.first().fileSize)
|
||||
}
|
||||
assertThat(created.extension).isNotNull
|
||||
assertThat(created.extension).isInstanceOf(MediaExtensionEpub::class.java)
|
||||
assertThat(created.extension).isEqualTo(media.extension)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -127,6 +121,7 @@ class MediaDaoTest(
|
|||
assertThat(created.comment).isNull()
|
||||
assertThat(created.pages).isEmpty()
|
||||
assertThat(created.files).isEmpty()
|
||||
assertThat(created.extension).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -141,9 +136,6 @@ class MediaDaoTest(
|
|||
),
|
||||
),
|
||||
files = listOf(MediaFile("ComicInfo.xml", "application/xml", MediaFile.SubType.EPUB_ASSET, 5)),
|
||||
extension = MediaExtensionEpub(
|
||||
landmarks = listOf(EpubTocEntry("title2", "href2", listOf(EpubTocEntry("subtitle2", "subhref2")))),
|
||||
),
|
||||
comment = "comment",
|
||||
bookId = book.id,
|
||||
)
|
||||
|
|
@ -165,9 +157,6 @@ class MediaDaoTest(
|
|||
),
|
||||
),
|
||||
files = listOf(MediaFile("id.txt")),
|
||||
extension = MediaExtensionEpub(
|
||||
toc = listOf(EpubTocEntry("title", "href", listOf(EpubTocEntry("subtitle", "subhref")))),
|
||||
),
|
||||
comment = "comment2",
|
||||
)
|
||||
}
|
||||
|
|
@ -192,7 +181,6 @@ class MediaDaoTest(
|
|||
assertThat(modified.files.first().mediaType).isEqualTo(updated.files.first().mediaType)
|
||||
assertThat(modified.files.first().subType).isEqualTo(updated.files.first().subType)
|
||||
assertThat(modified.files.first().fileSize).isEqualTo(updated.files.first().fileSize)
|
||||
assertThat(modified.extension).isEqualTo(updated.extension)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -224,6 +212,98 @@ class MediaDaoTest(
|
|||
assertThat(found).isInstanceOf(Exception::class.java)
|
||||
}
|
||||
|
||||
@Nested
|
||||
inner class MediaExtension {
|
||||
@Test
|
||||
fun `given a media with extension when inserting then it is persisted`() {
|
||||
val media = Media(
|
||||
status = Media.Status.READY,
|
||||
mediaType = "application/epub+zip",
|
||||
extension = MediaExtensionEpub(
|
||||
toc = listOf(EpubTocEntry("title", "href", listOf(EpubTocEntry("subtitle", "subhref")))),
|
||||
landmarks = listOf(EpubTocEntry("title2", "href2", listOf(EpubTocEntry("subtitle2", "subhref2")))),
|
||||
),
|
||||
bookId = book.id,
|
||||
)
|
||||
|
||||
mediaDao.insert(media)
|
||||
val created = mediaDao.findById(media.bookId)
|
||||
|
||||
assertThat(created.extension).isNotNull
|
||||
assertThat(created.extension).isInstanceOf(ProxyExtension::class.java)
|
||||
assertThat((created.extension as ProxyExtension).extensionClassName).isEqualTo(MediaExtensionEpub::class.qualifiedName)
|
||||
|
||||
val extension = mediaDao.findExtensionByIdOrNull(media.bookId)
|
||||
assertThat(extension).isNotNull
|
||||
assertThat(extension).isInstanceOf(MediaExtensionEpub::class.java)
|
||||
assertThat(extension).isEqualTo(media.extension)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `given existing media with extension when updating then it is persisted`() {
|
||||
val media = Media(
|
||||
status = Media.Status.READY,
|
||||
mediaType = "application/epub+zip",
|
||||
extension = MediaExtensionEpub(
|
||||
landmarks = listOf(EpubTocEntry("title2", "href2", listOf(EpubTocEntry("subtitle2", "subhref2")))),
|
||||
),
|
||||
bookId = book.id,
|
||||
)
|
||||
mediaDao.insert(media)
|
||||
|
||||
val updated = with(mediaDao.findById(media.bookId)) {
|
||||
copy(
|
||||
extension = MediaExtensionEpub(
|
||||
toc = listOf(EpubTocEntry("title", "href", listOf(EpubTocEntry("subtitle", "subhref")))),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
mediaDao.update(updated)
|
||||
val modified = mediaDao.findById(updated.bookId)
|
||||
|
||||
assertThat(modified.bookId).isEqualTo(updated.bookId)
|
||||
assertThat(modified.createdDate).isEqualTo(updated.createdDate)
|
||||
assertThat(modified.lastModifiedDate).isNotEqualTo(updated.lastModifiedDate)
|
||||
assertThat(modified.extension).isNotNull
|
||||
assertThat(modified.extension).isInstanceOf(ProxyExtension::class.java)
|
||||
assertThat((modified.extension as ProxyExtension).extensionClassName).isEqualTo(MediaExtensionEpub::class.qualifiedName)
|
||||
|
||||
assertThat(mediaDao.findExtensionByIdOrNull(media.bookId)).isEqualTo(updated.extension)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `given existing media with proxy extension when updating then it is kept as-is`() {
|
||||
val media = Media(
|
||||
status = Media.Status.READY,
|
||||
mediaType = "application/epub+zip",
|
||||
extension = MediaExtensionEpub(
|
||||
landmarks = listOf(EpubTocEntry("title2", "href2", listOf(EpubTocEntry("subtitle2", "subhref2")))),
|
||||
),
|
||||
bookId = book.id,
|
||||
)
|
||||
mediaDao.insert(media)
|
||||
|
||||
val updated = mediaDao.findById(media.bookId).copy(comment = "updated")
|
||||
|
||||
mediaDao.update(updated)
|
||||
val modified = mediaDao.findById(updated.bookId)
|
||||
|
||||
assertThat(modified.bookId).isEqualTo(updated.bookId)
|
||||
assertThat(modified.createdDate).isEqualTo(updated.createdDate)
|
||||
assertThat(modified.lastModifiedDate).isNotEqualTo(updated.lastModifiedDate)
|
||||
assertThat(modified.comment).isEqualTo(updated.comment)
|
||||
assertThat(modified.extension).isNotNull
|
||||
assertThat(modified.extension).isInstanceOf(ProxyExtension::class.java)
|
||||
assertThat((modified.extension as ProxyExtension).extensionClassName).isEqualTo(MediaExtensionEpub::class.qualifiedName)
|
||||
|
||||
val extension = mediaDao.findExtensionByIdOrNull(media.bookId)
|
||||
assertThat(extension).isNotNull
|
||||
assertThat(extension).isInstanceOf(MediaExtensionEpub::class.java)
|
||||
assertThat(extension).isEqualTo(media.extension)
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
inner class MissingPageHash {
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue