Kemono & Coomer | Fixed Latest, Added Filters, Increased Post Limit (#5383)

* Fixed Latest, Added Filters, Increased Post Limit

* Fix Lint Errors

- There's no "Coomer" multisrc.

* Add Favourites Filter

- For #547

* Added `KemonoFavouritesDto`

- also a `fav` var in KemonoCreatorDTo

* Apply suggestion

* Apply suggestion

* Apply Suggestions

- Apply AwkwardPeak's suggestions

* Fix Lint Errors
This commit is contained in:
KenjieDec 2024-10-11 18:11:55 +07:00 committed by Draff
parent 4a105eb6ed
commit eb397b2b5f
No known key found for this signature in database
GPG Key ID: E8A89F3211677653
5 changed files with 192 additions and 87 deletions

View File

@ -2,4 +2,4 @@ plugins {
id("lib-multisrc") id("lib-multisrc")
} }
baseVersionCode = 12 baseVersionCode = 13

View File

@ -4,29 +4,26 @@ import android.app.Application
import androidx.preference.ListPreference import androidx.preference.ListPreference
import androidx.preference.PreferenceScreen import androidx.preference.PreferenceScreen
import androidx.preference.SwitchPreferenceCompat import androidx.preference.SwitchPreferenceCompat
import eu.kanade.tachiyomi.multisrc.kemono.KemonoCreatorDto.Companion.serviceName
import eu.kanade.tachiyomi.network.GET import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.interceptor.rateLimit import eu.kanade.tachiyomi.network.interceptor.rateLimit
import eu.kanade.tachiyomi.source.ConfigurableSource import eu.kanade.tachiyomi.source.ConfigurableSource
import eu.kanade.tachiyomi.source.model.Filter
import eu.kanade.tachiyomi.source.model.FilterList import eu.kanade.tachiyomi.source.model.FilterList
import eu.kanade.tachiyomi.source.model.MangasPage import eu.kanade.tachiyomi.source.model.MangasPage
import eu.kanade.tachiyomi.source.model.Page import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.source.model.SChapter import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.source.online.HttpSource import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.util.asJsoup
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlinx.serialization.json.decodeFromStream import kotlinx.serialization.json.decodeFromStream
import okhttp3.Call
import okhttp3.Callback
import okhttp3.Request import okhttp3.Request
import okhttp3.Response import okhttp3.Response
import okio.blackholeSink
import org.jsoup.select.Evaluator
import rx.Observable import rx.Observable
import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get import uy.kohesive.injekt.api.get
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
import java.io.IOException import java.lang.Thread.sleep
import java.util.TimeZone import java.util.TimeZone
import kotlin.math.min import kotlin.math.min
@ -51,6 +48,8 @@ open class Kemono(
private val imgCdnUrl = baseUrl.replace("//", "//img.") private val imgCdnUrl = baseUrl.replace("//", "//img.")
private var mangasCache: List<KemonoCreatorDto> = emptyList()
private fun String.formatAvatarUrl(): String = removePrefix("https://").replaceBefore('/', imgCdnUrl) private fun String.formatAvatarUrl(): String = removePrefix("https://").replaceBefore('/', imgCdnUrl)
override fun popularMangaRequest(page: Int) = throw UnsupportedOperationException() override fun popularMangaRequest(page: Int) = throw UnsupportedOperationException()
@ -63,82 +62,123 @@ open class Kemono(
override fun fetchPopularManga(page: Int): Observable<MangasPage> { override fun fetchPopularManga(page: Int): Observable<MangasPage> {
return Observable.fromCallable { return Observable.fromCallable {
fetchNewDesignListing(page, "/artists", compareByDescending { it.favorited }) searchMangas(page, sortBy = "pop" to "desc")
} }
} }
override fun fetchLatestUpdates(page: Int): Observable<MangasPage> { override fun fetchLatestUpdates(page: Int): Observable<MangasPage> {
return Observable.fromCallable { return Observable.fromCallable {
fetchNewDesignListing(page, "/artists/updated", compareByDescending { it.updatedDate }) searchMangas(page, sortBy = "lat" to "desc")
}
}
private fun fetchNewDesignListing(
page: Int,
path: String,
comparator: Comparator<KemonoCreatorDto>,
): MangasPage {
val baseUrl = baseUrl
return if (page == 1) {
val document = client.newCall(GET(baseUrl + path, headers)).execute().asJsoup()
val cardList = document.selectFirst(Evaluator.Class("card-list__items"))!!
val creators = cardList.children().map {
SManga.create().apply {
url = it.attr("href")
title = it.selectFirst(Evaluator.Class("user-card__name"))!!.ownText()
author = it.selectFirst(Evaluator.Class("user-card__service"))!!.ownText()
thumbnail_url = it.selectFirst(Evaluator.Tag("img"))!!.absUrl("src").formatAvatarUrl()
description = PROMPT
initialized = true
}
}.filterUnsupported()
MangasPage(creators, true).also { cacheCreators() }
} else {
fetchCreatorsPage(page) { it.apply { sortWith(comparator) } }
} }
} }
override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> = Observable.fromCallable { override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> = Observable.fromCallable {
if (query.isBlank()) throw Exception("Query is empty") searchMangas(page, query, filters)
fetchCreatorsPage(page) { all ->
val result = all.filterTo(ArrayList()) { it.name.contains(query, ignoreCase = true) }
if (result.isEmpty()) return@fetchCreatorsPage emptyList()
if (result[0].favorited != -1) {
result.sortByDescending { it.favorited }
} else {
result.sortByDescending { it.updatedDate }
} }
result
private fun searchMangas(page: Int = 1, title: String = "", filters: FilterList? = null, sortBy: Pair<String, String> = "" to ""): MangasPage {
var sort = sortBy
val typeIncluded: MutableList<String> = mutableListOf()
val typeExcluded: MutableList<String> = mutableListOf()
var fav: Boolean? = null
filters?.forEach { filter ->
when (filter) {
is SortFilter -> {
sort = filter.getValue() to if (filter.state!!.ascending) "asc" else "desc"
}
is TypeFilter -> {
filter.state.filter { state -> state.isIncluded() }.forEach { tri ->
typeIncluded.add(tri.value)
}
filter.state.filter { state -> state.isExcluded() }.forEach { tri ->
typeExcluded.add(tri.value)
}
}
is FavouritesFilter -> {
fav = when (filter.state[0].state) {
0 -> null
1 -> true
else -> false
}
}
else -> {}
} }
} }
private fun fetchCreatorsPage( var mangas = mangasCache
page: Int, if (page == 1) {
block: (ArrayList<KemonoCreatorDto>) -> List<KemonoCreatorDto>, var favourites: List<KemonoFavouritesDto> = emptyList()
): MangasPage { if (fav != null) {
val imgCdnUrl = this.imgCdnUrl val favores = client.newCall(GET("$baseUrl/$apiPath/account/favorites", headers)).execute()
if (favores.code == 401) throw Exception("You are not Logged In")
favourites = favores.parseAs<List<KemonoFavouritesDto>>().filterNot { it.service.lowercase() == "discord" }
}
val response = client.newCall(GET("$baseUrl/$apiPath/creators", headers)).execute() val response = client.newCall(GET("$baseUrl/$apiPath/creators", headers)).execute()
val allCreators = block(response.parseAs()) val allCreators = response.parseAs<List<KemonoCreatorDto>>().filterNot { it.service.lowercase() == "discord" }
val count = allCreators.size mangas = allCreators.filter {
val fromIndex = (page - 1) * NEW_PAGE_SIZE val includeType = typeIncluded.isEmpty() || typeIncluded.contains(it.service.serviceName().lowercase())
val toIndex = min(count, fromIndex + NEW_PAGE_SIZE) val excludeType = typeExcluded.isNotEmpty() && typeExcluded.contains(it.service.serviceName().lowercase())
val creators = allCreators.subList(fromIndex, toIndex)
.map { it.toSManga(imgCdnUrl) } val regularSearch = it.name.contains(title, true)
.filterUnsupported()
return MangasPage(creators, toIndex < count) val isFavourited = when (fav) {
true -> favourites.any { f -> f.id == it.id.also { _ -> it.fav = f.faved_seq } }
false -> favourites.none { f -> f.id == it.id }
else -> true
} }
private fun cacheCreators() { includeType && !excludeType && isFavourited &&
val callback = object : Callback { regularSearch
override fun onResponse(call: Call, response: Response) = }.also { mangasCache = mangas }
response.body.source().run {
readAll(blackholeSink())
close()
} }
override fun onFailure(call: Call, e: IOException) = Unit val sorted = when (sort.first) {
"pop" -> {
if (sort.second == "desc") {
mangas.sortedByDescending { it.favorited }
} else {
mangas.sortedBy { it.favorited }
} }
client.newCall(GET("$baseUrl/$apiPath/creators", headers)).enqueue(callback) }
"tit" -> {
if (sort.second == "desc") {
mangas.sortedByDescending { it.name }
} else {
mangas.sortedBy { it.name }
}
}
"new" -> {
if (sort.second == "desc") {
mangas.sortedByDescending { it.id }
} else {
mangas.sortedBy { it.id }
}
}
"fav" -> {
if (fav != true) throw Exception("Please check 'Favourites Only' Filter")
if (sort.second == "desc") {
mangas.sortedByDescending { it.fav }
} else {
mangas.sortedBy { it.fav }
}
}
else -> {
if (sort.second == "desc") {
mangas.sortedByDescending { it.updatedDate }
} else {
mangas.sortedBy { it.updatedDate }
}
}
}
val maxIndex = mangas.size
val fromIndex = (page - 1) * PAGE_CREATORS_LIMIT
val toIndex = min(maxIndex, fromIndex + PAGE_CREATORS_LIMIT)
val final = sorted.subList(fromIndex, toIndex).map { it.toSManga(imgCdnUrl) }
return MangasPage(final, toIndex != maxIndex)
} }
override fun searchMangaRequest(page: Int, query: String, filters: FilterList) = throw UnsupportedOperationException() override fun searchMangaRequest(page: Int, query: String, filters: FilterList) = throw UnsupportedOperationException()
@ -156,28 +196,31 @@ open class Kemono(
"Pixiv Fanbox", "Fantia" -> TimeZone.getTimeZone("GMT+09:00") "Pixiv Fanbox", "Fantia" -> TimeZone.getTimeZone("GMT+09:00")
else -> TimeZone.getTimeZone("GMT") else -> TimeZone.getTimeZone("GMT")
} }
val maxPosts = preferences.getString(POST_PAGES_PREF, POST_PAGES_DEFAULT)!! val prefMaxPost = preferences.getString(POST_PAGES_PREF, POST_PAGES_DEFAULT)!!
.toInt().coerceAtMost(POST_PAGES_MAX) * POST_PAGE_SIZE .toInt().coerceAtMost(POST_PAGES_MAX) * PAGE_POST_LIMIT
var offset = 0 var offset = 0
var hasNextPage = true var hasNextPage = true
val result = ArrayList<SChapter>() val result = ArrayList<SChapter>()
while (offset < maxPosts && hasNextPage) { while (offset < prefMaxPost && hasNextPage) {
val request = GET("$baseUrl/$apiPath${manga.url}?limit=$POST_PAGE_SIZE&o=$offset", headers) val request = GET("$baseUrl/$apiPath${manga.url}?o=$offset", headers)
val page: List<KemonoPostDto> = retry(request).parseAs() val page: List<KemonoPostDto> = retry(request).parseAs()
page.forEach { post -> if (post.images.isNotEmpty()) result.add(post.toSChapter()) } page.forEach { post -> if (post.images.isNotEmpty()) result.add(post.toSChapter()) }
offset += POST_PAGE_SIZE offset += PAGE_POST_LIMIT
hasNextPage = page.size == POST_PAGE_SIZE hasNextPage = page.size == PAGE_POST_LIMIT
} }
result result
} }
private fun retry(request: Request): Response { private fun retry(request: Request): Response {
var code = 0 var code = 0
repeat(3) { repeat(5) {
val response = client.newCall(request).execute() val response = client.newCall(request).execute()
if (response.isSuccessful) return response if (response.isSuccessful) return response
response.close() response.close()
code = response.code code = response.code
if (code == 429) {
sleep(10000)
}
} }
throw Exception("HTTP error $code") throw Exception("HTTP error $code")
} }
@ -217,10 +260,8 @@ open class Kemono(
key = POST_PAGES_PREF key = POST_PAGES_PREF
title = "Maximum posts to load" title = "Maximum posts to load"
summary = "Loading more posts costs more time and network traffic.\nCurrently: %s" summary = "Loading more posts costs more time and network traffic.\nCurrently: %s"
entryValues = (1..POST_PAGES_MAX).map { it.toString() }.toTypedArray() entryValues = Array(POST_PAGES_MAX) { (it + 1).toString() }
entries = (1..POST_PAGES_MAX).map { entries = Array(POST_PAGES_MAX) { "${(it + 1)} pages (${(it + 1) * PAGE_POST_LIMIT} posts)" }
if (it == 1) "1 page ($POST_PAGE_SIZE posts)" else "$it pages (${it * POST_PAGE_SIZE} posts)"
}.toTypedArray()
setDefaultValue(POST_PAGES_DEFAULT) setDefaultValue(POST_PAGES_DEFAULT)
}.let { screen.addPreference(it) } }.let { screen.addPreference(it) }
@ -232,16 +273,55 @@ open class Kemono(
}.let(screen::addPreference) }.let(screen::addPreference)
} }
// Filters
override fun getFilterList(): FilterList =
FilterList(
SortFilter(
"Sort by",
Filter.Sort.Selection(0, false),
getSortsList,
),
TypeFilter("Types", getTypes),
FavouritesFilter(),
)
open val getTypes: List<String> = emptyList()
open val getSortsList: List<Pair<String, String>> = listOf(
Pair("Popularity", "pop"),
Pair("Date Indexed", "new"),
Pair("Date Updated", "lat"),
Pair("Alphabetical Order", "tit"),
Pair("Service", "serv"),
Pair("Date Favourited", "fav"),
)
internal open class TypeFilter(name: String, vals: List<String>) :
Filter.Group<TriFilter>(
name,
vals.map { TriFilter(it, it.lowercase()) },
)
internal class FavouritesFilter() :
Filter.Group<TriFilter>(
"Favourites",
listOf(TriFilter("Favourites Only", "fav")),
)
internal open class TriFilter(name: String, val value: String) : Filter.TriState(name)
internal open class SortFilter(name: String, selection: Selection, private val vals: List<Pair<String, String>>) :
Filter.Sort(name, vals.map { it.first }.toTypedArray(), selection) {
fun getValue() = vals[state!!.index].second
}
companion object { companion object {
private const val NEW_PAGE_SIZE = 50 private const val PAGE_POST_LIMIT = 50
private const val PAGE_CREATORS_LIMIT = 50
const val PROMPT = "You can change how many posts to load in the extension preferences." const val PROMPT = "You can change how many posts to load in the extension preferences."
private const val POST_PAGE_SIZE = 50
private const val POST_PAGES_PREF = "POST_PAGES" private const val POST_PAGES_PREF = "POST_PAGES"
private const val POST_PAGES_DEFAULT = "1" private const val POST_PAGES_DEFAULT = "1"
private const val POST_PAGES_MAX = 50 private const val POST_PAGES_MAX = 75
private fun List<SManga>.filterUnsupported() = filterNot { it.author == "Discord" }
// private const val BASE_URL_PREF = "BASE_URL" // private const val BASE_URL_PREF = "BASE_URL"
private const val USE_LOW_RES_IMG = "USE_LOW_RES_IMG" private const val USE_LOW_RES_IMG = "USE_LOW_RES_IMG"

View File

@ -7,15 +7,23 @@ import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.double import kotlinx.serialization.json.double
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Locale import java.util.Locale
@Serializable
class KemonoFavouritesDto(
val id: String,
val name: String,
val service: String,
val faved_seq: Long,
)
@Serializable @Serializable
class KemonoCreatorDto( class KemonoCreatorDto(
private val id: String, val id: String,
val name: String, val name: String,
private val service: String, val service: String,
private val updated: JsonPrimitive, private val updated: JsonPrimitive,
val favorited: Int = -1, val favorited: Int = -1,
) { ) {
var fav: Long = 0
val updatedDate get() = when { val updatedDate get() = when {
updated.isString -> dateFormat.parse(updated.content)?.time ?: 0 updated.isString -> dateFormat.parse(updated.content)?.time ?: 0
else -> (updated.double * 1000).toLong() else -> (updated.double * 1000).toLong()

View File

@ -2,4 +2,10 @@ package eu.kanade.tachiyomi.extension.all.coomer
import eu.kanade.tachiyomi.multisrc.kemono.Kemono import eu.kanade.tachiyomi.multisrc.kemono.Kemono
class Coomer : Kemono("Coomer", "https://coomer.su", "all") class Coomer : Kemono("Coomer", "https://coomer.su", "all") {
override val getTypes = listOf(
"OnlyFans",
"Fansly",
"CandFans",
)
}

View File

@ -2,4 +2,15 @@ package eu.kanade.tachiyomi.extension.all.kemono
import eu.kanade.tachiyomi.multisrc.kemono.Kemono import eu.kanade.tachiyomi.multisrc.kemono.Kemono
class Kemono : Kemono("Kemono", "https://kemono.su", "all") class Kemono : Kemono("Kemono", "https://kemono.su", "all") {
override val getTypes = listOf(
"Patreon",
"Pixiv Fanbox",
"Discord",
"Fantia",
"Afdian",
"Boosty",
"Gumroad",
"SubscribeStar",
)
}