diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/model/KomgaUser.kt b/komga/src/main/kotlin/org/gotson/komga/domain/model/KomgaUser.kt index fae7f66f..fba66c76 100644 --- a/komga/src/main/kotlin/org/gotson/komga/domain/model/KomgaUser.kt +++ b/komga/src/main/kotlin/org/gotson/komga/domain/model/KomgaUser.kt @@ -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 } diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/persistence/ReferentialRepository.kt b/komga/src/main/kotlin/org/gotson/komga/domain/persistence/ReferentialRepository.kt index 79dac775..aae0e677 100644 --- a/komga/src/main/kotlin/org/gotson/komga/domain/persistence/ReferentialRepository.kt +++ b/komga/src/main/kotlin/org/gotson/komga/domain/persistence/ReferentialRepository.kt @@ -38,6 +38,7 @@ interface ReferentialRepository { fun findAllLanguagesByCollection(collectionId: String, filterOnLibraryIds: Collection?): Set fun findAllPublishers(filterOnLibraryIds: Collection?): Set + fun findAllPublishers(filterOnLibraryIds: Collection?, pageable: Pageable): Page fun findAllPublishersByLibrary(libraryId: String, filterOnLibraryIds: Collection?): Set fun findAllPublishersByCollection(collectionId: String, filterOnLibraryIds: Collection?): Set diff --git a/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/ReferentialDao.kt b/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/ReferentialDao.kt index 1b632006..314389b4 100644 --- a/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/ReferentialDao.kt +++ b/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/ReferentialDao.kt @@ -357,6 +357,30 @@ class ReferentialDao( .orderBy(sd.PUBLISHER.collate(SqliteUdfDataSource.collationUnicode3)) .fetchSet(sd.PUBLISHER) + override fun findAllPublishers(filterOnLibraryIds: Collection?, pageable: Pageable): Page { + 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?): Set = dsl.selectDistinct(sd.PUBLISHER) .from(sd) diff --git a/komga/src/main/kotlin/org/gotson/komga/infrastructure/swagger/PageableAnnotations.kt b/komga/src/main/kotlin/org/gotson/komga/infrastructure/swagger/PageableAnnotations.kt index 68593172..38191503 100644 --- a/komga/src/main/kotlin/org/gotson/komga/infrastructure/swagger/PageableAnnotations.kt +++ b/komga/src/main/kotlin/org/gotson/komga/infrastructure/swagger/PageableAnnotations.kt @@ -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( diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/opds/OpdsController.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/opds/OpdsController.kt index b66dcb61..d2fc9ac3 100644 --- a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/opds/OpdsController.kt +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/opds/OpdsController.kt @@ -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 linkPage(uriBuilder: UriComponentsBuilder, page: Page): List { + 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?, - @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(";", "") } diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/opds/dto/OpdsLink.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/opds/dto/OpdsLink.kt index 5ef43159..c2e92208 100644 --- a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/opds/dto/OpdsLink.kt +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/opds/dto/OpdsLink.kt @@ -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" diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/BookController.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/BookController.kt index 3163f906..c5fc06b5 100644 --- a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/BookController.kt +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/BookController.kt @@ -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 = 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) }