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:
she11sh0cked 2020-05-22 17:35:43 +02:00 committed by GitHub
parent 99dd9a0750
commit 87fbda0b29
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

View File

@ -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,63 +12,47 @@ 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
open class RecommendsPager( abstract class API(_endpoint: String) {
val manga: Manga, var endpoint: String = _endpoint
val smart: Boolean = true, val client = OkHttpClient.Builder().build()
var preferredApi: API = API.MYANIMELIST val scope = CoroutineScope(Job() + Dispatchers.Default)
) : Pager() {
private val client = OkHttpClient.Builder().build()
private fun myAnimeList(): Observable<List<SMangaImpl>>? { abstract fun getRecsBySearch(
fun getId(): Observable<String?> { search: String,
val endpoint = callback: (onResolve: List<SMangaImpl>?, onReject: Throwable?) -> Unit
myAnimeListEndpoint.toHttpUrlOrNull() )
?: throw Exception("Could not convert endpoint url") }
val urlBuilder = endpoint.newBuilder()
urlBuilder.addPathSegment("search")
urlBuilder.addPathSegment("manga")
urlBuilder.addQueryParameter("q", manga.title)
val url = urlBuilder.build().toUrl()
val request = Request.Builder() class MyAnimeList() : API("https://api.jikan.moe/v3/") {
.url(url) fun getRecsById(
.get() id: String,
.build() callback: (resolve: List<SMangaImpl>?, reject: Throwable?) -> Unit
) {
return client.newCall(request) val httpUrl =
.asObservableSuccess().subscribeOn(Schedulers.io()) endpoint.toHttpUrlOrNull()
.map { netResponse -> if (httpUrl == null) {
val responseBody = netResponse.body?.string().orEmpty() callback.invoke(null, Exception("Could not convert endpoint url"))
if (responseBody.isEmpty()) { return
throw Exception("Null Response")
} }
val response = JsonParser.parseString(responseBody).obj val urlBuilder = httpUrl.newBuilder()
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("manga")
urlBuilder.addPathSegment(id) urlBuilder.addPathSegment(id)
urlBuilder.addPathSegment("recommendations") urlBuilder.addPathSegment("recommendations")
@ -79,17 +63,22 @@ open class RecommendsPager(
.get() .get()
.build() .build()
client.newCall(request) val handler = CoroutineExceptionHandler { _, exception ->
.asObservableSuccess().subscribeOn(Schedulers.io()) callback.invoke(null, exception)
.map { netResponse -> }
val responseBody = netResponse.body?.string().orEmpty()
if (responseBody.isEmpty()) { scope.launch(handler) {
val response = client.newCall(request).await()
val body = response.body?.string().orEmpty()
if (body.isEmpty()) {
throw Exception("Null Response") throw Exception("Null Response")
} }
val response = JsonParser.parseString(responseBody).obj val data = JsonParser.parseString(body).obj
val recommendations = response["recommendations"].array val recommendations = data["recommendations"].nullArray
recommendations.map { rec -> ?: throw Exception("Unexpected response")
Log.d("MYANIMELIST RECOMMEND", "${rec["title"].string}") val recs = recommendations.map { rec ->
Timber.tag("RECOMMENDATIONS")
.d("MYANIMELIST > FOUND RECOMMENDATION > %s", rec["title"].string)
SMangaImpl().apply { SMangaImpl().apply {
this.title = rec["title"].string this.title = rec["title"].string
this.thumbnail_url = rec["image_url"].string this.thumbnail_url = rec["image_url"].string
@ -97,99 +86,209 @@ open class RecommendsPager(
this.url = rec["url"].string this.url = rec["url"].string
} }
} }
}.toBlocking().first() callback.invoke(recs, null)
} }
} }
private fun anilist(): Observable<List<SMangaImpl>>? { override fun getRecsBySearch(
val query = search: String,
""" callback: (recs: List<SMangaImpl>?, error: Throwable?) -> Unit
{ ) {
Page { val httpUrl =
media(search: "${manga.title}", type: MANGA) { endpoint.toHttpUrlOrNull()
title { if (httpUrl == null) {
romaji callback.invoke(null, Exception("Could not convert endpoint url"))
english return
native
} }
synonyms val urlBuilder = httpUrl.newBuilder()
recommendations { urlBuilder.addPathSegment("search")
edges { urlBuilder.addPathSegment("manga")
node { urlBuilder.addQueryParameter("q", search)
mediaRecommendation { val url = urlBuilder.build().toUrl()
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() val request = Request.Builder()
.url(anilistEndpoint) .url(url)
.post(body) .get()
.build() .build()
fun countOccurrence(array: JsonArray, search: String): Int { val handler = CoroutineExceptionHandler { _, exception ->
return array.count { 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 val synonym = it.string
synonym.contains(search, true) synonym.contains(search, true)
} }
} }
fun languageContains(it: JsonObject, language: String, search: String): Boolean { private fun languageContains(obj: JsonObject, language: String, search: String): Boolean {
return it["title"].obj[language].nullString?.contains(search, true) == true return obj["title"].obj[language].nullString?.contains(search, true) == true
} }
return client.newCall(request) private fun getTitle(obj: JsonObject): String {
.asObservableSuccess().subscribeOn(Schedulers.io()) return obj["title"].obj["romaji"].nullString
.map { netResponse -> ?: obj["title"].obj["english"].nullString
val responseBody = netResponse.body?.string().orEmpty() ?: obj["title"].obj["native"].string
if (responseBody.isEmpty()) { }
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") throw Exception("Null Response")
} }
val response = JsonParser.parseString(responseBody).obj val data = JsonParser.parseString(body).obj["data"].nullObj
val data = response["data"]!!.obj ?: throw Exception("Unexpected response")
val page = data["Page"].obj val page = data["Page"].obj
val media = page["media"].array val media = page["media"].array
if (media.size() <= 0) {
throw Exception("'$search' not found")
}
val result = media.sortedWith( val result = media.sortedWith(
compareBy( compareBy(
{ languageContains(it.obj, "romaji", manga.title) }, { languageContains(it.obj, "romaji", search) },
{ languageContains(it.obj, "english", manga.title) }, { languageContains(it.obj, "english", search) },
{ languageContains(it.obj, "native", manga.title) }, { languageContains(it.obj, "native", search) },
{ countOccurrence(it.obj["synonyms"].array, manga.title) > 0 } { countOccurrence(it.obj["synonyms"].array, search) > 0 }
) )
).last().nullObj ?: return@map null ).last().obj
val recommendations = result["recommendations"].obj Timber.tag("RECOMMENDATIONS")
val edges = recommendations["edges"].array .d("ANILIST > FOUND TITLE > %s", getTitle(result))
edges.map { val recommendations = result["recommendations"].obj["edges"].array
val recs = recommendations.map {
val rec = it["node"]["mediaRecommendation"].obj val rec = it["node"]["mediaRecommendation"].obj
Log.d("ANILIST RECOMMEND", "${rec["title"].obj["romaji"].string}") Timber.tag("RECOMMENDATIONS")
.d("ANILIST: FOUND RECOMMENDATION: %s", getTitle(rec))
SMangaImpl().apply { SMangaImpl().apply {
this.title = rec["title"].obj["romaji"].nullString this.title = getTitle(rec)
?: rec["title"].obj["english"].nullString
?: rec["title"].obj["native"].string
this.thumbnail_url = rec["coverImage"].obj["large"].string this.thumbnail_url = rec["coverImage"].obj["large"].string
this.initialized = true this.initialized = true
this.url = rec["siteUrl"].string this.url = rec["siteUrl"].string
} }
} }
callback.invoke(recs, null)
}
}
}
open class RecommendsPager(
val manga: Manga,
val smart: Boolean = true,
var preferredApi: API = API.MYANIMELIST
) : Pager() {
private val apiList = API_MAP.toMutableMap()
private var currentApi: API? = null
private fun handleSuccess(recs: List<SMangaImpl>) {
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.title) { recs, error ->
if (error != null) {
handleError(error)
}
if (recs != null) {
handleSuccess(recs)
}
} }
} }
@ -197,42 +296,20 @@ open class RecommendsPager(
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 }
} }