feat(opds): acquisition feeds are paginated

closes #572
This commit is contained in:
Gauthier Roebroeck 2021-12-03 17:03:10 +08:00
parent a3c48601f8
commit 734403a366
7 changed files with 275 additions and 169 deletions

View file

@ -59,10 +59,6 @@ data class KomgaUser(
return sharedAllLibraries || sharedLibrariesIds.any { it == book.libraryId } return sharedAllLibraries || sharedLibrariesIds.any { it == book.libraryId }
} }
fun canAccessSeries(series: Series): Boolean {
return sharedAllLibraries || sharedLibrariesIds.any { it == series.libraryId }
}
fun canAccessLibrary(libraryId: String): Boolean = fun canAccessLibrary(libraryId: String): Boolean =
sharedAllLibraries || sharedLibrariesIds.any { it == libraryId } sharedAllLibraries || sharedLibrariesIds.any { it == libraryId }

View file

@ -38,6 +38,7 @@ interface ReferentialRepository {
fun findAllLanguagesByCollection(collectionId: String, filterOnLibraryIds: Collection<String>?): Set<String> fun findAllLanguagesByCollection(collectionId: String, filterOnLibraryIds: Collection<String>?): Set<String>
fun findAllPublishers(filterOnLibraryIds: Collection<String>?): Set<String> fun findAllPublishers(filterOnLibraryIds: Collection<String>?): Set<String>
fun findAllPublishers(filterOnLibraryIds: Collection<String>?, pageable: Pageable): Page<String>
fun findAllPublishersByLibrary(libraryId: String, filterOnLibraryIds: Collection<String>?): Set<String> fun findAllPublishersByLibrary(libraryId: String, filterOnLibraryIds: Collection<String>?): Set<String>
fun findAllPublishersByCollection(collectionId: String, filterOnLibraryIds: Collection<String>?): Set<String> fun findAllPublishersByCollection(collectionId: String, filterOnLibraryIds: Collection<String>?): Set<String>

View file

@ -357,6 +357,30 @@ class ReferentialDao(
.orderBy(sd.PUBLISHER.collate(SqliteUdfDataSource.collationUnicode3)) .orderBy(sd.PUBLISHER.collate(SqliteUdfDataSource.collationUnicode3))
.fetchSet(sd.PUBLISHER) .fetchSet(sd.PUBLISHER)
override fun findAllPublishers(filterOnLibraryIds: Collection<String>?, pageable: Pageable): Page<String> {
val query = dsl.selectDistinct(sd.PUBLISHER)
.from(sd)
.apply { filterOnLibraryIds?.let { leftJoin(s).on(sd.SERIES_ID.eq(s.ID)) } }
.where(sd.PUBLISHER.ne(""))
.apply { filterOnLibraryIds?.let { and(s.LIBRARY_ID.`in`(it)) } }
val count = dsl.fetchCount(query)
val sort = sd.PUBLISHER.collate(SqliteUdfDataSource.collationUnicode3)
val items = query
.orderBy(sort)
.apply { if (pageable.isPaged) limit(pageable.pageSize).offset(pageable.offset) }
.fetch(sd.PUBLISHER)
val pageSort = Sort.by("name")
return PageImpl(
items,
if (pageable.isPaged) PageRequest.of(pageable.pageNumber, pageable.pageSize, pageSort)
else PageRequest.of(0, maxOf(count, 20), pageSort),
count.toLong()
)
}
override fun findAllPublishersByLibrary(libraryId: String, filterOnLibraryIds: Collection<String>?): Set<String> = override fun findAllPublishersByLibrary(libraryId: String, filterOnLibraryIds: Collection<String>?): Set<String> =
dsl.selectDistinct(sd.PUBLISHER) dsl.selectDistinct(sd.PUBLISHER)
.from(sd) .from(sd)

View file

@ -6,6 +6,15 @@ import io.swagger.v3.oas.annotations.enums.ParameterIn
import io.swagger.v3.oas.annotations.media.ArraySchema import io.swagger.v3.oas.annotations.media.ArraySchema
import io.swagger.v3.oas.annotations.media.Schema import io.swagger.v3.oas.annotations.media.Schema
@Target(AnnotationTarget.ANNOTATION_CLASS, AnnotationTarget.FUNCTION)
@Parameter(
description = "Zero-based page index (0..N)",
`in` = ParameterIn.QUERY,
name = "page",
schema = Schema(type = "integer")
)
annotation class PageAsQueryParam
@Target(AnnotationTarget.ANNOTATION_CLASS, AnnotationTarget.FUNCTION) @Target(AnnotationTarget.ANNOTATION_CLASS, AnnotationTarget.FUNCTION)
@Parameters( @Parameters(
Parameter( Parameter(

View file

@ -1,28 +1,22 @@
package org.gotson.komga.interfaces.api.opds package org.gotson.komga.interfaces.api.opds
import io.swagger.v3.oas.annotations.Parameter
import mu.KotlinLogging import mu.KotlinLogging
import org.apache.commons.io.FilenameUtils import org.apache.commons.io.FilenameUtils
import org.gotson.komga.domain.model.Book import org.gotson.komga.domain.model.BookSearchWithReadProgress
import org.gotson.komga.domain.model.BookMetadata
import org.gotson.komga.domain.model.BookSearch
import org.gotson.komga.domain.model.Library import org.gotson.komga.domain.model.Library
import org.gotson.komga.domain.model.Media import org.gotson.komga.domain.model.Media
import org.gotson.komga.domain.model.ReadList import org.gotson.komga.domain.model.ReadList
import org.gotson.komga.domain.model.Series
import org.gotson.komga.domain.model.SeriesCollection import org.gotson.komga.domain.model.SeriesCollection
import org.gotson.komga.domain.model.SeriesMetadata import org.gotson.komga.domain.model.SeriesSearchWithReadProgress
import org.gotson.komga.domain.model.SeriesSearch
import org.gotson.komga.domain.persistence.BookMetadataRepository
import org.gotson.komga.domain.persistence.BookRepository
import org.gotson.komga.domain.persistence.LibraryRepository import org.gotson.komga.domain.persistence.LibraryRepository
import org.gotson.komga.domain.persistence.MediaRepository import org.gotson.komga.domain.persistence.MediaRepository
import org.gotson.komga.domain.persistence.ReadListRepository import org.gotson.komga.domain.persistence.ReadListRepository
import org.gotson.komga.domain.persistence.ReferentialRepository import org.gotson.komga.domain.persistence.ReferentialRepository
import org.gotson.komga.domain.persistence.SeriesCollectionRepository import org.gotson.komga.domain.persistence.SeriesCollectionRepository
import org.gotson.komga.domain.persistence.SeriesMetadataRepository import org.gotson.komga.infrastructure.jooq.toCurrentTimeZone
import org.gotson.komga.domain.persistence.SeriesRepository
import org.gotson.komga.infrastructure.jooq.UnpagedSorted
import org.gotson.komga.infrastructure.security.KomgaPrincipal import org.gotson.komga.infrastructure.security.KomgaPrincipal
import org.gotson.komga.infrastructure.swagger.PageAsQueryParam
import org.gotson.komga.interfaces.api.MARK_READ import org.gotson.komga.interfaces.api.MARK_READ
import org.gotson.komga.interfaces.api.opds.dto.OpdsAuthor import org.gotson.komga.interfaces.api.opds.dto.OpdsAuthor
import org.gotson.komga.interfaces.api.opds.dto.OpdsEntryAcquisition import org.gotson.komga.interfaces.api.opds.dto.OpdsEntryAcquisition
@ -30,6 +24,7 @@ import org.gotson.komga.interfaces.api.opds.dto.OpdsEntryNavigation
import org.gotson.komga.interfaces.api.opds.dto.OpdsFeed import org.gotson.komga.interfaces.api.opds.dto.OpdsFeed
import org.gotson.komga.interfaces.api.opds.dto.OpdsFeedAcquisition import org.gotson.komga.interfaces.api.opds.dto.OpdsFeedAcquisition
import org.gotson.komga.interfaces.api.opds.dto.OpdsFeedNavigation import org.gotson.komga.interfaces.api.opds.dto.OpdsFeedNavigation
import org.gotson.komga.interfaces.api.opds.dto.OpdsLink
import org.gotson.komga.interfaces.api.opds.dto.OpdsLinkFeedNavigation import org.gotson.komga.interfaces.api.opds.dto.OpdsLinkFeedNavigation
import org.gotson.komga.interfaces.api.opds.dto.OpdsLinkFileAcquisition import org.gotson.komga.interfaces.api.opds.dto.OpdsLinkFileAcquisition
import org.gotson.komga.interfaces.api.opds.dto.OpdsLinkImage import org.gotson.komga.interfaces.api.opds.dto.OpdsLinkImage
@ -38,7 +33,13 @@ import org.gotson.komga.interfaces.api.opds.dto.OpdsLinkPageStreaming
import org.gotson.komga.interfaces.api.opds.dto.OpdsLinkRel import org.gotson.komga.interfaces.api.opds.dto.OpdsLinkRel
import org.gotson.komga.interfaces.api.opds.dto.OpdsLinkSearch import org.gotson.komga.interfaces.api.opds.dto.OpdsLinkSearch
import org.gotson.komga.interfaces.api.opds.dto.OpenSearchDescription import org.gotson.komga.interfaces.api.opds.dto.OpenSearchDescription
import org.gotson.komga.interfaces.api.persistence.BookDtoRepository
import org.gotson.komga.interfaces.api.persistence.SeriesDtoRepository
import org.gotson.komga.interfaces.api.rest.dto.BookDto
import org.gotson.komga.interfaces.api.rest.dto.SeriesDto
import org.springframework.data.domain.Page
import org.springframework.data.domain.PageRequest import org.springframework.data.domain.PageRequest
import org.springframework.data.domain.Pageable
import org.springframework.data.domain.Sort import org.springframework.data.domain.Sort
import org.springframework.http.HttpHeaders import org.springframework.http.HttpHeaders
import org.springframework.http.HttpStatus import org.springframework.http.HttpStatus
@ -51,14 +52,15 @@ import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController import org.springframework.web.bind.annotation.RestController
import org.springframework.web.server.ResponseStatusException import org.springframework.web.server.ResponseStatusException
import org.springframework.web.util.UriComponentsBuilder
import org.springframework.web.util.UriUtils import org.springframework.web.util.UriUtils
import java.net.URI import java.net.URI
import java.nio.charset.StandardCharsets import java.nio.charset.StandardCharsets
import java.text.DecimalFormat import java.text.DecimalFormat
import java.time.ZoneId import java.time.ZoneId
import java.time.ZonedDateTime import java.time.ZonedDateTime
import java.util.Optional
import javax.servlet.ServletContext import javax.servlet.ServletContext
import kotlin.io.path.extension
private val logger = KotlinLogging.logger {} private val logger = KotlinLogging.logger {}
@ -88,10 +90,8 @@ class OpdsController(
private val libraryRepository: LibraryRepository, private val libraryRepository: LibraryRepository,
private val collectionRepository: SeriesCollectionRepository, private val collectionRepository: SeriesCollectionRepository,
private val readListRepository: ReadListRepository, private val readListRepository: ReadListRepository,
private val seriesRepository: SeriesRepository, private val seriesDtoRepository: SeriesDtoRepository,
private val seriesMetadataRepository: SeriesMetadataRepository, private val bookDtoRepository: BookDtoRepository,
private val bookRepository: BookRepository,
private val bookMetadataRepository: BookMetadataRepository,
private val mediaRepository: MediaRepository, private val mediaRepository: MediaRepository,
private val referentialRepository: ReferentialRepository private val referentialRepository: ReferentialRepository
) { ) {
@ -104,20 +104,44 @@ class OpdsController(
private val opdsPseSupportedFormats = listOf("image/jpeg", "image/png", "image/gif") private val opdsPseSupportedFormats = listOf("image/jpeg", "image/png", "image/gif")
private fun linkStart(markRead: Boolean) = OpdsLinkFeedNavigation(OpdsLinkRel.START, "$routeBase$ROUTE_CATALOG?$MARK_READ=$markRead") private fun uriBuilder(path: String, markRead: Boolean?) =
UriComponentsBuilder
.fromPath("$routeBase$path")
.queryParamIfPresent(MARK_READ, Optional.ofNullable(markRead))
private fun linkStart(markRead: Boolean?) =
OpdsLinkFeedNavigation(OpdsLinkRel.START, uriBuilder(ROUTE_CATALOG, markRead).toUriString())
private fun <T> linkPage(uriBuilder: UriComponentsBuilder, page: Page<T>): List<OpdsLink> {
val pageBuilder = uriBuilder.cloneBuilder()
.queryParam("page", "{page}")
.build()
return listOfNotNull(
if (!page.isFirst) OpdsLinkFeedNavigation(
OpdsLinkRel.PREVIOUS,
pageBuilder.expand(mapOf("page" to page.pageable.previousOrFirst().pageNumber)).toUriString()
)
else null,
if (!page.isLast) OpdsLinkFeedNavigation(
OpdsLinkRel.NEXT,
pageBuilder.expand(mapOf("page" to page.pageable.next().pageNumber)).toUriString()
)
else null,
)
}
@GetMapping(ROUTE_CATALOG) @GetMapping(ROUTE_CATALOG)
fun getCatalog( fun getCatalog(
@RequestParam(name = MARK_READ, required = false) markRead: Boolean = false, @RequestParam(name = MARK_READ, required = false) markRead: Boolean?,
): OpdsFeed = OpdsFeedNavigation( ): OpdsFeed = OpdsFeedNavigation(
id = "root", id = "root",
title = "Komga OPDS catalog", title = "Komga OPDS catalog",
updated = ZonedDateTime.now(), updated = ZonedDateTime.now(),
author = komgaAuthor, author = komgaAuthor,
links = listOf( links = listOf(
OpdsLinkFeedNavigation(OpdsLinkRel.SELF, "$routeBase$ROUTE_CATALOG?$MARK_READ=$markRead"), OpdsLinkFeedNavigation(OpdsLinkRel.SELF, uriBuilder(ROUTE_CATALOG, markRead).toUriString()),
linkStart(markRead), linkStart(markRead),
OpdsLinkSearch("$routeBase$ROUTE_SEARCH?$MARK_READ=$markRead"), OpdsLinkSearch(uriBuilder(ROUTE_SEARCH, markRead).toUriString()),
), ),
entries = listOf( entries = listOf(
OpdsEntryNavigation( OpdsEntryNavigation(
@ -125,108 +149,119 @@ class OpdsController(
updated = ZonedDateTime.now(), updated = ZonedDateTime.now(),
id = ID_SERIES_ALL, id = ID_SERIES_ALL,
content = "Browse by series", content = "Browse by series",
link = OpdsLinkFeedNavigation(OpdsLinkRel.SUBSECTION, "$routeBase$ROUTE_SERIES_ALL?$MARK_READ=$markRead"), link = OpdsLinkFeedNavigation(OpdsLinkRel.SUBSECTION, uriBuilder(ROUTE_SERIES_ALL, markRead).toUriString()),
), ),
OpdsEntryNavigation( OpdsEntryNavigation(
title = "Latest series", title = "Latest series",
updated = ZonedDateTime.now(), updated = ZonedDateTime.now(),
id = ID_SERIES_LATEST, id = ID_SERIES_LATEST,
content = "Browse latest series", content = "Browse latest series",
link = OpdsLinkFeedNavigation(OpdsLinkRel.SUBSECTION, "$routeBase$ROUTE_SERIES_LATEST?$MARK_READ=$markRead"), link = OpdsLinkFeedNavigation(OpdsLinkRel.SUBSECTION, uriBuilder(ROUTE_SERIES_LATEST, markRead).toUriString()),
), ),
OpdsEntryNavigation( OpdsEntryNavigation(
title = "Latest books", title = "Latest books",
updated = ZonedDateTime.now(), updated = ZonedDateTime.now(),
id = ID_BOOKS_LATEST, id = ID_BOOKS_LATEST,
content = "Browse latest books", content = "Browse latest books",
link = OpdsLinkFeedNavigation(OpdsLinkRel.SUBSECTION, "$routeBase$ROUTE_BOOKS_LATEST?$MARK_READ=$markRead"), link = OpdsLinkFeedNavigation(OpdsLinkRel.SUBSECTION, uriBuilder(ROUTE_BOOKS_LATEST, markRead).toUriString()),
), ),
OpdsEntryNavigation( OpdsEntryNavigation(
title = "All libraries", title = "All libraries",
updated = ZonedDateTime.now(), updated = ZonedDateTime.now(),
id = ID_LIBRARIES_ALL, id = ID_LIBRARIES_ALL,
content = "Browse by library", content = "Browse by library",
link = OpdsLinkFeedNavigation(OpdsLinkRel.SUBSECTION, "$routeBase$ROUTE_LIBRARIES_ALL?$MARK_READ=$markRead"), link = OpdsLinkFeedNavigation(OpdsLinkRel.SUBSECTION, uriBuilder(ROUTE_LIBRARIES_ALL, markRead).toUriString()),
), ),
OpdsEntryNavigation( OpdsEntryNavigation(
title = "All collections", title = "All collections",
updated = ZonedDateTime.now(), updated = ZonedDateTime.now(),
id = ID_COLLECTIONS_ALL, id = ID_COLLECTIONS_ALL,
content = "Browse by collection", content = "Browse by collection",
link = OpdsLinkFeedNavigation(OpdsLinkRel.SUBSECTION, "$routeBase$ROUTE_COLLECTIONS_ALL?$MARK_READ=$markRead"), link = OpdsLinkFeedNavigation(OpdsLinkRel.SUBSECTION, uriBuilder(ROUTE_COLLECTIONS_ALL, markRead).toUriString()),
), ),
OpdsEntryNavigation( OpdsEntryNavigation(
title = "All read lists", title = "All read lists",
updated = ZonedDateTime.now(), updated = ZonedDateTime.now(),
id = ID_READLISTS_ALL, id = ID_READLISTS_ALL,
content = "Browse by read lists", content = "Browse by read lists",
link = OpdsLinkFeedNavigation(OpdsLinkRel.SUBSECTION, "$routeBase$ROUTE_READLISTS_ALL?$MARK_READ=$markRead"), link = OpdsLinkFeedNavigation(OpdsLinkRel.SUBSECTION, uriBuilder(ROUTE_READLISTS_ALL, markRead).toUriString()),
), ),
OpdsEntryNavigation( OpdsEntryNavigation(
title = "All publishers", title = "All publishers",
updated = ZonedDateTime.now(), updated = ZonedDateTime.now(),
id = ID_PUBLISHERS_ALL, id = ID_PUBLISHERS_ALL,
content = "Browse by publishers", content = "Browse by publishers",
link = OpdsLinkFeedNavigation(OpdsLinkRel.SUBSECTION, "$routeBase$ROUTE_PUBLISHERS_ALL?$MARK_READ=$markRead"), link = OpdsLinkFeedNavigation(OpdsLinkRel.SUBSECTION, uriBuilder(ROUTE_PUBLISHERS_ALL, markRead).toUriString()),
) )
) )
) )
@GetMapping(ROUTE_SEARCH) @GetMapping(ROUTE_SEARCH)
fun getSearch( fun getSearch(
@RequestParam(name = MARK_READ, required = false) markRead: Boolean = false, @RequestParam(name = MARK_READ, required = false) markRead: Boolean?,
): OpenSearchDescription = OpenSearchDescription( ): OpenSearchDescription = OpenSearchDescription(
shortName = "Search", shortName = "Search",
description = "Search for series", description = "Search for series",
url = OpenSearchDescription.OpenSearchUrl("$routeBase$ROUTE_SERIES_ALL?search={searchTerms}&$MARK_READ=$markRead") url = OpenSearchDescription.OpenSearchUrl("$routeBase$ROUTE_SERIES_ALL?search={searchTerms}${markRead?.let { "&$MARK_READ=$it" } ?: ""}"),
) )
@PageAsQueryParam
@GetMapping(ROUTE_SERIES_ALL) @GetMapping(ROUTE_SERIES_ALL)
fun getAllSeries( fun getAllSeries(
@AuthenticationPrincipal principal: KomgaPrincipal, @AuthenticationPrincipal principal: KomgaPrincipal,
@RequestParam(name = "search", required = false) searchTerm: String?, @RequestParam(name = "search", required = false) searchTerm: String?,
@RequestParam(name = "publisher", required = false) publishers: List<String>?, @RequestParam(name = "publisher", required = false) publishers: List<String>?,
@RequestParam(name = MARK_READ, required = false) markRead: Boolean = false, @RequestParam(name = MARK_READ, required = false) markRead: Boolean?,
@Parameter(hidden = true) page: Pageable,
): OpdsFeed { ): OpdsFeed {
val seriesSearch = SeriesSearch( val sort =
if (!searchTerm.isNullOrBlank()) Sort.by("relevance")
else Sort.by(Sort.Order.asc("metadata.titleSort"))
val pageable = PageRequest.of(page.pageNumber, page.pageSize, sort)
val seriesSearch = SeriesSearchWithReadProgress(
libraryIds = principal.user.getAuthorizedLibraryIds(null), libraryIds = principal.user.getAuthorizedLibraryIds(null),
searchTerm = searchTerm, searchTerm = searchTerm,
publishers = publishers, publishers = publishers,
deleted = false, deleted = false,
) )
val entries = seriesRepository.findAll(seriesSearch) val seriesPage = seriesDtoRepository.findAll(seriesSearch, principal.user.id, pageable)
.map { SeriesWithInfo(it, seriesMetadataRepository.findById(it.id)) }
.sortedBy { it.metadata.titleSort.lowercase() } val builder = uriBuilder(ROUTE_SERIES_ALL, markRead)
.map { it.toOpdsEntry(markRead) } .queryParamIfPresent("search", Optional.ofNullable(searchTerm))
return OpdsFeedNavigation( return OpdsFeedNavigation(
id = ID_SERIES_ALL, id = ID_SERIES_ALL,
title = "All series", title = if (!searchTerm.isNullOrBlank()) "Series search for: $searchTerm" else "All series",
updated = ZonedDateTime.now(), updated = ZonedDateTime.now(),
author = komgaAuthor, author = komgaAuthor,
links = listOf( links = listOf(
OpdsLinkFeedNavigation(OpdsLinkRel.SELF, "$routeBase$ROUTE_SERIES_ALL?$MARK_READ=$markRead"), OpdsLinkFeedNavigation(OpdsLinkRel.SELF, builder.toUriString()),
linkStart(markRead), linkStart(markRead),
*linkPage(builder, seriesPage).toTypedArray(),
), ),
entries = entries entries = seriesPage.content.map { it.toOpdsEntry(markRead) },
) )
} }
@PageAsQueryParam
@GetMapping(ROUTE_SERIES_LATEST) @GetMapping(ROUTE_SERIES_LATEST)
fun getLatestSeries( fun getLatestSeries(
@AuthenticationPrincipal principal: KomgaPrincipal, @AuthenticationPrincipal principal: KomgaPrincipal,
@RequestParam(name = MARK_READ, required = false) markRead: Boolean = false, @RequestParam(name = MARK_READ, required = false) markRead: Boolean?,
@Parameter(hidden = true) page: Pageable,
): OpdsFeed { ): OpdsFeed {
val seriesSearch = SeriesSearch( val pageable = PageRequest.of(page.pageNumber, page.pageSize, Sort.by(Sort.Order.desc("lastModified")))
val seriesSearch = SeriesSearchWithReadProgress(
libraryIds = principal.user.getAuthorizedLibraryIds(null), libraryIds = principal.user.getAuthorizedLibraryIds(null),
deleted = false, deleted = false,
) )
val entries = seriesRepository.findAll(seriesSearch) val seriesPage = seriesDtoRepository.findAll(seriesSearch, principal.user.id, pageable)
.map { SeriesWithInfo(it, seriesMetadataRepository.findById(it.id)) }
.sortedByDescending { it.series.lastModifiedDate } val uriBuilder = uriBuilder(ROUTE_SERIES_LATEST, markRead)
.map { it.toOpdsEntry(markRead) }
return OpdsFeedNavigation( return OpdsFeedNavigation(
id = ID_SERIES_LATEST, id = ID_SERIES_LATEST,
@ -234,30 +269,33 @@ class OpdsController(
updated = ZonedDateTime.now(), updated = ZonedDateTime.now(),
author = komgaAuthor, author = komgaAuthor,
links = listOf( links = listOf(
OpdsLinkFeedNavigation(OpdsLinkRel.SELF, "$routeBase$ROUTE_SERIES_LATEST?$MARK_READ=$markRead"), OpdsLinkFeedNavigation(OpdsLinkRel.SELF, uriBuilder.build().toUriString()),
linkStart(markRead), linkStart(markRead),
*linkPage(uriBuilder, seriesPage).toTypedArray(),
), ),
entries = entries entries = seriesPage.content.map { it.toOpdsEntry(markRead) },
) )
} }
@PageAsQueryParam
@GetMapping(ROUTE_BOOKS_LATEST) @GetMapping(ROUTE_BOOKS_LATEST)
fun getLatestBooks( fun getLatestBooks(
@AuthenticationPrincipal principal: KomgaPrincipal, @AuthenticationPrincipal principal: KomgaPrincipal,
@RequestHeader(name = HttpHeaders.USER_AGENT, required = false, defaultValue = "") userAgent: String, @RequestHeader(name = HttpHeaders.USER_AGENT, required = false, defaultValue = "") userAgent: String,
@RequestParam(name = MARK_READ, required = false) markRead: Boolean = false, @RequestParam(name = MARK_READ, required = false) markRead: Boolean?,
@Parameter(hidden = true) page: Pageable,
): OpdsFeed { ): OpdsFeed {
val bookSearch = BookSearch( val bookSearch = BookSearchWithReadProgress(
libraryIds = principal.user.getAuthorizedLibraryIds(null), libraryIds = principal.user.getAuthorizedLibraryIds(null),
mediaStatus = setOf(Media.Status.READY), mediaStatus = setOf(Media.Status.READY),
deleted = false, deleted = false,
) )
val pageRequest = PageRequest.of(0, 50, Sort.by(Sort.Order.desc("createdDate"))) val pageable = PageRequest.of(page.pageNumber, page.pageSize, Sort.by(Sort.Order.desc("createdDate")))
val entries = bookRepository.findAll(bookSearch, pageRequest) val entries = bookDtoRepository.findAll(bookSearch, principal.user.id, pageable)
.map { BookWithInfo(it, mediaRepository.findById(it.id), bookMetadataRepository.findById(it.id)) } .map { it.toOpdsEntry(mediaRepository.findById(it.id), markRead, shouldPrependBookNumbers(userAgent)) }
.content
.map { it.toOpdsEntry(markRead, shouldPrependBookNumbers(userAgent)) } val uriBuilder = uriBuilder(ROUTE_BOOKS_LATEST, markRead)
return OpdsFeedAcquisition( return OpdsFeedAcquisition(
id = ID_BOOKS_LATEST, id = ID_BOOKS_LATEST,
@ -265,17 +303,18 @@ class OpdsController(
updated = ZonedDateTime.now(), updated = ZonedDateTime.now(),
author = komgaAuthor, author = komgaAuthor,
links = listOf( links = listOf(
OpdsLinkFeedNavigation(OpdsLinkRel.SELF, "$routeBase$ROUTE_BOOKS_LATEST?$MARK_READ=$markRead"), OpdsLinkFeedNavigation(OpdsLinkRel.SELF, uriBuilder.build().toUriString()),
linkStart(markRead), linkStart(markRead),
*linkPage(uriBuilder, entries).toTypedArray(),
), ),
entries = entries entries = entries.content,
) )
} }
@GetMapping(ROUTE_LIBRARIES_ALL) @GetMapping(ROUTE_LIBRARIES_ALL)
fun getLibraries( fun getLibraries(
@AuthenticationPrincipal principal: KomgaPrincipal, @AuthenticationPrincipal principal: KomgaPrincipal,
@RequestParam(name = MARK_READ, required = false) markRead: Boolean = false, @RequestParam(name = MARK_READ, required = false) markRead: Boolean?,
): OpdsFeed { ): OpdsFeed {
val libraries = val libraries =
if (principal.user.sharedAllLibraries) { if (principal.user.sharedAllLibraries) {
@ -289,69 +328,85 @@ class OpdsController(
updated = ZonedDateTime.now(), updated = ZonedDateTime.now(),
author = komgaAuthor, author = komgaAuthor,
links = listOf( links = listOf(
OpdsLinkFeedNavigation(OpdsLinkRel.SELF, "$routeBase$ROUTE_LIBRARIES_ALL?$MARK_READ=$markRead"), OpdsLinkFeedNavigation(OpdsLinkRel.SELF, uriBuilder(ROUTE_LIBRARIES_ALL, markRead).toUriString()),
linkStart(markRead), linkStart(markRead),
), ),
entries = libraries.map { it.toOpdsEntry(markRead) } entries = libraries.map { it.toOpdsEntry(markRead) }
) )
} }
@PageAsQueryParam
@GetMapping(ROUTE_COLLECTIONS_ALL) @GetMapping(ROUTE_COLLECTIONS_ALL)
fun getCollections( fun getCollections(
@AuthenticationPrincipal principal: KomgaPrincipal, @AuthenticationPrincipal principal: KomgaPrincipal,
@RequestParam(name = MARK_READ, required = false) markRead: Boolean = false, @RequestParam(name = MARK_READ, required = false) markRead: Boolean?,
@Parameter(hidden = true) page: Pageable,
): OpdsFeed { ): OpdsFeed {
val pageRequest = UnpagedSorted(Sort.by(Sort.Order.asc("name"))) val pageable = PageRequest.of(page.pageNumber, page.pageSize, Sort.by(Sort.Order.asc("name")))
val collections = val collections =
if (principal.user.sharedAllLibraries) { if (principal.user.sharedAllLibraries) {
collectionRepository.findAll(pageable = pageRequest) collectionRepository.findAll(pageable = pageable)
} else { } else {
collectionRepository.findAllByLibraryIds(principal.user.sharedLibrariesIds, principal.user.sharedLibrariesIds, pageable = pageRequest) collectionRepository.findAllByLibraryIds(principal.user.sharedLibrariesIds, principal.user.sharedLibrariesIds, pageable = pageable)
} }
val uriBuilder = uriBuilder(ROUTE_COLLECTIONS_ALL, markRead)
return OpdsFeedNavigation( return OpdsFeedNavigation(
id = ID_COLLECTIONS_ALL, id = ID_COLLECTIONS_ALL,
title = "All collections", title = "All collections",
updated = ZonedDateTime.now(), updated = ZonedDateTime.now(),
author = komgaAuthor, author = komgaAuthor,
links = listOf( links = listOf(
OpdsLinkFeedNavigation(OpdsLinkRel.SELF, "$routeBase$ROUTE_COLLECTIONS_ALL?$MARK_READ=$markRead"), OpdsLinkFeedNavigation(OpdsLinkRel.SELF, uriBuilder.toUriString()),
linkStart(markRead), linkStart(markRead),
*linkPage(uriBuilder, collections).toTypedArray(),
), ),
entries = collections.content.map { it.toOpdsEntry(markRead) } entries = collections.content.map { it.toOpdsEntry(markRead) }
) )
} }
@PageAsQueryParam
@GetMapping(ROUTE_READLISTS_ALL) @GetMapping(ROUTE_READLISTS_ALL)
fun getReadLists( fun getReadLists(
@AuthenticationPrincipal principal: KomgaPrincipal, @AuthenticationPrincipal principal: KomgaPrincipal,
@RequestParam(name = MARK_READ, required = false) markRead: Boolean = false, @RequestParam(name = MARK_READ, required = false) markRead: Boolean?,
@Parameter(hidden = true) page: Pageable,
): OpdsFeed { ): OpdsFeed {
val pageRequest = UnpagedSorted(Sort.by(Sort.Order.asc("name"))) val pageable = PageRequest.of(page.pageNumber, page.pageSize, Sort.by(Sort.Order.asc("name")))
val readLists = val readLists =
if (principal.user.sharedAllLibraries) { if (principal.user.sharedAllLibraries) {
readListRepository.findAll(pageable = pageRequest) readListRepository.findAll(pageable = pageable)
} else { } else {
readListRepository.findAllByLibraryIds(principal.user.sharedLibrariesIds, principal.user.sharedLibrariesIds, pageable = pageRequest) readListRepository.findAllByLibraryIds(principal.user.sharedLibrariesIds, principal.user.sharedLibrariesIds, pageable = pageable)
} }
val uriBuilder = uriBuilder(ROUTE_READLISTS_ALL, markRead)
return OpdsFeedNavigation( return OpdsFeedNavigation(
id = ID_READLISTS_ALL, id = ID_READLISTS_ALL,
title = "All read lists", title = "All read lists",
updated = ZonedDateTime.now(), updated = ZonedDateTime.now(),
author = komgaAuthor, author = komgaAuthor,
links = listOf( links = listOf(
OpdsLinkFeedNavigation(OpdsLinkRel.SELF, "$routeBase$ROUTE_READLISTS_ALL?$MARK_READ=$markRead"), OpdsLinkFeedNavigation(OpdsLinkRel.SELF, uriBuilder.toUriString()),
linkStart(markRead), linkStart(markRead),
*linkPage(uriBuilder, readLists).toTypedArray(),
), ),
entries = readLists.content.map { it.toOpdsEntry(markRead) } entries = readLists.content.map { it.toOpdsEntry(markRead) }
) )
} }
@PageAsQueryParam
@GetMapping(ROUTE_PUBLISHERS_ALL) @GetMapping(ROUTE_PUBLISHERS_ALL)
fun getPublishers( fun getPublishers(
@AuthenticationPrincipal principal: KomgaPrincipal, @AuthenticationPrincipal principal: KomgaPrincipal,
@RequestParam(name = MARK_READ, required = false) markRead: Boolean = false, @RequestParam(name = MARK_READ, required = false) markRead: Boolean?,
@Parameter(hidden = true) page: Pageable,
): OpdsFeed { ): OpdsFeed {
val publishers = referentialRepository.findAllPublishers(principal.user.getAuthorizedLibraryIds(null)) val publishers = referentialRepository.findAllPublishers(principal.user.getAuthorizedLibraryIds(null), page)
val uriBuilder = uriBuilder(ROUTE_PUBLISHERS_ALL, markRead)
return OpdsFeedNavigation( return OpdsFeedNavigation(
id = ID_PUBLISHERS_ALL, id = ID_PUBLISHERS_ALL,
@ -359,109 +414,123 @@ class OpdsController(
updated = ZonedDateTime.now(), updated = ZonedDateTime.now(),
author = komgaAuthor, author = komgaAuthor,
links = listOf( links = listOf(
OpdsLinkFeedNavigation(OpdsLinkRel.SELF, "$routeBase$ROUTE_PUBLISHERS_ALL?$MARK_READ=$markRead"), OpdsLinkFeedNavigation(OpdsLinkRel.SELF, uriBuilder.toUriString()),
linkStart(markRead), linkStart(markRead),
*linkPage(uriBuilder, publishers).toTypedArray(),
), ),
entries = publishers.map { entries = publishers.content.map { publisher ->
val publisherEncoded = UriUtils.encodeQueryParam(it, StandardCharsets.UTF_8)
OpdsEntryNavigation( OpdsEntryNavigation(
title = it, title = publisher,
updated = ZonedDateTime.now(), updated = ZonedDateTime.now(),
id = "publisher:$publisherEncoded", id = "publisher:${UriUtils.encodeQueryParam(publisher, StandardCharsets.UTF_8)}",
content = "", content = "",
link = OpdsLinkFeedNavigation(OpdsLinkRel.SUBSECTION, "$routeBase$ROUTE_SERIES_ALL?publisher=$publisherEncoded&$MARK_READ=$markRead") link = OpdsLinkFeedNavigation(OpdsLinkRel.SUBSECTION, uriBuilder(ROUTE_SERIES_ALL, markRead).queryParam("publisher", publisher).toUriString()),
) )
} }
) )
} }
@PageAsQueryParam
@GetMapping("series/{id}") @GetMapping("series/{id}")
fun getOneSeries( fun getOneSeries(
@AuthenticationPrincipal principal: KomgaPrincipal, @AuthenticationPrincipal principal: KomgaPrincipal,
@RequestHeader(name = HttpHeaders.USER_AGENT, required = false, defaultValue = "") userAgent: String, @RequestHeader(name = HttpHeaders.USER_AGENT, required = false, defaultValue = "") userAgent: String,
@PathVariable id: String, @PathVariable id: String,
@RequestParam(name = MARK_READ, required = false) markRead: Boolean = false, @RequestParam(name = MARK_READ, required = false) markRead: Boolean?,
@Parameter(hidden = true) page: Pageable,
): OpdsFeed = ): OpdsFeed =
seriesRepository.findByIdOrNull(id)?.let { series -> seriesDtoRepository.findByIdOrNull(id, principal.user.id)?.let { series ->
if (!principal.user.canAccessSeries(series)) throw ResponseStatusException(HttpStatus.FORBIDDEN) if (!principal.user.canAccessLibrary(series.libraryId)) throw ResponseStatusException(HttpStatus.FORBIDDEN)
val books = bookRepository.findAll( val bookSearch = BookSearchWithReadProgress(
BookSearch( seriesIds = listOf(id),
seriesIds = listOf(id), mediaStatus = setOf(Media.Status.READY),
mediaStatus = setOf(Media.Status.READY), deleted = false,
deleted = false,
)
) )
val metadata = seriesMetadataRepository.findById(series.id) val pageable = PageRequest.of(page.pageNumber, page.pageSize, Sort.by(Sort.Order.asc("metadata.numberSort")))
val entries = books val entries = bookDtoRepository.findAll(bookSearch, principal.user.id, pageable)
.map { BookWithInfo(it, mediaRepository.findById(it.id), bookMetadataRepository.findById(it.id)) } .map { it.toOpdsEntry(mediaRepository.findById(it.id), markRead, shouldPrependBookNumbers(userAgent)) }
.sortedBy { it.metadata.numberSort }
.map { it.toOpdsEntry(markRead, shouldPrependBookNumbers(userAgent)) } val uriBuilder = uriBuilder("series/$id", markRead)
OpdsFeedAcquisition( OpdsFeedAcquisition(
id = series.id, id = series.id,
title = metadata.title, title = series.metadata.title,
updated = series.lastModifiedDate.atZone(ZoneId.systemDefault()) ?: ZonedDateTime.now(), updated = series.lastModified.toCurrentTimeZone().atZone(ZoneId.systemDefault()) ?: ZonedDateTime.now(),
author = komgaAuthor, author = komgaAuthor,
links = listOf( links = listOf(
OpdsLinkFeedNavigation(OpdsLinkRel.SELF, "${routeBase}series/$id?$MARK_READ=$markRead"), OpdsLinkFeedNavigation(OpdsLinkRel.SELF, uriBuilder.toUriString()),
linkStart(markRead), linkStart(markRead),
*linkPage(uriBuilder, entries).toTypedArray(),
), ),
entries = entries entries = entries.content,
) )
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
@PageAsQueryParam
@GetMapping("libraries/{id}") @GetMapping("libraries/{id}")
fun getOneLibrary( fun getOneLibrary(
@AuthenticationPrincipal principal: KomgaPrincipal, @AuthenticationPrincipal principal: KomgaPrincipal,
@PathVariable id: String, @PathVariable id: String,
@RequestParam(name = MARK_READ, required = false) markRead: Boolean = false, @RequestParam(name = MARK_READ, required = false) markRead: Boolean?,
@Parameter(hidden = true) page: Pageable,
): OpdsFeed = ): OpdsFeed =
libraryRepository.findByIdOrNull(id)?.let { library -> libraryRepository.findByIdOrNull(id)?.let { library ->
if (!principal.user.canAccessLibrary(library)) throw ResponseStatusException(HttpStatus.FORBIDDEN) if (!principal.user.canAccessLibrary(library)) throw ResponseStatusException(HttpStatus.FORBIDDEN)
val seriesSearch = SeriesSearch( val seriesSearch = SeriesSearchWithReadProgress(
libraryIds = setOf(library.id), libraryIds = setOf(library.id),
deleted = false, deleted = false,
) )
val entries = seriesRepository.findAll(seriesSearch) val pageable = PageRequest.of(page.pageNumber, page.pageSize, Sort.by(Sort.Order.asc("metadata.titleSort")))
.map { SeriesWithInfo(it, seriesMetadataRepository.findById(it.id)) }
.sortedBy { it.metadata.titleSort.lowercase() } val entries = seriesDtoRepository.findAll(seriesSearch, principal.user.id, pageable)
.map { it.toOpdsEntry(markRead) } .map { it.toOpdsEntry(markRead) }
val uriBuilder = uriBuilder("libraries/$id", markRead)
OpdsFeedNavigation( OpdsFeedNavigation(
id = library.id, id = library.id,
title = library.name, title = library.name,
updated = library.lastModifiedDate.atZone(ZoneId.systemDefault()) ?: ZonedDateTime.now(), updated = library.lastModifiedDate.atZone(ZoneId.systemDefault()) ?: ZonedDateTime.now(),
author = komgaAuthor, author = komgaAuthor,
links = listOf( links = listOf(
OpdsLinkFeedNavigation(OpdsLinkRel.SELF, "${routeBase}libraries/$id?$MARK_READ=$markRead"), OpdsLinkFeedNavigation(OpdsLinkRel.SELF, uriBuilder.toUriString()),
linkStart(markRead), linkStart(markRead),
*linkPage(uriBuilder, entries).toTypedArray(),
), ),
entries = entries entries = entries.content
) )
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
@PageAsQueryParam
@GetMapping("collections/{id}") @GetMapping("collections/{id}")
fun getOneCollection( fun getOneCollection(
@AuthenticationPrincipal principal: KomgaPrincipal, @AuthenticationPrincipal principal: KomgaPrincipal,
@PathVariable id: String, @PathVariable id: String,
@RequestParam(name = MARK_READ, required = false) markRead: Boolean = false, @RequestParam(name = MARK_READ, required = false) markRead: Boolean?,
): OpdsFeed { @Parameter(hidden = true) page: Pageable,
return collectionRepository.findByIdOrNull(id, principal.user.getAuthorizedLibraryIds(null))?.let { collection -> ): OpdsFeed =
val series = collection.seriesIds.mapNotNull { seriesRepository.findByIdOrNull(it) } collectionRepository.findByIdOrNull(id, principal.user.getAuthorizedLibraryIds(null))?.let { collection ->
.filterNot { it.deletedDate != null } val sort =
.map { SeriesWithInfo(it, seriesMetadataRepository.findById(it.id)) } if (collection.ordered) Sort.by(Sort.Order.asc("collection.number"))
else Sort.by(Sort.Order.asc("metadata.titleSort"))
val pageable = PageRequest.of(page.pageNumber, page.pageSize, sort)
val sorted = val seriesSearch = SeriesSearchWithReadProgress(
if (!collection.ordered) series.sortedBy { it.metadata.titleSort } libraryIds = principal.user.getAuthorizedLibraryIds(null),
else series deleted = false,
)
val entries = sorted.mapIndexed { index, it -> val entries = seriesDtoRepository.findAllByCollectionId(collection.id, seriesSearch, principal.user.id, pageable)
it.toOpdsEntry(markRead, if (collection.ordered) index + 1 else null) .map { seriesDto ->
} val index = if (collection.ordered) collection.seriesIds.indexOf(seriesDto.id) + 1 else null
seriesDto.toOpdsEntry(markRead, index)
}
val uriBuilder = uriBuilder("collections/$id", markRead)
OpdsFeedNavigation( OpdsFeedNavigation(
id = collection.id, id = collection.id,
@ -469,124 +538,129 @@ class OpdsController(
updated = collection.lastModifiedDate.atZone(ZoneId.systemDefault()) ?: ZonedDateTime.now(), updated = collection.lastModifiedDate.atZone(ZoneId.systemDefault()) ?: ZonedDateTime.now(),
author = komgaAuthor, author = komgaAuthor,
links = listOf( links = listOf(
OpdsLinkFeedNavigation(OpdsLinkRel.SELF, "${routeBase}collections/$id?$MARK_READ=$markRead"), OpdsLinkFeedNavigation(OpdsLinkRel.SELF, uriBuilder.toUriString()),
linkStart(markRead), linkStart(markRead),
*linkPage(uriBuilder, entries).toTypedArray(),
), ),
entries = entries entries = entries.content
) )
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
}
@PageAsQueryParam
@GetMapping("readlists/{id}") @GetMapping("readlists/{id}")
fun getOneReadList( fun getOneReadList(
@AuthenticationPrincipal principal: KomgaPrincipal, @AuthenticationPrincipal principal: KomgaPrincipal,
@PathVariable id: String, @PathVariable id: String,
@RequestParam(name = MARK_READ, required = false) markRead: Boolean = false, @RequestParam(name = MARK_READ, required = false) markRead: Boolean?,
): OpdsFeed { @Parameter(hidden = true) page: Pageable,
return readListRepository.findByIdOrNull(id, principal.user.getAuthorizedLibraryIds(null))?.let { readList -> ): OpdsFeed =
val books = readList.bookIds.values.mapNotNull { bookRepository.findByIdOrNull(it) } readListRepository.findByIdOrNull(id, principal.user.getAuthorizedLibraryIds(null))?.let { readList ->
.filterNot { it.deletedDate != null } val pageable = PageRequest.of(page.pageNumber, page.pageSize, Sort.by(Sort.Order.asc("readList.number")))
.map { BookWithInfo(it, mediaRepository.findById(it.id), bookMetadataRepository.findById(it.id)) }
val entries = books.mapIndexed { index, it -> val bookSearch = BookSearchWithReadProgress(deleted = false)
it.toOpdsEntry(markRead, prependNumber = false, prepend = index + 1)
val booksPage = bookDtoRepository.findAllByReadListId(
readList.id,
principal.user.id,
principal.user.getAuthorizedLibraryIds(null),
bookSearch,
pageable,
)
val entries = booksPage.map { bookDto ->
val index = readList.bookIds.filterValues { it == bookDto.id }.keys.first()
bookDto.toOpdsEntry(mediaRepository.findById(bookDto.id), markRead, prependNumber = false, prepend = index + 1)
} }
val uriBuilder = uriBuilder("readlists/$id", markRead)
OpdsFeedAcquisition( OpdsFeedAcquisition(
id = readList.id, id = readList.id,
title = readList.name, title = readList.name,
updated = readList.lastModifiedDate.atZone(ZoneId.systemDefault()) ?: ZonedDateTime.now(), updated = readList.lastModifiedDate.atZone(ZoneId.systemDefault()) ?: ZonedDateTime.now(),
author = komgaAuthor, author = komgaAuthor,
links = listOf( links = listOf(
OpdsLinkFeedNavigation(OpdsLinkRel.SELF, "${routeBase}readlists/$id?$MARK_READ=$markRead"), OpdsLinkFeedNavigation(OpdsLinkRel.SELF, uriBuilder.toUriString()),
linkStart(markRead), linkStart(markRead),
*linkPage(uriBuilder, booksPage).toTypedArray(),
), ),
entries = entries entries = entries.content
) )
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
}
private fun SeriesWithInfo.toOpdsEntry(markRead: Boolean, prepend: Int? = null): OpdsEntryNavigation { private fun SeriesDto.toOpdsEntry(markRead: Boolean?, prepend: Int? = null): OpdsEntryNavigation {
val pre = prepend?.let { decimalFormat.format(it) + " - " } ?: "" val pre = prepend?.let { decimalFormat.format(it) + " - " } ?: ""
return OpdsEntryNavigation( return OpdsEntryNavigation(
title = pre + metadata.title, title = pre + metadata.title,
updated = series.lastModifiedDate.atZone(ZoneId.systemDefault()) ?: ZonedDateTime.now(), updated = lastModified.atZone(ZoneId.of("Z")) ?: ZonedDateTime.now(),
id = series.id, id = id,
content = "", content = "",
link = OpdsLinkFeedNavigation(OpdsLinkRel.SUBSECTION, "${routeBase}series/${series.id}?$MARK_READ=$markRead") link = OpdsLinkFeedNavigation(OpdsLinkRel.SUBSECTION, uriBuilder("series/$id", markRead).toUriString()),
) )
} }
private fun BookWithInfo.toOpdsEntry(markRead: Boolean, prependNumber: Boolean, prepend: Int? = null): OpdsEntryAcquisition { private fun BookDto.toOpdsEntry(media: Media, markRead: Boolean?, prependNumber: Boolean, prepend: Int? = null): OpdsEntryAcquisition {
val mediaTypes = media.pages.map { it.mediaType }.distinct()
val opdsLinkPageStreaming = if (mediaTypes.size == 1 && mediaTypes.first() in opdsPseSupportedFormats) {
OpdsLinkPageStreaming(mediaTypes.first(), "${routeBase}books/${book.id}/pages/{pageNumber}?zero_based=true&$MARK_READ=$markRead", media.pages.size)
} else {
OpdsLinkPageStreaming("image/jpeg", "${routeBase}books/${book.id}/pages/{pageNumber}?convert=jpeg&zero_based=true&$MARK_READ=$markRead", media.pages.size)
}
val pre = prepend?.let { decimalFormat.format(it) + " - " } ?: "" val pre = prepend?.let { decimalFormat.format(it) + " - " } ?: ""
return OpdsEntryAcquisition( return OpdsEntryAcquisition(
title = "$pre${if (prependNumber) "${decimalFormat.format(metadata.numberSort)} - " else ""}${metadata.title}", title = "$pre${if (prependNumber) "${decimalFormat.format(metadata.numberSort)} - " else ""}${metadata.title}",
updated = book.lastModifiedDate.atZone(ZoneId.systemDefault()) ?: ZonedDateTime.now(), updated = lastModified.toCurrentTimeZone().atZone(ZoneId.systemDefault()) ?: ZonedDateTime.now(),
id = book.id, id = id,
content = run { content = run {
var content = "${book.path.extension.lowercase()} - ${book.fileSizeHumanReadable}" var content = "${FilenameUtils.getExtension(url).lowercase()} - $size"
if (metadata.summary.isNotBlank()) if (metadata.summary.isNotBlank())
content += "\n\n${metadata.summary}" content += "\n\n${metadata.summary}"
content content
}, },
authors = metadata.authors.map { OpdsAuthor(it.name) }, authors = metadata.authors.map { OpdsAuthor(it.name) },
links = listOf( links = listOf(
OpdsLinkImageThumbnail("image/jpeg", "${routeBase}books/${book.id}/thumbnail"), OpdsLinkImageThumbnail("image/jpeg", uriBuilder("books/$id/thumbnail", null).toUriString()),
OpdsLinkImage(media.pages[0].mediaType, "${routeBase}books/${book.id}/pages/1"), OpdsLinkImage(media.pages[0].mediaType, uriBuilder("books/$id/pages/1", null).toUriString()),
OpdsLinkFileAcquisition(media.mediaType, "${routeBase}books/${book.id}/file/${sanitize(FilenameUtils.getName(book.url.toString()))}"), OpdsLinkFileAcquisition(media.mediaType, uriBuilder("books/$id/file/${sanitize(FilenameUtils.getName(url))}", null).toUriString()),
opdsLinkPageStreaming, media.toOpdsLinkPageStreaming(id, markRead),
) )
) )
} }
private fun sanitize(fileName: String): String = fileName.replace(";", "") private fun Media.toOpdsLinkPageStreaming(bookId: String, markRead: Boolean?): OpdsLinkPageStreaming {
val mediaTypes = pages.map { it.mediaType }.distinct()
private fun Library.toOpdsEntry(markRead: Boolean): OpdsEntryNavigation = return if (mediaTypes.size == 1 && mediaTypes.first() in opdsPseSupportedFormats) {
OpdsLinkPageStreaming(mediaTypes.first(), "${routeBase}books/$bookId/pages/{pageNumber}?zero_based=true${markRead?.let { "&$MARK_READ=$it" } ?: ""}", pages.size)
} else {
OpdsLinkPageStreaming("image/jpeg", "${routeBase}books/$bookId/pages/{pageNumber}?convert=jpeg&zero_based=true${markRead?.let { "&$MARK_READ=$it" } ?: ""}", pages.size)
}
}
private fun Library.toOpdsEntry(markRead: Boolean?): OpdsEntryNavigation =
OpdsEntryNavigation( OpdsEntryNavigation(
title = name, title = name,
updated = lastModifiedDate.atZone(ZoneId.systemDefault()) ?: ZonedDateTime.now(), updated = lastModifiedDate.atZone(ZoneId.systemDefault()) ?: ZonedDateTime.now(),
id = id, id = id,
content = "", content = "",
link = OpdsLinkFeedNavigation(OpdsLinkRel.SUBSECTION, "${routeBase}libraries/$id?$MARK_READ=$markRead") link = OpdsLinkFeedNavigation(OpdsLinkRel.SUBSECTION, uriBuilder("libraries/$id", markRead).toUriString())
) )
private fun SeriesCollection.toOpdsEntry(markRead: Boolean): OpdsEntryNavigation = private fun SeriesCollection.toOpdsEntry(markRead: Boolean?): OpdsEntryNavigation =
OpdsEntryNavigation( OpdsEntryNavigation(
title = name, title = name,
updated = lastModifiedDate.atZone(ZoneId.systemDefault()) ?: ZonedDateTime.now(), updated = lastModifiedDate.atZone(ZoneId.systemDefault()) ?: ZonedDateTime.now(),
id = id, id = id,
content = "", content = "",
link = OpdsLinkFeedNavigation(OpdsLinkRel.SUBSECTION, "${routeBase}collections/$id?$MARK_READ=$markRead") link = OpdsLinkFeedNavigation(OpdsLinkRel.SUBSECTION, uriBuilder("collections/$id", markRead).toUriString())
) )
private fun ReadList.toOpdsEntry(markRead: Boolean): OpdsEntryNavigation = private fun ReadList.toOpdsEntry(markRead: Boolean?): OpdsEntryNavigation =
OpdsEntryNavigation( OpdsEntryNavigation(
title = name, title = name,
updated = lastModifiedDate.atZone(ZoneId.systemDefault()) ?: ZonedDateTime.now(), updated = lastModifiedDate.atZone(ZoneId.systemDefault()) ?: ZonedDateTime.now(),
id = id, id = id,
content = "", content = "",
link = OpdsLinkFeedNavigation(OpdsLinkRel.SUBSECTION, "${routeBase}readlists/$id?$MARK_READ=$markRead") link = OpdsLinkFeedNavigation(OpdsLinkRel.SUBSECTION, uriBuilder("readlists/$id", markRead).toUriString())
) )
private fun shouldPrependBookNumbers(userAgent: String) = private fun shouldPrependBookNumbers(userAgent: String) =
userAgent.contains("chunky", ignoreCase = true) userAgent.contains("chunky", ignoreCase = true)
private data class BookWithInfo( private fun sanitize(fileName: String): String =
val book: Book, fileName.replace(";", "")
val media: Media,
val metadata: BookMetadata
)
private data class SeriesWithInfo(
val series: Series,
val metadata: SeriesMetadata
)
} }

View file

@ -72,6 +72,8 @@ class OpdsLinkRel {
companion object { companion object {
const val SELF = "self" const val SELF = "self"
const val START = "start" const val START = "start"
const val PREVIOUS = "previous"
const val NEXT = "next"
const val SUBSECTION = "subsection" const val SUBSECTION = "subsection"
const val SORT_NEW = "http://opds-spec.org/sort/new" const val SORT_NEW = "http://opds-spec.org/sort/new"
const val SORT_POPULAR = "http://opds-spec.org/sort/popular" const val SORT_POPULAR = "http://opds-spec.org/sort/popular"

View file

@ -343,7 +343,7 @@ class BookController(
@Parameter(description = "If set to true, pages will start at index 0. If set to false, pages will start at index 1.") @Parameter(description = "If set to true, pages will start at index 0. If set to false, pages will start at index 1.")
@RequestParam(value = "zero_based", defaultValue = "false") zeroBasedIndex: Boolean, @RequestParam(value = "zero_based", defaultValue = "false") zeroBasedIndex: Boolean,
@Parameter(description = "If set to true, read progress will be marked on the requested paged. Works only for OPDS.") @Parameter(description = "If set to true, read progress will be marked on the requested paged. Works only for OPDS.")
@RequestParam(name = MARK_READ, required = false) markRead: Boolean = false, @RequestParam(name = MARK_READ, required = false) markRead: Boolean?,
): ResponseEntity<ByteArray> = ): ResponseEntity<ByteArray> =
bookRepository.findByIdOrNull((bookId))?.let { book -> bookRepository.findByIdOrNull((bookId))?.let { book ->
val media = mediaRepository.findById(bookId) val media = mediaRepository.findById(bookId)
@ -366,7 +366,7 @@ class BookController(
val pageContent = bookLifecycle.getBookPage(book, pageNum, convertFormat) val pageContent = bookLifecycle.getBookPage(book, pageNum, convertFormat)
if (markRead && request.request.requestURI.startsWith("/opds")) { if (markRead == true && request.request.requestURI.startsWith("/opds")) {
bookLifecycle.markReadProgress(book, principal.user, pageNum) bookLifecycle.markReadProgress(book, principal.user, pageNum)
} }