Implement Neko similar manga, Mangadex only recommendations

This commit is contained in:
Jobobby04
2020-10-26 02:13:02 -04:00
parent 3f1dede133
commit eb3a987826
32 changed files with 1155 additions and 139 deletions
@@ -0,0 +1,70 @@
package exh.recs
import android.os.Bundle
import android.view.Menu
import android.view.View
import androidx.core.os.bundleOf
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.source.CatalogueSource
import eu.kanade.tachiyomi.ui.base.controller.withFadeTransaction
import eu.kanade.tachiyomi.ui.browse.source.SourceController
import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourceController
import eu.kanade.tachiyomi.ui.browse.source.browse.SourceItem
/**
* Controller that shows the latest manga from the catalogue. Inherit [BrowseSourceController].
*/
class RecommendsController(bundle: Bundle) : BrowseSourceController(bundle) {
constructor(manga: Manga, source: CatalogueSource) : this(
bundleOf(
MANGA_ID to manga.id!!,
SOURCE_ID_KEY to source.id
)
)
override fun getTitle(): String? {
return (presenter as? RecommendsPresenter)?.manga?.title
}
override fun createPresenter(): RecommendsPresenter {
return RecommendsPresenter(args.getLong(MANGA_ID), args.getLong(SOURCE_ID_KEY))
}
override fun onPrepareOptionsMenu(menu: Menu) {
super.onPrepareOptionsMenu(menu)
menu.findItem(R.id.action_search).isVisible = false
menu.findItem(R.id.action_open_in_web_view).isVisible = false
menu.findItem(R.id.action_settings).isVisible = false
}
override fun initFilterSheet() {
// No-op: we don't allow filtering in recs
}
override fun onItemClick(view: View, position: Int): Boolean {
val item = adapter?.getItem(position) as? SourceItem ?: return false
openSmartSearch(item.manga.originalTitle)
return true
}
private fun openSmartSearch(title: String) {
val smartSearchConfig = SourceController.SmartSearchConfig(title)
router.pushController(
SourceController(
bundleOf(
SourceController.SMART_SEARCH_CONFIG to smartSearchConfig
)
).withFadeTransaction()
)
}
override fun onItemLongClick(position: Int) {
return
}
companion object {
const val MANGA_ID = "manga_id"
}
}
@@ -0,0 +1,320 @@
package exh.recs
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.Pager
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.serialization.decodeFromString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.put
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import rx.Observable
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 = Json.decodeFromString<JsonObject>(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)
}
}
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 = Json.decodeFromString<JsonObject>(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)
}
}
}
class Anilist() : API("https://graphql.anilist.co/") {
private fun countOccurrence(arr: JsonArray, search: String): Int {
return arr.count {
val synonym = it.jsonPrimitive.content
synonym.contains(search, true)
}
}
private fun languageContains(obj: JsonObject, language: String, search: String): Boolean {
return obj["title"]?.jsonObject?.get(language)?.jsonPrimitive?.content?.contains(search, true) == true
}
private fun getTitle(obj: JsonObject): String {
return obj["title"]!!.jsonObject.let {
it["romaji"]?.jsonPrimitive?.content
?: it["english"]?.jsonPrimitive?.content
?: it["native"]!!.jsonPrimitive.content
}
}
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 = buildJsonObject {
put("search", search)
}
val payload = buildJsonObject {
put("query", query)
put("variables", 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 = Json.decodeFromString<JsonObject>(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)
}
}
}
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.originalTitle) { recs, error ->
if (error != null) {
handleError(error)
}
if (recs != null) {
handleSuccess(recs)
}
}
}
override fun requestNext(): Observable<MangasPage> {
if (smart) {
preferredApi =
if (manga.mangaType() != MangaType.TYPE_MANGA) API.ANILIST else preferredApi
Timber.tag("RECOMMENDATIONS").d("SMART > %s", preferredApi.toString())
}
currentApi = preferredApi
getRecs(currentApi!!)
return Observable.just(MangasPage(listOf(), false))
}
companion object {
val API_MAP = mapOf(
API.MYANIMELIST to MyAnimeList(),
API.ANILIST to Anilist()
)
enum class API { MYANIMELIST, ANILIST }
}
}
@@ -0,0 +1,23 @@
package exh.recs
import eu.kanade.tachiyomi.data.database.DatabaseHelper
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.source.model.FilterList
import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourcePresenter
import eu.kanade.tachiyomi.ui.browse.source.browse.Pager
import uy.kohesive.injekt.injectLazy
/**
* Presenter of [RecommendsController]. Inherit BrowseCataloguePresenter.
*/
class RecommendsPresenter(val mangaId: Long, sourceId: Long) : BrowseSourcePresenter(sourceId) {
var manga: Manga? = null
val db: DatabaseHelper by injectLazy()
override fun createPager(query: String, filters: FilterList): Pager {
this.manga = db.getManga(mangaId).executeAsBlocking()
return RecommendsPager(manga!!)
}
}