Suwayomi (Tachidesk): Implement pagination, sorting, tag-based filtering (#16252)
* Tachidesk: Implement pagination * Added pagination to improve performance on large libraries * Route `popular` functions through `search` * Avoids significant code duplication * * Implemented sorting * Can now sort by title, author/artist, date added, no. of chapters * Issues with unread chapters and date updated * Server doesn't seem to return a last updated time * Server returns unread regardless of local read status * * Added "All" category * Added a category that shows all manga across all categories * Removed "toggle global search" button (now redundant) * Search now more intuitive as a result * * Added tag-based filtering * * Stop using reflection to get the property to sort by * Comment cleanup
This commit is contained in:
parent
04ce9fcb0f
commit
1d5418f3b9
@ -6,7 +6,7 @@ ext {
|
||||
extName = 'Suwayomi'
|
||||
pkgNameSuffix = 'all.tachidesk'
|
||||
extClass = '.Tachidesk'
|
||||
extVersionCode = 9
|
||||
extVersionCode = 10
|
||||
}
|
||||
|
||||
dependencies {
|
||||
|
@ -39,8 +39,10 @@ data class MangaDataClass(
|
||||
val genre: List<String> = emptyList(),
|
||||
val status: String = "UNKNOWN",
|
||||
val inLibrary: Boolean = false,
|
||||
val inLibraryAt: Int = 0,
|
||||
val source: SourceDataClass? = null,
|
||||
val meta: Map<String, String> = emptyMap(),
|
||||
val chapterCount: Int? = 0,
|
||||
|
||||
val realUrl: String? = null,
|
||||
|
||||
|
@ -19,17 +19,15 @@ import eu.kanade.tachiyomi.source.model.SChapter
|
||||
import eu.kanade.tachiyomi.source.model.SManga
|
||||
import eu.kanade.tachiyomi.source.online.HttpSource
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.Credentials
|
||||
import okhttp3.Dns
|
||||
import okhttp3.Headers
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import okhttp3.ResponseBody.Companion.toResponseBody
|
||||
import okhttp3.internal.toImmutableList
|
||||
import rx.Observable
|
||||
import rx.Single
|
||||
import rx.android.schedulers.AndroidSchedulers
|
||||
@ -37,6 +35,7 @@ import rx.schedulers.Schedulers
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import kotlin.math.min
|
||||
|
||||
class Tachidesk : ConfigurableSource, UnmeteredSource, HttpSource() {
|
||||
override val name = "Suwayomi"
|
||||
@ -64,16 +63,13 @@ class Tachidesk : ConfigurableSource, UnmeteredSource, HttpSource() {
|
||||
|
||||
// ------------- Popular Manga -------------
|
||||
|
||||
// Route the popular manga view through search to avoid duplicate code path
|
||||
override fun popularMangaRequest(page: Int): Request =
|
||||
GET("$checkedBaseUrl/api/v1/category/$defaultCategoryId", headers)
|
||||
searchMangaRequest(page, "", FilterList())
|
||||
|
||||
override fun popularMangaParse(response: Response): MangasPage =
|
||||
MangasPage(
|
||||
json.decodeFromString<List<MangaDataClass>>(response.body.string()).map {
|
||||
it.toSManga()
|
||||
},
|
||||
false,
|
||||
)
|
||||
searchMangaParse(response)
|
||||
|
||||
// ------------- Manga Details -------------
|
||||
|
||||
override fun mangaDetailsRequest(manga: SManga) =
|
||||
@ -109,7 +105,7 @@ class Tachidesk : ConfigurableSource, UnmeteredSource, HttpSource() {
|
||||
return GET("$checkedBaseUrl/api/v1/manga/$mangaId/chapter/$chapterIndex/?onlineFetch=True", headers)
|
||||
}
|
||||
|
||||
fun pageListParse(response: Response, sChapter: SChapter): List<Page> {
|
||||
private fun pageListParse(response: Response, sChapter: SChapter): List<Page> {
|
||||
val mangaId = sChapter.url.split(" ").first()
|
||||
val chapterIndex = sChapter.url.split(" ").last()
|
||||
|
||||
@ -123,23 +119,88 @@ class Tachidesk : ConfigurableSource, UnmeteredSource, HttpSource() {
|
||||
// ------------- Filters & Search -------------
|
||||
|
||||
private var categoryList: List<CategoryDataClass> = emptyList()
|
||||
|
||||
private val defaultCategoryId: Int
|
||||
get() = categoryList.firstOrNull()?.id ?: 0
|
||||
|
||||
private val resultsPerPageOptions = listOf(10, 15, 20, 25)
|
||||
private val defaultResultsPerPage = resultsPerPageOptions.first()
|
||||
|
||||
private val sortByOptions = listOf(
|
||||
"Title",
|
||||
"Artist",
|
||||
"Author",
|
||||
"Date added",
|
||||
"Total chapters",
|
||||
)
|
||||
private val defaultSortByIndex = 0
|
||||
|
||||
private var tagList: List<String> = emptyList()
|
||||
private val tagModeAndString = "AND"
|
||||
private val tagModeOrString = "OR"
|
||||
private val tagModes = listOf(tagModeAndString, tagModeOrString)
|
||||
private val defaultIncludeTagModeIndex = tagModes.indexOf(tagModeAndString)
|
||||
private val defaultExcludeTagModeIndex = tagModes.indexOf(tagModeOrString)
|
||||
private val tagFilterModeIncludeString = "Include"
|
||||
private val tagFilterModeExcludeString = "Exclude"
|
||||
|
||||
class CategorySelect(categoryList: List<CategoryDataClass>) :
|
||||
Filter.Select<String>("Category", categoryList.map { it.name }.toTypedArray())
|
||||
|
||||
class DisableGlobalSearch() :
|
||||
Filter.CheckBox("Search only current category", false)
|
||||
class ResultsPerPageSelect(options: List<Int>) :
|
||||
Filter.Select<Int>("Results per page", options.toTypedArray())
|
||||
|
||||
override fun getFilterList(): FilterList =
|
||||
FilterList(
|
||||
CategorySelect(refreshCategoryList(baseUrl).let { categoryList }),
|
||||
Filter.Header("Press reset to attempt to fetch categories"),
|
||||
DisableGlobalSearch(),
|
||||
class SortBy(options: List<String>) :
|
||||
Filter.Sort(
|
||||
"Sort by",
|
||||
options.toTypedArray(),
|
||||
Selection(0, true),
|
||||
)
|
||||
|
||||
class Tag(name: String, state: Int) :
|
||||
Filter.TriState(name, state)
|
||||
|
||||
class TagFilterMode(type: String, tagModes: List<String>, defaultIndex: Int = 0) :
|
||||
Filter.Select<String>(type, tagModes.toTypedArray(), defaultIndex)
|
||||
|
||||
class TagSelector(tagList: List<String>) :
|
||||
Filter.Group<Tag>(
|
||||
"Tags",
|
||||
tagList.map { tag -> Tag(tag, 0) },
|
||||
)
|
||||
|
||||
class TagFilterModeGroup(
|
||||
tagModes: List<String>,
|
||||
includeString: String,
|
||||
excludeString: String,
|
||||
includeDefaultIndex: Int = 0,
|
||||
excludeDefaultIndex: Int = 0,
|
||||
) :
|
||||
Filter.Group<TagFilterMode>(
|
||||
"Tag Filter Modes",
|
||||
listOf(
|
||||
TagFilterMode(includeString, tagModes, includeDefaultIndex),
|
||||
TagFilterMode(excludeString, tagModes, excludeDefaultIndex),
|
||||
),
|
||||
)
|
||||
|
||||
override fun getFilterList(): FilterList = FilterList(
|
||||
Filter.Header("Press reset to refresh tag list and attempt to fetch categories."),
|
||||
Filter.Header("Tag list shows only the tags of currently displayed manga."),
|
||||
Filter.Header("\"All\" shows all manga regardless of category."),
|
||||
CategorySelect(refreshCategoryList(baseUrl).let { categoryList }),
|
||||
Filter.Separator(),
|
||||
TagFilterModeGroup(
|
||||
tagModes,
|
||||
tagFilterModeIncludeString,
|
||||
tagFilterModeExcludeString,
|
||||
defaultIncludeTagModeIndex,
|
||||
defaultExcludeTagModeIndex,
|
||||
),
|
||||
TagSelector(tagList),
|
||||
SortBy(sortByOptions),
|
||||
ResultsPerPageSelect(resultsPerPageOptions),
|
||||
)
|
||||
|
||||
private fun refreshCategoryList(baseUrl: String) {
|
||||
Single.fromCallable {
|
||||
client.newCall(GET("$baseUrl/api/v1/category", headers)).execute()
|
||||
@ -149,7 +210,9 @@ class Tachidesk : ConfigurableSource, UnmeteredSource, HttpSource() {
|
||||
.subscribe(
|
||||
{ response ->
|
||||
categoryList = try {
|
||||
json.decodeFromString<List<CategoryDataClass>>(response.body.string())
|
||||
// Add a pseudo category to list all manga across all categories
|
||||
listOf(CategoryDataClass(-1, -1, "All", false)) +
|
||||
json.decodeFromString<List<CategoryDataClass>>(response.body.string())
|
||||
} catch (e: Exception) {
|
||||
emptyList()
|
||||
}
|
||||
@ -158,14 +221,51 @@ class Tachidesk : ConfigurableSource, UnmeteredSource, HttpSource() {
|
||||
)
|
||||
}
|
||||
|
||||
private fun refreshTagList(mangaList: List<MangaDataClass>) {
|
||||
val newTagList = mutableListOf<String>()
|
||||
for (mangaDetails in mangaList) {
|
||||
newTagList.addAll(mangaDetails.genre)
|
||||
}
|
||||
tagList = newTagList
|
||||
.distinctBy { tag -> tag.lowercase() }
|
||||
.sortedBy { tag -> tag.lowercase() }
|
||||
.filter { tag -> tag.trim() != "" }
|
||||
}
|
||||
|
||||
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
|
||||
// Embed search query and scope into URL params for processing in searchMangaParse
|
||||
var currentCategoryId = defaultCategoryId
|
||||
var disableGlobalSearch = false
|
||||
var resultsPerPage = defaultResultsPerPage
|
||||
var sortByIndex = defaultSortByIndex
|
||||
var sortByAscending = true
|
||||
val tagIncludeList = mutableListOf<String>()
|
||||
val tagExcludeList = mutableListOf<String>()
|
||||
var tagFilterIncludeModeIndex = defaultIncludeTagModeIndex
|
||||
var tagFilterExcludeModeIndex = defaultExcludeTagModeIndex
|
||||
filters.forEach { filter ->
|
||||
when (filter) {
|
||||
is CategorySelect -> currentCategoryId = categoryList[filter.state].id
|
||||
is DisableGlobalSearch -> disableGlobalSearch = filter.state
|
||||
is ResultsPerPageSelect -> resultsPerPage = resultsPerPageOptions[filter.state]
|
||||
is SortBy -> {
|
||||
sortByIndex = filter.state?.index ?: sortByIndex
|
||||
sortByAscending = filter.state?.ascending ?: sortByAscending
|
||||
}
|
||||
is TagFilterModeGroup -> {
|
||||
filter.state.forEach { tagFilterMode ->
|
||||
when (tagFilterMode.name) {
|
||||
tagFilterModeIncludeString -> tagFilterIncludeModeIndex = tagFilterMode.state
|
||||
tagFilterModeExcludeString -> tagFilterExcludeModeIndex = tagFilterMode.state
|
||||
}
|
||||
}
|
||||
}
|
||||
is TagSelector -> {
|
||||
filter.state.forEach { tagFilter ->
|
||||
when {
|
||||
tagFilter.isIncluded() -> tagIncludeList.add(tagFilter.name)
|
||||
tagFilter.isExcluded() -> tagExcludeList.add(tagFilter.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
@ -174,7 +274,13 @@ class Tachidesk : ConfigurableSource, UnmeteredSource, HttpSource() {
|
||||
.newBuilder()
|
||||
.addQueryParameter("searchQuery", query)
|
||||
.addQueryParameter("currentCategoryId", currentCategoryId.toString())
|
||||
.addQueryParameter("disableGlobalSearch", disableGlobalSearch.toString())
|
||||
.addQueryParameter("sortBy", sortByIndex.toString())
|
||||
.addQueryParameter("sortByAscending", sortByAscending.toString())
|
||||
.addQueryParameter("tagFilterIncludeMode", tagFilterIncludeModeIndex.toString())
|
||||
.addQueryParameter("tagFilterExcludeMode", tagFilterExcludeModeIndex.toString())
|
||||
.addQueryParameter("tagIncludeList", tagIncludeList.joinToString(","))
|
||||
.addQueryParameter("tagExcludeList", tagExcludeList.joinToString(","))
|
||||
.addQueryParameter("resultsPerPage", resultsPerPage.toString())
|
||||
.addQueryParameter("page", page.toString())
|
||||
.build()
|
||||
return GET(url, headers)
|
||||
@ -182,44 +288,91 @@ class Tachidesk : ConfigurableSource, UnmeteredSource, HttpSource() {
|
||||
|
||||
override fun searchMangaParse(response: Response): MangasPage {
|
||||
val request = response.request
|
||||
val newResponse: Response
|
||||
var searchQuery: String? = ""
|
||||
var currentCategoryId: Int? = defaultCategoryId
|
||||
var disableGlobalSearch = false
|
||||
var currentCategoryId = defaultCategoryId
|
||||
var sortByIndex = defaultSortByIndex
|
||||
var sortByAscending = true
|
||||
var tagIncludeList = mutableListOf<String>()
|
||||
var tagExcludeList = mutableListOf<String>()
|
||||
var tagFilterIncludeModeIndex = defaultIncludeTagModeIndex
|
||||
var tagFilterExcludeModeIndex = defaultExcludeTagModeIndex
|
||||
var resultsPerPage = defaultResultsPerPage
|
||||
var page = 1
|
||||
|
||||
// Check if URL has query params and parse them
|
||||
if (!request.url.query.isNullOrEmpty()) {
|
||||
searchQuery = request.url.queryParameter("searchQuery")
|
||||
currentCategoryId = request.url.queryParameter("currentCategoryId")?.toIntOrNull()
|
||||
disableGlobalSearch = request.url.queryParameter("disableGlobalSearch").toBoolean()
|
||||
}
|
||||
newResponse = if (!searchQuery.isNullOrEmpty()) {
|
||||
// Get URLs of categories to search
|
||||
val categoryUrlList = if (!disableGlobalSearch) {
|
||||
categoryList.map { category ->
|
||||
val categoryId = category.id
|
||||
"$checkedBaseUrl/api/v1/category/$categoryId"
|
||||
currentCategoryId = request.url.queryParameter("currentCategoryId")?.toIntOrNull() ?: currentCategoryId
|
||||
sortByIndex = request.url.queryParameter("sortBy")?.toIntOrNull() ?: sortByIndex
|
||||
sortByAscending = request.url.queryParameter("sortByAscending").toBoolean()
|
||||
tagIncludeList = request.url.queryParameter("tagIncludeList").let { param ->
|
||||
if (param is String && param.isNotEmpty()) {
|
||||
param.split(",").toMutableList()
|
||||
} else {
|
||||
tagIncludeList
|
||||
}
|
||||
} else {
|
||||
listOfNotNull("$checkedBaseUrl/api/v1/category/$currentCategoryId")
|
||||
}
|
||||
|
||||
// Construct a list of all manga in the required categories by querying each one
|
||||
val mangaList = mutableListOf<MangaDataClass>()
|
||||
categoryUrlList.forEach { categoryUrl ->
|
||||
val categoryMangaListRequest =
|
||||
GET(categoryUrl, headers)
|
||||
val categoryMangaListResponse =
|
||||
client.newCall(categoryMangaListRequest).execute()
|
||||
val categoryMangaListJson =
|
||||
categoryMangaListResponse.body.string()
|
||||
val categoryMangaList =
|
||||
json.decodeFromString<List<MangaDataClass>>(categoryMangaListJson)
|
||||
mangaList.addAll(categoryMangaList)
|
||||
tagExcludeList = request.url.queryParameter("tagExcludeList").let { param ->
|
||||
if (param is String && param.isNotEmpty()) {
|
||||
param.split(",").toMutableList()
|
||||
} else {
|
||||
tagExcludeList
|
||||
}
|
||||
}
|
||||
tagFilterIncludeModeIndex = request.url.queryParameter("tagFilterIncludeMode")?.toIntOrNull() ?: tagFilterIncludeModeIndex
|
||||
tagFilterExcludeModeIndex = request.url.queryParameter("tagFilterExcludeMode")?.toIntOrNull() ?: tagFilterExcludeModeIndex
|
||||
resultsPerPage = request.url.queryParameter("resultsPerPage")?.toIntOrNull() ?: resultsPerPage
|
||||
page = request.url.queryParameter("page")?.toIntOrNull() ?: page
|
||||
}
|
||||
val sortByProperty = sortByOptions[sortByIndex]
|
||||
val tagFilterIncludeMode = tagModes[tagFilterIncludeModeIndex]
|
||||
val tagFilterExcludeMode = tagModes[tagFilterExcludeModeIndex]
|
||||
|
||||
// Filter according to search terms.
|
||||
// Basic substring search, room for improvement.
|
||||
val searchResults = mangaList.filter { mangaData ->
|
||||
// Get URLs of categories to search
|
||||
val categoryUrlList = if (currentCategoryId == -1) {
|
||||
categoryList.map { category -> "$checkedBaseUrl/api/v1/category/${category.id}" }
|
||||
} else {
|
||||
listOfNotNull("$checkedBaseUrl/api/v1/category/$currentCategoryId")
|
||||
}
|
||||
|
||||
// Construct a list of all manga in the required categories by querying each one
|
||||
val mangaList = mutableListOf<MangaDataClass>()
|
||||
categoryUrlList.forEach { categoryUrl ->
|
||||
val categoryMangaListRequest =
|
||||
GET(categoryUrl, headers)
|
||||
val categoryMangaListResponse =
|
||||
client.newCall(categoryMangaListRequest).execute()
|
||||
val categoryMangaListJson =
|
||||
categoryMangaListResponse.body.string()
|
||||
val categoryMangaList =
|
||||
json.decodeFromString<List<MangaDataClass>>(categoryMangaListJson)
|
||||
mangaList.addAll(categoryMangaList)
|
||||
}
|
||||
|
||||
// Filter by tags
|
||||
var searchResults = mangaList.toImmutableList()
|
||||
val filterConfigs = mutableListOf<Triple<Boolean, String, List<String>>>()
|
||||
if (tagExcludeList.isNotEmpty()) filterConfigs.add(Triple(false, tagFilterExcludeMode, tagExcludeList))
|
||||
if (tagIncludeList.isNotEmpty()) filterConfigs.add(Triple(true, tagFilterIncludeMode, tagIncludeList))
|
||||
filterConfigs.forEach { config ->
|
||||
val isInclude = config.first
|
||||
val filterMode = config.second
|
||||
val filteredTagList = config.third
|
||||
searchResults = searchResults.filter { mangaData ->
|
||||
val lowerCaseTags = mangaData.genre.map { it.lowercase() }
|
||||
val filterResult = when (filterMode) {
|
||||
tagModeAndString -> lowerCaseTags.containsAll(filteredTagList.map { tag -> tag.lowercase() })
|
||||
tagModeOrString -> lowerCaseTags.any { tag -> tag in filteredTagList.map { tag -> tag.lowercase() } }
|
||||
else -> false
|
||||
}
|
||||
if (isInclude) filterResult else !filterResult
|
||||
}
|
||||
}
|
||||
|
||||
// Filter according to search terms.
|
||||
// Basic substring search, room for improvement.
|
||||
searchResults = if (!searchQuery.isNullOrEmpty()) {
|
||||
searchResults.filter { mangaData ->
|
||||
val fieldsToCheck = listOfNotNull(
|
||||
mangaData.title,
|
||||
mangaData.url,
|
||||
@ -230,23 +383,35 @@ class Tachidesk : ConfigurableSource, UnmeteredSource, HttpSource() {
|
||||
fieldsToCheck.any { field ->
|
||||
field.contains(searchQuery, ignoreCase = true)
|
||||
}
|
||||
}.distinct()
|
||||
|
||||
// Construct new response with search results
|
||||
val jsonString = json.encodeToString(searchResults)
|
||||
val mediaType = "application/json".toMediaType()
|
||||
val responseBody = jsonString.toResponseBody(mediaType)
|
||||
Response.Builder()
|
||||
.request(request)
|
||||
.protocol(response.protocol)
|
||||
.code(200)
|
||||
.body(responseBody)
|
||||
.message("OK")
|
||||
.build()
|
||||
}
|
||||
} else {
|
||||
response
|
||||
searchResults
|
||||
}.distinct()
|
||||
|
||||
// Sort results
|
||||
searchResults = when (sortByProperty) {
|
||||
"Title" -> searchResults.sortedBy { it.title }
|
||||
"Artist" -> searchResults.sortedBy { it.artist }
|
||||
"Author" -> searchResults.sortedBy { it.author }
|
||||
"Date added" -> searchResults.sortedBy { it.inLibraryAt }
|
||||
"Total chapters" -> searchResults.sortedBy { it.chapterCount }
|
||||
else -> searchResults
|
||||
}
|
||||
return popularMangaParse(newResponse)
|
||||
if (!sortByAscending) {
|
||||
searchResults = searchResults.asReversed()
|
||||
}
|
||||
|
||||
// Get new list of tags from the search results
|
||||
refreshTagList(searchResults)
|
||||
|
||||
// Paginate results
|
||||
val hasNextPage: Boolean
|
||||
with(paginateResults(searchResults, page, resultsPerPage)) {
|
||||
searchResults = first
|
||||
hasNextPage = second
|
||||
}
|
||||
|
||||
return MangasPage(searchResults.map { mangaData -> mangaData.toSManga() }, hasNextPage)
|
||||
}
|
||||
|
||||
// ------------- Images -------------
|
||||
@ -359,4 +524,17 @@ class Tachidesk : ConfigurableSource, UnmeteredSource, HttpSource() {
|
||||
|
||||
private val checkedBaseUrl: String
|
||||
get(): String = baseUrl.ifEmpty { throw RuntimeException("Set Tachidesk server url in extension settings") }
|
||||
|
||||
private fun paginateResults(mangaList: List<MangaDataClass>, page: Int?, itemsPerPage: Int?): Pair<List<MangaDataClass>, Boolean> {
|
||||
var hasNextPage = false
|
||||
val pageItems = if (mangaList.isNotEmpty() && itemsPerPage is Int && page is Int) {
|
||||
val fromIndex = (page - 1) * itemsPerPage
|
||||
val toIndex = min(fromIndex + itemsPerPage, mangaList.size)
|
||||
hasNextPage = toIndex < mangaList.size
|
||||
mangaList.subList(fromIndex, toIndex)
|
||||
} else {
|
||||
mangaList
|
||||
}
|
||||
return Pair(pageItems, hasNextPage)
|
||||
}
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user