mirror of
https://github.com/Suwayomi/Suwayomi-Server.git
synced 2026-07-12 15:24:33 -05:00
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:
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user