mirror of
https://github.com/mihonapp/mihon.git
synced 2025-11-06 09:08:57 +01:00
Implemented J2K Auto Source Migration
(cherry picked from commit 8ba75831e6f51f6472d85f813405ede3f679cfd8)
This commit is contained in:
@@ -2,7 +2,7 @@ package exh.metadata.metadata.base
|
||||
|
||||
import com.pushtorefresh.storio.operations.PreparedOperation
|
||||
import eu.kanade.tachiyomi.data.database.DatabaseHelper
|
||||
import exh.metadata.sql.models.SearchMetadata
|
||||
import eu.kanade.tachiyomi.data.database.models.SearchMetadata
|
||||
import exh.metadata.sql.models.SearchTag
|
||||
import exh.metadata.sql.models.SearchTitle
|
||||
import kotlin.reflect.KClass
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
package exh.metadata.metadata.base
|
||||
|
||||
import com.google.gson.GsonBuilder
|
||||
import eu.kanade.tachiyomi.data.database.models.SearchMetadata
|
||||
import eu.kanade.tachiyomi.source.model.SManga
|
||||
import exh.metadata.forEach
|
||||
import exh.metadata.sql.models.SearchMetadata
|
||||
import exh.metadata.sql.models.SearchTag
|
||||
import exh.metadata.sql.models.SearchTitle
|
||||
import exh.plusAssign
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package exh.search
|
||||
|
||||
import exh.metadata.sql.tables.SearchMetadataTable
|
||||
import eu.kanade.tachiyomi.data.database.tables.SearchMetadataTable
|
||||
import exh.metadata.sql.tables.SearchTagTable
|
||||
import exh.metadata.sql.tables.SearchTitleTable
|
||||
|
||||
|
||||
@@ -1,192 +0,0 @@
|
||||
package exh.smartsearch
|
||||
|
||||
import eu.kanade.tachiyomi.data.database.DatabaseHelper
|
||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||
import eu.kanade.tachiyomi.source.CatalogueSource
|
||||
import eu.kanade.tachiyomi.source.model.FilterList
|
||||
import eu.kanade.tachiyomi.source.model.SManga
|
||||
import exh.ui.smartsearch.SmartSearchPresenter
|
||||
import exh.util.await
|
||||
import info.debatty.java.stringsimilarity.NormalizedLevenshtein
|
||||
import kotlin.coroutines.CoroutineContext
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.supervisorScope
|
||||
import rx.schedulers.Schedulers
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
|
||||
class SmartSearchEngine(
|
||||
parentContext: CoroutineContext,
|
||||
val extraSearchParams: String? = null
|
||||
) : CoroutineScope {
|
||||
override val coroutineContext: CoroutineContext = parentContext + Job() + Dispatchers.Default
|
||||
|
||||
private val db: DatabaseHelper by injectLazy()
|
||||
|
||||
private val normalizedLevenshtein = NormalizedLevenshtein()
|
||||
|
||||
suspend fun smartSearch(source: CatalogueSource, title: String): SManga? {
|
||||
val cleanedTitle = cleanSmartSearchTitle(title)
|
||||
|
||||
val queries = getSmartSearchQueries(cleanedTitle)
|
||||
|
||||
val eligibleManga = supervisorScope {
|
||||
queries.map { query ->
|
||||
async(Dispatchers.Default) {
|
||||
val builtQuery = if (extraSearchParams != null) {
|
||||
"$query ${extraSearchParams.trim()}"
|
||||
} else query
|
||||
|
||||
val searchResults = source.fetchSearchManga(1, builtQuery, FilterList()).toSingle().await(Schedulers.io())
|
||||
|
||||
searchResults.mangas.map {
|
||||
val cleanedMangaTitle = cleanSmartSearchTitle(it.title)
|
||||
val normalizedDistance = normalizedLevenshtein.similarity(cleanedTitle, cleanedMangaTitle)
|
||||
SmartSearchPresenter.SearchEntry(it, normalizedDistance)
|
||||
}.filter { (_, normalizedDistance) ->
|
||||
normalizedDistance >= MIN_SMART_ELIGIBLE_THRESHOLD
|
||||
}
|
||||
}
|
||||
}.flatMap { it.await() }
|
||||
}
|
||||
|
||||
return eligibleManga.maxBy { it.dist }?.manga
|
||||
}
|
||||
|
||||
suspend fun normalSearch(source: CatalogueSource, title: String): SManga? {
|
||||
val eligibleManga = supervisorScope {
|
||||
val searchQuery = if (extraSearchParams != null) {
|
||||
"$title ${extraSearchParams.trim()}"
|
||||
} else title
|
||||
val searchResults = source.fetchSearchManga(1, searchQuery, FilterList()).toSingle().await(Schedulers.io())
|
||||
|
||||
searchResults.mangas.map {
|
||||
val normalizedDistance = normalizedLevenshtein.similarity(title, it.title)
|
||||
SmartSearchPresenter.SearchEntry(it, normalizedDistance)
|
||||
}.filter { (_, normalizedDistance) ->
|
||||
normalizedDistance >= MIN_NORMAL_ELIGIBLE_THRESHOLD
|
||||
}
|
||||
}
|
||||
|
||||
return eligibleManga.maxBy { it.dist }?.manga
|
||||
}
|
||||
|
||||
private fun getSmartSearchQueries(cleanedTitle: String): List<String> {
|
||||
val splitCleanedTitle = cleanedTitle.split(" ")
|
||||
val splitSortedByLargest = splitCleanedTitle.sortedByDescending { it.length }
|
||||
|
||||
if (splitCleanedTitle.isEmpty()) {
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
// Search cleaned title
|
||||
// Search two largest words
|
||||
// Search largest word
|
||||
// Search first two words
|
||||
// Search first word
|
||||
|
||||
val searchQueries = listOf(
|
||||
listOf(cleanedTitle),
|
||||
splitSortedByLargest.take(2),
|
||||
splitSortedByLargest.take(1),
|
||||
splitCleanedTitle.take(2),
|
||||
splitCleanedTitle.take(1)
|
||||
)
|
||||
|
||||
return searchQueries.map {
|
||||
it.joinToString(" ").trim()
|
||||
}.distinct()
|
||||
}
|
||||
|
||||
private fun cleanSmartSearchTitle(title: String): String {
|
||||
val preTitle = title.toLowerCase()
|
||||
|
||||
// Remove text in brackets
|
||||
var cleanedTitle = removeTextInBrackets(preTitle, true)
|
||||
if (cleanedTitle.length <= 5) { // Title is suspiciously short, try parsing it backwards
|
||||
cleanedTitle = removeTextInBrackets(preTitle, false)
|
||||
}
|
||||
|
||||
// Strip non-special characters
|
||||
cleanedTitle = cleanedTitle.replace(titleRegex, " ")
|
||||
|
||||
// Strip splitters and consecutive spaces
|
||||
cleanedTitle = cleanedTitle.trim().replace(" - ", " ").replace(consecutiveSpacesRegex, " ").trim()
|
||||
|
||||
return cleanedTitle
|
||||
}
|
||||
|
||||
private fun removeTextInBrackets(text: String, readForward: Boolean): String {
|
||||
val bracketPairs = listOf(
|
||||
'(' to ')',
|
||||
'[' to ']',
|
||||
'<' to '>',
|
||||
'{' to '}'
|
||||
)
|
||||
var openingBracketPairs = bracketPairs.mapIndexed { index, (opening, _) ->
|
||||
opening to index
|
||||
}.toMap()
|
||||
var closingBracketPairs = bracketPairs.mapIndexed { index, (_, closing) ->
|
||||
closing to index
|
||||
}.toMap()
|
||||
|
||||
// Reverse pairs if reading backwards
|
||||
if (!readForward) {
|
||||
val tmp = openingBracketPairs
|
||||
openingBracketPairs = closingBracketPairs
|
||||
closingBracketPairs = tmp
|
||||
}
|
||||
|
||||
val depthPairs = bracketPairs.map { 0 }.toMutableList()
|
||||
|
||||
val result = StringBuilder()
|
||||
for (c in if (readForward) text else text.reversed()) {
|
||||
val openingBracketDepthIndex = openingBracketPairs[c]
|
||||
if (openingBracketDepthIndex != null) {
|
||||
depthPairs[openingBracketDepthIndex]++
|
||||
} else {
|
||||
val closingBracketDepthIndex = closingBracketPairs[c]
|
||||
if (closingBracketDepthIndex != null) {
|
||||
depthPairs[closingBracketDepthIndex]--
|
||||
} else {
|
||||
if (depthPairs.all { it <= 0 }) {
|
||||
result.append(c)
|
||||
} else {
|
||||
// In brackets, do not append to result
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result.toString()
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a manga from the database for the given manga from network. It creates a new entry
|
||||
* if the manga is not yet in the database.
|
||||
*
|
||||
* @param sManga the manga from the source.
|
||||
* @return a manga from the database.
|
||||
*/
|
||||
suspend fun networkToLocalManga(sManga: SManga, sourceId: Long): Manga {
|
||||
var localManga = db.getManga(sManga.url, sourceId).await()
|
||||
if (localManga == null) {
|
||||
val newManga = Manga.create(sManga.url, sManga.title, sourceId)
|
||||
newManga.copyFrom(sManga)
|
||||
val result = db.insertManga(newManga).await()
|
||||
newManga.id = result.insertedId()
|
||||
localManga = newManga
|
||||
}
|
||||
return localManga
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val MIN_SMART_ELIGIBLE_THRESHOLD = 0.4
|
||||
const val MIN_NORMAL_ELIGIBLE_THRESHOLD = 0.4
|
||||
|
||||
private val titleRegex = Regex("[^a-zA-Z0-9- ]")
|
||||
private val consecutiveSpacesRegex = Regex(" +")
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
package exh.ui.migration
|
||||
|
||||
class MigrationStatus {
|
||||
companion object {
|
||||
val NOT_INITIALIZED = -1
|
||||
val COMPLETED = 0
|
||||
|
||||
// Migration process
|
||||
val NOTIFY_USER = 1
|
||||
val OPEN_BACKUP_MENU = 2
|
||||
val PERFORM_BACKUP = 3
|
||||
val FINALIZE_MIGRATION = 4
|
||||
|
||||
val MAX_MIGRATION_STEPS = 2
|
||||
}
|
||||
}
|
||||
@@ -1,198 +0,0 @@
|
||||
package exh.ui.migration.manga.design
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import eu.davidea.flexibleadapter.FlexibleAdapter
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
||||
import eu.kanade.tachiyomi.data.preference.getOrDefault
|
||||
import eu.kanade.tachiyomi.source.SourceManager
|
||||
import eu.kanade.tachiyomi.source.online.HttpSource
|
||||
import eu.kanade.tachiyomi.ui.base.controller.withFadeTransaction
|
||||
import eu.kanade.tachiyomi.ui.migration.MigrationFlags
|
||||
import eu.kanade.tachiyomi.util.view.gone
|
||||
import eu.kanade.tachiyomi.util.view.visible
|
||||
import exh.ui.base.BaseExhController
|
||||
import exh.ui.migration.manga.process.MigrationProcedureConfig
|
||||
import exh.ui.migration.manga.process.MigrationProcedureController
|
||||
import kotlinx.android.synthetic.main.eh_migration_design.begin_migration_btn
|
||||
import kotlinx.android.synthetic.main.eh_migration_design.copy_manga
|
||||
import kotlinx.android.synthetic.main.eh_migration_design.copy_manga_desc
|
||||
import kotlinx.android.synthetic.main.eh_migration_design.extra_search_param
|
||||
import kotlinx.android.synthetic.main.eh_migration_design.extra_search_param_desc
|
||||
import kotlinx.android.synthetic.main.eh_migration_design.extra_search_param_text
|
||||
import kotlinx.android.synthetic.main.eh_migration_design.fuzzy_search
|
||||
import kotlinx.android.synthetic.main.eh_migration_design.mig_categories
|
||||
import kotlinx.android.synthetic.main.eh_migration_design.mig_chapters
|
||||
import kotlinx.android.synthetic.main.eh_migration_design.migration_mode
|
||||
import kotlinx.android.synthetic.main.eh_migration_design.options_group
|
||||
import kotlinx.android.synthetic.main.eh_migration_design.prioritize_chapter_count
|
||||
import kotlinx.android.synthetic.main.eh_migration_design.recycler
|
||||
import kotlinx.android.synthetic.main.eh_migration_design.use_smart_search
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
|
||||
// TODO Select all in library
|
||||
class MigrationDesignController(bundle: Bundle? = null) : BaseExhController(bundle), FlexibleAdapter.OnItemClickListener {
|
||||
private val sourceManager: SourceManager by injectLazy()
|
||||
private val prefs: PreferencesHelper by injectLazy()
|
||||
|
||||
override val layoutId: Int = R.layout.eh_migration_design
|
||||
|
||||
private var adapter: MigrationSourceAdapter? = null
|
||||
|
||||
private val config: LongArray = args.getLongArray(MANGA_IDS_EXTRA) ?: LongArray(0)
|
||||
|
||||
private var showingOptions = false
|
||||
|
||||
override fun getTitle() = "Select target sources"
|
||||
|
||||
override fun onViewCreated(view: View) {
|
||||
super.onViewCreated(view)
|
||||
|
||||
val ourAdapter = adapter ?: MigrationSourceAdapter(
|
||||
getEnabledSources().map { MigrationSourceItem(it, true) },
|
||||
this
|
||||
)
|
||||
adapter = ourAdapter
|
||||
recycler.layoutManager = androidx.recyclerview.widget.LinearLayoutManager(view.context)
|
||||
recycler.setHasFixedSize(true)
|
||||
recycler.adapter = ourAdapter
|
||||
ourAdapter.itemTouchHelperCallback = null // Reset adapter touch adapter to fix drag after rotation
|
||||
ourAdapter.isHandleDragEnabled = true
|
||||
|
||||
migration_mode.setOnClickListener {
|
||||
prioritize_chapter_count.toggle()
|
||||
}
|
||||
|
||||
fuzzy_search.setOnClickListener {
|
||||
use_smart_search.toggle()
|
||||
}
|
||||
|
||||
copy_manga_desc.setOnClickListener {
|
||||
copy_manga.toggle()
|
||||
}
|
||||
|
||||
extra_search_param_desc.setOnClickListener {
|
||||
extra_search_param.toggle()
|
||||
}
|
||||
|
||||
prioritize_chapter_count.setOnCheckedChangeListener { _, b ->
|
||||
updatePrioritizeChapterCount(b)
|
||||
}
|
||||
|
||||
extra_search_param.setOnCheckedChangeListener { _, b ->
|
||||
updateOptionsState()
|
||||
}
|
||||
|
||||
updatePrioritizeChapterCount(prioritize_chapter_count.isChecked)
|
||||
|
||||
updateOptionsState()
|
||||
|
||||
begin_migration_btn.setOnClickListener {
|
||||
if (!showingOptions) {
|
||||
showingOptions = true
|
||||
updateOptionsState()
|
||||
return@setOnClickListener
|
||||
}
|
||||
|
||||
var flags = 0
|
||||
if (mig_chapters.isChecked) flags = flags or MigrationFlags.CHAPTERS
|
||||
if (mig_categories.isChecked) flags = flags or MigrationFlags.CATEGORIES
|
||||
if (mig_categories.isChecked) flags = flags or MigrationFlags.TRACK
|
||||
|
||||
router.replaceTopController(MigrationProcedureController.create(
|
||||
MigrationProcedureConfig(
|
||||
config.toList(),
|
||||
ourAdapter.items.filter {
|
||||
it.sourceEnabled
|
||||
}.map { it.source.id },
|
||||
useSourceWithMostChapters = prioritize_chapter_count.isChecked,
|
||||
enableLenientSearch = use_smart_search.isChecked,
|
||||
migrationFlags = flags,
|
||||
copy = copy_manga.isChecked,
|
||||
extraSearchParams = if (extra_search_param.isChecked && extra_search_param_text.text.isNotBlank()) {
|
||||
extra_search_param_text.text.toString()
|
||||
} else null
|
||||
)
|
||||
).withFadeTransaction())
|
||||
}
|
||||
}
|
||||
|
||||
fun updateOptionsState() {
|
||||
if (showingOptions) {
|
||||
begin_migration_btn.text = "Begin migration"
|
||||
options_group.visible()
|
||||
if (extra_search_param.isChecked) {
|
||||
extra_search_param_text.visible()
|
||||
} else {
|
||||
extra_search_param_text.gone()
|
||||
}
|
||||
} else {
|
||||
begin_migration_btn.text = "Next step"
|
||||
options_group.gone()
|
||||
extra_search_param_text.gone()
|
||||
}
|
||||
}
|
||||
|
||||
override fun handleBack(): Boolean {
|
||||
if (showingOptions) {
|
||||
showingOptions = false
|
||||
updateOptionsState()
|
||||
return true
|
||||
}
|
||||
return super.handleBack()
|
||||
}
|
||||
|
||||
override fun onSaveInstanceState(outState: Bundle) {
|
||||
super.onSaveInstanceState(outState)
|
||||
adapter?.onSaveInstanceState(outState)
|
||||
}
|
||||
|
||||
// TODO Still incorrect, why is this called before onViewCreated?
|
||||
override fun onRestoreInstanceState(savedInstanceState: Bundle) {
|
||||
super.onRestoreInstanceState(savedInstanceState)
|
||||
adapter?.onRestoreInstanceState(savedInstanceState)
|
||||
}
|
||||
|
||||
private fun updatePrioritizeChapterCount(migrationMode: Boolean) {
|
||||
migration_mode.text = if (migrationMode) {
|
||||
"Currently using the source with the most chapters and the above list to break ties (slow with many sources or smart search)"
|
||||
} else {
|
||||
"Currently using the first source in the list that has the manga"
|
||||
}
|
||||
}
|
||||
|
||||
override fun onItemClick(view: View, position: Int): Boolean {
|
||||
adapter?.getItem(position)?.let {
|
||||
it.sourceEnabled = !it.sourceEnabled
|
||||
}
|
||||
adapter?.notifyItemChanged(position)
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a list of enabled sources ordered by language and name.
|
||||
*
|
||||
* @return list containing enabled sources.
|
||||
*/
|
||||
private fun getEnabledSources(): List<HttpSource> {
|
||||
val languages = prefs.enabledLanguages().getOrDefault()
|
||||
val hiddenCatalogues = prefs.hiddenCatalogues().getOrDefault()
|
||||
|
||||
return sourceManager.getVisibleCatalogueSources()
|
||||
.filterIsInstance<HttpSource>()
|
||||
.filter { it.lang in languages }
|
||||
.filterNot { it.id.toString() in hiddenCatalogues }
|
||||
.sortedBy { "(${it.lang}) ${it.name}" }
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val MANGA_IDS_EXTRA = "manga_ids"
|
||||
|
||||
fun create(mangaIds: List<Long>): MigrationDesignController {
|
||||
return MigrationDesignController(Bundle().apply {
|
||||
putLongArray(MANGA_IDS_EXTRA, mangaIds.toLongArray())
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
package exh.ui.migration.manga.design
|
||||
|
||||
import android.os.Bundle
|
||||
import eu.davidea.flexibleadapter.FlexibleAdapter
|
||||
import exh.debug.DebugFunctions.sourceManager
|
||||
|
||||
class MigrationSourceAdapter(
|
||||
val items: List<MigrationSourceItem>,
|
||||
val controller: MigrationDesignController
|
||||
) : FlexibleAdapter<MigrationSourceItem>(
|
||||
items,
|
||||
controller,
|
||||
true
|
||||
) {
|
||||
override fun onSaveInstanceState(outState: Bundle) {
|
||||
super.onSaveInstanceState(outState)
|
||||
|
||||
outState.putParcelableArrayList(SELECTED_SOURCES_KEY, ArrayList(currentItems.map {
|
||||
it.asParcelable()
|
||||
}))
|
||||
}
|
||||
|
||||
override fun onRestoreInstanceState(savedInstanceState: Bundle) {
|
||||
savedInstanceState.getParcelableArrayList<MigrationSourceItem.ParcelableSI>(SELECTED_SOURCES_KEY)?.let {
|
||||
updateDataSet(it.map { MigrationSourceItem.fromParcelable(sourceManager, it) })
|
||||
}
|
||||
|
||||
super.onRestoreInstanceState(savedInstanceState)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val SELECTED_SOURCES_KEY = "selected_sources"
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
package exh.ui.migration.manga.design
|
||||
|
||||
import android.graphics.Paint.STRIKE_THRU_TEXT_FLAG
|
||||
import android.view.View
|
||||
import eu.davidea.flexibleadapter.FlexibleAdapter
|
||||
import eu.kanade.tachiyomi.source.online.HttpSource
|
||||
import eu.kanade.tachiyomi.ui.base.holder.BaseFlexibleViewHolder
|
||||
import eu.kanade.tachiyomi.util.view.getRound
|
||||
import kotlinx.android.synthetic.main.eh_source_item.image
|
||||
import kotlinx.android.synthetic.main.eh_source_item.reorder
|
||||
import kotlinx.android.synthetic.main.eh_source_item.title
|
||||
|
||||
class MigrationSourceHolder(view: View, val adapter: FlexibleAdapter<MigrationSourceItem>) :
|
||||
BaseFlexibleViewHolder(view, adapter) {
|
||||
init {
|
||||
setDragHandleView(reorder)
|
||||
}
|
||||
|
||||
fun bind(source: HttpSource, sourceEnabled: Boolean) {
|
||||
// Set capitalized title.
|
||||
title.text = source.name.capitalize()
|
||||
|
||||
// Update circle letter image.
|
||||
itemView.post {
|
||||
image.setImageDrawable(image.getRound(source.name.take(1).toUpperCase(), false))
|
||||
}
|
||||
|
||||
if (sourceEnabled) {
|
||||
title.alpha = 1.0f
|
||||
image.alpha = 1.0f
|
||||
title.paintFlags = title.paintFlags and STRIKE_THRU_TEXT_FLAG.inv()
|
||||
} else {
|
||||
title.alpha = DISABLED_ALPHA
|
||||
image.alpha = DISABLED_ALPHA
|
||||
title.paintFlags = title.paintFlags or STRIKE_THRU_TEXT_FLAG
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val DISABLED_ALPHA = 0.3f
|
||||
}
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
package exh.ui.migration.manga.design
|
||||
|
||||
import android.os.Parcelable
|
||||
import android.view.View
|
||||
import eu.davidea.flexibleadapter.FlexibleAdapter
|
||||
import eu.davidea.flexibleadapter.items.AbstractFlexibleItem
|
||||
import eu.davidea.flexibleadapter.items.IFlexible
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.source.SourceManager
|
||||
import eu.kanade.tachiyomi.source.online.HttpSource
|
||||
import kotlinx.android.parcel.Parcelize
|
||||
|
||||
class MigrationSourceItem(val source: HttpSource, var sourceEnabled: Boolean) : AbstractFlexibleItem<MigrationSourceHolder>() {
|
||||
override fun getLayoutRes() = R.layout.eh_source_item
|
||||
|
||||
override fun createViewHolder(view: View, adapter: FlexibleAdapter<IFlexible<androidx.recyclerview.widget.RecyclerView.ViewHolder>>): MigrationSourceHolder {
|
||||
return MigrationSourceHolder(view, adapter as MigrationSourceAdapter)
|
||||
}
|
||||
|
||||
/**
|
||||
* Binds the given view holder with this item.
|
||||
*
|
||||
* @param adapter The adapter of this item.
|
||||
* @param holder The holder to bind.
|
||||
* @param position The position of this item in the adapter.
|
||||
* @param payloads List of partial changes.
|
||||
*/
|
||||
override fun bindViewHolder(
|
||||
adapter: FlexibleAdapter<IFlexible<androidx.recyclerview.widget.RecyclerView.ViewHolder>>,
|
||||
holder: MigrationSourceHolder,
|
||||
position: Int,
|
||||
payloads: List<Any?>?
|
||||
) {
|
||||
holder.bind(source, sourceEnabled)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if this item is draggable.
|
||||
*/
|
||||
override fun isDraggable(): Boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (other is MigrationSourceItem) {
|
||||
return source.id == other.source.id
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
return source.id.hashCode()
|
||||
}
|
||||
|
||||
@Parcelize
|
||||
data class ParcelableSI(val sourceId: Long, val sourceEnabled: Boolean) : Parcelable
|
||||
|
||||
fun asParcelable(): ParcelableSI {
|
||||
return ParcelableSI(source.id, sourceEnabled)
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun fromParcelable(sourceManager: SourceManager, si: ParcelableSI): MigrationSourceItem? {
|
||||
val source = sourceManager.get(si.sourceId) as? HttpSource ?: return null
|
||||
|
||||
return MigrationSourceItem(
|
||||
source,
|
||||
si.sourceEnabled
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
package exh.ui.migration.manga.process
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import android.view.MotionEvent
|
||||
|
||||
class DeactivatableViewPager : androidx.viewpager.widget.ViewPager {
|
||||
constructor(context: Context) : super(context)
|
||||
constructor(context: Context, attrs: AttributeSet) : super(context, attrs)
|
||||
|
||||
override fun onTouchEvent(event: MotionEvent): Boolean {
|
||||
return !isEnabled || super.onTouchEvent(event)
|
||||
}
|
||||
|
||||
override fun onInterceptTouchEvent(event: MotionEvent): Boolean {
|
||||
return isEnabled && super.onInterceptTouchEvent(event)
|
||||
}
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
package exh.ui.migration.manga.process
|
||||
|
||||
import eu.kanade.tachiyomi.data.database.DatabaseHelper
|
||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||
import eu.kanade.tachiyomi.source.Source
|
||||
import eu.kanade.tachiyomi.source.SourceManager
|
||||
import exh.util.DeferredField
|
||||
import exh.util.await
|
||||
import kotlin.coroutines.CoroutineContext
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.channels.ConflatedBroadcastChannel
|
||||
|
||||
class MigratingManga(
|
||||
private val db: DatabaseHelper,
|
||||
private val sourceManager: SourceManager,
|
||||
val mangaId: Long,
|
||||
parentContext: CoroutineContext
|
||||
) {
|
||||
val searchResult = DeferredField<Long?>()
|
||||
|
||||
// <MAX, PROGRESS>
|
||||
val progress = ConflatedBroadcastChannel(1 to 0)
|
||||
|
||||
val migrationJob = parentContext + SupervisorJob() + Dispatchers.Default
|
||||
|
||||
@Volatile
|
||||
private var manga: Manga? = null
|
||||
suspend fun manga(): Manga? {
|
||||
if (manga == null) manga = db.getManga(mangaId).await()
|
||||
return manga
|
||||
}
|
||||
|
||||
suspend fun mangaSource(): Source {
|
||||
return sourceManager.getOrStub(manga()?.source ?: -1)
|
||||
}
|
||||
}
|
||||
@@ -1,305 +0,0 @@
|
||||
package exh.ui.migration.manga.process
|
||||
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import com.bumptech.glide.load.engine.DiskCacheStrategy
|
||||
import com.elvishew.xlog.XLog
|
||||
import com.google.gson.Gson
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.data.database.DatabaseHelper
|
||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||
import eu.kanade.tachiyomi.data.database.models.MangaCategory
|
||||
import eu.kanade.tachiyomi.data.glide.GlideApp
|
||||
import eu.kanade.tachiyomi.source.Source
|
||||
import eu.kanade.tachiyomi.source.SourceManager
|
||||
import eu.kanade.tachiyomi.source.model.SManga
|
||||
import eu.kanade.tachiyomi.source.online.all.MergedSource
|
||||
import eu.kanade.tachiyomi.ui.base.controller.withFadeTransaction
|
||||
import eu.kanade.tachiyomi.ui.manga.MangaController
|
||||
import eu.kanade.tachiyomi.ui.migration.MigrationFlags
|
||||
import eu.kanade.tachiyomi.util.view.gone
|
||||
import eu.kanade.tachiyomi.util.view.inflate
|
||||
import eu.kanade.tachiyomi.util.view.visible
|
||||
import exh.MERGED_SOURCE_ID
|
||||
import exh.util.await
|
||||
import java.text.DateFormat
|
||||
import java.text.DecimalFormat
|
||||
import java.util.Date
|
||||
import kotlin.coroutines.CoroutineContext
|
||||
import kotlinx.android.synthetic.main.eh_manga_card.view.loading_group
|
||||
import kotlinx.android.synthetic.main.eh_manga_card.view.manga_artist
|
||||
import kotlinx.android.synthetic.main.eh_manga_card.view.manga_author
|
||||
import kotlinx.android.synthetic.main.eh_manga_card.view.manga_chapters
|
||||
import kotlinx.android.synthetic.main.eh_manga_card.view.manga_cover
|
||||
import kotlinx.android.synthetic.main.eh_manga_card.view.manga_full_title
|
||||
import kotlinx.android.synthetic.main.eh_manga_card.view.manga_last_chapter
|
||||
import kotlinx.android.synthetic.main.eh_manga_card.view.manga_last_update
|
||||
import kotlinx.android.synthetic.main.eh_manga_card.view.manga_source
|
||||
import kotlinx.android.synthetic.main.eh_manga_card.view.manga_source_label
|
||||
import kotlinx.android.synthetic.main.eh_manga_card.view.manga_status
|
||||
import kotlinx.android.synthetic.main.eh_manga_card.view.search_progress
|
||||
import kotlinx.android.synthetic.main.eh_manga_card.view.search_status
|
||||
import kotlinx.android.synthetic.main.eh_migration_process_item.view.accept_migration
|
||||
import kotlinx.android.synthetic.main.eh_migration_process_item.view.eh_manga_card_from
|
||||
import kotlinx.android.synthetic.main.eh_migration_process_item.view.eh_manga_card_to
|
||||
import kotlinx.android.synthetic.main.eh_migration_process_item.view.migrating_frame
|
||||
import kotlinx.android.synthetic.main.eh_migration_process_item.view.skip_migration
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.flow.asFlow
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
|
||||
class MigrationProcedureAdapter(
|
||||
val controller: MigrationProcedureController,
|
||||
val migratingManga: List<MigratingManga>,
|
||||
override val coroutineContext: CoroutineContext
|
||||
) : androidx.viewpager.widget.PagerAdapter(), CoroutineScope {
|
||||
private val db: DatabaseHelper by injectLazy()
|
||||
private val gson: Gson by injectLazy()
|
||||
private val sourceManager: SourceManager by injectLazy()
|
||||
|
||||
private val logger = XLog.tag(this::class.simpleName)
|
||||
|
||||
override fun isViewFromObject(p0: View, p1: Any): Boolean {
|
||||
return p0 == p1
|
||||
}
|
||||
|
||||
override fun getCount() = migratingManga.size
|
||||
|
||||
override fun instantiateItem(container: ViewGroup, position: Int): Any {
|
||||
val item = migratingManga[position]
|
||||
val view = container.inflate(R.layout.eh_migration_process_item)
|
||||
container.addView(view)
|
||||
|
||||
view.skip_migration.setOnClickListener {
|
||||
controller.nextMigration()
|
||||
}
|
||||
|
||||
val viewTag = ViewTag(coroutineContext)
|
||||
view.tag = viewTag
|
||||
view.setupView(viewTag, item)
|
||||
|
||||
view.accept_migration.setOnClickListener {
|
||||
viewTag.launch(Dispatchers.Main) {
|
||||
view.migrating_frame.visible()
|
||||
try {
|
||||
withContext(Dispatchers.Default) {
|
||||
performMigration(item)
|
||||
}
|
||||
controller.nextMigration()
|
||||
} catch (e: Exception) {
|
||||
logger.e("Migration failure!", e)
|
||||
controller.migrationFailure()
|
||||
}
|
||||
view.migrating_frame.gone()
|
||||
}
|
||||
}
|
||||
|
||||
return view
|
||||
}
|
||||
|
||||
suspend fun performMigration(manga: MigratingManga) {
|
||||
if (!manga.searchResult.initialized) {
|
||||
return
|
||||
}
|
||||
|
||||
val toMangaObj = db.getManga(manga.searchResult.get() ?: return).await() ?: return
|
||||
|
||||
withContext(Dispatchers.IO) {
|
||||
migrateMangaInternal(
|
||||
manga.manga() ?: return@withContext,
|
||||
toMangaObj,
|
||||
!controller.config.copy
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun migrateMangaInternal(
|
||||
prevManga: Manga,
|
||||
manga: Manga,
|
||||
replace: Boolean
|
||||
) {
|
||||
db.inTransaction {
|
||||
// Update chapters read
|
||||
if (MigrationFlags.hasChapters(controller.config.migrationFlags)) {
|
||||
val prevMangaChapters = db.getChapters(prevManga).executeAsBlocking()
|
||||
val maxChapterRead = prevMangaChapters.filter { it.read }
|
||||
.maxBy { it.chapter_number }?.chapter_number
|
||||
if (maxChapterRead != null) {
|
||||
val dbChapters = db.getChapters(manga).executeAsBlocking()
|
||||
for (chapter in dbChapters) {
|
||||
if (chapter.isRecognizedNumber && chapter.chapter_number <= maxChapterRead) {
|
||||
chapter.read = true
|
||||
}
|
||||
}
|
||||
db.insertChapters(dbChapters).executeAsBlocking()
|
||||
}
|
||||
}
|
||||
// Update categories
|
||||
if (MigrationFlags.hasCategories(controller.config.migrationFlags)) {
|
||||
val categories = db.getCategoriesForManga(prevManga).executeAsBlocking()
|
||||
val mangaCategories = categories.map { MangaCategory.create(manga, it) }
|
||||
db.setMangaCategories(mangaCategories, listOf(manga))
|
||||
}
|
||||
// Update track
|
||||
if (MigrationFlags.hasTracks(controller.config.migrationFlags)) {
|
||||
val tracks = db.getTracks(prevManga).executeAsBlocking()
|
||||
for (track in tracks) {
|
||||
track.id = null
|
||||
track.manga_id = manga.id!!
|
||||
}
|
||||
db.insertTracks(tracks).executeAsBlocking()
|
||||
}
|
||||
// Update favorite status
|
||||
if (replace) {
|
||||
prevManga.favorite = false
|
||||
db.updateMangaFavorite(prevManga).executeAsBlocking()
|
||||
}
|
||||
manga.favorite = true
|
||||
db.updateMangaFavorite(manga).executeAsBlocking()
|
||||
|
||||
// SearchPresenter#networkToLocalManga may have updated the manga title, so ensure db gets updated title
|
||||
db.updateMangaTitle(manga).executeAsBlocking()
|
||||
}
|
||||
}
|
||||
|
||||
fun View.setupView(tag: ViewTag, migratingManga: MigratingManga) {
|
||||
tag.launch {
|
||||
val manga = migratingManga.manga()
|
||||
val source = migratingManga.mangaSource()
|
||||
if (manga != null) {
|
||||
withContext(Dispatchers.Main) {
|
||||
eh_manga_card_from.loading_group.gone()
|
||||
eh_manga_card_from.attachManga(tag, manga, source)
|
||||
eh_manga_card_from.setOnClickListener {
|
||||
controller.router.pushController(MangaController(manga, true).withFadeTransaction())
|
||||
}
|
||||
}
|
||||
|
||||
tag.launch {
|
||||
migratingManga.progress.asFlow().collect { (max, progress) ->
|
||||
withContext(Dispatchers.Main) {
|
||||
eh_manga_card_to.search_progress.let { progressBar ->
|
||||
progressBar.max = max
|
||||
progressBar.progress = progress
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val searchResult = migratingManga.searchResult.get()?.let {
|
||||
db.getManga(it).await()
|
||||
}
|
||||
val resultSource = searchResult?.source?.let {
|
||||
sourceManager.get(it)
|
||||
}
|
||||
withContext(Dispatchers.Main) {
|
||||
if (searchResult != null && resultSource != null) {
|
||||
eh_manga_card_to.loading_group.gone()
|
||||
eh_manga_card_to.attachManga(tag, searchResult, resultSource)
|
||||
eh_manga_card_to.setOnClickListener {
|
||||
controller.router.pushController(MangaController(searchResult, true).withFadeTransaction())
|
||||
}
|
||||
accept_migration.isEnabled = true
|
||||
accept_migration.alpha = 1.0f
|
||||
} else {
|
||||
eh_manga_card_to.search_progress.gone()
|
||||
eh_manga_card_to.search_status.text = "Found no manga"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun View.attachManga(tag: ViewTag, manga: Manga, source: Source) {
|
||||
// TODO Duplicated in MangaInfoController
|
||||
|
||||
GlideApp.with(context)
|
||||
.load(manga)
|
||||
.diskCacheStrategy(DiskCacheStrategy.RESOURCE)
|
||||
.centerCrop()
|
||||
.into(manga_cover)
|
||||
|
||||
manga_full_title.text = if (manga.title.isBlank()) {
|
||||
context.getString(R.string.unknown)
|
||||
} else {
|
||||
manga.title
|
||||
}
|
||||
|
||||
manga_artist.text = if (manga.artist.isNullOrBlank()) {
|
||||
context.getString(R.string.unknown)
|
||||
} else {
|
||||
manga.artist
|
||||
}
|
||||
|
||||
manga_author.text = if (manga.author.isNullOrBlank()) {
|
||||
context.getString(R.string.unknown)
|
||||
} else {
|
||||
manga.author
|
||||
}
|
||||
|
||||
manga_source.text = if (source.id == MERGED_SOURCE_ID) {
|
||||
MergedSource.MangaConfig.readFromUrl(gson, manga.url).children.map {
|
||||
sourceManager.getOrStub(it.source).toString()
|
||||
}.distinct().joinToString()
|
||||
} else {
|
||||
source.toString()
|
||||
}
|
||||
|
||||
if (source.id == MERGED_SOURCE_ID) {
|
||||
manga_source_label.text = "Sources"
|
||||
} else {
|
||||
manga_source_label.setText(R.string.manga_info_source_label)
|
||||
}
|
||||
|
||||
manga_status.setText(when (manga.status) {
|
||||
SManga.ONGOING -> R.string.ongoing
|
||||
SManga.COMPLETED -> R.string.completed
|
||||
SManga.LICENSED -> R.string.licensed
|
||||
else -> R.string.unknown
|
||||
})
|
||||
|
||||
val mangaChapters = db.getChapters(manga).await()
|
||||
manga_chapters.text = mangaChapters.size.toString()
|
||||
val latestChapter = mangaChapters.maxBy { it.chapter_number }?.chapter_number ?: -1f
|
||||
val lastUpdate = Date(mangaChapters.maxBy { it.date_upload }?.date_upload ?: 0)
|
||||
|
||||
if (latestChapter > 0f) {
|
||||
manga_last_chapter.text = DecimalFormat("#.#").format(latestChapter)
|
||||
} else {
|
||||
manga_last_chapter.setText(R.string.unknown)
|
||||
}
|
||||
|
||||
if (lastUpdate.time != 0L) {
|
||||
manga_last_update.text = DateFormat.getDateInstance(DateFormat.SHORT).format(lastUpdate)
|
||||
} else {
|
||||
manga_last_update.setText(R.string.unknown)
|
||||
}
|
||||
}
|
||||
|
||||
override fun destroyItem(container: ViewGroup, position: Int, `object`: Any) {
|
||||
val objectAsView = `object` as View
|
||||
container.removeView(objectAsView)
|
||||
(objectAsView.tag as? ViewTag)?.destroy()
|
||||
}
|
||||
|
||||
class ViewTag(parent: CoroutineContext) : CoroutineScope {
|
||||
/**
|
||||
* The context of this scope.
|
||||
* Context is encapsulated by the scope and used for implementation of coroutine builders that are extensions on the scope.
|
||||
* Accessing this property in general code is not recommended for any purposes except accessing the [Job] instance for advanced usages.
|
||||
*
|
||||
* By convention, should contain an instance of a [job][Job] to enforce structured concurrency.
|
||||
*/
|
||||
override val coroutineContext = parent + Job() + Dispatchers.Default
|
||||
|
||||
fun destroy() {
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
package exh.ui.migration.manga.process
|
||||
|
||||
import android.os.Parcelable
|
||||
import kotlinx.android.parcel.Parcelize
|
||||
|
||||
@Parcelize
|
||||
data class MigrationProcedureConfig(
|
||||
val mangaIds: List<Long>,
|
||||
val targetSourceIds: List<Long>,
|
||||
val useSourceWithMostChapters: Boolean,
|
||||
val enableLenientSearch: Boolean,
|
||||
val migrationFlags: Int,
|
||||
val copy: Boolean,
|
||||
val extraSearchParams: String?
|
||||
) : Parcelable
|
||||
@@ -1,252 +0,0 @@
|
||||
package exh.ui.migration.manga.process
|
||||
|
||||
import android.content.pm.ActivityInfo
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import com.afollestad.materialdialogs.MaterialDialog
|
||||
import com.elvishew.xlog.XLog
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.data.database.DatabaseHelper
|
||||
import eu.kanade.tachiyomi.source.CatalogueSource
|
||||
import eu.kanade.tachiyomi.source.SourceManager
|
||||
import eu.kanade.tachiyomi.util.chapter.syncChaptersWithSource
|
||||
import eu.kanade.tachiyomi.util.system.toast
|
||||
import exh.smartsearch.SmartSearchEngine
|
||||
import exh.ui.base.BaseExhController
|
||||
import exh.util.await
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import kotlinx.android.synthetic.main.eh_migration_process.pager
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Semaphore
|
||||
import kotlinx.coroutines.sync.withPermit
|
||||
import kotlinx.coroutines.withContext
|
||||
import rx.schedulers.Schedulers
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
|
||||
// TODO Will probably implode if activity is fully destroyed
|
||||
class MigrationProcedureController(bundle: Bundle? = null) : BaseExhController(bundle), CoroutineScope {
|
||||
override val layoutId = R.layout.eh_migration_process
|
||||
|
||||
private var titleText = "Migrate manga"
|
||||
|
||||
private var adapter: MigrationProcedureAdapter? = null
|
||||
|
||||
val config: MigrationProcedureConfig = args.getParcelable(CONFIG_EXTRA)
|
||||
|
||||
private val db: DatabaseHelper by injectLazy()
|
||||
private val sourceManager: SourceManager by injectLazy()
|
||||
|
||||
private val smartSearchEngine = SmartSearchEngine(coroutineContext, config.extraSearchParams)
|
||||
|
||||
private val logger = XLog.tag("MigrationProcedureController")
|
||||
|
||||
private var migrationsJob: Job? = null
|
||||
private var migratingManga: List<MigratingManga>? = null
|
||||
|
||||
override fun getTitle(): String {
|
||||
return titleText
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View) {
|
||||
super.onViewCreated(view)
|
||||
setTitle()
|
||||
|
||||
activity?.requestedOrientation = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR2) {
|
||||
ActivityInfo.SCREEN_ORIENTATION_USER_PORTRAIT
|
||||
} else {
|
||||
ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
|
||||
}
|
||||
|
||||
val newMigratingManga = migratingManga ?: run {
|
||||
val new = config.mangaIds.map {
|
||||
MigratingManga(db, sourceManager, it, coroutineContext)
|
||||
}
|
||||
migratingManga = new
|
||||
new
|
||||
}
|
||||
|
||||
adapter = MigrationProcedureAdapter(this, newMigratingManga, coroutineContext)
|
||||
|
||||
pager.adapter = adapter
|
||||
pager.isEnabled = false
|
||||
|
||||
if (migrationsJob == null) {
|
||||
migrationsJob = launch {
|
||||
runMigrations(newMigratingManga)
|
||||
}
|
||||
}
|
||||
|
||||
pager.post {
|
||||
// pager.currentItem doesn't appear to be valid if we don't do this in a post
|
||||
updateTitle()
|
||||
}
|
||||
}
|
||||
|
||||
fun updateTitle() {
|
||||
titleText = "Migrate manga (${pager.currentItem + 1}/${adapter?.count ?: 0})"
|
||||
setTitle()
|
||||
}
|
||||
|
||||
fun nextMigration() {
|
||||
adapter?.let { adapter ->
|
||||
if (pager.currentItem >= adapter.count - 1) {
|
||||
applicationContext?.toast("All migrations complete!")
|
||||
router.popCurrentController()
|
||||
} else {
|
||||
adapter.migratingManga[pager.currentItem].migrationJob.cancel()
|
||||
pager.setCurrentItem(pager.currentItem + 1, true)
|
||||
launch(Dispatchers.Main) {
|
||||
updateTitle()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun migrationFailure() {
|
||||
activity?.let {
|
||||
MaterialDialog.Builder(it)
|
||||
.title("Migration failure")
|
||||
.content("An unknown error occured while migrating this manga!")
|
||||
.positiveText("Ok")
|
||||
.show()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun runMigrations(mangas: List<MigratingManga>) {
|
||||
val sources = config.targetSourceIds.mapNotNull { sourceManager.get(it) as? CatalogueSource }
|
||||
|
||||
for (manga in mangas) {
|
||||
if (!manga.searchResult.initialized && manga.migrationJob.isActive) {
|
||||
val mangaObj = manga.manga()
|
||||
|
||||
if (mangaObj == null) {
|
||||
manga.searchResult.initialize(null)
|
||||
continue
|
||||
}
|
||||
|
||||
val mangaSource = manga.mangaSource()
|
||||
|
||||
val result = try {
|
||||
CoroutineScope(manga.migrationJob).async {
|
||||
val validSources = sources.filter {
|
||||
it.id != mangaSource.id
|
||||
}
|
||||
if (config.useSourceWithMostChapters) {
|
||||
val sourceSemaphore = Semaphore(3)
|
||||
val processedSources = AtomicInteger()
|
||||
|
||||
validSources.map { source ->
|
||||
async {
|
||||
sourceSemaphore.withPermit {
|
||||
try {
|
||||
val searchResult = if (config.enableLenientSearch) {
|
||||
smartSearchEngine.smartSearch(source, mangaObj.title)
|
||||
} else {
|
||||
smartSearchEngine.normalSearch(source, mangaObj.title)
|
||||
}
|
||||
|
||||
if (searchResult != null) {
|
||||
val localManga = smartSearchEngine.networkToLocalManga(searchResult, source.id)
|
||||
val chapters = source.fetchChapterList(localManga).toSingle().await(Schedulers.io())
|
||||
withContext(Dispatchers.IO) {
|
||||
syncChaptersWithSource(db, chapters, localManga, source)
|
||||
}
|
||||
manga.progress.send(validSources.size to processedSources.incrementAndGet())
|
||||
localManga to chapters.size
|
||||
} else {
|
||||
null
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
// Ignore cancellations
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
logger.e("Failed to search in source: ${source.id}!", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
}.mapNotNull { it.await() }.maxBy { it.second }?.first
|
||||
} else {
|
||||
validSources.forEachIndexed { index, source ->
|
||||
val searchResult = try {
|
||||
val searchResult = if (config.enableLenientSearch) {
|
||||
smartSearchEngine.smartSearch(source, mangaObj.title)
|
||||
} else {
|
||||
smartSearchEngine.normalSearch(source, mangaObj.title)
|
||||
}
|
||||
|
||||
if (searchResult != null) {
|
||||
val localManga = smartSearchEngine.networkToLocalManga(searchResult, source.id)
|
||||
val chapters = source.fetchChapterList(localManga).toSingle().await(Schedulers.io())
|
||||
withContext(Dispatchers.IO) {
|
||||
syncChaptersWithSource(db, chapters, localManga, source)
|
||||
}
|
||||
localManga
|
||||
} else null
|
||||
} catch (e: CancellationException) {
|
||||
// Ignore cancellations
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
logger.e("Failed to search in source: ${source.id}!", e)
|
||||
null
|
||||
}
|
||||
|
||||
manga.progress.send(validSources.size to (index + 1))
|
||||
|
||||
if (searchResult != null) return@async searchResult
|
||||
}
|
||||
|
||||
null
|
||||
}
|
||||
}.await()
|
||||
} catch (e: CancellationException) {
|
||||
// Ignore canceled migrations
|
||||
continue
|
||||
}
|
||||
|
||||
if (result != null && result.thumbnail_url == null) {
|
||||
try {
|
||||
val newManga = sourceManager.getOrStub(result.source)
|
||||
.fetchMangaDetails(result)
|
||||
.toSingle()
|
||||
.await()
|
||||
result.copyFrom(newManga)
|
||||
|
||||
db.insertManga(result).await()
|
||||
} catch (e: CancellationException) {
|
||||
// Ignore cancellations
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
logger.e("Could not load search manga details", e)
|
||||
}
|
||||
}
|
||||
|
||||
manga.searchResult.initialize(result?.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
|
||||
activity?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val CONFIG_EXTRA = "config_extra"
|
||||
|
||||
fun create(config: MigrationProcedureConfig): MigrationProcedureController {
|
||||
return MigrationProcedureController(Bundle().apply {
|
||||
putParcelable(CONFIG_EXTRA, config)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
package exh.ui.smartsearch
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.source.CatalogueSource
|
||||
import eu.kanade.tachiyomi.source.SourceManager
|
||||
import eu.kanade.tachiyomi.ui.base.controller.NucleusController
|
||||
import eu.kanade.tachiyomi.ui.base.controller.withFadeTransaction
|
||||
import eu.kanade.tachiyomi.ui.manga.MangaController
|
||||
import eu.kanade.tachiyomi.ui.source.SourceController
|
||||
import eu.kanade.tachiyomi.ui.source.browse.BrowseSourceController
|
||||
import eu.kanade.tachiyomi.util.system.toast
|
||||
import kotlinx.android.synthetic.main.eh_smart_search.appbar
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
|
||||
class SmartSearchController(bundle: Bundle? = null) : NucleusController<SmartSearchPresenter>(), CoroutineScope {
|
||||
override val coroutineContext = Job() + Dispatchers.Main
|
||||
|
||||
private val sourceManager: SourceManager by injectLazy()
|
||||
|
||||
private val source = sourceManager.get(bundle?.getLong(ARG_SOURCE_ID, -1) ?: -1) as? CatalogueSource
|
||||
private val smartSearchConfig: SourceController.SmartSearchConfig? = bundle?.getParcelable(ARG_SMART_SEARCH_CONFIG)
|
||||
|
||||
override fun inflateView(inflater: LayoutInflater, container: ViewGroup) =
|
||||
inflater.inflate(R.layout.eh_smart_search, container, false)!!
|
||||
|
||||
override fun getTitle() = source?.name ?: ""
|
||||
|
||||
override fun createPresenter() = SmartSearchPresenter(source, smartSearchConfig)
|
||||
|
||||
override fun onViewCreated(view: View) {
|
||||
super.onViewCreated(view)
|
||||
|
||||
appbar.bringToFront()
|
||||
|
||||
if (source == null || smartSearchConfig == null) {
|
||||
router.popCurrentController()
|
||||
applicationContext?.toast("Missing data!")
|
||||
return
|
||||
}
|
||||
|
||||
// Init presenter now to resolve threading issues
|
||||
presenter
|
||||
|
||||
launch(Dispatchers.Default) {
|
||||
for (event in presenter.smartSearchChannel) {
|
||||
withContext(NonCancellable) {
|
||||
if (event is SmartSearchPresenter.SearchResults.Found) {
|
||||
val transaction = MangaController(event.manga, true, smartSearchConfig).withFadeTransaction()
|
||||
withContext(Dispatchers.Main) {
|
||||
router.replaceTopController(transaction)
|
||||
}
|
||||
} else {
|
||||
if (event is SmartSearchPresenter.SearchResults.NotFound) {
|
||||
applicationContext?.toast("Couldn't find the manga in the source!")
|
||||
} else {
|
||||
applicationContext?.toast("Error performing automatic search!")
|
||||
}
|
||||
|
||||
val transaction = BrowseSourceController(source, smartSearchConfig.origTitle, smartSearchConfig).withFadeTransaction()
|
||||
withContext(Dispatchers.Main) {
|
||||
router.replaceTopController(transaction)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
|
||||
cancel()
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val ARG_SOURCE_ID = "SOURCE_ID"
|
||||
const val ARG_SMART_SEARCH_CONFIG = "SMART_SEARCH_CONFIG"
|
||||
}
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
package exh.ui.smartsearch
|
||||
|
||||
import android.os.Bundle
|
||||
import com.elvishew.xlog.XLog
|
||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||
import eu.kanade.tachiyomi.source.CatalogueSource
|
||||
import eu.kanade.tachiyomi.source.model.SManga
|
||||
import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
|
||||
import eu.kanade.tachiyomi.ui.source.SourceController
|
||||
import exh.smartsearch.SmartSearchEngine
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class SmartSearchPresenter(private val source: CatalogueSource?, private val config: SourceController.SmartSearchConfig?) :
|
||||
BasePresenter<SmartSearchController>(), CoroutineScope {
|
||||
private val logger = XLog.tag("SmartSearchPresenter")
|
||||
|
||||
override val coroutineContext = Job() + Dispatchers.Main
|
||||
|
||||
val smartSearchChannel = Channel<SearchResults>()
|
||||
|
||||
private val smartSearchEngine = SmartSearchEngine(coroutineContext)
|
||||
|
||||
override fun onCreate(savedState: Bundle?) {
|
||||
super.onCreate(savedState)
|
||||
|
||||
if (source != null && config != null) {
|
||||
launch(Dispatchers.Default) {
|
||||
val result = try {
|
||||
val resultManga = smartSearchEngine.smartSearch(source, config.origTitle)
|
||||
if (resultManga != null) {
|
||||
val localManga = smartSearchEngine.networkToLocalManga(resultManga, source.id)
|
||||
SearchResults.Found(localManga)
|
||||
} else {
|
||||
SearchResults.NotFound
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) {
|
||||
throw e
|
||||
} else {
|
||||
logger.e("Smart search error", e)
|
||||
SearchResults.Error
|
||||
}
|
||||
}
|
||||
|
||||
smartSearchChannel.send(result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
|
||||
cancel()
|
||||
}
|
||||
|
||||
data class SearchEntry(val manga: SManga, val dist: Double)
|
||||
|
||||
sealed class SearchResults {
|
||||
data class Found(val manga: Manga) : SearchResults()
|
||||
object NotFound : SearchResults()
|
||||
object Error : SearchResults()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user