diff --git a/app/src/main/java/exh/recs/RecommendsPager.kt b/app/src/main/java/exh/recs/RecommendsPager.kt index cd5e6f255..37901c522 100644 --- a/app/src/main/java/exh/recs/RecommendsPager.kt +++ b/app/src/main/java/exh/recs/RecommendsPager.kt @@ -4,15 +4,18 @@ import eu.kanade.tachiyomi.data.database.models.Manga import eu.kanade.tachiyomi.network.await import eu.kanade.tachiyomi.source.model.MangasPage import eu.kanade.tachiyomi.source.model.SMangaImpl +import eu.kanade.tachiyomi.ui.browse.source.browse.NoResultsException import eu.kanade.tachiyomi.ui.browse.source.browse.Pager +import eu.kanade.tachiyomi.util.lang.asObservable import exh.log.maybeInjectEHLogger 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 kotlinx.coroutines.async +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.supervisorScope import kotlinx.serialization.decodeFromString import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonArray @@ -28,6 +31,7 @@ import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody import rx.Observable +import rx.android.schedulers.AndroidSchedulers import timber.log.Timber abstract class API(_endpoint: String) { @@ -37,21 +41,14 @@ abstract class API(_endpoint: String) { .build() val scope = CoroutineScope(Job() + Dispatchers.Default) - abstract fun getRecsBySearch( - search: String, - callback: (onResolve: List?, onReject: Throwable?) -> Unit - ) + abstract suspend fun getRecsBySearch(search: String): List } class MyAnimeList : API("https://api.jikan.moe/v3/") { - private fun getRecsById( - id: String, - callback: (resolve: List?, reject: Throwable?) -> Unit - ) { + private suspend fun getRecsById(id: String): List { val httpUrl = endpoint.toHttpUrlOrNull() if (httpUrl == null) { - callback.invoke(null, Exception("Could not convert endpoint url")) - return + throw Exception("Could not convert endpoint url") } val urlBuilder = httpUrl.newBuilder() urlBuilder.addPathSegment("manga") @@ -64,43 +61,32 @@ class MyAnimeList : API("https://api.jikan.moe/v3/") { .get() .build() - val handler = CoroutineExceptionHandler { _, exception -> - callback.invoke(null, exception) + val response = client.newCall(request).await() + val body = response.body?.string().orEmpty() + if (body.isEmpty()) { + throw Exception("Null Response") } - - scope.launch(handler) { - val response = client.newCall(request).await() - val body = response.body?.string().orEmpty() - if (body.isEmpty()) { - throw Exception("Null Response") + val data = Json.decodeFromString(body) + val recommendations = data["recommendations"] as? JsonArray + ?: throw Exception("Unexpected response") + val recs = recommendations.map { rec -> + rec as? JsonObject ?: throw Exception("Invalid json") + Timber.tag("RECOMMENDATIONS").d("MYANIMELIST > RECOMMENDATION: %s", rec["title"]!!.jsonPrimitive.content) + SMangaImpl().apply { + this.title = rec["title"]!!.jsonPrimitive.content + this.thumbnail_url = rec["image_url"]!!.jsonPrimitive.content + this.initialized = true + this.url = rec["url"]!!.jsonPrimitive.content } - val data = Json.decodeFromString(body) - val recommendations = data["recommendations"] as? JsonArray - ?: throw Exception("Unexpected response") - val recs = recommendations.map { rec -> - rec as? JsonObject ?: throw Exception("Invalid json") - Timber.tag("RECOMMENDATIONS") - .d("MYANIMELIST > FOUND RECOMMENDATION > %s", rec["title"]!!.jsonPrimitive.content) - SMangaImpl().apply { - this.title = rec["title"]!!.jsonPrimitive.content - this.thumbnail_url = rec["image_url"]!!.jsonPrimitive.content - this.initialized = true - this.url = rec["url"]!!.jsonPrimitive.content - } - } - callback.invoke(recs, null) } + return recs } - override fun getRecsBySearch( - search: String, - callback: (recs: List?, error: Throwable?) -> Unit - ) { + override suspend fun getRecsBySearch(search: String): List { val httpUrl = endpoint.toHttpUrlOrNull() if (httpUrl == null) { - callback.invoke(null, Exception("Could not convert endpoint url")) - return + throw Exception("Could not convert endpoint url") } val urlBuilder = httpUrl.newBuilder() urlBuilder.addPathSegment("search") @@ -113,27 +99,19 @@ class MyAnimeList : API("https://api.jikan.moe/v3/") { .get() .build() - val handler = CoroutineExceptionHandler { _, exception -> - callback.invoke(null, exception) + val response = client.newCall(request).await() + val body = response.body?.string().orEmpty() + if (body.isEmpty()) { + throw Exception("Null Response") } - - scope.launch(handler) { - val response = client.newCall(request).await() - val body = response.body?.string().orEmpty() - if (body.isEmpty()) { - throw Exception("Null Response") - } - val data = Json.decodeFromString(body) - val results = data["results"] as? JsonArray ?: throw Exception("Unexpected response") - if (results.size <= 0) { - throw Exception("'$search' not found") - } - val result = results.first().jsonObject - Timber.tag("RECOMMENDATIONS") - .d("MYANIMELIST > FOUND TITLE > %s", result["title"]!!.jsonPrimitive.content) - val id = result["mal_id"]!!.jsonPrimitive.content - getRecsById(id, callback) + val data = Json.decodeFromString(body) + val results = data["results"] as? JsonArray ?: throw Exception("Unexpected response") + if (results.size <= 0) { + throw Exception("'$search' not found") } + val result = results.first().jsonObject + val id = result["mal_id"]!!.jsonPrimitive.content + return getRecsById(id) } } @@ -157,10 +135,7 @@ class Anilist : API("https://graphql.anilist.co/") { } } - override fun getRecsBySearch( - search: String, - callback: (onResolve: List?, onReject: Throwable?) -> Unit - ) { + override suspend fun getRecsBySearch(search: String): List { val query = """ |query Recommendations(${'$'}search: String!) { @@ -207,47 +182,38 @@ class Anilist : API("https://graphql.anilist.co/") { .post(payloadBody) .build() - val handler = CoroutineExceptionHandler { _, exception -> - callback.invoke(null, exception) + val response = client.newCall(request).await() + val body = response.body?.string().orEmpty() + if (body.isEmpty()) { + throw Exception("Null Response") } - - scope.launch(handler) { - val response = client.newCall(request).await() - val body = response.body?.string().orEmpty() - if (body.isEmpty()) { - throw Exception("Null Response") - } - val data = Json.decodeFromString(body)["data"] as? JsonObject - ?: throw Exception("Unexpected response") - val page = data["Page"]!!.jsonObject - val media = page["media"]!!.jsonArray - if (media.size <= 0) { - throw Exception("'$search' not found") - } - val result = media.sortedWith( - compareBy( - { languageContains(it.jsonObject, "romaji", search) }, - { languageContains(it.jsonObject, "english", search) }, - { languageContains(it.jsonObject, "native", search) }, - { countOccurrence(it.jsonObject["synonyms"]!!.jsonArray, search) > 0 } - ) - ).last().jsonObject - Timber.tag("RECOMMENDATIONS") - .d("ANILIST > FOUND TITLE > %s", getTitle(result)) - val recommendations = result["recommendations"]!!.jsonObject["edges"]!!.jsonArray - val recs = recommendations.map { - val rec = it.jsonObject["node"]!!.jsonObject["mediaRecommendation"]!!.jsonObject - Timber.tag("RECOMMENDATIONS") - .d("ANILIST: FOUND RECOMMENDATION: %s", getTitle(rec)) - SMangaImpl().apply { - this.title = getTitle(rec) - this.thumbnail_url = rec["coverImage"]!!.jsonObject["large"]!!.jsonPrimitive.content - this.initialized = true - this.url = rec["siteUrl"]!!.jsonPrimitive.content - } - } - callback.invoke(recs, null) + val data = Json.decodeFromString(body)["data"] as? JsonObject + ?: throw Exception("Unexpected response") + val page = data["Page"]!!.jsonObject + val media = page["media"]!!.jsonArray + if (media.size <= 0) { + throw Exception("'$search' not found") } + val result = media.sortedWith( + compareBy( + { languageContains(it.jsonObject, "romaji", search) }, + { languageContains(it.jsonObject, "english", search) }, + { languageContains(it.jsonObject, "native", search) }, + { countOccurrence(it.jsonObject["synonyms"]!!.jsonArray, search) > 0 } + ) + ).last().jsonObject + val recommendations = result["recommendations"]!!.jsonObject["edges"]!!.jsonArray + val recs = recommendations.map { + val rec = it.jsonObject["node"]!!.jsonObject["mediaRecommendation"]!!.jsonObject + Timber.tag("RECOMMENDATIONS").d("ANILIST > RECOMMENDATION: %s", getTitle(rec)) + SMangaImpl().apply { + this.title = getTitle(rec) + this.thumbnail_url = rec["coverImage"]!!.jsonObject["large"]!!.jsonPrimitive.content + this.initialized = true + this.url = rec["siteUrl"]!!.jsonPrimitive.content + } + } + return recs } } @@ -256,59 +222,43 @@ open class RecommendsPager( private val smart: Boolean = true, private var preferredApi: API = API.MYANIMELIST ) : Pager() { - private val apiList = API_MAP.toMutableMap() - private var currentApi: API? = null - - private fun handleSuccess(recs: List) { - if (recs.isEmpty()) { - Timber.tag("RECOMMENDATIONS").e("%s > Couldn't find any", currentApi.toString()) - apiList.remove(currentApi) - val list = apiList.toList() - currentApi = if (list.isEmpty()) { - null - } else { - apiList.toList().first().first - } - - if (currentApi != null) { - getRecs(currentApi!!) - } else { - Timber.tag("RECOMMENDATIONS").e("Couldn't find any") - onPageReceived(MangasPage(recs, false)) - } - } else { - onPageReceived(MangasPage(recs, false)) - } - } - - private fun handleError(error: Throwable) { - Timber.tag("RECOMMENDATIONS").e(error) - handleSuccess(listOf()) // tmp workaround until errors can be displayed in app - } - - private fun getRecs(api: API) { - Timber.tag("RECOMMENDATIONS").d("USING > %s", api.toString()) - apiList[api]?.getRecsBySearch(manga.originalTitle) { recs, error -> - if (error != null) { - handleError(error) - } - if (recs != null) { - handleSuccess(recs) - } - } - } - override fun requestNext(): Observable { - if (smart) { - preferredApi = - if (manga.mangaType() != MangaType.TYPE_MANGA) API.ANILIST else preferredApi - Timber.tag("RECOMMENDATIONS").d("SMART > %s", preferredApi.toString()) + return flow { + if (smart) preferredApi = if (manga.mangaType() != MangaType.TYPE_MANGA) API.ANILIST else preferredApi + + val apiList = mapOf(preferredApi to API_MAP[preferredApi]!!) + API_MAP.filter { it.key != preferredApi }.toList() + + val recs = supervisorScope { + apiList + .asSequence() + .map { (key, api) -> + async(Dispatchers.Default) { + try { + val recs = api.getRecsBySearch(manga.originalTitle).orEmpty() + Timber.tag("RECOMMENDATIONS").d("%s > Results: %s", key, recs.count()) + recs + } catch (e: Exception) { + Timber.tag("RECOMMENDATIONS").e("%s > Error: %s", key, e.message) + listOf() + } + } + } + .firstOrNull { it.await().isNotEmpty() } + ?.await().orEmpty() + } + + val page = MangasPage(recs, false) + emit(page) } - currentApi = preferredApi - - getRecs(currentApi!!) - - return Observable.just(MangasPage(listOf(), false)) + .asObservable() + .observeOn(AndroidSchedulers.mainThread()) + .doOnNext { + if (it.mangas.isNotEmpty()) { + onPageReceived(it) + } else { + throw NoResultsException() + } + } } companion object {