Implement Neko similar manga, Mangadex only recommendations
This commit is contained in:
@@ -4,7 +4,7 @@ import eu.kanade.tachiyomi.source.model.FilterList
|
||||
import eu.kanade.tachiyomi.source.online.all.MangaDex
|
||||
import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourcePresenter
|
||||
import eu.kanade.tachiyomi.ui.browse.source.browse.Pager
|
||||
import exh.source.EnhancedHttpSource
|
||||
import exh.source.EnhancedHttpSource.Companion.getMainSource
|
||||
|
||||
/**
|
||||
* Presenter of [MangaDexFollowsController]. Inherit BrowseCataloguePresenter.
|
||||
@@ -12,7 +12,7 @@ import exh.source.EnhancedHttpSource
|
||||
class MangaDexFollowsPresenter(sourceId: Long) : BrowseSourcePresenter(sourceId) {
|
||||
|
||||
override fun createPager(query: String, filters: FilterList): Pager {
|
||||
val sourceAsMangaDex = (source as EnhancedHttpSource).enhancedSource as MangaDex
|
||||
val sourceAsMangaDex = source.getMainSource() as MangaDex
|
||||
return MangaDexFollowsPager(sourceAsMangaDex)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,47 +1,41 @@
|
||||
package exh.md.handlers
|
||||
|
||||
// todo make this work
|
||||
/*import eu.kanade.tachiyomi.data.database.DatabaseHelper
|
||||
import eu.kanade.tachiyomi.data.database.DatabaseHelper
|
||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||
import eu.kanade.tachiyomi.data.database.models.MangaSimilarImpl
|
||||
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
||||
import eu.kanade.tachiyomi.source.model.MangasPage
|
||||
import eu.kanade.tachiyomi.source.model.SManga
|
||||
import exh.md.similar.sql.models.MangaSimilar
|
||||
import exh.md.similar.sql.models.MangaSimilarImpl
|
||||
import exh.md.utils.MdUtil
|
||||
import rx.Observable
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
|
||||
class SimilarHandler(val preferences: PreferencesHelper) {
|
||||
class SimilarHandler(val preferences: PreferencesHelper, private val useLowQualityCovers: Boolean) {
|
||||
|
||||
*//**
|
||||
* fetch our similar mangas
|
||||
*//*
|
||||
/*
|
||||
* fetch our similar mangas
|
||||
*/
|
||||
fun fetchSimilar(manga: Manga): Observable<MangasPage> {
|
||||
|
||||
// Parse the Mangadex id from the URL
|
||||
val mangaid = MdUtil.getMangaId(manga.url).toLong()
|
||||
|
||||
val lowQualityCovers = preferences.mangaDexLowQualityCovers().get()
|
||||
|
||||
// Get our current database
|
||||
val db = Injekt.get<DatabaseHelper>()
|
||||
val similarMangaDb = db.getSimilar(mangaid).executeAsBlocking() ?: return Observable.just(MangasPage(mutableListOf(), false))
|
||||
|
||||
// Check if we have a result
|
||||
|
||||
val similarMangaTitles = similarMangaDb.matched_titles.split(MangaSimilarImpl.DELIMITER)
|
||||
val similarMangaIds = similarMangaDb.matched_ids.split(MangaSimilarImpl.DELIMITER)
|
||||
|
||||
val similarMangas = similarMangaIds.mapIndexed { index, similarId ->
|
||||
SManga.create().apply {
|
||||
title = similarMangaTitles[index]
|
||||
url = "/manga/$similarId/"
|
||||
thumbnail_url = MdUtil.formThumbUrl(url, lowQualityCovers)
|
||||
return Observable.just(MdUtil.getMangaId(manga.url).toLong())
|
||||
.flatMap { mangaId ->
|
||||
val db = Injekt.get<DatabaseHelper>()
|
||||
db.getSimilar(mangaId).asRxObservable()
|
||||
}.map { similarMangaDb: MangaSimilar? ->
|
||||
similarMangaDb?.let { mangaSimilar ->
|
||||
val similarMangaTitles = mangaSimilar.matched_titles.split(MangaSimilarImpl.DELIMITER)
|
||||
val similarMangaIds = mangaSimilar.matched_ids.split(MangaSimilarImpl.DELIMITER)
|
||||
val similarMangas = similarMangaIds.mapIndexed { index, similarId ->
|
||||
SManga.create().apply {
|
||||
title = similarMangaTitles[index]
|
||||
url = "/manga/$similarId/"
|
||||
thumbnail_url = MdUtil.formThumbUrl(url, useLowQualityCovers)
|
||||
}
|
||||
}
|
||||
MangasPage(similarMangas, false)
|
||||
} ?: MangasPage(mutableListOf(), false)
|
||||
}
|
||||
}
|
||||
|
||||
// Return the matches
|
||||
return Observable.just(MangasPage(similarMangas, false))
|
||||
}
|
||||
}*/
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package exh.md.similar
|
||||
|
||||
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
|
||||
import eu.kanade.tachiyomi.network.NetworkHelper
|
||||
import kotlinx.serialization.ExperimentalSerializationApi
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.ResponseBody
|
||||
import retrofit2.Call
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.Streaming
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
|
||||
interface SimilarHttpService {
|
||||
companion object {
|
||||
private val client by lazy {
|
||||
val network: NetworkHelper by injectLazy()
|
||||
network.client.newBuilder()
|
||||
// unzip interceptor which will add the correct headers
|
||||
.addNetworkInterceptor { chain ->
|
||||
val originalResponse = chain.proceed(chain.request())
|
||||
originalResponse.newBuilder()
|
||||
.header("Content-Encoding", "gzip")
|
||||
.header("Content-Type", "application/json")
|
||||
.build()
|
||||
}
|
||||
.build()
|
||||
}
|
||||
|
||||
@ExperimentalSerializationApi
|
||||
fun create(): SimilarHttpService {
|
||||
// actual builder, which will parse the underlying json file
|
||||
val adapter = Retrofit.Builder()
|
||||
.baseUrl("https://raw.githubusercontent.com")
|
||||
.addConverterFactory(Json.asConverterFactory("application/json".toMediaType()))
|
||||
.client(client)
|
||||
.build()
|
||||
|
||||
return adapter.create(SimilarHttpService::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
@Streaming
|
||||
@GET("/goldbattle/MangadexRecomendations/master/output/mangas_compressed.json.gz")
|
||||
fun getSimilarResults(): Call<ResponseBody>
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package exh.md.similar
|
||||
|
||||
import android.content.Context
|
||||
import androidx.work.Constraints
|
||||
import androidx.work.ExistingPeriodicWorkPolicy
|
||||
import androidx.work.NetworkType
|
||||
import androidx.work.OneTimeWorkRequestBuilder
|
||||
import androidx.work.PeriodicWorkRequestBuilder
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.Worker
|
||||
import androidx.work.WorkerParameters
|
||||
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class SimilarUpdateJob(private val context: Context, workerParams: WorkerParameters) :
|
||||
Worker(context, workerParams) {
|
||||
|
||||
override fun doWork(): Result {
|
||||
SimilarUpdateService.start(context)
|
||||
return Result.success()
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val TAG = "RelatedUpdate"
|
||||
|
||||
fun setupTask(context: Context, skipInitial: Boolean = false) {
|
||||
val preferences = Injekt.get<PreferencesHelper>()
|
||||
val enabled = preferences.mangadexSimilarEnabled().get()
|
||||
val interval = preferences.mangadexSimilarUpdateInterval().get()
|
||||
if (enabled) {
|
||||
// We are enabled, so construct the constraints
|
||||
val wifiRestriction = if (preferences.mangadexSimilarOnlyOverWifi().get()) {
|
||||
NetworkType.UNMETERED
|
||||
} else {
|
||||
NetworkType.CONNECTED
|
||||
}
|
||||
val constraints = Constraints.Builder()
|
||||
.setRequiredNetworkType(wifiRestriction)
|
||||
.build()
|
||||
|
||||
// If we are not skipping the initial then run it right now
|
||||
// Note that we won't run it if the constraints are not satisfied
|
||||
if (!skipInitial) {
|
||||
WorkManager.getInstance(context).enqueue(OneTimeWorkRequestBuilder<SimilarUpdateJob>().setConstraints(constraints).build())
|
||||
}
|
||||
|
||||
// Finally build the periodic request
|
||||
val request = PeriodicWorkRequestBuilder<SimilarUpdateJob>(
|
||||
interval.toLong(),
|
||||
TimeUnit.DAYS,
|
||||
1,
|
||||
TimeUnit.HOURS
|
||||
)
|
||||
.addTag(TAG)
|
||||
.setConstraints(constraints)
|
||||
.build()
|
||||
|
||||
if (interval > 0) {
|
||||
WorkManager.getInstance(context).enqueueUniquePeriodicWork(TAG, ExistingPeriodicWorkPolicy.REPLACE, request)
|
||||
} else {
|
||||
WorkManager.getInstance(context).cancelAllWorkByTag(TAG)
|
||||
}
|
||||
} else {
|
||||
WorkManager.getInstance(context).cancelAllWorkByTag(TAG)
|
||||
}
|
||||
}
|
||||
|
||||
fun doWorkNow(context: Context) {
|
||||
WorkManager.getInstance(context).enqueue(OneTimeWorkRequestBuilder<SimilarUpdateJob>().build())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
package exh.md.similar
|
||||
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.BitmapFactory
|
||||
import android.os.PowerManager
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.text.isDigitsOnly
|
||||
import com.elvishew.xlog.XLog
|
||||
import com.squareup.moshi.JsonReader
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.data.database.DatabaseHelper
|
||||
import eu.kanade.tachiyomi.data.notification.NotificationReceiver
|
||||
import eu.kanade.tachiyomi.data.notification.Notifications
|
||||
import eu.kanade.tachiyomi.util.system.isServiceRunning
|
||||
import eu.kanade.tachiyomi.util.system.notificationManager
|
||||
import exh.md.similar.sql.models.MangaSimilarImpl
|
||||
import kotlinx.coroutines.CoroutineExceptionHandler
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import okio.buffer
|
||||
import okio.sink
|
||||
import okio.source
|
||||
import retrofit2.awaitResponse
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
import java.io.File
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class SimilarUpdateService(
|
||||
val db: DatabaseHelper = Injekt.get()
|
||||
) : Service() {
|
||||
|
||||
/**
|
||||
* Wake lock that will be held until the service is destroyed.
|
||||
*/
|
||||
private lateinit var wakeLock: PowerManager.WakeLock
|
||||
|
||||
var similarServiceScope = CoroutineScope(Dispatchers.IO + Job())
|
||||
|
||||
/**
|
||||
* Subscription where the update is done.
|
||||
*/
|
||||
private var job: Job? = null
|
||||
|
||||
/**
|
||||
* Pending intent of action that cancels the library update
|
||||
*/
|
||||
private val cancelIntent by lazy {
|
||||
NotificationReceiver.cancelSimilarUpdatePendingBroadcast(this)
|
||||
}
|
||||
|
||||
private val progressNotification by lazy {
|
||||
NotificationCompat.Builder(this, Notifications.CHANNEL_SIMILAR)
|
||||
.setLargeIcon(BitmapFactory.decodeResource(this.resources, R.mipmap.ic_launcher))
|
||||
.setSmallIcon(R.drawable.ic_tachi)
|
||||
.setOngoing(true)
|
||||
.setContentTitle(getString(R.string.similar_loading_progress_start))
|
||||
.setAutoCancel(true)
|
||||
.addAction(
|
||||
R.drawable.ic_close_24dp,
|
||||
getString(android.R.string.cancel),
|
||||
cancelIntent
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Method called when the service is created. It injects dagger dependencies and acquire
|
||||
* the wake lock.
|
||||
*/
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
startForeground(Notifications.ID_SIMILAR_PROGRESS, progressNotification.build())
|
||||
wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager).newWakeLock(
|
||||
PowerManager.PARTIAL_WAKE_LOCK,
|
||||
"SimilarUpdateService:WakeLock"
|
||||
)
|
||||
wakeLock.acquire(TimeUnit.MINUTES.toMillis(30))
|
||||
}
|
||||
|
||||
/**
|
||||
* Method called when the service is destroyed. It destroys subscriptions and releases the wake
|
||||
* lock.
|
||||
*/
|
||||
override fun onDestroy() {
|
||||
job?.cancel()
|
||||
similarServiceScope.cancel()
|
||||
if (wakeLock.isHeld) {
|
||||
wakeLock.release()
|
||||
}
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
/**
|
||||
* This method needs to be implemented, but it's not used/needed.
|
||||
*/
|
||||
override fun onBind(intent: Intent) = null
|
||||
|
||||
/**
|
||||
* Method called when the service receives an intent.
|
||||
*
|
||||
* @param intent the start intent from.
|
||||
* @param flags the flags of the command.
|
||||
* @param startId the start id of this command.
|
||||
* @return the start value of the command.
|
||||
*/
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
if (intent == null) return Service.START_NOT_STICKY
|
||||
|
||||
// Unsubscribe from any previous subscription if needed.
|
||||
job?.cancel()
|
||||
val handler = CoroutineExceptionHandler { _, exception ->
|
||||
XLog.e(exception)
|
||||
stopSelf(startId)
|
||||
showResultNotification(true)
|
||||
cancelProgressNotification()
|
||||
}
|
||||
job = similarServiceScope.launch(handler) {
|
||||
updateSimilar()
|
||||
}
|
||||
job?.invokeOnCompletion { stopSelf(startId) }
|
||||
|
||||
return START_REDELIVER_INTENT
|
||||
}
|
||||
|
||||
/**
|
||||
* Method that updates the similar database for manga
|
||||
*/
|
||||
private suspend fun updateSimilar() = withContext(Dispatchers.IO) {
|
||||
val response = SimilarHttpService.create().getSimilarResults().awaitResponse()
|
||||
if (!response.isSuccessful) {
|
||||
throw Exception("Error trying to download similar file")
|
||||
}
|
||||
val destinationFile = File(filesDir, "neko-similar.json")
|
||||
val buffer = withContext(Dispatchers.IO) { destinationFile.sink().buffer() }
|
||||
|
||||
// write json to file
|
||||
response.body()?.byteStream()?.source()?.use { input ->
|
||||
buffer.use { output ->
|
||||
output.writeAll(input)
|
||||
}
|
||||
}
|
||||
|
||||
val listSimilar = getSimilar(destinationFile)
|
||||
|
||||
// Delete the old similar table
|
||||
db.deleteAllSimilar().executeAsBlocking()
|
||||
|
||||
val totalManga = listSimilar.size
|
||||
|
||||
// Loop through each and insert into the database
|
||||
|
||||
val dataToInsert = listSimilar.mapIndexed { index, similarFromJson ->
|
||||
showProgressNotification(index, totalManga)
|
||||
|
||||
if (similarFromJson.similarIds.size != similarFromJson.similarTitles.size) {
|
||||
return@mapIndexed null
|
||||
}
|
||||
|
||||
val similar = MangaSimilarImpl()
|
||||
similar.id = index.toLong()
|
||||
similar.manga_id = similarFromJson.id.toLong()
|
||||
similar.matched_ids = similarFromJson.similarIds.joinToString(MangaSimilarImpl.DELIMITER)
|
||||
similar.matched_titles = similarFromJson.similarTitles.joinToString(MangaSimilarImpl.DELIMITER)
|
||||
return@mapIndexed similar
|
||||
}.filterNotNull()
|
||||
|
||||
showProgressNotification(dataToInsert.size, totalManga)
|
||||
|
||||
if (dataToInsert.isNotEmpty()) {
|
||||
db.insertSimilar(dataToInsert).executeAsBlocking()
|
||||
}
|
||||
destinationFile.delete()
|
||||
showResultNotification(!this.isActive)
|
||||
cancelProgressNotification()
|
||||
}
|
||||
|
||||
private fun getSimilar(destinationFile: File): List<SimilarFromJson> {
|
||||
val reader = JsonReader.of(destinationFile.source().buffer())
|
||||
|
||||
var processingManga = false
|
||||
var processingTitles = false
|
||||
var mangaId: String? = null
|
||||
var similarIds = mutableListOf<String>()
|
||||
var similarTitles = mutableListOf<String>()
|
||||
val similars = mutableListOf<SimilarFromJson>()
|
||||
|
||||
while (reader.peek() != JsonReader.Token.END_DOCUMENT) {
|
||||
val nextToken = reader.peek()
|
||||
|
||||
if (JsonReader.Token.BEGIN_OBJECT == nextToken) {
|
||||
reader.beginObject()
|
||||
} else if (JsonReader.Token.NAME == nextToken) {
|
||||
val name = reader.nextName()
|
||||
if (!processingManga && name.isDigitsOnly()) {
|
||||
processingManga = true
|
||||
// similar add id
|
||||
mangaId = name
|
||||
} else if (name == "m_titles") {
|
||||
processingTitles = true
|
||||
}
|
||||
} else if (JsonReader.Token.BEGIN_ARRAY == nextToken) {
|
||||
reader.beginArray()
|
||||
} else if (JsonReader.Token.END_ARRAY == nextToken) {
|
||||
reader.endArray()
|
||||
if (processingTitles) {
|
||||
processingManga = false
|
||||
processingTitles = false
|
||||
similars.add(SimilarFromJson(mangaId!!, similarIds.toList(), similarTitles.toList()))
|
||||
mangaId = null
|
||||
similarIds = mutableListOf()
|
||||
similarTitles = mutableListOf()
|
||||
}
|
||||
} else if (JsonReader.Token.NUMBER == nextToken) {
|
||||
similarIds.add(reader.nextInt().toString())
|
||||
} else if (JsonReader.Token.STRING == nextToken) {
|
||||
if (processingTitles) {
|
||||
similarTitles.add(reader.nextString())
|
||||
}
|
||||
} else if (JsonReader.Token.END_OBJECT == nextToken) {
|
||||
reader.endObject()
|
||||
}
|
||||
}
|
||||
|
||||
return similars
|
||||
}
|
||||
|
||||
data class SimilarFromJson(val id: String, val similarIds: List<String>, val similarTitles: List<String>)
|
||||
|
||||
/**
|
||||
* Shows the notification containing the currently updating manga and the progress.
|
||||
*
|
||||
* @param current the current progress.
|
||||
* @param total the total progress.
|
||||
*/
|
||||
private fun showProgressNotification(current: Int, total: Int) {
|
||||
notificationManager.notify(
|
||||
Notifications.ID_SIMILAR_PROGRESS,
|
||||
progressNotification
|
||||
.setContentTitle(
|
||||
getString(
|
||||
R.string.similar_loading_percent,
|
||||
current,
|
||||
total
|
||||
)
|
||||
)
|
||||
.setProgress(total, current, false)
|
||||
.build()
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows the notification containing the result of the update done by the service.
|
||||
*
|
||||
* @param error if the result was a error.
|
||||
*/
|
||||
private fun showResultNotification(error: Boolean = false) {
|
||||
val title = if (error) {
|
||||
getString(R.string.similar_loading_complete_error)
|
||||
} else {
|
||||
getString(
|
||||
R.string.similar_loading_complete
|
||||
)
|
||||
}
|
||||
|
||||
val result = NotificationCompat.Builder(this, Notifications.CHANNEL_SIMILAR)
|
||||
.setContentTitle(title)
|
||||
.setLargeIcon(BitmapFactory.decodeResource(this.resources, R.mipmap.ic_launcher))
|
||||
.setSmallIcon(R.drawable.ic_tachi)
|
||||
.setAutoCancel(true)
|
||||
NotificationManagerCompat.from(this)
|
||||
.notify(Notifications.ID_SIMILAR_COMPLETE, result.build())
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancels the progress notification.
|
||||
*/
|
||||
private fun cancelProgressNotification() {
|
||||
notificationManager.cancel(Notifications.ID_SIMILAR_PROGRESS)
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
/**
|
||||
* Returns the status of the service.
|
||||
*
|
||||
* @param context the application context.
|
||||
* @return true if the service is running, false otherwise.
|
||||
*/
|
||||
fun isRunning(context: Context): Boolean {
|
||||
return context.isServiceRunning(SimilarUpdateService::class.java)
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the service. It will be started only if there isn't another instance already
|
||||
* running.
|
||||
*
|
||||
* @param context the application context.
|
||||
*/
|
||||
fun start(context: Context) {
|
||||
if (!isRunning(context)) {
|
||||
val intent = Intent(context, SimilarUpdateService::class.java)
|
||||
ContextCompat.startForegroundService(context, intent)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops the service.
|
||||
*
|
||||
* @param context the application context.
|
||||
*/
|
||||
fun stop(context: Context) {
|
||||
context.stopService(Intent(context, SimilarUpdateService::class.java))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package exh.md.similar.sql.mappers
|
||||
|
||||
import android.content.ContentValues
|
||||
import android.database.Cursor
|
||||
import com.pushtorefresh.storio.sqlite.SQLiteTypeMapping
|
||||
import com.pushtorefresh.storio.sqlite.operations.delete.DefaultDeleteResolver
|
||||
import com.pushtorefresh.storio.sqlite.operations.get.DefaultGetResolver
|
||||
import com.pushtorefresh.storio.sqlite.operations.put.DefaultPutResolver
|
||||
import com.pushtorefresh.storio.sqlite.queries.DeleteQuery
|
||||
import com.pushtorefresh.storio.sqlite.queries.InsertQuery
|
||||
import com.pushtorefresh.storio.sqlite.queries.UpdateQuery
|
||||
import exh.md.similar.sql.models.MangaSimilar
|
||||
import exh.md.similar.sql.models.MangaSimilarImpl
|
||||
import exh.md.similar.sql.tables.SimilarTable.COL_ID
|
||||
import exh.md.similar.sql.tables.SimilarTable.COL_MANGA_ID
|
||||
import exh.md.similar.sql.tables.SimilarTable.COL_MANGA_SIMILAR_MATCHED_IDS
|
||||
import exh.md.similar.sql.tables.SimilarTable.COL_MANGA_SIMILAR_MATCHED_TITLES
|
||||
import exh.md.similar.sql.tables.SimilarTable.TABLE
|
||||
|
||||
class SimilarTypeMapping : SQLiteTypeMapping<MangaSimilar>(
|
||||
SimilarPutResolver(),
|
||||
SimilarGetResolver(),
|
||||
SimilarDeleteResolver()
|
||||
)
|
||||
|
||||
class SimilarPutResolver : DefaultPutResolver<MangaSimilar>() {
|
||||
|
||||
override fun mapToInsertQuery(obj: MangaSimilar) = InsertQuery.builder()
|
||||
.table(TABLE)
|
||||
.build()
|
||||
|
||||
override fun mapToUpdateQuery(obj: MangaSimilar) = UpdateQuery.builder()
|
||||
.table(TABLE)
|
||||
.where("$COL_ID = ?")
|
||||
.whereArgs(obj.id)
|
||||
.build()
|
||||
|
||||
override fun mapToContentValues(obj: MangaSimilar) = ContentValues(4).apply {
|
||||
put(COL_ID, obj.id)
|
||||
put(COL_MANGA_ID, obj.manga_id)
|
||||
put(COL_MANGA_SIMILAR_MATCHED_IDS, obj.matched_ids)
|
||||
put(COL_MANGA_SIMILAR_MATCHED_TITLES, obj.matched_titles)
|
||||
}
|
||||
}
|
||||
|
||||
class SimilarGetResolver : DefaultGetResolver<MangaSimilar>() {
|
||||
|
||||
override fun mapFromCursor(cursor: Cursor): MangaSimilar = MangaSimilarImpl().apply {
|
||||
id = cursor.getLong(cursor.getColumnIndex(COL_ID))
|
||||
manga_id = cursor.getLong(cursor.getColumnIndex(COL_MANGA_ID))
|
||||
matched_ids = cursor.getString(cursor.getColumnIndex(COL_MANGA_SIMILAR_MATCHED_IDS))
|
||||
matched_titles = cursor.getString(cursor.getColumnIndex(COL_MANGA_SIMILAR_MATCHED_TITLES))
|
||||
}
|
||||
}
|
||||
|
||||
class SimilarDeleteResolver : DefaultDeleteResolver<MangaSimilar>() {
|
||||
|
||||
override fun mapToDeleteQuery(obj: MangaSimilar) = DeleteQuery.builder()
|
||||
.table(TABLE)
|
||||
.where("$COL_ID = ?")
|
||||
.whereArgs(obj.id)
|
||||
.build()
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package exh.md.similar.sql.models
|
||||
|
||||
import java.io.Serializable
|
||||
|
||||
/**
|
||||
* Object containing the history statistics of a chapter
|
||||
*/
|
||||
interface MangaSimilar : Serializable {
|
||||
|
||||
/**
|
||||
* Id of this similar manga object.
|
||||
*/
|
||||
var id: Long?
|
||||
|
||||
/**
|
||||
* Id of matching manga
|
||||
*/
|
||||
var manga_id: Long?
|
||||
|
||||
/**
|
||||
* JSONArray.toString() list of ids for this manga
|
||||
* Example: [3467, 5907, 21052, 2141, 6139, 5602, 3999]
|
||||
*/
|
||||
var matched_ids: String
|
||||
|
||||
/**
|
||||
* JSONArray.toString() list of titles for this manga
|
||||
* Example: [Title1, Title2, ..., Title10]
|
||||
*/
|
||||
var matched_titles: String
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package exh.md.similar.sql.models
|
||||
|
||||
class MangaSimilarImpl : MangaSimilar {
|
||||
|
||||
override var id: Long? = null
|
||||
|
||||
override var manga_id: Long? = null
|
||||
|
||||
override lateinit var matched_ids: String
|
||||
|
||||
override lateinit var matched_titles: String
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (other == null || javaClass != other.javaClass) return false
|
||||
|
||||
other as MangaSimilar
|
||||
|
||||
if (id != other.id) return false
|
||||
if (manga_id != other.manga_id) return false
|
||||
if (matched_ids != other.matched_ids) return false
|
||||
return matched_titles != other.matched_titles
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
return id.hashCode() + manga_id.hashCode()
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val DELIMITER = "|*|"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package exh.md.similar.sql.queries
|
||||
|
||||
import com.pushtorefresh.storio.sqlite.queries.DeleteQuery
|
||||
import com.pushtorefresh.storio.sqlite.queries.Query
|
||||
import eu.kanade.tachiyomi.data.database.DbProvider
|
||||
import exh.md.similar.sql.models.MangaSimilar
|
||||
import exh.md.similar.sql.tables.SimilarTable
|
||||
|
||||
interface SimilarQueries : DbProvider {
|
||||
|
||||
fun getAllSimilar() = db.get()
|
||||
.listOfObjects(MangaSimilar::class.java)
|
||||
.withQuery(
|
||||
Query.builder()
|
||||
.table(SimilarTable.TABLE)
|
||||
.build()
|
||||
)
|
||||
.prepare()
|
||||
|
||||
fun getSimilar(manga_id: Long) = db.get()
|
||||
.`object`(MangaSimilar::class.java)
|
||||
.withQuery(
|
||||
Query.builder()
|
||||
.table(SimilarTable.TABLE)
|
||||
.where("${SimilarTable.COL_MANGA_ID} = ?")
|
||||
.whereArgs(manga_id)
|
||||
.build()
|
||||
)
|
||||
.prepare()
|
||||
|
||||
fun insertSimilar(similar: MangaSimilar) = db.put().`object`(similar).prepare()
|
||||
|
||||
fun insertSimilar(similarList: List<MangaSimilar>) = db.put().objects(similarList).prepare()
|
||||
|
||||
fun deleteAllSimilar() = db.delete()
|
||||
.byQuery(
|
||||
DeleteQuery.builder()
|
||||
.table(SimilarTable.TABLE)
|
||||
.build()
|
||||
)
|
||||
.prepare()
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package exh.md.similar.sql.tables
|
||||
|
||||
object SimilarTable {
|
||||
|
||||
const val TABLE = "manga_related"
|
||||
|
||||
const val COL_ID = "_id"
|
||||
|
||||
const val COL_MANGA_ID = "manga_id"
|
||||
|
||||
const val COL_MANGA_SIMILAR_MATCHED_IDS = "matched_ids"
|
||||
|
||||
const val COL_MANGA_SIMILAR_MATCHED_TITLES = "matched_titles"
|
||||
|
||||
val createTableQuery: String
|
||||
get() =
|
||||
"""CREATE TABLE $TABLE(
|
||||
$COL_ID INTEGER NOT NULL PRIMARY KEY,
|
||||
$COL_MANGA_ID INTEGER NOT NULL,
|
||||
$COL_MANGA_SIMILAR_MATCHED_IDS TEXT NOT NULL,
|
||||
$COL_MANGA_SIMILAR_MATCHED_TITLES TEXT NOT NULL,
|
||||
UNIQUE ($COL_ID) ON CONFLICT REPLACE
|
||||
)"""
|
||||
|
||||
val createMangaIdIndexQuery: String
|
||||
get() = "CREATE INDEX ${TABLE}_${COL_MANGA_SIMILAR_MATCHED_IDS}_index ON $TABLE($COL_MANGA_SIMILAR_MATCHED_IDS)"
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package exh.md.similar.ui
|
||||
|
||||
import android.app.Dialog
|
||||
import android.os.Bundle
|
||||
import com.afollestad.materialdialogs.MaterialDialog
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
||||
import eu.kanade.tachiyomi.ui.base.controller.DialogController
|
||||
import exh.md.similar.SimilarUpdateJob
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
|
||||
class EnableMangaDexSimilarDialogController : DialogController() {
|
||||
|
||||
override fun onCreateDialog(savedViewState: Bundle?): Dialog {
|
||||
val activity = activity!!
|
||||
val preferences = Injekt.get<PreferencesHelper>()
|
||||
return MaterialDialog(activity)
|
||||
.title(text = activity.getString(R.string.similar_ask_to_enable_title))
|
||||
.message(R.string.similar_ask_to_enable)
|
||||
.negativeButton(R.string.similar_ask_to_enable_no, activity.getString(R.string.similar_ask_to_enable_no))
|
||||
.positiveButton(R.string.similar_ask_to_enable_yes, activity.getString(R.string.similar_ask_to_enable_yes)) {
|
||||
preferences.mangadexSimilarEnabled().set(true)
|
||||
SimilarUpdateJob.setupTask(activity)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package exh.md.similar.ui
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.Menu
|
||||
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.browse.source.browse.BrowseSourceController
|
||||
import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourcePresenter
|
||||
|
||||
/**
|
||||
* Controller that shows the latest manga from the catalogue. Inherit [BrowseSourceController].
|
||||
*/
|
||||
class MangaDexSimilarController(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 view?.context?.getString(R.string.similar)
|
||||
}
|
||||
|
||||
override fun createPresenter(): BrowseSourcePresenter {
|
||||
return MangaDexSimilarPresenter(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 similar
|
||||
}
|
||||
|
||||
override fun onItemLongClick(position: Int) {
|
||||
return
|
||||
}
|
||||
|
||||
override fun onAddPageError(error: Throwable) {
|
||||
super.onAddPageError(error)
|
||||
binding.emptyView.show("No Similar Manga found")
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val MANGA_ID = "manga_id"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package exh.md.similar.ui
|
||||
|
||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||
import eu.kanade.tachiyomi.source.model.MangasPage
|
||||
import eu.kanade.tachiyomi.source.online.all.MangaDex
|
||||
import eu.kanade.tachiyomi.ui.browse.source.browse.NoResultsException
|
||||
import eu.kanade.tachiyomi.ui.browse.source.browse.Pager
|
||||
import rx.Observable
|
||||
import rx.android.schedulers.AndroidSchedulers
|
||||
import rx.schedulers.Schedulers
|
||||
|
||||
/**
|
||||
* MangaDexSimilarPager inherited from the general Pager.
|
||||
*/
|
||||
class MangaDexSimilarPager(val manga: Manga, val source: MangaDex) : Pager() {
|
||||
|
||||
override fun requestNext(): Observable<MangasPage> {
|
||||
return source.fetchMangaSimilar(manga)
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.doOnNext {
|
||||
if (it.mangas.isNotEmpty()) {
|
||||
onPageReceived(it)
|
||||
} else {
|
||||
throw NoResultsException()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package exh.md.similar.ui
|
||||
|
||||
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.source.online.all.MangaDex
|
||||
import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourcePresenter
|
||||
import eu.kanade.tachiyomi.ui.browse.source.browse.Pager
|
||||
import exh.source.EnhancedHttpSource.Companion.getMainSource
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
|
||||
/**
|
||||
* Presenter of [MangaDexSimilarController]. Inherit BrowseCataloguePresenter.
|
||||
*/
|
||||
class MangaDexSimilarPresenter(val mangaId: Long, sourceId: Long) : BrowseSourcePresenter(sourceId) {
|
||||
|
||||
var manga: Manga? = null
|
||||
|
||||
val db: DatabaseHelper by injectLazy()
|
||||
|
||||
override fun createPager(query: String, filters: FilterList): Pager {
|
||||
val sourceAsMangaDex = source.getMainSource() as MangaDex
|
||||
this.manga = db.getManga(mangaId).executeAsBlocking()
|
||||
return MangaDexSimilarPager(manga!!, sourceAsMangaDex)
|
||||
}
|
||||
}
|
||||
@@ -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!!)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user