Implemented J2K Auto Source Migration

(cherry picked from commit 8ba75831e6f51f6472d85f813405ede3f679cfd8)
This commit is contained in:
jobobby04
2020-04-16 20:56:52 -04:00
committed by Jobobby04
parent c62d3abbc5
commit e7b39f29f2
40 changed files with 742 additions and 326 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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