feat(api): add positions endpoint to get pre-computed positions of epub books

This commit is contained in:
Gauthier Roebroeck 2023-12-01 16:25:12 +08:00
parent 834b51d744
commit eb8a644234
15 changed files with 435 additions and 60 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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],

View file

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

View file

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

View file

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