Recommendations rewrite (#23)
* WIP Rewrite api requests to use a coroutine scope * Use scope.async instead of scope.launch * Use onPageReceived to async update Pager; Reimplement api select logic * Implement seperate classes; Bug fixes * More bug fixes * Use timber; Add more logs Co-authored-by: she11sh0cked <she11sh0cked@users.noreply.github.com>
This commit is contained in:
parent
99dd9a0750
commit
87fbda0b29
@ -1,9 +1,9 @@
|
|||||||
package eu.kanade.tachiyomi.ui.browse.source.browse
|
package eu.kanade.tachiyomi.ui.browse.source.browse
|
||||||
|
|
||||||
import android.util.Log
|
|
||||||
import com.github.salomonbrys.kotson.array
|
import com.github.salomonbrys.kotson.array
|
||||||
import com.github.salomonbrys.kotson.get
|
import com.github.salomonbrys.kotson.get
|
||||||
import com.github.salomonbrys.kotson.jsonObject
|
import com.github.salomonbrys.kotson.jsonObject
|
||||||
|
import com.github.salomonbrys.kotson.nullArray
|
||||||
import com.github.salomonbrys.kotson.nullObj
|
import com.github.salomonbrys.kotson.nullObj
|
||||||
import com.github.salomonbrys.kotson.nullString
|
import com.github.salomonbrys.kotson.nullString
|
||||||
import com.github.salomonbrys.kotson.obj
|
import com.github.salomonbrys.kotson.obj
|
||||||
@ -12,227 +12,304 @@ import com.google.gson.JsonArray
|
|||||||
import com.google.gson.JsonObject
|
import com.google.gson.JsonObject
|
||||||
import com.google.gson.JsonParser
|
import com.google.gson.JsonParser
|
||||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||||
import eu.kanade.tachiyomi.network.asObservableSuccess
|
import eu.kanade.tachiyomi.network.await
|
||||||
import eu.kanade.tachiyomi.source.model.MangasPage
|
import eu.kanade.tachiyomi.source.model.MangasPage
|
||||||
import eu.kanade.tachiyomi.source.model.SMangaImpl
|
import eu.kanade.tachiyomi.source.model.SMangaImpl
|
||||||
import exh.util.MangaType
|
import exh.util.MangaType
|
||||||
import exh.util.mangaType
|
import exh.util.mangaType
|
||||||
|
import kotlinx.coroutines.CoroutineExceptionHandler
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||||
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import okhttp3.RequestBody.Companion.toRequestBody
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
import rx.Observable
|
import rx.Observable
|
||||||
import rx.schedulers.Schedulers
|
import timber.log.Timber
|
||||||
|
|
||||||
|
abstract class API(_endpoint: String) {
|
||||||
|
var endpoint: String = _endpoint
|
||||||
|
val client = OkHttpClient.Builder().build()
|
||||||
|
val scope = CoroutineScope(Job() + Dispatchers.Default)
|
||||||
|
|
||||||
|
abstract fun getRecsBySearch(
|
||||||
|
search: String,
|
||||||
|
callback: (onResolve: List<SMangaImpl>?, onReject: Throwable?) -> Unit
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
class MyAnimeList() : API("https://api.jikan.moe/v3/") {
|
||||||
|
fun getRecsById(
|
||||||
|
id: String,
|
||||||
|
callback: (resolve: List<SMangaImpl>?, reject: Throwable?) -> Unit
|
||||||
|
) {
|
||||||
|
val httpUrl =
|
||||||
|
endpoint.toHttpUrlOrNull()
|
||||||
|
if (httpUrl == null) {
|
||||||
|
callback.invoke(null, Exception("Could not convert endpoint url"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val urlBuilder = httpUrl.newBuilder()
|
||||||
|
urlBuilder.addPathSegment("manga")
|
||||||
|
urlBuilder.addPathSegment(id)
|
||||||
|
urlBuilder.addPathSegment("recommendations")
|
||||||
|
val url = urlBuilder.build().toUrl()
|
||||||
|
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url(url)
|
||||||
|
.get()
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val handler = CoroutineExceptionHandler { _, exception ->
|
||||||
|
callback.invoke(null, exception)
|
||||||
|
}
|
||||||
|
|
||||||
|
scope.launch(handler) {
|
||||||
|
val response = client.newCall(request).await()
|
||||||
|
val body = response.body?.string().orEmpty()
|
||||||
|
if (body.isEmpty()) {
|
||||||
|
throw Exception("Null Response")
|
||||||
|
}
|
||||||
|
val data = JsonParser.parseString(body).obj
|
||||||
|
val recommendations = data["recommendations"].nullArray
|
||||||
|
?: throw Exception("Unexpected response")
|
||||||
|
val recs = recommendations.map { rec ->
|
||||||
|
Timber.tag("RECOMMENDATIONS")
|
||||||
|
.d("MYANIMELIST > FOUND RECOMMENDATION > %s", rec["title"].string)
|
||||||
|
SMangaImpl().apply {
|
||||||
|
this.title = rec["title"].string
|
||||||
|
this.thumbnail_url = rec["image_url"].string
|
||||||
|
this.initialized = true
|
||||||
|
this.url = rec["url"].string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
callback.invoke(recs, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getRecsBySearch(
|
||||||
|
search: String,
|
||||||
|
callback: (recs: List<SMangaImpl>?, error: Throwable?) -> Unit
|
||||||
|
) {
|
||||||
|
val httpUrl =
|
||||||
|
endpoint.toHttpUrlOrNull()
|
||||||
|
if (httpUrl == null) {
|
||||||
|
callback.invoke(null, Exception("Could not convert endpoint url"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val urlBuilder = httpUrl.newBuilder()
|
||||||
|
urlBuilder.addPathSegment("search")
|
||||||
|
urlBuilder.addPathSegment("manga")
|
||||||
|
urlBuilder.addQueryParameter("q", search)
|
||||||
|
val url = urlBuilder.build().toUrl()
|
||||||
|
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url(url)
|
||||||
|
.get()
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val handler = CoroutineExceptionHandler { _, exception ->
|
||||||
|
callback.invoke(null, exception)
|
||||||
|
}
|
||||||
|
|
||||||
|
scope.launch(handler) {
|
||||||
|
val response = client.newCall(request).await()
|
||||||
|
val body = response.body?.string().orEmpty()
|
||||||
|
if (body.isEmpty()) {
|
||||||
|
throw Exception("Null Response")
|
||||||
|
}
|
||||||
|
val data = JsonParser.parseString(body).obj
|
||||||
|
val results = data["results"].nullArray ?: throw Exception("Unexpected response")
|
||||||
|
if (results.size() <= 0) {
|
||||||
|
throw Exception("'$search' not found")
|
||||||
|
}
|
||||||
|
val result = results.first().obj
|
||||||
|
Timber.tag("RECOMMENDATIONS")
|
||||||
|
.d("MYANIMELIST > FOUND TITLE > %s", result["title"].string)
|
||||||
|
val id = result["mal_id"].string
|
||||||
|
getRecsById(id, callback)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Anilist() : API("https://graphql.anilist.co/") {
|
||||||
|
private fun countOccurrence(arr: JsonArray, search: String): Int {
|
||||||
|
return arr.count {
|
||||||
|
val synonym = it.string
|
||||||
|
synonym.contains(search, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun languageContains(obj: JsonObject, language: String, search: String): Boolean {
|
||||||
|
return obj["title"].obj[language].nullString?.contains(search, true) == true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getTitle(obj: JsonObject): String {
|
||||||
|
return obj["title"].obj["romaji"].nullString
|
||||||
|
?: obj["title"].obj["english"].nullString
|
||||||
|
?: obj["title"].obj["native"].string
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getRecsBySearch(
|
||||||
|
search: String,
|
||||||
|
callback: (onResolve: List<SMangaImpl>?, onReject: Throwable?) -> Unit
|
||||||
|
) {
|
||||||
|
val query =
|
||||||
|
"""
|
||||||
|
|query Recommendations(${'$'}search: String!) {
|
||||||
|
|Page {
|
||||||
|
|media(search: ${'$'}search, type: MANGA) {
|
||||||
|
|title {
|
||||||
|
|romaji
|
||||||
|
|english
|
||||||
|
|native
|
||||||
|
|}
|
||||||
|
|synonyms
|
||||||
|
|recommendations {
|
||||||
|
|edges {
|
||||||
|
|node {
|
||||||
|
|mediaRecommendation {
|
||||||
|
|siteUrl
|
||||||
|
|title {
|
||||||
|
|romaji
|
||||||
|
|english
|
||||||
|
|native
|
||||||
|
|}
|
||||||
|
|coverImage {
|
||||||
|
|large
|
||||||
|
|}
|
||||||
|
|}
|
||||||
|
|}
|
||||||
|
|}
|
||||||
|
|}
|
||||||
|
|}
|
||||||
|
|}
|
||||||
|
|}
|
||||||
|
|""".trimMargin()
|
||||||
|
val variables = jsonObject("search" to search)
|
||||||
|
val payload = jsonObject(
|
||||||
|
"query" to query,
|
||||||
|
"variables" to variables
|
||||||
|
)
|
||||||
|
val payloadBody =
|
||||||
|
payload.toString().toRequestBody("application/json; charset=utf-8".toMediaTypeOrNull())
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url(endpoint)
|
||||||
|
.post(payloadBody)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val handler = CoroutineExceptionHandler { _, exception ->
|
||||||
|
callback.invoke(null, exception)
|
||||||
|
}
|
||||||
|
|
||||||
|
scope.launch(handler) {
|
||||||
|
val response = client.newCall(request).await()
|
||||||
|
val body = response.body?.string().orEmpty()
|
||||||
|
if (body.isEmpty()) {
|
||||||
|
throw Exception("Null Response")
|
||||||
|
}
|
||||||
|
val data = JsonParser.parseString(body).obj["data"].nullObj
|
||||||
|
?: throw Exception("Unexpected response")
|
||||||
|
val page = data["Page"].obj
|
||||||
|
val media = page["media"].array
|
||||||
|
if (media.size() <= 0) {
|
||||||
|
throw Exception("'$search' not found")
|
||||||
|
}
|
||||||
|
val result = media.sortedWith(
|
||||||
|
compareBy(
|
||||||
|
{ languageContains(it.obj, "romaji", search) },
|
||||||
|
{ languageContains(it.obj, "english", search) },
|
||||||
|
{ languageContains(it.obj, "native", search) },
|
||||||
|
{ countOccurrence(it.obj["synonyms"].array, search) > 0 }
|
||||||
|
)
|
||||||
|
).last().obj
|
||||||
|
Timber.tag("RECOMMENDATIONS")
|
||||||
|
.d("ANILIST > FOUND TITLE > %s", getTitle(result))
|
||||||
|
val recommendations = result["recommendations"].obj["edges"].array
|
||||||
|
val recs = recommendations.map {
|
||||||
|
val rec = it["node"]["mediaRecommendation"].obj
|
||||||
|
Timber.tag("RECOMMENDATIONS")
|
||||||
|
.d("ANILIST: FOUND RECOMMENDATION: %s", getTitle(rec))
|
||||||
|
SMangaImpl().apply {
|
||||||
|
this.title = getTitle(rec)
|
||||||
|
this.thumbnail_url = rec["coverImage"].obj["large"].string
|
||||||
|
this.initialized = true
|
||||||
|
this.url = rec["siteUrl"].string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
callback.invoke(recs, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
open class RecommendsPager(
|
open class RecommendsPager(
|
||||||
val manga: Manga,
|
val manga: Manga,
|
||||||
val smart: Boolean = true,
|
val smart: Boolean = true,
|
||||||
var preferredApi: API = API.MYANIMELIST
|
var preferredApi: API = API.MYANIMELIST
|
||||||
) : Pager() {
|
) : Pager() {
|
||||||
private val client = OkHttpClient.Builder().build()
|
private val apiList = API_MAP.toMutableMap()
|
||||||
|
private var currentApi: API? = null
|
||||||
|
|
||||||
private fun myAnimeList(): Observable<List<SMangaImpl>>? {
|
private fun handleSuccess(recs: List<SMangaImpl>) {
|
||||||
fun getId(): Observable<String?> {
|
if (recs.isEmpty()) {
|
||||||
val endpoint =
|
Timber.tag("RECOMMENDATIONS").e("%s > Couldn't find any", currentApi.toString())
|
||||||
myAnimeListEndpoint.toHttpUrlOrNull()
|
apiList.remove(currentApi)
|
||||||
?: throw Exception("Could not convert endpoint url")
|
val list = apiList.toList()
|
||||||
val urlBuilder = endpoint.newBuilder()
|
currentApi = if (list.isEmpty()) {
|
||||||
urlBuilder.addPathSegment("search")
|
null
|
||||||
urlBuilder.addPathSegment("manga")
|
} else {
|
||||||
urlBuilder.addQueryParameter("q", manga.title)
|
apiList.toList().first().first
|
||||||
val url = urlBuilder.build().toUrl()
|
|
||||||
|
|
||||||
val request = Request.Builder()
|
|
||||||
.url(url)
|
|
||||||
.get()
|
|
||||||
.build()
|
|
||||||
|
|
||||||
return client.newCall(request)
|
|
||||||
.asObservableSuccess().subscribeOn(Schedulers.io())
|
|
||||||
.map { netResponse ->
|
|
||||||
val responseBody = netResponse.body?.string().orEmpty()
|
|
||||||
if (responseBody.isEmpty()) {
|
|
||||||
throw Exception("Null Response")
|
|
||||||
}
|
|
||||||
val response = JsonParser.parseString(responseBody).obj
|
|
||||||
val result = response["results"].array.first().nullObj ?: return@map null
|
|
||||||
result["mal_id"].string
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return getId().map { id ->
|
|
||||||
if (id == null) {
|
|
||||||
return@map null
|
|
||||||
}
|
}
|
||||||
val endpoint =
|
|
||||||
myAnimeListEndpoint.toHttpUrlOrNull()
|
|
||||||
?: throw Exception("Could not convert endpoint url")
|
|
||||||
val urlBuilder = endpoint.newBuilder()
|
|
||||||
urlBuilder.addPathSegment("manga")
|
|
||||||
urlBuilder.addPathSegment(id)
|
|
||||||
urlBuilder.addPathSegment("recommendations")
|
|
||||||
val url = urlBuilder.build().toUrl()
|
|
||||||
|
|
||||||
val request = Request.Builder()
|
if (currentApi != null) {
|
||||||
.url(url)
|
getRecs(currentApi!!)
|
||||||
.get()
|
} else {
|
||||||
.build()
|
Timber.tag("RECOMMENDATIONS").e("Couldn't find any")
|
||||||
|
onPageReceived(MangasPage(recs, false))
|
||||||
client.newCall(request)
|
}
|
||||||
.asObservableSuccess().subscribeOn(Schedulers.io())
|
} else {
|
||||||
.map { netResponse ->
|
onPageReceived(MangasPage(recs, false))
|
||||||
val responseBody = netResponse.body?.string().orEmpty()
|
|
||||||
if (responseBody.isEmpty()) {
|
|
||||||
throw Exception("Null Response")
|
|
||||||
}
|
|
||||||
val response = JsonParser.parseString(responseBody).obj
|
|
||||||
val recommendations = response["recommendations"].array
|
|
||||||
recommendations.map { rec ->
|
|
||||||
Log.d("MYANIMELIST RECOMMEND", "${rec["title"].string}")
|
|
||||||
SMangaImpl().apply {
|
|
||||||
this.title = rec["title"].string
|
|
||||||
this.thumbnail_url = rec["image_url"].string
|
|
||||||
this.initialized = true
|
|
||||||
this.url = rec["url"].string
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}.toBlocking().first()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun anilist(): Observable<List<SMangaImpl>>? {
|
private fun handleError(error: Throwable) {
|
||||||
val query =
|
Timber.tag("RECOMMENDATIONS").e(error)
|
||||||
"""
|
handleSuccess(listOf()) // tmp workaround until errors can be displayed in app
|
||||||
{
|
}
|
||||||
Page {
|
|
||||||
media(search: "${manga.title}", type: MANGA) {
|
|
||||||
title {
|
|
||||||
romaji
|
|
||||||
english
|
|
||||||
native
|
|
||||||
}
|
|
||||||
synonyms
|
|
||||||
recommendations {
|
|
||||||
edges {
|
|
||||||
node {
|
|
||||||
mediaRecommendation {
|
|
||||||
siteUrl
|
|
||||||
title {
|
|
||||||
romaji
|
|
||||||
english
|
|
||||||
native
|
|
||||||
}
|
|
||||||
coverImage {
|
|
||||||
large
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
""".trimIndent()
|
|
||||||
val variables = jsonObject()
|
|
||||||
val payload = jsonObject(
|
|
||||||
"query" to query,
|
|
||||||
"variables" to variables
|
|
||||||
)
|
|
||||||
val body =
|
|
||||||
payload.toString().toRequestBody("application/json; charset=utf-8".toMediaTypeOrNull())
|
|
||||||
val request = Request.Builder()
|
|
||||||
.url(anilistEndpoint)
|
|
||||||
.post(body)
|
|
||||||
.build()
|
|
||||||
|
|
||||||
fun countOccurrence(array: JsonArray, search: String): Int {
|
private fun getRecs(api: API) {
|
||||||
return array.count {
|
Timber.tag("RECOMMENDATIONS").d("USING > %s", api.toString())
|
||||||
val synonym = it.string
|
apiList[api]?.getRecsBySearch(manga.title) { recs, error ->
|
||||||
synonym.contains(search, true)
|
if (error != null) {
|
||||||
|
handleError(error)
|
||||||
|
}
|
||||||
|
if (recs != null) {
|
||||||
|
handleSuccess(recs)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun languageContains(it: JsonObject, language: String, search: String): Boolean {
|
|
||||||
return it["title"].obj[language].nullString?.contains(search, true) == true
|
|
||||||
}
|
|
||||||
|
|
||||||
return client.newCall(request)
|
|
||||||
.asObservableSuccess().subscribeOn(Schedulers.io())
|
|
||||||
.map { netResponse ->
|
|
||||||
val responseBody = netResponse.body?.string().orEmpty()
|
|
||||||
if (responseBody.isEmpty()) {
|
|
||||||
throw Exception("Null Response")
|
|
||||||
}
|
|
||||||
val response = JsonParser.parseString(responseBody).obj
|
|
||||||
val data = response["data"]!!.obj
|
|
||||||
val page = data["Page"].obj
|
|
||||||
val media = page["media"].array
|
|
||||||
val result = media.sortedWith(
|
|
||||||
compareBy(
|
|
||||||
{ languageContains(it.obj, "romaji", manga.title) },
|
|
||||||
{ languageContains(it.obj, "english", manga.title) },
|
|
||||||
{ languageContains(it.obj, "native", manga.title) },
|
|
||||||
{ countOccurrence(it.obj["synonyms"].array, manga.title) > 0 }
|
|
||||||
)
|
|
||||||
).last().nullObj ?: return@map null
|
|
||||||
val recommendations = result["recommendations"].obj
|
|
||||||
val edges = recommendations["edges"].array
|
|
||||||
edges.map {
|
|
||||||
val rec = it["node"]["mediaRecommendation"].obj
|
|
||||||
Log.d("ANILIST RECOMMEND", "${rec["title"].obj["romaji"].string}")
|
|
||||||
SMangaImpl().apply {
|
|
||||||
this.title = rec["title"].obj["romaji"].nullString
|
|
||||||
?: rec["title"].obj["english"].nullString
|
|
||||||
?: rec["title"].obj["native"].string
|
|
||||||
this.thumbnail_url = rec["coverImage"].obj["large"].string
|
|
||||||
this.initialized = true
|
|
||||||
this.url = rec["siteUrl"].string
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun requestNext(): Observable<MangasPage> {
|
override fun requestNext(): Observable<MangasPage> {
|
||||||
if (smart) {
|
if (smart) {
|
||||||
preferredApi =
|
preferredApi =
|
||||||
if (manga.mangaType() != MangaType.TYPE_MANGA) API.ANILIST else preferredApi
|
if (manga.mangaType() != MangaType.TYPE_MANGA) API.ANILIST else preferredApi
|
||||||
Log.d("SMART RECOMMEND", preferredApi.toString())
|
Timber.tag("RECOMMENDATIONS").d("SMART > %s", preferredApi.toString())
|
||||||
}
|
}
|
||||||
|
currentApi = preferredApi
|
||||||
|
|
||||||
val apiList = API.values().toMutableList()
|
getRecs(currentApi!!)
|
||||||
apiList.removeAt(apiList.indexOf(preferredApi))
|
|
||||||
apiList.add(0, preferredApi)
|
|
||||||
|
|
||||||
var recommendations: Observable<List<SMangaImpl>>? = null
|
return Observable.just(MangasPage(listOf(), false))
|
||||||
for (api in apiList) {
|
|
||||||
val currentRecommendations = when (api) {
|
|
||||||
API.MYANIMELIST -> myAnimeList()
|
|
||||||
API.ANILIST -> anilist()
|
|
||||||
}
|
|
||||||
|
|
||||||
if (currentRecommendations != null &&
|
|
||||||
currentRecommendations.toBlocking().first().isNotEmpty()
|
|
||||||
) {
|
|
||||||
recommendations = currentRecommendations
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (recommendations == null) {
|
|
||||||
throw Exception("No recommendations found")
|
|
||||||
}
|
|
||||||
|
|
||||||
return recommendations.map {
|
|
||||||
MangasPage(it, false)
|
|
||||||
}.doOnNext {
|
|
||||||
onPageReceived(it)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val myAnimeListEndpoint = "https://api.jikan.moe/v3/"
|
val API_MAP = mapOf(
|
||||||
private const val anilistEndpoint = "https://graphql.anilist.co/"
|
API.MYANIMELIST to MyAnimeList(),
|
||||||
|
API.ANILIST to Anilist()
|
||||||
|
)
|
||||||
|
|
||||||
enum class API { MYANIMELIST, ANILIST }
|
enum class API { MYANIMELIST, ANILIST }
|
||||||
}
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user