Feat: Adds OPDS Chapter Filtering/Ordering (#1392)

* Adds server level configs for OPDS

* PR comments

* Refactor server-reference.conf (itemsPerPage range)

* Coerce itemsPerPage (10, 5000) and default invalid sort orders to DESC

* Coerce itemsPerPage (10, 5000) and default invalid sort orders to DESC

* Change opdsChapterSortOrder type to Enum(SortOrder)

* Fix serialization of SortOrderEnum & Add `opdsShowOnlyDownloadedChapters` config
This commit is contained in:
Shirish
2025-05-23 05:27:55 +05:30
committed by GitHub
parent 814e4ba744
commit 0405a535c7
11 changed files with 192 additions and 67 deletions

View File

@@ -112,7 +112,8 @@ open class ConfigManager {
value: Any, value: Any,
) { ) {
mutex.withLock { mutex.withLock {
val configValue = ConfigValueFactory.fromAnyRef(value) val actualValue = if (value is Enum<*>) value.name else value
val configValue = ConfigValueFactory.fromAnyRef(actualValue)
updateUserConfigFile(path, configValue) updateUserConfigFile(path, configValue)
internalConfig = internalConfig.withValue(path, configValue) internalConfig = internalConfig.withValue(path, configValue)

View File

@@ -94,6 +94,9 @@ class SettingsMutation {
// local source // local source
validateFilePath(settings.localSourcePath, "localSourcePath") validateFilePath(settings.localSourcePath, "localSourcePath")
// opds
validateValue(settings.opdsItemsPerPage, "opdsItemsPerPage") { it in 10..5000 }
} }
private fun <SettingType : Any> updateSetting( private fun <SettingType : Any> updateSetting(
@@ -177,6 +180,14 @@ class SettingsMutation {
updateSetting(settings.flareSolverrSessionName, serverConfig.flareSolverrSessionName) updateSetting(settings.flareSolverrSessionName, serverConfig.flareSolverrSessionName)
updateSetting(settings.flareSolverrSessionTtl, serverConfig.flareSolverrSessionTtl) updateSetting(settings.flareSolverrSessionTtl, serverConfig.flareSolverrSessionTtl)
updateSetting(settings.flareSolverrAsResponseFallback, serverConfig.flareSolverrAsResponseFallback) updateSetting(settings.flareSolverrAsResponseFallback, serverConfig.flareSolverrAsResponseFallback)
// opds
updateSetting(settings.opdsItemsPerPage, serverConfig.opdsItemsPerPage)
updateSetting(settings.opdsEnablePageReadProgress, serverConfig.opdsEnablePageReadProgress)
updateSetting(settings.opdsMarkAsReadOnDownload, serverConfig.opdsMarkAsReadOnDownload)
updateSetting(settings.opdsShowOnlyUnreadChapters, serverConfig.opdsShowOnlyUnreadChapters)
updateSetting(settings.opdsShowOnlyDownloadedChapters, serverConfig.opdsShowOnlyDownloadedChapters)
updateSetting(settings.opdsChapterSortOrder, serverConfig.opdsChapterSortOrder)
} }
fun setSettings(input: SetSettingsInput): SetSettingsPayload { fun setSettings(input: SetSettingsInput): SetSettingsPayload {

View File

@@ -8,6 +8,7 @@
package suwayomi.tachidesk.graphql.types package suwayomi.tachidesk.graphql.types
import com.expediagroup.graphql.generator.annotations.GraphQLDeprecated import com.expediagroup.graphql.generator.annotations.GraphQLDeprecated
import org.jetbrains.exposed.sql.SortOrder
import suwayomi.tachidesk.graphql.server.primitives.Node import suwayomi.tachidesk.graphql.server.primitives.Node
import suwayomi.tachidesk.server.ServerConfig import suwayomi.tachidesk.server.ServerConfig
import suwayomi.tachidesk.server.serverConfig import suwayomi.tachidesk.server.serverConfig
@@ -92,6 +93,14 @@ interface Settings : Node {
val flareSolverrSessionName: String? val flareSolverrSessionName: String?
val flareSolverrSessionTtl: Int? val flareSolverrSessionTtl: Int?
val flareSolverrAsResponseFallback: Boolean? val flareSolverrAsResponseFallback: Boolean?
// opds
val opdsItemsPerPage: Int?
val opdsEnablePageReadProgress: Boolean?
val opdsMarkAsReadOnDownload: Boolean?
val opdsShowOnlyUnreadChapters: Boolean?
val opdsShowOnlyDownloadedChapters: Boolean?
val opdsChapterSortOrder: SortOrder?
} }
data class PartialSettingsType( data class PartialSettingsType(
@@ -159,6 +168,13 @@ data class PartialSettingsType(
override val flareSolverrSessionName: String?, override val flareSolverrSessionName: String?,
override val flareSolverrSessionTtl: Int?, override val flareSolverrSessionTtl: Int?,
override val flareSolverrAsResponseFallback: Boolean?, override val flareSolverrAsResponseFallback: Boolean?,
// opds
override val opdsItemsPerPage: Int?,
override val opdsEnablePageReadProgress: Boolean?,
override val opdsMarkAsReadOnDownload: Boolean?,
override val opdsShowOnlyUnreadChapters: Boolean?,
override val opdsShowOnlyDownloadedChapters: Boolean?,
override val opdsChapterSortOrder: SortOrder?,
) : Settings ) : Settings
class SettingsType( class SettingsType(
@@ -226,6 +242,13 @@ class SettingsType(
override val flareSolverrSessionName: String, override val flareSolverrSessionName: String,
override val flareSolverrSessionTtl: Int, override val flareSolverrSessionTtl: Int,
override val flareSolverrAsResponseFallback: Boolean, override val flareSolverrAsResponseFallback: Boolean,
// opds
override val opdsItemsPerPage: Int,
override val opdsEnablePageReadProgress: Boolean,
override val opdsMarkAsReadOnDownload: Boolean,
override val opdsShowOnlyUnreadChapters: Boolean,
override val opdsShowOnlyDownloadedChapters: Boolean,
override val opdsChapterSortOrder: SortOrder,
) : Settings { ) : Settings {
constructor(config: ServerConfig = serverConfig) : this( constructor(config: ServerConfig = serverConfig) : this(
config.ip.value, config.ip.value,
@@ -287,5 +310,12 @@ class SettingsType(
config.flareSolverrSessionName.value, config.flareSolverrSessionName.value,
config.flareSolverrSessionTtl.value, config.flareSolverrSessionTtl.value,
config.flareSolverrAsResponseFallback.value, config.flareSolverrAsResponseFallback.value,
// opds
config.opdsItemsPerPage.value,
config.opdsEnablePageReadProgress.value,
config.opdsMarkAsReadOnDownload.value,
config.opdsShowOnlyUnreadChapters.value,
config.opdsShowOnlyDownloadedChapters.value,
config.opdsChapterSortOrder.value,
) )
} }

View File

@@ -9,6 +9,7 @@ package suwayomi.tachidesk.manga
import io.javalin.apibuilder.ApiBuilder.delete import io.javalin.apibuilder.ApiBuilder.delete
import io.javalin.apibuilder.ApiBuilder.get import io.javalin.apibuilder.ApiBuilder.get
import io.javalin.apibuilder.ApiBuilder.head
import io.javalin.apibuilder.ApiBuilder.patch import io.javalin.apibuilder.ApiBuilder.patch
import io.javalin.apibuilder.ApiBuilder.path import io.javalin.apibuilder.ApiBuilder.path
import io.javalin.apibuilder.ApiBuilder.post import io.javalin.apibuilder.ApiBuilder.post
@@ -83,6 +84,7 @@ object MangaAPI {
path("chapter") { path("chapter") {
post("batch", MangaController.anyChapterBatch) post("batch", MangaController.anyChapterBatch)
get("{chapterId}/download", MangaController.downloadChapter) get("{chapterId}/download", MangaController.downloadChapter)
head("{chapterId}/download", MangaController.downloadChapter)
} }
path("category") { path("category") {

View File

@@ -7,6 +7,7 @@ package suwayomi.tachidesk.manga.controller
* License, v. 2.0. If a copy of the MPL was not distributed with this * License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import io.javalin.http.HandlerType
import io.javalin.http.HttpStatus import io.javalin.http.HttpStatus
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import suwayomi.tachidesk.manga.impl.CategoryManga import suwayomi.tachidesk.manga.impl.CategoryManga
@@ -434,20 +435,27 @@ object MangaController {
val downloadChapter = val downloadChapter =
handler( handler(
pathParam<Int>("chapterId"), pathParam<Int>("chapterId"),
queryParam<Boolean?>("markAsRead"),
documentWith = { documentWith = {
withOperation { withOperation {
summary("Download chapter as CBZ") summary("Download chapter as CBZ")
description("Get the CBZ file of the specified chapter") description("Get the CBZ file of the specified chapter")
} }
}, },
behaviorOf = { ctx, chapterId -> behaviorOf = { ctx, chapterId, markAsRead ->
val shouldMarkAsRead = if (ctx.method() == HandlerType.HEAD) false else markAsRead
ctx.future { ctx.future {
future { ChapterDownloadHelper.getCbzForDownload(chapterId) } future { ChapterDownloadHelper.getCbzForDownload(chapterId, shouldMarkAsRead) }
.thenApply { (inputStream, fileName, fileSize) -> .thenApply { (inputStream, fileName, fileSize) ->
ctx.header("Content-Type", "application/vnd.comicbook+zip") ctx.header("Content-Type", "application/vnd.comicbook+zip")
ctx.header("Content-Disposition", "attachment; filename=\"$fileName\"") ctx.header("Content-Disposition", "attachment; filename=\"$fileName\"")
ctx.header("Content-Length", fileSize.toString()) ctx.header("Content-Length", fileSize.toString())
ctx.result(inputStream) if (ctx.method() == HandlerType.HEAD) {
inputStream.close()
ctx.status(200)
} else {
ctx.result(inputStream)
}
} }
} }
}, },

View File

@@ -57,7 +57,10 @@ object ChapterDownloadHelper {
chapterId: Int, chapterId: Int,
): Pair<InputStream, Long> = provider(mangaId, chapterId).getAsArchiveStream() ): Pair<InputStream, Long> = provider(mangaId, chapterId).getAsArchiveStream()
fun getCbzForDownload(chapterId: Int): Triple<InputStream, String, Long> { fun getCbzForDownload(
chapterId: Int,
markAsRead: Boolean?,
): Triple<InputStream, String, Long> {
val (chapterData, mangaTitle) = val (chapterData, mangaTitle) =
transaction { transaction {
val row = val row =
@@ -74,6 +77,17 @@ object ChapterDownloadHelper {
val cbzFile = provider(chapterData.mangaId, chapterData.id).getAsArchiveStream() val cbzFile = provider(chapterData.mangaId, chapterData.id).getAsArchiveStream()
if (markAsRead == true) {
Chapter.modifyChapter(
chapterData.mangaId,
chapterData.index,
isRead = true,
isBookmarked = null,
markPrevRead = null,
lastPageRead = null,
)
}
return Triple(cbzFile.first, fileName, cbzFile.second) return Triple(cbzFile.first, fileName, cbzFile.second)
} }
} }

View File

@@ -9,6 +9,7 @@ import nl.adaptivity.xmlutil.serialization.XML
import org.jetbrains.exposed.sql.JoinType import org.jetbrains.exposed.sql.JoinType
import org.jetbrains.exposed.sql.Op import org.jetbrains.exposed.sql.Op
import org.jetbrains.exposed.sql.SortOrder import org.jetbrains.exposed.sql.SortOrder
import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq
import org.jetbrains.exposed.sql.and import org.jetbrains.exposed.sql.and
import org.jetbrains.exposed.sql.lowerCase import org.jetbrains.exposed.sql.lowerCase
import org.jetbrains.exposed.sql.or import org.jetbrains.exposed.sql.or
@@ -30,6 +31,7 @@ import suwayomi.tachidesk.manga.model.table.MangaTable
import suwayomi.tachidesk.manga.model.table.SourceTable import suwayomi.tachidesk.manga.model.table.SourceTable
import suwayomi.tachidesk.manga.model.table.toDataClass import suwayomi.tachidesk.manga.model.table.toDataClass
import suwayomi.tachidesk.opds.model.OpdsXmlModels import suwayomi.tachidesk.opds.model.OpdsXmlModels
import suwayomi.tachidesk.server.serverConfig
import java.net.URLEncoder import java.net.URLEncoder
import java.nio.charset.StandardCharsets import java.nio.charset.StandardCharsets
import java.time.Instant import java.time.Instant
@@ -37,22 +39,25 @@ import java.time.ZoneOffset
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
object Opds { object Opds {
private const val ITEMS_PER_PAGE = 20 private val opdsItemsPerPageBounded: Int
get() = serverConfig.opdsItemsPerPage.value.coerceIn(10, 5000)
fun getRootFeed(baseUrl: String): String { fun getRootFeed(baseUrl: String): String {
val rootSection =
listOf(
"mangas" to "All Manga",
"sources" to "Sources",
"categories" to "Categories",
"genres" to "Genres",
"status" to "Status",
"languages" to "Languages",
"library-updates" to "Library Update History",
)
val builder = val builder =
FeedBuilder(baseUrl, 1, "opds", "Suwayomi OPDS Catalog").apply { FeedBuilder(baseUrl, 1, "opds", "Suwayomi OPDS Catalog").apply {
totalResults = 6 totalResults = rootSection.size.toLong()
entries += entries +=
listOf( rootSection.map { (id, title) ->
"mangas" to "All Manga",
"sources" to "Sources",
"categories" to "Categories",
"genres" to "Genres",
"status" to "Status",
"languages" to "Languages",
"library-updates" to "Library Update History",
).map { (id, title) ->
OpdsXmlModels.Entry( OpdsXmlModels.Entry(
id = id, id = id,
title = title, title = title,
@@ -84,6 +89,7 @@ object Opds {
.select(MangaTable.columns) .select(MangaTable.columns)
.where { .where {
val conditions = mutableListOf<Op<Boolean>>() val conditions = mutableListOf<Op<Boolean>>()
conditions += (MangaTable.inLibrary eq true)
criteria?.query?.takeIf { it.isNotBlank() }?.let { q -> criteria?.query?.takeIf { it.isNotBlank() }?.let { q ->
val lowerQ = q.lowercase() val lowerQ = q.lowercase()
@@ -102,14 +108,14 @@ object Opds {
conditions += (MangaTable.title.lowerCase() like "%${title.lowercase()}%") conditions += (MangaTable.title.lowerCase() like "%${title.lowercase()}%")
} }
if (conditions.isEmpty()) (MangaTable.inLibrary eq true) else conditions.reduce { acc, op -> acc and op } conditions.reduce { acc, op -> acc and op }
}.groupBy(MangaTable.id) }.groupBy(MangaTable.id)
.orderBy(MangaTable.title to SortOrder.ASC) .orderBy(MangaTable.title to SortOrder.ASC)
val totalCount = query.count() val totalCount = query.count()
val mangas = val mangas =
query query
.limit(ITEMS_PER_PAGE) .limit(opdsItemsPerPageBounded)
.offset(((pageNum - 1) * ITEMS_PER_PAGE).toLong()) .offset(((pageNum - 1) * opdsItemsPerPageBounded).toLong())
.map { MangaTable.toDataClass(it, includeMangaMeta = false) } .map { MangaTable.toDataClass(it, includeMangaMeta = false) }
Pair(mangas, totalCount) Pair(mangas, totalCount)
} }
@@ -146,8 +152,8 @@ object Opds {
val totalCount = query.count() val totalCount = query.count()
val sources = val sources =
query query
.limit(ITEMS_PER_PAGE) .limit(opdsItemsPerPageBounded)
.offset(((pageNum - 1) * ITEMS_PER_PAGE).toLong()) .offset(((pageNum - 1) * opdsItemsPerPageBounded).toLong())
.map { .map {
SourceDataClass( SourceDataClass(
id = it[SourceTable.id].value.toString(), id = it[SourceTable.id].value.toString(),
@@ -165,7 +171,7 @@ object Opds {
return FeedBuilder(baseUrl, pageNum, "sources", "Sources") return FeedBuilder(baseUrl, pageNum, "sources", "Sources")
.apply { .apply {
totalResults = totalCount.toLong() totalResults = totalCount
entries += entries +=
sourceList.map { sourceList.map {
OpdsXmlModels.Entry( OpdsXmlModels.Entry(
@@ -191,30 +197,33 @@ object Opds {
pageNum: Int, pageNum: Int,
): String { ): String {
val formattedNow = opdsDateFormatter.format(Instant.now()) val formattedNow = opdsDateFormatter.format(Instant.now())
val categoryList = val (categoryList, total) =
transaction { transaction {
CategoryTable val query =
.join(CategoryMangaTable, JoinType.INNER, onColumn = CategoryTable.id, otherColumn = CategoryMangaTable.category) CategoryTable
.join(MangaTable, JoinType.INNER, onColumn = CategoryMangaTable.manga, otherColumn = MangaTable.id) .join(CategoryMangaTable, JoinType.INNER, onColumn = CategoryTable.id, otherColumn = CategoryMangaTable.category)
.join(ChapterTable, JoinType.INNER, onColumn = MangaTable.id, otherColumn = ChapterTable.manga) .join(MangaTable, JoinType.INNER, onColumn = CategoryMangaTable.manga, otherColumn = MangaTable.id)
.select(CategoryTable.id, CategoryTable.name) .join(ChapterTable, JoinType.INNER, onColumn = MangaTable.id, otherColumn = ChapterTable.manga)
.groupBy(CategoryTable.id) .select(CategoryTable.id, CategoryTable.name)
.orderBy(CategoryTable.order to SortOrder.ASC) .groupBy(CategoryTable.id)
.map { row -> .orderBy(CategoryTable.order to SortOrder.ASC)
Pair(row[CategoryTable.id].value, row[CategoryTable.name])
}
}
val totalCount = categoryList.size val total = query.count()
val fromIndex = (pageNum - 1) * ITEMS_PER_PAGE
val toIndex = minOf(fromIndex + ITEMS_PER_PAGE, totalCount) val paginated =
val paginatedCategories = if (fromIndex < totalCount) categoryList.subList(fromIndex, toIndex) else emptyList() query
.limit(opdsItemsPerPageBounded)
.offset(((pageNum - 1) * opdsItemsPerPageBounded).toLong())
.map { row -> Pair(row[CategoryTable.id].value, row[CategoryTable.name]) }
Pair(paginated, total)
}
return FeedBuilder(baseUrl, pageNum, "categories", "Categories") return FeedBuilder(baseUrl, pageNum, "categories", "Categories")
.apply { .apply {
totalResults = totalCount.toLong() totalResults = total
entries += entries +=
paginatedCategories.map { (id, name) -> categoryList.map { (id, name) ->
OpdsXmlModels.Entry( OpdsXmlModels.Entry(
id = "category/$id", id = "category/$id",
title = name, title = name,
@@ -252,8 +261,8 @@ object Opds {
} }
val totalCount = genres.size val totalCount = genres.size
val fromIndex = (pageNum - 1) * ITEMS_PER_PAGE val fromIndex = (pageNum - 1) * opdsItemsPerPageBounded
val toIndex = minOf(fromIndex + ITEMS_PER_PAGE, totalCount) val toIndex = minOf(fromIndex + opdsItemsPerPageBounded, totalCount)
val paginatedGenres = if (fromIndex < totalCount) genres.subList(fromIndex, toIndex) else emptyList() val paginatedGenres = if (fromIndex < totalCount) genres.subList(fromIndex, toIndex) else emptyList()
return serialize( return serialize(
@@ -263,7 +272,7 @@ object Opds {
updated = formattedNow, updated = formattedNow,
author = OpdsXmlModels.Author("Suwayomi", "https://suwayomi.org/"), author = OpdsXmlModels.Author("Suwayomi", "https://suwayomi.org/"),
totalResults = totalCount.toLong(), totalResults = totalCount.toLong(),
itemsPerPage = ITEMS_PER_PAGE, itemsPerPage = opdsItemsPerPageBounded,
startIndex = fromIndex + 1, startIndex = fromIndex + 1,
links = links =
listOf( listOf(
@@ -306,8 +315,8 @@ object Opds {
val statuses = MangaStatus.entries.sortedBy { it.value } val statuses = MangaStatus.entries.sortedBy { it.value }
val totalCount = statuses.size val totalCount = statuses.size
val fromIndex = (pageNum - 1) * ITEMS_PER_PAGE val fromIndex = (pageNum - 1) * opdsItemsPerPageBounded
val toIndex = minOf(fromIndex + ITEMS_PER_PAGE, totalCount) val toIndex = minOf(fromIndex + opdsItemsPerPageBounded, totalCount)
val paginatedStatuses = if (fromIndex < totalCount) statuses.subList(fromIndex, toIndex) else emptyList() val paginatedStatuses = if (fromIndex < totalCount) statuses.subList(fromIndex, toIndex) else emptyList()
return FeedBuilder(baseUrl, pageNum, "status", "Status") return FeedBuilder(baseUrl, pageNum, "status", "Status")
@@ -378,6 +387,8 @@ object Opds {
baseUrl: String, baseUrl: String,
pageNum: Int, pageNum: Int,
): String { ): String {
val sortOrder = serverConfig.opdsChapterSortOrder.value
val (manga, chapters, totalCount) = val (manga, chapters, totalCount) =
transaction { transaction {
val mangaEntry = val mangaEntry =
@@ -386,18 +397,30 @@ object Opds {
.where { MangaTable.id eq mangaId } .where { MangaTable.id eq mangaId }
.first() .first()
val mangaData = MangaTable.toDataClass(mangaEntry, includeMangaMeta = false) val mangaData = MangaTable.toDataClass(mangaEntry, includeMangaMeta = false)
val chapterConditions =
buildList {
if (serverConfig.opdsShowOnlyUnreadChapters.value) {
add(ChapterTable.isRead eq false)
}
if (serverConfig.opdsShowOnlyDownloadedChapters.value) {
add(ChapterTable.isDownloaded eq true)
}
add(ChapterTable.manga eq mangaId)
}.reduce { acc, op -> acc and op }
val chaptersQuery = val chaptersQuery =
ChapterTable ChapterTable
.selectAll() .selectAll()
.where { .where { chapterConditions }
(ChapterTable.manga eq mangaId) .orderBy(ChapterTable.sourceOrder to sortOrder)
}.orderBy(ChapterTable.sourceOrder to SortOrder.DESC)
val total = chaptersQuery.count() val total = chaptersQuery.count()
val chaptersData = val chaptersData =
chaptersQuery chaptersQuery
.limit(ITEMS_PER_PAGE) .limit(opdsItemsPerPageBounded)
.offset(((pageNum - 1) * ITEMS_PER_PAGE).toLong()) .offset(((pageNum - 1) * opdsItemsPerPageBounded).toLong())
.map { ChapterTable.toDataClass(it, includeChapterCount = false, includeChapterMeta = false) } .map { ChapterTable.toDataClass(it, includeChapterCount = false, includeChapterMeta = false) }
Triple(mangaData, chaptersData, total) Triple(mangaData, chaptersData, total)
} }
@@ -489,6 +512,7 @@ object Opds {
val entryTitle = val entryTitle =
when { when {
isMetaDataEntry -> ""
chapter.read -> "" chapter.read -> ""
chapter.lastPageRead > 0 -> "" chapter.lastPageRead > 0 -> ""
chapter.pageCount == 0 -> "" chapter.pageCount == 0 -> ""
@@ -506,7 +530,9 @@ object Opds {
add( add(
OpdsXmlModels.Link( OpdsXmlModels.Link(
rel = "http://opds-spec.org/acquisition/open-access", rel = "http://opds-spec.org/acquisition/open-access",
href = "/api/v1/chapter/${chapter.id}/download", href =
"/api/v1/chapter/${chapter.id}/download" +
"?markAsRead=${serverConfig.opdsMarkAsReadOnDownload.value}",
type = "application/vnd.comicbook+zip", type = "application/vnd.comicbook+zip",
), ),
) )
@@ -515,7 +541,9 @@ object Opds {
add( add(
OpdsXmlModels.Link( OpdsXmlModels.Link(
rel = "http://vaemendis.net/opds-pse/stream", rel = "http://vaemendis.net/opds-pse/stream",
href = "/api/v1/manga/${manga.id}/chapter/${chapter.index}/page/{pageNumber}?updateProgress=true", href =
"/api/v1/manga/${manga.id}/chapter/${chapter.index}/page/{pageNumber}" +
"?updateProgress=${serverConfig.opdsEnablePageReadProgress.value}",
type = "image/jpeg", type = "image/jpeg",
pseCount = chapter.pageCount, pseCount = chapter.pageCount,
pseLastRead = chapter.lastPageRead.takeIf { it != 0 }, pseLastRead = chapter.lastPageRead.takeIf { it != 0 },
@@ -584,8 +612,8 @@ object Opds {
val totalCount = query.count() val totalCount = query.count()
val paginatedResults = val paginatedResults =
query query
.limit(ITEMS_PER_PAGE) .limit(opdsItemsPerPageBounded)
.offset(((pageNum - 1) * ITEMS_PER_PAGE).toLong()) .offset(((pageNum - 1) * opdsItemsPerPageBounded).toLong())
.map { MangaTable.toDataClass(it, includeMangaMeta = false) } .map { MangaTable.toDataClass(it, includeMangaMeta = false) }
Triple(paginatedResults, totalCount, sourceRow) Triple(paginatedResults, totalCount, sourceRow)
@@ -624,8 +652,8 @@ object Opds {
val totalCount = query.count() val totalCount = query.count()
val mangas = val mangas =
query query
.limit(ITEMS_PER_PAGE) .limit(opdsItemsPerPageBounded)
.offset(((pageNum - 1) * ITEMS_PER_PAGE).toLong()) .offset(((pageNum - 1) * opdsItemsPerPageBounded).toLong())
.map { MangaTable.toDataClass(it, includeMangaMeta = false) } .map { MangaTable.toDataClass(it, includeMangaMeta = false) }
Triple(mangas, totalCount, categoryName) Triple(mangas, totalCount, categoryName)
} }
@@ -655,8 +683,8 @@ object Opds {
val totalCount = query.count() val totalCount = query.count()
val mangas = val mangas =
query query
.limit(ITEMS_PER_PAGE) .limit(opdsItemsPerPageBounded)
.offset(((pageNum - 1) * ITEMS_PER_PAGE).toLong()) .offset(((pageNum - 1) * opdsItemsPerPageBounded).toLong())
.map { MangaTable.toDataClass(it, includeMangaMeta = false) } .map { MangaTable.toDataClass(it, includeMangaMeta = false) }
Pair(mangas, totalCount) Pair(mangas, totalCount)
} }
@@ -692,8 +720,8 @@ object Opds {
val totalCount = query.count() val totalCount = query.count()
val mangas = val mangas =
query query
.limit(ITEMS_PER_PAGE) .limit(opdsItemsPerPageBounded)
.offset(((pageNum - 1) * ITEMS_PER_PAGE).toLong()) .offset(((pageNum - 1) * opdsItemsPerPageBounded).toLong())
.map { MangaTable.toDataClass(it, includeMangaMeta = false) } .map { MangaTable.toDataClass(it, includeMangaMeta = false) }
Pair(mangas, totalCount) Pair(mangas, totalCount)
} }
@@ -724,8 +752,8 @@ object Opds {
val totalCount = query.count() val totalCount = query.count()
val mangas = val mangas =
query query
.limit(ITEMS_PER_PAGE) .limit(opdsItemsPerPageBounded)
.offset(((pageNum - 1) * ITEMS_PER_PAGE).toLong()) .offset(((pageNum - 1) * opdsItemsPerPageBounded).toLong())
.map { MangaTable.toDataClass(it, includeMangaMeta = false) } .map { MangaTable.toDataClass(it, includeMangaMeta = false) }
Pair(mangas, totalCount) Pair(mangas, totalCount)
} }
@@ -753,8 +781,8 @@ object Opds {
val totalCount = query.count() val totalCount = query.count()
val chapters = val chapters =
query query
.limit(ITEMS_PER_PAGE) .limit(opdsItemsPerPageBounded)
.offset(((pageNum - 1) * ITEMS_PER_PAGE).toLong()) .offset(((pageNum - 1) * opdsItemsPerPageBounded).toLong())
.map { .map {
ChapterTable.toDataClass( ChapterTable.toDataClass(
it, it,
@@ -833,7 +861,7 @@ object Opds {
type = "application/atom+xml;profile=opds-catalog;kind=navigation", type = "application/atom+xml;profile=opds-catalog;kind=navigation",
) )
}, },
(totalResults > pageNum * ITEMS_PER_PAGE).takeIf { it }?.let { (totalResults > pageNum * opdsItemsPerPageBounded).takeIf { it }?.let {
OpdsXmlModels.Link( OpdsXmlModels.Link(
rel = "next", rel = "next",
href = "$baseUrl/$id?pageNumber=${pageNum + 1}", href = "$baseUrl/$id?pageNumber=${pageNum + 1}",
@@ -843,8 +871,8 @@ object Opds {
), ),
entries = entries, entries = entries,
totalResults = totalResults, totalResults = totalResults,
itemsPerPage = ITEMS_PER_PAGE, itemsPerPage = opdsItemsPerPageBounded,
startIndex = (pageNum - 1) * ITEMS_PER_PAGE + 1, startIndex = (pageNum - 1) * opdsItemsPerPageBounded + 1,
) )
} }

View File

@@ -1,5 +1,7 @@
package suwayomi.tachidesk.server package suwayomi.tachidesk.server
import org.jetbrains.exposed.sql.SortOrder
interface ConfigAdapter<T> { interface ConfigAdapter<T> {
fun toType(configValue: String): T fun toType(configValue: String): T
} }
@@ -19,3 +21,7 @@ object BooleanConfigAdapter : ConfigAdapter<Boolean> {
object DoubleConfigAdapter : ConfigAdapter<Double> { object DoubleConfigAdapter : ConfigAdapter<Double> {
override fun toType(configValue: String): Double = configValue.toDouble() override fun toType(configValue: String): Double = configValue.toDouble()
} }
object SortOrderConfigAdapter : ConfigAdapter<SortOrder> {
override fun toType(configValue: String): SortOrder = SortOrder.valueOf(configValue)
}

View File

@@ -23,6 +23,7 @@ import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import org.jetbrains.exposed.sql.SortOrder
import xyz.nulldev.ts.config.GlobalConfigManager import xyz.nulldev.ts.config.GlobalConfigManager
import xyz.nulldev.ts.config.SystemPropertyOverridableConfigModule import xyz.nulldev.ts.config.SystemPropertyOverridableConfigModule
import kotlin.reflect.KProperty import kotlin.reflect.KProperty
@@ -155,6 +156,14 @@ class ServerConfig(
val flareSolverrSessionTtl: MutableStateFlow<Int> by OverrideConfigValue(IntConfigAdapter) val flareSolverrSessionTtl: MutableStateFlow<Int> by OverrideConfigValue(IntConfigAdapter)
val flareSolverrAsResponseFallback: MutableStateFlow<Boolean> by OverrideConfigValue(BooleanConfigAdapter) val flareSolverrAsResponseFallback: MutableStateFlow<Boolean> by OverrideConfigValue(BooleanConfigAdapter)
// opds settings
val opdsItemsPerPage: MutableStateFlow<Int> by OverrideConfigValue(IntConfigAdapter)
val opdsEnablePageReadProgress: MutableStateFlow<Boolean> by OverrideConfigValue(BooleanConfigAdapter)
val opdsMarkAsReadOnDownload: MutableStateFlow<Boolean> by OverrideConfigValue(BooleanConfigAdapter)
val opdsShowOnlyUnreadChapters: MutableStateFlow<Boolean> by OverrideConfigValue(BooleanConfigAdapter)
val opdsShowOnlyDownloadedChapters: MutableStateFlow<Boolean> by OverrideConfigValue(BooleanConfigAdapter)
val opdsChapterSortOrder: MutableStateFlow<SortOrder> by OverrideConfigValue(SortOrderConfigAdapter)
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
fun <T> subscribeTo( fun <T> subscribeTo(
flow: Flow<T>, flow: Flow<T>,

View File

@@ -70,3 +70,11 @@ server.flareSolverrTimeout = 60 # time in seconds
server.flareSolverrSessionName = "suwayomi" server.flareSolverrSessionName = "suwayomi"
server.flareSolverrSessionTtl = 15 # time in minutes server.flareSolverrSessionTtl = 15 # time in minutes
server.flareSolverrAsResponseFallback = false server.flareSolverrAsResponseFallback = false
# OPDS
server.opdsItemsPerPage = 50 # Range (10 - 5000)
server.opdsEnablePageReadProgress = true
server.opdsMarkAsReadOnDownload = false
server.opdsShowOnlyUnreadChapters = false
server.opdsShowOnlyDownloadedChapters = false
server.opdsChapterSortOrder = "DESC" # "ASC", "DESC"

View File

@@ -67,3 +67,11 @@ server.flareSolverrTimeout = 60 # time in seconds
server.flareSolverrSessionName = "suwayomi" server.flareSolverrSessionName = "suwayomi"
server.flareSolverrSessionTtl = 15 # time in minutes server.flareSolverrSessionTtl = 15 # time in minutes
server.flareSolverrAsResponseFallback = false server.flareSolverrAsResponseFallback = false
# OPDS
server.opdsItemsPerPage = 50 # Range (10 - 5000)
server.opdsEnablePageReadProgress = true
server.opdsMarkAsReadOnDownload = false
server.opdsShowOnlyUnreadChapters = false
server.opdsShowOnlyDownloadedChapters = false
server.opdsChapterSortOrder = "DESC" # "ASC", "DESC"