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 }
}
fun canAccessSeries(series: Series): Boolean {
return sharedAllLibraries || sharedLibrariesIds.any { it == series.libraryId }
}
fun canAccessLibrary(libraryId: String): Boolean =
sharedAllLibraries || sharedLibrariesIds.any { it == libraryId }

View file

@ -38,6 +38,7 @@ interface ReferentialRepository {
fun findAllLanguagesByCollection(collectionId: String, 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 findAllPublishersByCollection(collectionId: String, filterOnLibraryIds: Collection<String>?): Set<String>

View file

@ -357,6 +357,30 @@ class ReferentialDao(
.orderBy(sd.PUBLISHER.collate(SqliteUdfDataSource.collationUnicode3))
.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> =
dsl.selectDistinct(sd.PUBLISHER)
.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.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)
@Parameters(
Parameter(

View file

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

View file

@ -72,6 +72,8 @@ class OpdsLinkRel {
companion object {
const val SELF = "self"
const val START = "start"
const val PREVIOUS = "previous"
const val NEXT = "next"
const val SUBSECTION = "subsection"
const val SORT_NEW = "http://opds-spec.org/sort/new"
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.")
@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.")
@RequestParam(name = MARK_READ, required = false) markRead: Boolean = false,
@RequestParam(name = MARK_READ, required = false) markRead: Boolean?,
): ResponseEntity<ByteArray> =
bookRepository.findByIdOrNull((bookId))?.let { book ->
val media = mediaRepository.findById(bookId)
@ -366,7 +366,7 @@ class BookController(
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)
}