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
@@ -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!!)
}
}