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

View File

@ -328,6 +328,9 @@ dependencies {
// RatingBar (SY)
implementation 'me.zhanghai.android.materialratingbar:library:1.4.0'
// JsonReader for similar manga
implementation 'com.squareup.moshi:moshi:1.11.0'
implementation 'androidx.gridlayout:gridlayout:1.0.0'
implementation 'com.google.guava:guava:29.0-android'

View File

@ -153,6 +153,10 @@
android:exported="false" />
<!-- EH -->
<service
android:name="exh.md.similar.SimilarUpdateService"
android:exported="false" />
<service
android:name="exh.eh.EHentaiUpdateWorker"
android:permission="android.permission.BIND_JOB_SERVICE"

View File

@ -21,6 +21,9 @@ import eu.kanade.tachiyomi.data.database.queries.HistoryQueries
import eu.kanade.tachiyomi.data.database.queries.MangaCategoryQueries
import eu.kanade.tachiyomi.data.database.queries.MangaQueries
import eu.kanade.tachiyomi.data.database.queries.TrackQueries
import exh.md.similar.sql.mappers.SimilarTypeMapping
import exh.md.similar.sql.models.MangaSimilar
import exh.md.similar.sql.queries.SimilarQueries
import exh.merged.sql.mappers.MergedMangaTypeMapping
import exh.merged.sql.models.MergedMangaReference
import exh.merged.sql.queries.MergedQueries
@ -39,7 +42,7 @@ import io.requery.android.database.sqlite.RequerySQLiteOpenHelperFactory
* This class provides operations to manage the database through its interfaces.
*/
open class DatabaseHelper(context: Context) :
MangaQueries, ChapterQueries, TrackQueries, CategoryQueries, MangaCategoryQueries, HistoryQueries /* SY --> */, SearchMetadataQueries, SearchTagQueries, SearchTitleQueries, MergedQueries /* SY <-- */ {
MangaQueries, ChapterQueries, TrackQueries, CategoryQueries, MangaCategoryQueries, HistoryQueries /* SY --> */, SearchMetadataQueries, SearchTagQueries, SearchTitleQueries, MergedQueries, SimilarQueries /* SY <-- */ {
private val configuration = SupportSQLiteOpenHelper.Configuration.builder(context)
.name(DbOpenCallback.DATABASE_NAME)
@ -59,6 +62,7 @@ open class DatabaseHelper(context: Context) :
.addTypeMapping(SearchTag::class.java, SearchTagTypeMapping())
.addTypeMapping(SearchTitle::class.java, SearchTitleTypeMapping())
.addTypeMapping(MergedMangaReference::class.java, MergedMangaTypeMapping())
.addTypeMapping(MangaSimilar::class.java, SimilarTypeMapping())
// SY <--
.build()

View File

@ -8,6 +8,7 @@ import eu.kanade.tachiyomi.data.database.tables.HistoryTable
import eu.kanade.tachiyomi.data.database.tables.MangaCategoryTable
import eu.kanade.tachiyomi.data.database.tables.MangaTable
import eu.kanade.tachiyomi.data.database.tables.TrackTable
import exh.md.similar.sql.tables.SimilarTable
import exh.merged.sql.tables.MergedTable
import exh.metadata.sql.tables.SearchMetadataTable
import exh.metadata.sql.tables.SearchTagTable
@ -24,7 +25,7 @@ class DbOpenCallback : SupportSQLiteOpenHelper.Callback(DATABASE_VERSION) {
/**
* Version of the database.
*/
const val DATABASE_VERSION = /* SY --> */ 4 /* SY <-- */
const val DATABASE_VERSION = /* SY --> */ 5 /* SY <-- */
}
override fun onCreate(db: SupportSQLiteDatabase) = with(db) {
@ -39,6 +40,7 @@ class DbOpenCallback : SupportSQLiteOpenHelper.Callback(DATABASE_VERSION) {
execSQL(SearchTagTable.createTableQuery)
execSQL(SearchTitleTable.createTableQuery)
execSQL(MergedTable.createTableQuery)
execSQL(SimilarTable.createTableQuery)
// SY <--
// DB indexes
@ -55,6 +57,7 @@ class DbOpenCallback : SupportSQLiteOpenHelper.Callback(DATABASE_VERSION) {
execSQL(SearchTitleTable.createMangaIdIndexQuery)
execSQL(SearchTitleTable.createTitleIndexQuery)
execSQL(MergedTable.createIndexQuery)
execSQL(SimilarTable.createMangaIdIndexQuery)
// SY <--
}
@ -71,6 +74,10 @@ class DbOpenCallback : SupportSQLiteOpenHelper.Callback(DATABASE_VERSION) {
db.execSQL(MergedTable.createTableQuery)
db.execSQL(MergedTable.createIndexQuery)
}
if (oldVersion < 5) {
db.execSQL(SimilarTable.createTableQuery)
db.execSQL(SimilarTable.createMangaIdIndexQuery)
}
}
override fun onConfigure(db: SupportSQLiteDatabase) {

View File

@ -25,6 +25,7 @@ import eu.kanade.tachiyomi.util.storage.DiskUtil
import eu.kanade.tachiyomi.util.storage.getUriCompat
import eu.kanade.tachiyomi.util.system.notificationManager
import eu.kanade.tachiyomi.util.system.toast
import exh.md.similar.SimilarUpdateService
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import uy.kohesive.injekt.injectLazy
@ -100,6 +101,9 @@ class NotificationReceiver : BroadcastReceiver() {
markAsRead(urls, mangaId)
}
}
// SY -->
ACTION_CANCEL_SIMILAR_UPDATE -> cancelSimilarUpdate(context)
// SY <--
}
}
@ -241,6 +245,18 @@ class NotificationReceiver : BroadcastReceiver() {
}
}
// SY -->
/**
* Method called when user wants to stop a similar manga update
*
* @param context context of application
*/
private fun cancelSimilarUpdate(context: Context) {
SimilarUpdateService.stop(context)
Handler().post { dismissNotification(context, Notifications.ID_SIMILAR_PROGRESS) }
}
// SY <--
companion object {
private const val NAME = "NotificationReceiver"
@ -298,6 +314,11 @@ class NotificationReceiver : BroadcastReceiver() {
// Value containing chapter url.
private const val EXTRA_CHAPTER_URL = "$ID.$NAME.EXTRA_CHAPTER_URL"
// Sy -->
// Called to cancel similar manga update.
private const val ACTION_CANCEL_SIMILAR_UPDATE = "$ID.$NAME.CANCEL_SIMILAR_UPDATE"
// SY <--
/**
* Returns a [PendingIntent] that resumes the download of a chapter
*
@ -548,5 +569,20 @@ class NotificationReceiver : BroadcastReceiver() {
}
return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT)
}
// SY -->
/**
* Returns [PendingIntent] that starts a service which stops the similar update
*
* @param context context of application
* @return [PendingIntent]
*/
internal fun cancelSimilarUpdatePendingBroadcast(context: Context): PendingIntent {
val intent = Intent(context, NotificationReceiver::class.java).apply {
action = ACTION_CANCEL_SIMILAR_UPDATE
}
return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT)
}
// SY <--
}
}

View File

@ -62,6 +62,15 @@ object Notifications {
const val ID_BACKUP_COMPLETE = -502
const val ID_RESTORE_COMPLETE = -504
// SY -->
/**
* Notification channel and ids used for backup and restore.
*/
const val CHANNEL_SIMILAR = "similar_channel"
const val ID_SIMILAR_PROGRESS = -601
const val ID_SIMILAR_COMPLETE = -602
// SY <--
private val deprecatedChannels = listOf(
"downloader_channel",
"backup_restore_complete_channel"
@ -143,6 +152,13 @@ object Notifications {
group = GROUP_BACKUP_RESTORE
setShowBadge(false)
setSound(null, null)
},
NotificationChannel(
CHANNEL_SIMILAR,
context.getString(R.string.similar),
NotificationManager.IMPORTANCE_LOW
).apply {
setShowBadge(false)
}
).forEach(context.notificationManager::createNotificationChannel)

View File

@ -309,6 +309,12 @@ object PreferenceKeys {
const val mangaDexForceLatestCovers = "manga_dex_force_latest_covers"
const val mangadexSimilarEnabled = "pref_related_show_tab_key"
const val mangadexSimilarUpdateInterval = "related_update_interval"
const val mangadexSimilarOnlyOverWifi = "pref_simular_only_over_wifi_key"
const val preferredMangaDexId = "preferred_mangaDex_id"
const val dataSaver = "data_saver"

View File

@ -431,6 +431,14 @@ class PreferencesHelper(val context: Context) {
fun preferredMangaDexId() = flowPrefs.getString(Keys.preferredMangaDexId, "0")
fun mangadexSimilarEnabled() = flowPrefs.getBoolean(Keys.mangadexSimilarEnabled, false)
fun shownMangaDexSimilarAskDialog() = flowPrefs.getBoolean("shown_similar_ask_dialog", false)
fun mangadexSimilarOnlyOverWifi() = flowPrefs.getBoolean(Keys.mangadexSimilarOnlyOverWifi, true)
fun mangadexSimilarUpdateInterval() = flowPrefs.getInt(Keys.mangadexSimilarUpdateInterval, 2)
fun dataSaver() = flowPrefs.getBoolean(Keys.dataSaver, false)
fun ignoreJpeg() = flowPrefs.getBoolean(Keys.ignoreJpeg, false)

View File

@ -6,6 +6,7 @@ import android.content.SharedPreferences
import android.net.Uri
import androidx.core.text.HtmlCompat
import com.bluelinelabs.conductor.Controller
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.data.track.TrackManager
@ -35,6 +36,7 @@ import exh.md.handlers.ApiMangaParser
import exh.md.handlers.FollowsHandler
import exh.md.handlers.MangaHandler
import exh.md.handlers.MangaPlusHandler
import exh.md.handlers.SimilarHandler
import exh.md.utils.FollowStatus
import exh.md.utils.MdLang
import exh.md.utils.MdUtil
@ -257,6 +259,10 @@ class MangaDex(delegate: HttpSource, val context: Context) :
return MangaHandler(client, headers, listOf(mdLang)).fetchRandomMangaId()
}
fun fetchMangaSimilar(manga: Manga): Observable<MangasPage> {
return SimilarHandler(preferences, useLowQualityThumbnail()).fetchSimilar(manga)
}
private fun importIdToMdId(query: String, fail: () -> Observable<MangasPage>): Observable<MangasPage> =
when {
query.toIntOrNull() != null -> {

View File

@ -2,7 +2,6 @@ package eu.kanade.tachiyomi.ui.browse.source.browse
import android.content.res.Configuration
import android.os.Bundle
import android.os.Parcelable
import android.view.LayoutInflater
import android.view.Menu
import android.view.MenuInflater
@ -10,7 +9,6 @@ import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import androidx.appcompat.widget.SearchView
import androidx.core.os.bundleOf
import androidx.core.view.isVisible
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager
@ -37,6 +35,7 @@ import eu.kanade.tachiyomi.source.LocalSource
import eu.kanade.tachiyomi.source.model.FilterList
import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.source.online.LoginSource
import eu.kanade.tachiyomi.source.online.all.MangaDex
import eu.kanade.tachiyomi.ui.base.controller.FabController
import eu.kanade.tachiyomi.ui.base.controller.NucleusController
import eu.kanade.tachiyomi.ui.base.controller.withFadeTransaction
@ -55,9 +54,9 @@ import eu.kanade.tachiyomi.util.view.snack
import eu.kanade.tachiyomi.widget.AutofitRecyclerView
import eu.kanade.tachiyomi.widget.EmptyView
import exh.isEhBasedSource
import exh.md.similar.ui.EnableMangaDexSimilarDialogController
import exh.savedsearches.EXHSavedSearch
import exh.source.EnhancedHttpSource.Companion.getMainSource
import kotlinx.android.parcel.Parcelize
import kotlinx.android.synthetic.main.main_activity.root_coordinator
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.drop
@ -109,17 +108,10 @@ open class BrowseSourceController(bundle: Bundle) :
private val preferences: PreferencesHelper by injectLazy()
// SY -->
private val recommendsConfig: RecommendsConfig? = args.getParcelable(RECOMMENDS_CONFIG)
// SY <--
// AZ -->
private val mode = if (recommendsConfig == null) Mode.CATALOGUE else Mode.RECOMMENDS
// AZ <--
/**
* Adapter containing the list of manga from the catalogue.
*/
private var adapter: FlexibleAdapter<IFlexible<*>>? = null
/* SY --> */ protected /* SY <-- */ var adapter: FlexibleAdapter<IFlexible<*>>? = null
private var actionFab: ExtendedFloatingActionButton? = null
private var actionFabScrollListener: RecyclerView.OnScrollListener? = null
@ -154,12 +146,7 @@ open class BrowseSourceController(bundle: Bundle) :
}
override fun getTitle(): String? {
// SY -->
return when (mode) {
Mode.CATALOGUE -> presenter.source.name
Mode.RECOMMENDS -> recommendsConfig!!.title
}
// SY <--
return presenter.source.name
}
override fun createPresenter(): BrowseSourcePresenter {
@ -167,7 +154,6 @@ open class BrowseSourceController(bundle: Bundle) :
return BrowseSourcePresenter(
args.getLong(SOURCE_ID_KEY),
args.getString(SEARCH_QUERY_KEY),
recommendsMangaId = if (mode == Mode.RECOMMENDS) recommendsConfig?.mangaId else null,
filters = args.getString(FILTERS_CONFIG_KEY)
)
// SY <--
@ -192,6 +178,11 @@ open class BrowseSourceController(bundle: Bundle) :
// SY -->
val mainSource = presenter.source.getMainSource()
if (mainSource is MangaDex && !preferences.mangadexSimilarEnabled().get() && !preferences.shownMangaDexSimilarAskDialog().get()) {
EnableMangaDexSimilarDialogController().showDialog(router)
preferences.shownMangaDexSimilarAskDialog().set(true)
}
if (mainSource is LoginSource && mainSource.needsLogin && !mainSource.isLogged()) {
val dialog = mainSource.getLoginDialog(mainSource, activity!!)
dialog.showDialog(router)
@ -200,12 +191,6 @@ open class BrowseSourceController(bundle: Bundle) :
}
open fun initFilterSheet() {
// SY -->
if (mode == Mode.RECOMMENDS) {
return
}
// SY <--
if (presenter.sourceFilters.isEmpty()) {
// SY -->
actionFab?.text = activity!!.getString(R.string.saved_searches)
@ -455,10 +440,6 @@ open class BrowseSourceController(bundle: Bundle) :
}
menu.findItem(displayItem).isChecked = true
// SY -->
if (mode == Mode.RECOMMENDS) {
menu.findItem(R.id.action_search).isVisible = false
}
if (preferences.enhancedEHentaiView().get() && presenter.source.isEhBasedSource()) {
menu.findItem(R.id.action_display_mode).isVisible = false
}
@ -472,9 +453,9 @@ open class BrowseSourceController(bundle: Bundle) :
menu.findItem(R.id.action_open_in_web_view).isVisible = isHttpSource
val isLocalSource = presenter.source is LocalSource
// SY -->
menu.findItem(R.id.action_local_source_help).isVisible = isLocalSource && mode == Mode.CATALOGUE
menu.findItem(R.id.action_local_source_help).isVisible = isLocalSource
// SY -->
menu.findItem(R.id.action_settings).isVisible = presenter.source is ConfigurableSource
// SY <--
}
@ -555,19 +536,14 @@ open class BrowseSourceController(bundle: Bundle) :
*
* @param error the error received.
*/
fun onAddPageError(error: Throwable) {
/* SY --> */ open /* SY <-- */fun onAddPageError(error: Throwable) {
// SY -->
XLog.w("> Failed to load next catalogue page!", error)
if (mode == Mode.CATALOGUE) {
XLog.w(
"> (source.id: %s, source.name: %s)",
presenter.source.id,
presenter.source.name
)
} else {
XLog.w("> Recommendations")
}
XLog.w(
"> (source.id: %s, source.name: %s)",
presenter.source.id,
presenter.source.name
)
// SY <--
val adapter = adapter ?: return
@ -590,7 +566,7 @@ open class BrowseSourceController(bundle: Bundle) :
if (adapter.isEmpty) {
val actions = emptyList<EmptyView.Action>().toMutableList()
if (presenter.source is LocalSource /* SY --> */ && mode == Mode.CATALOGUE /* SY <-- */) {
if (presenter.source is LocalSource) {
actions += EmptyView.Action(R.string.local_source_help_guide, View.OnClickListener { openLocalSourceHelpGuide() })
} else {
actions += EmptyView.Action(R.string.action_retry, retryAction)
@ -734,36 +710,16 @@ open class BrowseSourceController(bundle: Bundle) :
*/
override fun onItemClick(view: View, position: Int): Boolean {
val item = adapter?.getItem(position) as? SourceItem ?: return false
// SY -->
when (mode) {
Mode.CATALOGUE -> {
router.pushController(
MangaController(
item.manga,
true,
args.getParcelable(MangaController.SMART_SEARCH_CONFIG_EXTRA)
).withFadeTransaction()
)
}
Mode.RECOMMENDS -> openSmartSearch(item.manga.originalTitle)
}
// SY <--
router.pushController(
MangaController(
item.manga,
true,
args.getParcelable(MangaController.SMART_SEARCH_CONFIG_EXTRA)
).withFadeTransaction()
)
return false
}
// AZ -->
private fun openSmartSearch(title: String) {
val smartSearchConfig = SourceController.SmartSearchConfig(title)
router.pushController(
SourceController(
bundleOf(
SourceController.SMART_SEARCH_CONFIG to smartSearchConfig
)
).withFadeTransaction()
)
}
// AZ <--
/**
* Called when a manga is long clicked.
*
@ -774,9 +730,6 @@ open class BrowseSourceController(bundle: Bundle) :
* @param position the position of the element clicked.
*/
override fun onItemLongClick(position: Int) {
// SY -->
if (mode == Mode.RECOMMENDS) return
// SY <--
val activity = activity ?: return
val manga = (adapter?.getItem(position) as? SourceItem?)?.manga ?: return
@ -852,16 +805,6 @@ open class BrowseSourceController(bundle: Bundle) :
activity?.toast(activity?.getString(R.string.manga_added_library))
}
// SY -->
@Parcelize
data class RecommendsConfig(val title: String, val mangaId: Long?) : Parcelable
enum class Mode {
CATALOGUE,
RECOMMENDS
}
// SY <--
companion object {
const val SOURCE_ID_KEY = "sourceId"
const val SEARCH_QUERY_KEY = "searchQuery"
@ -869,7 +812,6 @@ open class BrowseSourceController(bundle: Bundle) :
// SY -->
const val SMART_SEARCH_CONFIG_KEY = "smartSearchConfig"
const val FILTERS_CONFIG_KEY = "filters"
const val RECOMMENDS_CONFIG = "RECOMMENDS_CONFIG"
// SY <--
}
}

View File

@ -59,7 +59,6 @@ open class BrowseSourcePresenter(
private val sourceId: Long,
private val searchQuery: String? = null,
// SY -->
private val recommendsMangaId: Long? = null,
private val filters: String? = null,
// SY <--
private val sourceManager: SourceManager = Injekt.get(),
@ -145,10 +144,6 @@ open class BrowseSourcePresenter(
query = savedState.getString(::query.name, "")
}
if (recommendsMangaId != null) {
manga = db.getManga(recommendsMangaId).executeAsBlocking()
}
restartPager(/* SY -->*/ filters = if (allDefault) this.appliedFilters else sourceFilters /* SY <--*/)
}
@ -170,11 +165,7 @@ open class BrowseSourcePresenter(
subscribeToMangaInitializer()
// Create a new pager.
// SY -->
pager = if (recommendsMangaId != null) RecommendsPager(
manga ?: throw Exception("Could not get Manga")
) else createPager(query, filters)
// SY <--
pager = createPager(query, filters)
val sourceId = source.id

View File

@ -31,6 +31,8 @@ import androidx.core.view.isVisible
import androidx.recyclerview.widget.ConcatAdapter
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.afollestad.materialdialogs.MaterialDialog
import com.afollestad.materialdialogs.list.listItemsSingleChoice
import com.bluelinelabs.conductor.ControllerChangeHandler
import com.bluelinelabs.conductor.ControllerChangeType
import com.bumptech.glide.load.engine.DiskCacheStrategy
@ -50,11 +52,13 @@ import eu.kanade.tachiyomi.data.glide.GlideApp
import eu.kanade.tachiyomi.data.glide.toMangaThumbnail
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.databinding.MangaControllerBinding
import eu.kanade.tachiyomi.source.CatalogueSource
import eu.kanade.tachiyomi.source.LocalSource
import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.SourceManager
import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.source.online.MetadataSource
import eu.kanade.tachiyomi.source.online.all.MangaDex
import eu.kanade.tachiyomi.ui.base.controller.FabController
import eu.kanade.tachiyomi.ui.base.controller.NucleusController
import eu.kanade.tachiyomi.ui.base.controller.ToolbarLiftOnScrollController
@ -96,7 +100,9 @@ import eu.kanade.tachiyomi.util.view.shrinkOnScroll
import eu.kanade.tachiyomi.util.view.snack
import exh.MERGED_SOURCE_ID
import exh.isEhBasedSource
import exh.md.similar.ui.MangaDexSimilarController
import exh.metadata.metadata.base.FlatMetadata
import exh.recs.RecommendsController
import exh.source.EnhancedHttpSource.Companion.getMainSource
import kotlinx.android.synthetic.main.main_activity.root_coordinator
import kotlinx.android.synthetic.main.main_activity.toolbar
@ -748,15 +754,25 @@ class MangaController :
// AZ -->
fun openRecommends() {
val recommendsConfig = BrowseSourceController.RecommendsConfig(presenter.manga.originalTitle, presenter.manga.id)
router?.pushController(
BrowseSourceController(
bundleOf(
BrowseSourceController.RECOMMENDS_CONFIG to recommendsConfig
)
).withFadeTransaction()
)
val source = presenter.source.getMainSource()
if (source is MangaDex && preferences.mangadexSimilarEnabled().get()) {
MaterialDialog(activity!!)
.title(R.string.az_recommends)
.listItemsSingleChoice(
items = listOf(
"MangaDex similar",
"Community recommendations"
)
) { _, index, _ ->
when (index) {
0 -> router.pushController(MangaDexSimilarController(presenter.manga, source).withFadeTransaction())
1 -> router.pushController(RecommendsController(presenter.manga, source).withFadeTransaction())
}
}
.show()
} else if (source is CatalogueSource) {
router.pushController(RecommendsController(presenter.manga, source).withFadeTransaction())
}
}
// AZ <--

View File

@ -90,7 +90,7 @@ class SettingsMainController : SettingsController() {
preference {
iconRes = R.drawable.ic_tracker_mangadex_logo_24dp
iconTint = tintColor
titleRes = R.string.mangadex_specific_settings
titleRes = R.string.pref_category_mangadex
onClick { navigateTo(SettingsMangaDexController()) }
}
}

View File

@ -1,17 +1,25 @@
package eu.kanade.tachiyomi.ui.setting
import android.content.Intent
import androidx.core.net.toUri
import androidx.preference.PreferenceScreen
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.library.LibraryUpdateService
import eu.kanade.tachiyomi.data.preference.PreferenceKeys
import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.util.preference.defaultValue
import eu.kanade.tachiyomi.util.preference.entriesRes
import eu.kanade.tachiyomi.util.preference.intListPreference
import eu.kanade.tachiyomi.util.preference.listPreference
import eu.kanade.tachiyomi.util.preference.onChange
import eu.kanade.tachiyomi.util.preference.onClick
import eu.kanade.tachiyomi.util.preference.preference
import eu.kanade.tachiyomi.util.preference.preferenceCategory
import eu.kanade.tachiyomi.util.preference.summaryRes
import eu.kanade.tachiyomi.util.preference.switchPreference
import eu.kanade.tachiyomi.util.preference.titleRes
import eu.kanade.tachiyomi.util.system.toast
import exh.md.similar.SimilarUpdateJob
import exh.md.utils.MdUtil
import exh.widget.preference.MangaDexLoginPreference
import exh.widget.preference.MangadexLoginDialog
@ -85,6 +93,76 @@ class SettingsMangaDexController :
)
}
}
preferenceCategory {
titleRes = R.string.similar_settings
preference {
key = "pref_similar_screen"
titleRes = R.string.similar_screen
summaryRes = R.string.similar_screen_summary_message
isIconSpaceReserved = true
}
switchPreference {
key = PreferenceKeys.mangadexSimilarEnabled
titleRes = R.string.similar_screen
defaultValue = false
onClick {
SimilarUpdateJob.setupTask(context)
}
}
switchPreference {
key = PreferenceKeys.mangadexSimilarOnlyOverWifi
titleRes = R.string.pref_download_only_over_wifi
defaultValue = true
onClick {
SimilarUpdateJob.setupTask(context, true)
}
}
preference {
key = "pref_similar_manually_update"
titleRes = R.string.similar_manually_update
summaryRes = R.string.similar_manually_update_message
onClick {
SimilarUpdateJob.doWorkNow(context)
context.toast(R.string.similar_manually_toast)
}
}
intListPreference {
key = PreferenceKeys.mangadexSimilarUpdateInterval
titleRes = R.string.similar_update_fequency
entriesRes = arrayOf(
R.string.update_never,
R.string.update_24hour,
R.string.update_48hour,
R.string.update_weekly,
R.string.update_monthly
)
entryValues = arrayOf("0", "1", "2", "7", "30")
defaultValue = "2"
onChange {
SimilarUpdateJob.setupTask(context, true)
true
}
}
preference {
key = "similar_credits"
title = "Credits"
val url = "https://github.com/goldbattle/MangadexRecomendations"
summary = context.getString(R.string.similar_credit_message, url)
onClick {
val intent = Intent(Intent.ACTION_VIEW, url.toUri())
startActivity(intent)
}
isIconSpaceReserved = true
}
}
}
override fun siteLoginDialogClosed(source: Source) {

View File

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

View File

@ -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))
}
}*/
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 = "|*|"
}
}

View File

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

View File

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

View File

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

View File

@ -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"
}
}

View File

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

View File

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

View File

@ -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"
}
}

View File

@ -1,9 +1,10 @@
package eu.kanade.tachiyomi.ui.browse.source.browse
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

View File

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

View File

@ -32,6 +32,7 @@
<string name="pref_category_all_sources">All Sources</string>
<string name="pref_category_eh">E-Hentai</string>
<string name="pref_category_fork">Fork Settings</string>
<string name="pref_category_mangadex">MangaDex</string>
<!-- EH Settings -->
@ -573,4 +574,37 @@
<string name="metadata_corrupted">Metadata corrupted, please refresh the manga</string>
<string name="no_scanlators">No scanlators available</string>
<!-- Similar -->
<string name="similar">Similar manga</string>
<string name="similar_loading_percent">Updating similar manga (%1$d / %2$d updated)</string>
<string name="similar_loading_complete">Updating similar manga complete</string>
<string name="similar_loading_complete_error">Error trying to load/process similar manga</string>
<string name="similar_loading_progress_start">Downloading similar manga data file…</string>
<string name="similar_ask_to_enable_title">Enable Similar Manga?</string>
<string name="similar_ask_to_enable">
Would you like to enable similar manga recommendations?
This will download approximately 9 MB of data if enabled right now.
You can always enable it in the Settings / Mangadex menu.
</string>
<string name="similar_ask_to_enable_yes">Enable</string>
<string name="similar_ask_to_enable_no">Skip</string>
<string name="similar_settings">Similar Manga Settings</string>
<string name="similar_screen">Show similar manga</string>
<string name="similar_manually_update">Pull latest database</string>
<string name="similar_manually_toast">Starting manual update</string>
<string name="similar_manually_update_message">
Download the latest similar manga database.
This is around 9MB in size and is updated daily.
</string>
<string name="similar_update_fequency">Similar update frequency</string>
<string name="similar_screen_summary_message">
This is a feature where one can get manga recommendations.
This is a recommendation system outside of MangaDex, and works by matching by genres,
demographics, content type, themes, and then using term frequencyinverse document frequency (tfidf) to get the
similarity of two manga\'s descriptions. When enabled this file will download immediately!! The file is about 9 MB in size.
</string>
<string name="similar_credit_message">
For more information and to view the source code:\n%s
</string>
</resources>