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

@@ -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,22 +39,25 @@ 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 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 =
FeedBuilder(baseUrl, 1, "opds", "Suwayomi OPDS Catalog").apply {
totalResults = 6
totalResults = rootSection.size.toLong()
entries +=
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",
).map { (id, title) ->
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,30 +197,33 @@ object Opds {
pageNum: Int,
): String {
val formattedNow = opdsDateFormatter.format(Instant.now())
val categoryList =
val (categoryList, total) =
transaction {
CategoryTable
.join(CategoryMangaTable, JoinType.INNER, onColumn = CategoryTable.id, otherColumn = CategoryMangaTable.category)
.join(MangaTable, JoinType.INNER, onColumn = CategoryMangaTable.manga, otherColumn = MangaTable.id)
.join(ChapterTable, JoinType.INNER, onColumn = MangaTable.id, otherColumn = ChapterTable.manga)
.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 query =
CategoryTable
.join(CategoryMangaTable, JoinType.INNER, onColumn = CategoryTable.id, otherColumn = CategoryMangaTable.category)
.join(MangaTable, JoinType.INNER, onColumn = CategoryMangaTable.manga, otherColumn = MangaTable.id)
.join(ChapterTable, JoinType.INNER, onColumn = MangaTable.id, otherColumn = ChapterTable.manga)
.select(CategoryTable.id, CategoryTable.name)
.groupBy(CategoryTable.id)
.orderBy(CategoryTable.order to SortOrder.ASC)
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,
)
}