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,
) {
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)
internalConfig = internalConfig.withValue(path, configValue)

View File

@@ -94,6 +94,9 @@ class SettingsMutation {
// local source
validateFilePath(settings.localSourcePath, "localSourcePath")
// opds
validateValue(settings.opdsItemsPerPage, "opdsItemsPerPage") { it in 10..5000 }
}
private fun <SettingType : Any> updateSetting(
@@ -177,6 +180,14 @@ class SettingsMutation {
updateSetting(settings.flareSolverrSessionName, serverConfig.flareSolverrSessionName)
updateSetting(settings.flareSolverrSessionTtl, serverConfig.flareSolverrSessionTtl)
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 {

View File

@@ -8,6 +8,7 @@
package suwayomi.tachidesk.graphql.types
import com.expediagroup.graphql.generator.annotations.GraphQLDeprecated
import org.jetbrains.exposed.sql.SortOrder
import suwayomi.tachidesk.graphql.server.primitives.Node
import suwayomi.tachidesk.server.ServerConfig
import suwayomi.tachidesk.server.serverConfig
@@ -92,6 +93,14 @@ interface Settings : Node {
val flareSolverrSessionName: String?
val flareSolverrSessionTtl: Int?
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(
@@ -159,6 +168,13 @@ data class PartialSettingsType(
override val flareSolverrSessionName: String?,
override val flareSolverrSessionTtl: Int?,
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
class SettingsType(
@@ -226,6 +242,13 @@ class SettingsType(
override val flareSolverrSessionName: String,
override val flareSolverrSessionTtl: Int,
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 {
constructor(config: ServerConfig = serverConfig) : this(
config.ip.value,
@@ -287,5 +310,12 @@ class SettingsType(
config.flareSolverrSessionName.value,
config.flareSolverrSessionTtl.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.get
import io.javalin.apibuilder.ApiBuilder.head
import io.javalin.apibuilder.ApiBuilder.patch
import io.javalin.apibuilder.ApiBuilder.path
import io.javalin.apibuilder.ApiBuilder.post
@@ -83,6 +84,7 @@ object MangaAPI {
path("chapter") {
post("batch", MangaController.anyChapterBatch)
get("{chapterId}/download", MangaController.downloadChapter)
head("{chapterId}/download", MangaController.downloadChapter)
}
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
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import io.javalin.http.HandlerType
import io.javalin.http.HttpStatus
import kotlinx.serialization.json.Json
import suwayomi.tachidesk.manga.impl.CategoryManga
@@ -434,22 +435,29 @@ object MangaController {
val downloadChapter =
handler(
pathParam<Int>("chapterId"),
queryParam<Boolean?>("markAsRead"),
documentWith = {
withOperation {
summary("Download chapter as CBZ")
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 {
future { ChapterDownloadHelper.getCbzForDownload(chapterId) }
future { ChapterDownloadHelper.getCbzForDownload(chapterId, shouldMarkAsRead) }
.thenApply { (inputStream, fileName, fileSize) ->
ctx.header("Content-Type", "application/vnd.comicbook+zip")
ctx.header("Content-Disposition", "attachment; filename=\"$fileName\"")
ctx.header("Content-Length", fileSize.toString())
if (ctx.method() == HandlerType.HEAD) {
inputStream.close()
ctx.status(200)
} else {
ctx.result(inputStream)
}
}
}
},
withResults = {
httpCode(HttpStatus.OK)

View File

@@ -57,7 +57,10 @@ object ChapterDownloadHelper {
chapterId: Int,
): 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) =
transaction {
val row =
@@ -74,6 +77,17 @@ object ChapterDownloadHelper {
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)
}
}

View File

@@ -9,6 +9,7 @@ import nl.adaptivity.xmlutil.serialization.XML
import org.jetbrains.exposed.sql.JoinType
import org.jetbrains.exposed.sql.Op
import org.jetbrains.exposed.sql.SortOrder
import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq
import org.jetbrains.exposed.sql.and
import org.jetbrains.exposed.sql.lowerCase
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.toDataClass
import suwayomi.tachidesk.opds.model.OpdsXmlModels
import suwayomi.tachidesk.server.serverConfig
import java.net.URLEncoder
import java.nio.charset.StandardCharsets
import java.time.Instant
@@ -37,13 +39,11 @@ import java.time.ZoneOffset
import java.time.format.DateTimeFormatter
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 {
val builder =
FeedBuilder(baseUrl, 1, "opds", "Suwayomi OPDS Catalog").apply {
totalResults = 6
entries +=
val rootSection =
listOf(
"mangas" to "All Manga",
"sources" to "Sources",
@@ -52,7 +52,12 @@ object Opds {
"status" to "Status",
"languages" to "Languages",
"library-updates" to "Library Update History",
).map { (id, title) ->
)
val builder =
FeedBuilder(baseUrl, 1, "opds", "Suwayomi OPDS Catalog").apply {
totalResults = rootSection.size.toLong()
entries +=
rootSection.map { (id, title) ->
OpdsXmlModels.Entry(
id = id,
title = title,
@@ -84,6 +89,7 @@ object Opds {
.select(MangaTable.columns)
.where {
val conditions = mutableListOf<Op<Boolean>>()
conditions += (MangaTable.inLibrary eq true)
criteria?.query?.takeIf { it.isNotBlank() }?.let { q ->
val lowerQ = q.lowercase()
@@ -102,14 +108,14 @@ object Opds {
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)
.orderBy(MangaTable.title to SortOrder.ASC)
val totalCount = query.count()
val mangas =
query
.limit(ITEMS_PER_PAGE)
.offset(((pageNum - 1) * ITEMS_PER_PAGE).toLong())
.limit(opdsItemsPerPageBounded)
.offset(((pageNum - 1) * opdsItemsPerPageBounded).toLong())
.map { MangaTable.toDataClass(it, includeMangaMeta = false) }
Pair(mangas, totalCount)
}
@@ -146,8 +152,8 @@ object Opds {
val totalCount = query.count()
val sources =
query
.limit(ITEMS_PER_PAGE)
.offset(((pageNum - 1) * ITEMS_PER_PAGE).toLong())
.limit(opdsItemsPerPageBounded)
.offset(((pageNum - 1) * opdsItemsPerPageBounded).toLong())
.map {
SourceDataClass(
id = it[SourceTable.id].value.toString(),
@@ -165,7 +171,7 @@ object Opds {
return FeedBuilder(baseUrl, pageNum, "sources", "Sources")
.apply {
totalResults = totalCount.toLong()
totalResults = totalCount
entries +=
sourceList.map {
OpdsXmlModels.Entry(
@@ -191,8 +197,9 @@ object Opds {
pageNum: Int,
): String {
val formattedNow = opdsDateFormatter.format(Instant.now())
val categoryList =
val (categoryList, total) =
transaction {
val query =
CategoryTable
.join(CategoryMangaTable, JoinType.INNER, onColumn = CategoryTable.id, otherColumn = CategoryMangaTable.category)
.join(MangaTable, JoinType.INNER, onColumn = CategoryMangaTable.manga, otherColumn = MangaTable.id)
@@ -200,21 +207,23 @@ object Opds {
.select(CategoryTable.id, CategoryTable.name)
.groupBy(CategoryTable.id)
.orderBy(CategoryTable.order to SortOrder.ASC)
.map { row ->
Pair(row[CategoryTable.id].value, row[CategoryTable.name])
}
}
val totalCount = categoryList.size
val fromIndex = (pageNum - 1) * ITEMS_PER_PAGE
val toIndex = minOf(fromIndex + ITEMS_PER_PAGE, totalCount)
val paginatedCategories = if (fromIndex < totalCount) categoryList.subList(fromIndex, toIndex) else emptyList()
val total = query.count()
val paginated =
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")
.apply {
totalResults = totalCount.toLong()
totalResults = total
entries +=
paginatedCategories.map { (id, name) ->
categoryList.map { (id, name) ->
OpdsXmlModels.Entry(
id = "category/$id",
title = name,
@@ -252,8 +261,8 @@ object Opds {
}
val totalCount = genres.size
val fromIndex = (pageNum - 1) * ITEMS_PER_PAGE
val toIndex = minOf(fromIndex + ITEMS_PER_PAGE, totalCount)
val fromIndex = (pageNum - 1) * opdsItemsPerPageBounded
val toIndex = minOf(fromIndex + opdsItemsPerPageBounded, totalCount)
val paginatedGenres = if (fromIndex < totalCount) genres.subList(fromIndex, toIndex) else emptyList()
return serialize(
@@ -263,7 +272,7 @@ object Opds {
updated = formattedNow,
author = OpdsXmlModels.Author("Suwayomi", "https://suwayomi.org/"),
totalResults = totalCount.toLong(),
itemsPerPage = ITEMS_PER_PAGE,
itemsPerPage = opdsItemsPerPageBounded,
startIndex = fromIndex + 1,
links =
listOf(
@@ -306,8 +315,8 @@ object Opds {
val statuses = MangaStatus.entries.sortedBy { it.value }
val totalCount = statuses.size
val fromIndex = (pageNum - 1) * ITEMS_PER_PAGE
val toIndex = minOf(fromIndex + ITEMS_PER_PAGE, totalCount)
val fromIndex = (pageNum - 1) * opdsItemsPerPageBounded
val toIndex = minOf(fromIndex + opdsItemsPerPageBounded, totalCount)
val paginatedStatuses = if (fromIndex < totalCount) statuses.subList(fromIndex, toIndex) else emptyList()
return FeedBuilder(baseUrl, pageNum, "status", "Status")
@@ -378,6 +387,8 @@ object Opds {
baseUrl: String,
pageNum: Int,
): String {
val sortOrder = serverConfig.opdsChapterSortOrder.value
val (manga, chapters, totalCount) =
transaction {
val mangaEntry =
@@ -386,18 +397,30 @@ object Opds {
.where { MangaTable.id eq mangaId }
.first()
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 =
ChapterTable
.selectAll()
.where {
(ChapterTable.manga eq mangaId)
}.orderBy(ChapterTable.sourceOrder to SortOrder.DESC)
.where { chapterConditions }
.orderBy(ChapterTable.sourceOrder to sortOrder)
val total = chaptersQuery.count()
val chaptersData =
chaptersQuery
.limit(ITEMS_PER_PAGE)
.offset(((pageNum - 1) * ITEMS_PER_PAGE).toLong())
.limit(opdsItemsPerPageBounded)
.offset(((pageNum - 1) * opdsItemsPerPageBounded).toLong())
.map { ChapterTable.toDataClass(it, includeChapterCount = false, includeChapterMeta = false) }
Triple(mangaData, chaptersData, total)
}
@@ -489,6 +512,7 @@ object Opds {
val entryTitle =
when {
isMetaDataEntry -> ""
chapter.read -> ""
chapter.lastPageRead > 0 -> ""
chapter.pageCount == 0 -> ""
@@ -506,7 +530,9 @@ object Opds {
add(
OpdsXmlModels.Link(
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",
),
)
@@ -515,7 +541,9 @@ object Opds {
add(
OpdsXmlModels.Link(
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",
pseCount = chapter.pageCount,
pseLastRead = chapter.lastPageRead.takeIf { it != 0 },
@@ -584,8 +612,8 @@ object Opds {
val totalCount = query.count()
val paginatedResults =
query
.limit(ITEMS_PER_PAGE)
.offset(((pageNum - 1) * ITEMS_PER_PAGE).toLong())
.limit(opdsItemsPerPageBounded)
.offset(((pageNum - 1) * opdsItemsPerPageBounded).toLong())
.map { MangaTable.toDataClass(it, includeMangaMeta = false) }
Triple(paginatedResults, totalCount, sourceRow)
@@ -624,8 +652,8 @@ object Opds {
val totalCount = query.count()
val mangas =
query
.limit(ITEMS_PER_PAGE)
.offset(((pageNum - 1) * ITEMS_PER_PAGE).toLong())
.limit(opdsItemsPerPageBounded)
.offset(((pageNum - 1) * opdsItemsPerPageBounded).toLong())
.map { MangaTable.toDataClass(it, includeMangaMeta = false) }
Triple(mangas, totalCount, categoryName)
}
@@ -655,8 +683,8 @@ object Opds {
val totalCount = query.count()
val mangas =
query
.limit(ITEMS_PER_PAGE)
.offset(((pageNum - 1) * ITEMS_PER_PAGE).toLong())
.limit(opdsItemsPerPageBounded)
.offset(((pageNum - 1) * opdsItemsPerPageBounded).toLong())
.map { MangaTable.toDataClass(it, includeMangaMeta = false) }
Pair(mangas, totalCount)
}
@@ -692,8 +720,8 @@ object Opds {
val totalCount = query.count()
val mangas =
query
.limit(ITEMS_PER_PAGE)
.offset(((pageNum - 1) * ITEMS_PER_PAGE).toLong())
.limit(opdsItemsPerPageBounded)
.offset(((pageNum - 1) * opdsItemsPerPageBounded).toLong())
.map { MangaTable.toDataClass(it, includeMangaMeta = false) }
Pair(mangas, totalCount)
}
@@ -724,8 +752,8 @@ object Opds {
val totalCount = query.count()
val mangas =
query
.limit(ITEMS_PER_PAGE)
.offset(((pageNum - 1) * ITEMS_PER_PAGE).toLong())
.limit(opdsItemsPerPageBounded)
.offset(((pageNum - 1) * opdsItemsPerPageBounded).toLong())
.map { MangaTable.toDataClass(it, includeMangaMeta = false) }
Pair(mangas, totalCount)
}
@@ -753,8 +781,8 @@ object Opds {
val totalCount = query.count()
val chapters =
query
.limit(ITEMS_PER_PAGE)
.offset(((pageNum - 1) * ITEMS_PER_PAGE).toLong())
.limit(opdsItemsPerPageBounded)
.offset(((pageNum - 1) * opdsItemsPerPageBounded).toLong())
.map {
ChapterTable.toDataClass(
it,
@@ -833,7 +861,7 @@ object Opds {
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(
rel = "next",
href = "$baseUrl/$id?pageNumber=${pageNum + 1}",
@@ -843,8 +871,8 @@ object Opds {
),
entries = entries,
totalResults = totalResults,
itemsPerPage = ITEMS_PER_PAGE,
startIndex = (pageNum - 1) * ITEMS_PER_PAGE + 1,
itemsPerPage = opdsItemsPerPageBounded,
startIndex = (pageNum - 1) * opdsItemsPerPageBounded + 1,
)
}

View File

@@ -1,5 +1,7 @@
package suwayomi.tachidesk.server
import org.jetbrains.exposed.sql.SortOrder
interface ConfigAdapter<T> {
fun toType(configValue: String): T
}
@@ -19,3 +21,7 @@ object BooleanConfigAdapter : ConfigAdapter<Boolean> {
object DoubleConfigAdapter : ConfigAdapter<Double> {
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.mapLatest
import kotlinx.coroutines.flow.onEach
import org.jetbrains.exposed.sql.SortOrder
import xyz.nulldev.ts.config.GlobalConfigManager
import xyz.nulldev.ts.config.SystemPropertyOverridableConfigModule
import kotlin.reflect.KProperty
@@ -155,6 +156,14 @@ class ServerConfig(
val flareSolverrSessionTtl: MutableStateFlow<Int> by OverrideConfigValue(IntConfigAdapter)
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)
fun <T> subscribeTo(
flow: Flow<T>,

View File

@@ -70,3 +70,11 @@ server.flareSolverrTimeout = 60 # time in seconds
server.flareSolverrSessionName = "suwayomi"
server.flareSolverrSessionTtl = 15 # time in minutes
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.flareSolverrSessionTtl = 15 # time in minutes
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"