feat: batch processing for recommendations & sort by relevancy (#1383)
* refactor: use NoResultsException * refactor: cleanup RecommendationPagingSources * refactor: turn wake/wifi lock functions into reusable extensions * feat: implement batch recommendation (initial version) * fix: serialization issues * fix: wrong source id * refactor: increase performance using virtual paging * refactor: update string * refactor: handle 404 of MD source correctly * style: add newline * refactor: create universal throttle manager * refactor: throttle requests * chore: remove unused strings * feat: rank recommendations by match count * feat: add badges indicating match count to batch recommendations * fix: handle rec search with no results * fix: validate flags in pre-search bottom sheet * feat: implement 'hide library entries' for recommendation search using custom SmartSearchEngine for library items * style: run spotless * fix: cancel button * fix: racing condition causing loss of state
This commit is contained in:
parent
28cca49635
commit
254980695b
@ -19,6 +19,7 @@ import eu.kanade.presentation.library.components.MangaComfortableGridItem
|
||||
import eu.kanade.tachiyomi.R
|
||||
import exh.metadata.metadata.MangaDexSearchMetadata
|
||||
import exh.metadata.metadata.RaisedSearchMetadata
|
||||
import exh.metadata.metadata.RankedSearchMetadata
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import tachiyomi.domain.manga.model.Manga
|
||||
import tachiyomi.domain.manga.model.MangaCover
|
||||
@ -119,6 +120,14 @@ private fun BrowseSourceComfortableGridItem(
|
||||
textColor = MaterialTheme.colorScheme.onTertiary,
|
||||
)
|
||||
}
|
||||
} else if (metadata is RankedSearchMetadata) {
|
||||
metadata.rank?.let {
|
||||
Badge(
|
||||
text = "+$it",
|
||||
color = MaterialTheme.colorScheme.tertiary,
|
||||
textColor = MaterialTheme.colorScheme.onTertiary,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
// SY <--
|
||||
|
@ -19,6 +19,7 @@ import eu.kanade.presentation.library.components.MangaCompactGridItem
|
||||
import eu.kanade.tachiyomi.R
|
||||
import exh.metadata.metadata.MangaDexSearchMetadata
|
||||
import exh.metadata.metadata.RaisedSearchMetadata
|
||||
import exh.metadata.metadata.RankedSearchMetadata
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import tachiyomi.domain.manga.model.Manga
|
||||
import tachiyomi.domain.manga.model.MangaCover
|
||||
@ -119,6 +120,14 @@ private fun BrowseSourceCompactGridItem(
|
||||
textColor = MaterialTheme.colorScheme.onTertiary,
|
||||
)
|
||||
}
|
||||
} else if (metadata is RankedSearchMetadata) {
|
||||
metadata.rank?.let {
|
||||
Badge(
|
||||
text = "+$it",
|
||||
color = MaterialTheme.colorScheme.tertiary,
|
||||
textColor = MaterialTheme.colorScheme.onTertiary,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
// SY <--
|
||||
|
@ -16,6 +16,7 @@ import eu.kanade.presentation.library.components.MangaListItem
|
||||
import eu.kanade.tachiyomi.R
|
||||
import exh.metadata.metadata.MangaDexSearchMetadata
|
||||
import exh.metadata.metadata.RaisedSearchMetadata
|
||||
import exh.metadata.metadata.RankedSearchMetadata
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import tachiyomi.domain.manga.model.Manga
|
||||
import tachiyomi.domain.manga.model.MangaCover
|
||||
@ -110,6 +111,14 @@ private fun BrowseSourceListItem(
|
||||
textColor = MaterialTheme.colorScheme.onTertiary,
|
||||
)
|
||||
}
|
||||
} else if (metadata is RankedSearchMetadata) {
|
||||
metadata.rank?.let {
|
||||
Badge(
|
||||
text = "+$it",
|
||||
color = MaterialTheme.colorScheme.tertiary,
|
||||
textColor = MaterialTheme.colorScheme.onTertiary,
|
||||
)
|
||||
}
|
||||
}
|
||||
// SY <--
|
||||
},
|
||||
|
@ -28,7 +28,6 @@ import androidx.compose.material.icons.outlined.BookmarkRemove
|
||||
import androidx.compose.material.icons.outlined.Delete
|
||||
import androidx.compose.material.icons.outlined.DoneAll
|
||||
import androidx.compose.material.icons.outlined.Download
|
||||
import androidx.compose.material.icons.outlined.Label
|
||||
import androidx.compose.material.icons.outlined.MoreVert
|
||||
import androidx.compose.material.icons.outlined.RemoveDone
|
||||
import androidx.compose.material.icons.outlined.SwapCalls
|
||||
@ -237,6 +236,7 @@ fun LibraryBottomActionMenu(
|
||||
// SY -->
|
||||
onClickCleanTitles: (() -> Unit)?,
|
||||
onClickMigrate: (() -> Unit)?,
|
||||
onClickCollectRecommendations: (() -> Unit)?,
|
||||
onClickAddToMangaDex: (() -> Unit)?,
|
||||
onClickResetInfo: (() -> Unit)?,
|
||||
// SY <--
|
||||
@ -267,7 +267,10 @@ fun LibraryBottomActionMenu(
|
||||
}
|
||||
}
|
||||
// SY -->
|
||||
val showOverflow = onClickCleanTitles != null || onClickAddToMangaDex != null || onClickResetInfo != null
|
||||
val showOverflow = onClickCleanTitles != null ||
|
||||
onClickAddToMangaDex != null ||
|
||||
onClickResetInfo != null ||
|
||||
onClickCollectRecommendations != null
|
||||
val configuration = LocalConfiguration.current
|
||||
val moveMarkPrev = remember { !configuration.isTabletUi() }
|
||||
var overFlowOpen by remember { mutableStateOf(false) }
|
||||
@ -358,6 +361,12 @@ fun LibraryBottomActionMenu(
|
||||
onClick = onClickMigrate,
|
||||
)
|
||||
}
|
||||
if (onClickCollectRecommendations != null) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(SYMR.strings.rec_search_short)) },
|
||||
onClick = onClickCollectRecommendations,
|
||||
)
|
||||
}
|
||||
if (onClickAddToMangaDex != null) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(SYMR.strings.mangadex_add_to_follows)) },
|
||||
|
@ -17,9 +17,9 @@ import eu.kanade.tachiyomi.ui.browse.migration.MigrationFlags
|
||||
import eu.kanade.tachiyomi.ui.browse.migration.advanced.design.MigrationType
|
||||
import eu.kanade.tachiyomi.ui.browse.migration.advanced.process.MigratingManga.SearchResult
|
||||
import eu.kanade.tachiyomi.util.system.toast
|
||||
import exh.eh.EHentaiThrottleManager
|
||||
import exh.smartsearch.SmartSearchEngine
|
||||
import exh.smartsearch.SmartSourceSearchEngine
|
||||
import exh.source.MERGED_SOURCE_ID
|
||||
import exh.util.ThrottleManager
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.CancellationException
|
||||
@ -84,8 +84,8 @@ class MigrationListScreenModel(
|
||||
private val deleteTrack: DeleteTrack = Injekt.get(),
|
||||
) : ScreenModel {
|
||||
|
||||
private val smartSearchEngine = SmartSearchEngine(config.extraSearchParams)
|
||||
private val throttleManager = EHentaiThrottleManager()
|
||||
private val smartSearchEngine = SmartSourceSearchEngine(config.extraSearchParams)
|
||||
private val throttleManager = ThrottleManager()
|
||||
|
||||
val migratingItems = MutableStateFlow<ImmutableList<MigratingManga>?>(null)
|
||||
val migrationDone = MutableStateFlow(false)
|
||||
|
@ -42,6 +42,7 @@ import exh.md.utils.FollowStatus
|
||||
import exh.md.utils.MdUtil
|
||||
import exh.metadata.sql.models.SearchTag
|
||||
import exh.metadata.sql.models.SearchTitle
|
||||
import exh.recs.batch.RecommendationSearchHelper
|
||||
import exh.search.Namespace
|
||||
import exh.search.QueryComponent
|
||||
import exh.search.SearchEngine
|
||||
@ -61,6 +62,7 @@ import kotlinx.collections.immutable.mutate
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.asFlow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
@ -160,6 +162,9 @@ class LibraryScreenModel(
|
||||
|
||||
// SY -->
|
||||
val favoritesSync = FavoritesSyncHelper(preferences.context)
|
||||
val recommendationSearch = RecommendationSearchHelper(preferences.context)
|
||||
|
||||
private var recommendationSearchJob: Job? = null
|
||||
// SY <--
|
||||
|
||||
init {
|
||||
@ -898,6 +903,10 @@ class LibraryScreenModel(
|
||||
}
|
||||
|
||||
// SY -->
|
||||
fun showRecommendationSearchDialog() {
|
||||
val mangaList = state.value.selection.map { it.manga }
|
||||
mutableState.update { it.copy(dialog = Dialog.RecommendationSearchSheet(mangaList)) }
|
||||
}
|
||||
|
||||
fun getCategoryName(
|
||||
context: Context,
|
||||
@ -1222,8 +1231,12 @@ class LibraryScreenModel(
|
||||
val initialSelection: ImmutableList<CheckboxState<Category>>,
|
||||
) : Dialog
|
||||
data class DeleteManga(val manga: List<Manga>) : Dialog
|
||||
|
||||
// SY -->
|
||||
data object SyncFavoritesWarning : Dialog
|
||||
data object SyncFavoritesConfirm : Dialog
|
||||
data class RecommendationSearchSheet(val manga: List<Manga>) : Dialog
|
||||
// SY <--
|
||||
}
|
||||
|
||||
// SY -->
|
||||
@ -1316,6 +1329,16 @@ class LibraryScreenModel(
|
||||
}.toSortedMap(compareBy { it.order })
|
||||
}
|
||||
|
||||
fun runRecommendationSearch(selection: List<Manga>) {
|
||||
recommendationSearch.runSearch(screenModelScope, selection)?.let {
|
||||
recommendationSearchJob = it
|
||||
}
|
||||
}
|
||||
|
||||
fun cancelRecommendationSearch() {
|
||||
recommendationSearchJob?.cancel()
|
||||
}
|
||||
|
||||
fun runSync() {
|
||||
favoritesSync.runSync(screenModelScope)
|
||||
}
|
||||
|
@ -51,6 +51,10 @@ import eu.kanade.tachiyomi.ui.manga.MangaScreen
|
||||
import eu.kanade.tachiyomi.ui.reader.ReaderActivity
|
||||
import eu.kanade.tachiyomi.util.system.toast
|
||||
import exh.favorites.FavoritesSyncStatus
|
||||
import exh.recs.RecommendsScreen
|
||||
import exh.recs.batch.RecommendationSearchBottomSheetDialog
|
||||
import exh.recs.batch.RecommendationSearchProgressDialog
|
||||
import exh.recs.batch.SearchStatus
|
||||
import exh.source.MERGED_SOURCE_ID
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
@ -205,6 +209,7 @@ data object LibraryTab : Tab {
|
||||
context.toast(SYMR.strings.no_valid_entry)
|
||||
}
|
||||
},
|
||||
onClickCollectRecommendations = screenModel::showRecommendationSearchDialog.takeIf { state.selection.size > 1 },
|
||||
onClickAddToMangaDex = screenModel::syncMangaToDex.takeIf { state.showAddToMangadex },
|
||||
onClickResetInfo = screenModel::resetInfo.takeIf { state.showResetInfo },
|
||||
// SY <--
|
||||
@ -310,6 +315,7 @@ data object LibraryTab : Tab {
|
||||
},
|
||||
)
|
||||
}
|
||||
// SY -->
|
||||
LibraryScreenModel.Dialog.SyncFavoritesWarning -> {
|
||||
SyncFavoritesWarningDialog(
|
||||
onDismissRequest = onDismissRequest,
|
||||
@ -328,6 +334,17 @@ data object LibraryTab : Tab {
|
||||
},
|
||||
)
|
||||
}
|
||||
is LibraryScreenModel.Dialog.RecommendationSearchSheet -> {
|
||||
RecommendationSearchBottomSheetDialog(
|
||||
onDismissRequest = onDismissRequest,
|
||||
onSearchRequest = {
|
||||
onDismissRequest()
|
||||
screenModel.clearSelection()
|
||||
screenModel.runRecommendationSearch(dialog.manga)
|
||||
},
|
||||
)
|
||||
}
|
||||
// SY <--
|
||||
null -> {}
|
||||
}
|
||||
|
||||
@ -337,6 +354,12 @@ data object LibraryTab : Tab {
|
||||
setStatusIdle = { screenModel.favoritesSync.status.value = FavoritesSyncStatus.Idle },
|
||||
openManga = { navigator.push(MangaScreen(it)) },
|
||||
)
|
||||
|
||||
RecommendationSearchProgressDialog(
|
||||
status = screenModel.recommendationSearch.status.collectAsState().value,
|
||||
setStatusIdle = { screenModel.recommendationSearch.status.value = SearchStatus.Idle },
|
||||
setStatusCancelling = { screenModel.recommendationSearch.status.value = SearchStatus.Cancelling },
|
||||
)
|
||||
// SY <--
|
||||
|
||||
BackHandler(enabled = state.selectionMode || state.searchQuery != null) {
|
||||
@ -356,6 +379,30 @@ data object LibraryTab : Tab {
|
||||
}
|
||||
}
|
||||
|
||||
// SY -->
|
||||
val recSearchState by screenModel.recommendationSearch.status.collectAsState()
|
||||
LaunchedEffect(recSearchState) {
|
||||
when (val current = recSearchState) {
|
||||
is SearchStatus.Finished.WithResults -> {
|
||||
RecommendsScreen.Args.MergedSourceMangas(current.results)
|
||||
.let(::RecommendsScreen)
|
||||
.let(navigator::push)
|
||||
|
||||
screenModel.recommendationSearch.status.value = SearchStatus.Idle
|
||||
}
|
||||
is SearchStatus.Finished.WithoutResults -> {
|
||||
context.toast(SYMR.strings.rec_no_results)
|
||||
screenModel.recommendationSearch.status.value = SearchStatus.Idle
|
||||
}
|
||||
is SearchStatus.Cancelling -> {
|
||||
screenModel.cancelRecommendationSearch()
|
||||
screenModel.recommendationSearch.status.value = SearchStatus.Idle
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
// SY <--
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
launch { queryEvent.receiveAsFlow().collect(screenModel::search) }
|
||||
launch { requestSettingsSheetEvent.receiveAsFlow().collectLatest { screenModel.showSettingsDialog() } }
|
||||
|
@ -548,7 +548,9 @@ class MangaScreen(
|
||||
// AZ -->
|
||||
private fun openRecommends(navigator: Navigator, source: Source?, manga: Manga) {
|
||||
source ?: return
|
||||
navigator.push(RecommendsScreen(manga.id, source.id))
|
||||
RecommendsScreen.Args.SingleSourceManga(manga.id, source.id)
|
||||
.let(::RecommendsScreen)
|
||||
.let(navigator::push)
|
||||
}
|
||||
// AZ <--
|
||||
}
|
||||
|
@ -10,12 +10,12 @@ import eu.kanade.tachiyomi.data.sync.SyncDataJob
|
||||
import eu.kanade.tachiyomi.source.AndroidSourceManager
|
||||
import eu.kanade.tachiyomi.source.online.all.NHentai
|
||||
import eu.kanade.tachiyomi.util.system.workManager
|
||||
import exh.eh.EHentaiThrottleManager
|
||||
import exh.eh.EHentaiUpdateWorker
|
||||
import exh.metadata.metadata.EHentaiSearchMetadata
|
||||
import exh.source.EH_SOURCE_ID
|
||||
import exh.source.EXH_SOURCE_ID
|
||||
import exh.source.nHentaiSourceIds
|
||||
import exh.util.ThrottleManager
|
||||
import exh.util.jobScheduler
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.serialization.protobuf.schema.ProtoBufSchemaGenerator
|
||||
@ -76,7 +76,7 @@ object DebugFunctions {
|
||||
}
|
||||
}
|
||||
}
|
||||
private val throttleManager = EHentaiThrottleManager()
|
||||
private val throttleManager = ThrottleManager()
|
||||
|
||||
fun getDelegatedSourceList(): String = AndroidSourceManager.currentDelegatedSources.map {
|
||||
it.value.sourceName + " : " + it.value.sourceId + " : " + it.value.factory
|
||||
|
@ -2,24 +2,23 @@ package exh.favorites
|
||||
|
||||
import android.content.Context
|
||||
import android.net.wifi.WifiManager
|
||||
import android.os.Build
|
||||
import android.os.PowerManager
|
||||
import eu.kanade.domain.manga.interactor.UpdateManga
|
||||
import eu.kanade.tachiyomi.network.POST
|
||||
import eu.kanade.tachiyomi.network.await
|
||||
import eu.kanade.tachiyomi.source.online.all.EHentai
|
||||
import eu.kanade.tachiyomi.util.system.powerManager
|
||||
import eu.kanade.tachiyomi.util.system.toast
|
||||
import exh.GalleryAddEvent
|
||||
import exh.GalleryAdder
|
||||
import exh.eh.EHentaiThrottleManager
|
||||
import exh.eh.EHentaiUpdateWorker
|
||||
import exh.log.xLog
|
||||
import exh.source.EH_SOURCE_ID
|
||||
import exh.source.EXH_SOURCE_ID
|
||||
import exh.source.isEhBasedManga
|
||||
import exh.util.ThrottleManager
|
||||
import exh.util.createPartialWakeLock
|
||||
import exh.util.createWifiLock
|
||||
import exh.util.ignore
|
||||
import exh.util.wifiManager
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@ -69,7 +68,7 @@ class FavoritesSyncHelper(val context: Context) {
|
||||
|
||||
private val galleryAdder by lazy { GalleryAdder() }
|
||||
|
||||
private val throttleManager by lazy { EHentaiThrottleManager() }
|
||||
private val throttleManager by lazy { ThrottleManager() }
|
||||
|
||||
private var wifiLock: WifiManager.WifiLock? = null
|
||||
private var wakeLock: PowerManager.WakeLock? = null
|
||||
@ -130,27 +129,9 @@ class FavoritesSyncHelper(val context: Context) {
|
||||
try {
|
||||
// Take wake + wifi locks
|
||||
ignore { wakeLock?.release() }
|
||||
wakeLock = ignore {
|
||||
context.powerManager.newWakeLock(
|
||||
PowerManager.PARTIAL_WAKE_LOCK,
|
||||
"teh:ExhFavoritesSyncWakelock",
|
||||
)
|
||||
}
|
||||
wakeLock = ignore { context.createPartialWakeLock("teh:ExhFavoritesSyncWakelock") }
|
||||
ignore { wifiLock?.release() }
|
||||
wifiLock = ignore {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
context.wifiManager.createWifiLock(
|
||||
WifiManager.WIFI_MODE_FULL_LOW_LATENCY,
|
||||
"teh:ExhFavoritesSyncWifi",
|
||||
)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
context.wifiManager.createWifiLock(
|
||||
WifiManager.WIFI_MODE_FULL_HIGH_PERF,
|
||||
"teh:ExhFavoritesSyncWifi",
|
||||
)
|
||||
}
|
||||
}
|
||||
wifiLock = ignore { context.createWifiLock("teh:ExhFavoritesSyncWifi") }
|
||||
|
||||
// Do not update galleries while syncing favorites
|
||||
EHentaiUpdateWorker.cancelBackground(context)
|
||||
|
@ -2,6 +2,7 @@ package exh.md.similar
|
||||
|
||||
import dev.icerock.moko.resources.StringResource
|
||||
import eu.kanade.domain.manga.model.toSManga
|
||||
import eu.kanade.tachiyomi.network.HttpException
|
||||
import eu.kanade.tachiyomi.source.model.MangasPage
|
||||
import eu.kanade.tachiyomi.source.model.MetadataMangasPage
|
||||
import eu.kanade.tachiyomi.source.online.all.MangaDex
|
||||
@ -19,7 +20,7 @@ import tachiyomi.i18n.sy.SYMR
|
||||
class MangaDexSimilarPagingSource(
|
||||
manga: Manga,
|
||||
private val mangaDex: MangaDex,
|
||||
) : RecommendationPagingSource(mangaDex, manga) {
|
||||
) : RecommendationPagingSource(manga, mangaDex) {
|
||||
|
||||
override val name: String
|
||||
get() = "MangaDex"
|
||||
@ -32,6 +33,7 @@ class MangaDexSimilarPagingSource(
|
||||
|
||||
override suspend fun requestNextPage(currentPage: Int): MangasPage {
|
||||
val mangasPage = coroutineScope {
|
||||
try {
|
||||
val similarPageDef = async { mangaDex.getMangaSimilar(manga.toSManga()) }
|
||||
val relatedPageDef = async { mangaDex.getMangaRelated(manga.toSManga()) }
|
||||
val similarPage = similarPageDef.await()
|
||||
@ -42,6 +44,12 @@ class MangaDexSimilarPagingSource(
|
||||
false,
|
||||
relatedPage.mangasMetadata + similarPage.mangasMetadata,
|
||||
)
|
||||
} catch (e: HttpException) {
|
||||
when (e.code) {
|
||||
404 -> throw NoResultsException()
|
||||
else -> throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return mangasPage.takeIf { it.mangas.isNotEmpty() } ?: throw NoResultsException()
|
||||
|
@ -15,19 +15,28 @@ import eu.kanade.presentation.util.Screen
|
||||
import eu.kanade.tachiyomi.ui.browse.source.SourcesScreen
|
||||
import eu.kanade.tachiyomi.ui.manga.MangaScreen
|
||||
import eu.kanade.tachiyomi.ui.webview.WebViewActivity
|
||||
import exh.recs.batch.RankedSearchResults
|
||||
import exh.ui.ifSourcesLoaded
|
||||
import mihon.presentation.core.util.collectAsLazyPagingItems
|
||||
import tachiyomi.domain.manga.model.Manga
|
||||
import tachiyomi.presentation.core.components.material.Scaffold
|
||||
import tachiyomi.presentation.core.screens.LoadingScreen
|
||||
import java.io.Serializable
|
||||
|
||||
class BrowseRecommendsScreen(
|
||||
private val mangaId: Long,
|
||||
private val sourceId: Long,
|
||||
private val recommendationSourceName: String,
|
||||
private val args: Args,
|
||||
private val isExternalSource: Boolean,
|
||||
) : Screen() {
|
||||
|
||||
sealed interface Args : Serializable {
|
||||
data class SingleSourceManga(
|
||||
val mangaId: Long,
|
||||
val sourceId: Long,
|
||||
val recommendationSourceName: String,
|
||||
) : Args
|
||||
data class MergedSourceMangas(val results: RankedSearchResults) : Args
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun Content() {
|
||||
if (!ifSourcesLoaded()) {
|
||||
@ -38,9 +47,7 @@ class BrowseRecommendsScreen(
|
||||
val context = LocalContext.current
|
||||
val navigator = LocalNavigator.currentOrThrow
|
||||
|
||||
val screenModel = rememberScreenModel {
|
||||
BrowseRecommendsScreenModel(mangaId, sourceId, recommendationSourceName)
|
||||
}
|
||||
val screenModel = rememberScreenModel { BrowseRecommendsScreenModel(args) }
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
|
||||
val onClickItem = { manga: Manga ->
|
||||
|
@ -3,29 +3,47 @@ package exh.recs
|
||||
import eu.kanade.tachiyomi.source.CatalogueSource
|
||||
import eu.kanade.tachiyomi.source.model.FilterList
|
||||
import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourceScreenModel
|
||||
import exh.metadata.metadata.RaisedSearchMetadata
|
||||
import exh.recs.sources.RecommendationPagingSource
|
||||
import exh.recs.sources.StaticResultPagingSource
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import tachiyomi.domain.manga.interactor.GetManga
|
||||
import tachiyomi.domain.manga.model.Manga
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
|
||||
class BrowseRecommendsScreenModel(
|
||||
val mangaId: Long,
|
||||
sourceId: Long,
|
||||
private val recommendationSourceName: String,
|
||||
private val args: BrowseRecommendsScreen.Args,
|
||||
private val getManga: GetManga = Injekt.get(),
|
||||
) : BrowseSourceScreenModel(sourceId, null) {
|
||||
|
||||
val manga = runBlocking { getManga.await(mangaId) }!!
|
||||
|
||||
) : BrowseSourceScreenModel(
|
||||
sourceId = when (args) {
|
||||
is BrowseRecommendsScreen.Args.SingleSourceManga -> args.sourceId
|
||||
is BrowseRecommendsScreen.Args.MergedSourceMangas -> args.results.recAssociatedSourceId ?: -1
|
||||
},
|
||||
listingQuery = null,
|
||||
) {
|
||||
val recommendationSource: RecommendationPagingSource
|
||||
get() = RecommendationPagingSource.createSources(manga, source as CatalogueSource).first {
|
||||
it::class.qualifiedName == recommendationSourceName
|
||||
get() = when (args) {
|
||||
is BrowseRecommendsScreen.Args.MergedSourceMangas -> StaticResultPagingSource(args.results)
|
||||
is BrowseRecommendsScreen.Args.SingleSourceManga -> RecommendationPagingSource.createSources(
|
||||
runBlocking { getManga.await(args.mangaId)!! },
|
||||
source as CatalogueSource,
|
||||
).first {
|
||||
it::class.qualifiedName == args.recommendationSourceName
|
||||
}
|
||||
}
|
||||
|
||||
override fun createSourcePagingSource(query: String, filters: FilterList) = recommendationSource
|
||||
|
||||
override fun Flow<Manga>.combineMetadata(metadata: RaisedSearchMetadata?): Flow<Pair<Manga, RaisedSearchMetadata?>> {
|
||||
// Overridden to prevent our custom metadata from being replaced from a cache
|
||||
return flatMapLatest { manga -> flowOf(manga to metadata) }
|
||||
}
|
||||
|
||||
init {
|
||||
mutableState.update { it.copy(filterable = false) }
|
||||
}
|
||||
|
@ -11,12 +11,24 @@ import eu.kanade.presentation.util.Screen
|
||||
import eu.kanade.tachiyomi.ui.browse.source.SourcesScreen
|
||||
import eu.kanade.tachiyomi.ui.manga.MangaScreen
|
||||
import eu.kanade.tachiyomi.ui.webview.WebViewActivity
|
||||
import exh.recs.RecommendsScreen.Args.MergedSourceMangas
|
||||
import exh.recs.RecommendsScreen.Args.SingleSourceManga
|
||||
import exh.recs.batch.RankedSearchResults
|
||||
import exh.recs.components.RecommendsScreen
|
||||
import exh.recs.sources.StaticResultPagingSource
|
||||
import exh.ui.ifSourcesLoaded
|
||||
import tachiyomi.domain.manga.model.Manga
|
||||
import tachiyomi.i18n.sy.SYMR
|
||||
import tachiyomi.presentation.core.i18n.stringResource
|
||||
import tachiyomi.presentation.core.screens.LoadingScreen
|
||||
import java.io.Serializable
|
||||
|
||||
class RecommendsScreen(val mangaId: Long, val sourceId: Long) : Screen() {
|
||||
class RecommendsScreen(private val args: Args) : Screen() {
|
||||
|
||||
sealed interface Args : Serializable {
|
||||
data class SingleSourceManga(val mangaId: Long, val sourceId: Long) : Args
|
||||
data class MergedSourceMangas(val mergedResults: List<RankedSearchResults>) : Args
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun Content() {
|
||||
@ -28,9 +40,7 @@ class RecommendsScreen(val mangaId: Long, val sourceId: Long) : Screen() {
|
||||
val context = LocalContext.current
|
||||
val navigator = LocalNavigator.currentOrThrow
|
||||
|
||||
val screenModel = rememberScreenModel {
|
||||
RecommendsScreenModel(mangaId = mangaId, sourceId = sourceId)
|
||||
}
|
||||
val screenModel = rememberScreenModel { RecommendsScreenModel(args) }
|
||||
val state by screenModel.state.collectAsState()
|
||||
|
||||
val onClickItem = { manga: Manga ->
|
||||
@ -50,7 +60,11 @@ class RecommendsScreen(val mangaId: Long, val sourceId: Long) : Screen() {
|
||||
}
|
||||
|
||||
RecommendsScreen(
|
||||
manga = state.manga,
|
||||
title = if (args is SingleSourceManga) {
|
||||
stringResource(SYMR.strings.similar, state.title.orEmpty())
|
||||
} else {
|
||||
stringResource(SYMR.strings.rec_common_recommendations)
|
||||
},
|
||||
state = state,
|
||||
navigateUp = navigator::pop,
|
||||
getManga = @Composable { manga: Manga -> screenModel.getManga(manga) },
|
||||
@ -58,9 +72,18 @@ class RecommendsScreen(val mangaId: Long, val sourceId: Long) : Screen() {
|
||||
// Pass class name of paging source as screens need to be serializable
|
||||
navigator.push(
|
||||
BrowseRecommendsScreen(
|
||||
mangaId,
|
||||
sourceId,
|
||||
when (args) {
|
||||
is SingleSourceManga ->
|
||||
BrowseRecommendsScreen.Args.SingleSourceManga(
|
||||
args.mangaId,
|
||||
args.sourceId,
|
||||
pagingSource::class.qualifiedName!!,
|
||||
)
|
||||
is MergedSourceMangas ->
|
||||
BrowseRecommendsScreen.Args.MergedSourceMangas(
|
||||
(pagingSource as StaticResultPagingSource).data,
|
||||
)
|
||||
},
|
||||
pagingSource.associatedSourceId == null,
|
||||
),
|
||||
)
|
||||
|
@ -8,6 +8,7 @@ import eu.kanade.domain.manga.model.toDomainManga
|
||||
import eu.kanade.presentation.util.ioCoroutineScope
|
||||
import eu.kanade.tachiyomi.source.CatalogueSource
|
||||
import exh.recs.sources.RecommendationPagingSource
|
||||
import exh.recs.sources.StaticResultPagingSource
|
||||
import kotlinx.collections.immutable.PersistentMap
|
||||
import kotlinx.collections.immutable.mutate
|
||||
import kotlinx.collections.immutable.persistentMapOf
|
||||
@ -30,15 +31,12 @@ import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
|
||||
open class RecommendsScreenModel(
|
||||
val mangaId: Long,
|
||||
val sourceId: Long,
|
||||
private val args: RecommendsScreen.Args,
|
||||
sourceManager: SourceManager = Injekt.get(),
|
||||
private val getManga: GetManga = Injekt.get(),
|
||||
private val networkToLocalManga: NetworkToLocalManga = Injekt.get(),
|
||||
) : StateScreenModel<RecommendsScreenModel.State>(State()) {
|
||||
|
||||
val source = sourceManager.getOrStub(sourceId) as CatalogueSource
|
||||
|
||||
private val coroutineDispatcher = Dispatchers.IO.limitedParallelism(5)
|
||||
|
||||
private val sortComparator = { map: Map<RecommendationPagingSource, RecommendationItemResult> ->
|
||||
@ -51,9 +49,20 @@ open class RecommendsScreenModel(
|
||||
|
||||
init {
|
||||
ioCoroutineScope.launch {
|
||||
val manga = getManga.await(mangaId)!!
|
||||
mutableState.update { it.copy(manga = manga) }
|
||||
val recommendationSources = RecommendationPagingSource.createSources(manga, source)
|
||||
val recommendationSources = when (args) {
|
||||
is RecommendsScreen.Args.SingleSourceManga -> {
|
||||
val manga = getManga.await(args.mangaId)!!
|
||||
mutableState.update { it.copy(title = manga.title) }
|
||||
|
||||
RecommendationPagingSource.createSources(
|
||||
manga,
|
||||
sourceManager.getOrStub(args.sourceId) as CatalogueSource,
|
||||
)
|
||||
}
|
||||
is RecommendsScreen.Args.MergedSourceMangas -> {
|
||||
args.mergedResults.map(::StaticResultPagingSource)
|
||||
}
|
||||
}
|
||||
|
||||
updateItems(
|
||||
recommendationSources
|
||||
@ -118,15 +127,17 @@ open class RecommendsScreenModel(
|
||||
}
|
||||
|
||||
private fun updateItem(source: RecommendationPagingSource, result: RecommendationItemResult) {
|
||||
synchronized(state.value.items) {
|
||||
val newItems = state.value.items.mutate {
|
||||
it[source] = result
|
||||
}
|
||||
updateItems(newItems)
|
||||
}
|
||||
}
|
||||
|
||||
@Immutable
|
||||
data class State(
|
||||
val manga: Manga? = null,
|
||||
val title: String? = null,
|
||||
val items: PersistentMap<RecommendationPagingSource, RecommendationItemResult> = persistentMapOf(),
|
||||
) {
|
||||
val progress: Int = items.count { it.value !is RecommendationItemResult.Loading }
|
||||
|
@ -0,0 +1,65 @@
|
||||
package exh.recs.batch
|
||||
|
||||
import android.view.LayoutInflater
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import eu.kanade.presentation.components.AdaptiveSheet
|
||||
import eu.kanade.tachiyomi.databinding.RecommendationSearchBottomSheetBinding
|
||||
import tachiyomi.domain.UnsortedPreferences
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
|
||||
@Composable
|
||||
fun RecommendationSearchBottomSheetDialog(
|
||||
onDismissRequest: () -> Unit,
|
||||
onSearchRequest: () -> Unit,
|
||||
) {
|
||||
val state = remember { RecommendationSearchBottomSheetDialogState(onSearchRequest) }
|
||||
AdaptiveSheet(onDismissRequest = onDismissRequest) {
|
||||
AndroidView(
|
||||
factory = { factoryContext ->
|
||||
val binding = RecommendationSearchBottomSheetBinding.inflate(LayoutInflater.from(factoryContext))
|
||||
state.initPreferences(binding)
|
||||
binding.root
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
class RecommendationSearchBottomSheetDialogState(private val onSearchRequest: () -> Unit) {
|
||||
private val preferences: UnsortedPreferences by injectLazy()
|
||||
|
||||
fun initPreferences(binding: RecommendationSearchBottomSheetBinding) {
|
||||
val flags = preferences.recommendationSearchFlags().get()
|
||||
|
||||
binding.recSources.isChecked = SearchFlags.hasIncludeSources(flags)
|
||||
binding.recTrackers.isChecked = SearchFlags.hasIncludeTrackers(flags)
|
||||
binding.recHideLibraryEntries.isChecked = SearchFlags.hasHideLibraryResults(flags)
|
||||
|
||||
binding.recSources.setOnCheckedChangeListener { _, _ -> setFlags(binding) }
|
||||
binding.recTrackers.setOnCheckedChangeListener { _, _ -> setFlags(binding) }
|
||||
binding.recHideLibraryEntries.setOnCheckedChangeListener { _, _ -> setFlags(binding) }
|
||||
|
||||
binding.recSearchBtn.setOnClickListener { _ -> onSearchRequest() }
|
||||
|
||||
validate(binding)
|
||||
}
|
||||
|
||||
private fun setFlags(binding: RecommendationSearchBottomSheetBinding) {
|
||||
var flags = 0
|
||||
if (binding.recSources.isChecked) flags = flags or SearchFlags.INCLUDE_SOURCES
|
||||
if (binding.recTrackers.isChecked) flags = flags or SearchFlags.INCLUDE_TRACKERS
|
||||
if (binding.recHideLibraryEntries.isChecked) flags = flags or SearchFlags.HIDE_LIBRARY_RESULTS
|
||||
preferences.recommendationSearchFlags().set(flags)
|
||||
|
||||
validate(binding)
|
||||
}
|
||||
|
||||
private fun validate(binding: RecommendationSearchBottomSheetBinding) {
|
||||
// Only enable search button if at least one of the checkboxes is checked
|
||||
binding.recSearchBtn.isEnabled = binding.recSources.isChecked || binding.recTrackers.isChecked
|
||||
}
|
||||
}
|
248
app/src/main/java/exh/recs/batch/RecommendationSearchHelper.kt
Normal file
248
app/src/main/java/exh/recs/batch/RecommendationSearchHelper.kt
Normal file
@ -0,0 +1,248 @@
|
||||
package exh.recs.batch
|
||||
|
||||
import android.content.Context
|
||||
import android.net.wifi.WifiManager
|
||||
import android.os.PowerManager
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.core.net.toUri
|
||||
import eu.kanade.domain.manga.model.toDomainManga
|
||||
import eu.kanade.domain.manga.model.toSManga
|
||||
import eu.kanade.tachiyomi.source.CatalogueSource
|
||||
import eu.kanade.tachiyomi.source.model.SManga
|
||||
import exh.log.xLog
|
||||
import exh.recs.sources.RecommendationPagingSource
|
||||
import exh.recs.sources.TrackerRecommendationPagingSource
|
||||
import exh.smartsearch.SmartLibrarySearchEngine
|
||||
import exh.util.ThrottleManager
|
||||
import exh.util.createPartialWakeLock
|
||||
import exh.util.createWifiLock
|
||||
import exh.util.ignore
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import tachiyomi.data.source.NoResultsException
|
||||
import tachiyomi.domain.UnsortedPreferences
|
||||
import tachiyomi.domain.library.model.LibraryManga
|
||||
import tachiyomi.domain.manga.interactor.GetLibraryManga
|
||||
import tachiyomi.domain.manga.interactor.NetworkToLocalManga
|
||||
import tachiyomi.domain.manga.model.Manga
|
||||
import tachiyomi.domain.source.service.SourceManager
|
||||
import tachiyomi.domain.track.interactor.GetTracks
|
||||
import tachiyomi.domain.track.model.Track
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import java.io.Serializable
|
||||
import java.util.Collections
|
||||
import kotlin.coroutines.coroutineContext
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
class RecommendationSearchHelper(val context: Context) {
|
||||
private val getLibraryManga: GetLibraryManga by injectLazy()
|
||||
private val getTracks: GetTracks by injectLazy()
|
||||
private val networkToLocalManga: NetworkToLocalManga by injectLazy()
|
||||
private val sourceManager: SourceManager by injectLazy()
|
||||
private val prefs: UnsortedPreferences by injectLazy()
|
||||
|
||||
private var wifiLock: WifiManager.WifiLock? = null
|
||||
private var wakeLock: PowerManager.WakeLock? = null
|
||||
|
||||
private val smartSearchEngine by lazy { SmartLibrarySearchEngine() }
|
||||
|
||||
private val logger by lazy { xLog() }
|
||||
|
||||
val status: MutableStateFlow<SearchStatus> = MutableStateFlow(SearchStatus.Idle)
|
||||
|
||||
@Synchronized
|
||||
fun runSearch(scope: CoroutineScope, mangaList: List<Manga>): Job? {
|
||||
if (status.value !is SearchStatus.Idle) {
|
||||
return null
|
||||
}
|
||||
|
||||
status.value = SearchStatus.Initializing
|
||||
|
||||
return scope.launch(Dispatchers.IO) { beginSearch(mangaList) }
|
||||
}
|
||||
|
||||
private suspend fun beginSearch(mangaList: List<Manga>) {
|
||||
val flags = prefs.recommendationSearchFlags().get()
|
||||
val libraryManga = getLibraryManga.await()
|
||||
val tracks = getTracks.await()
|
||||
|
||||
// Trackers such as MAL need to be throttled more strictly
|
||||
val stricterThrottling = SearchFlags.hasIncludeTrackers(flags)
|
||||
|
||||
val throttleManager =
|
||||
ThrottleManager(
|
||||
max = 3.seconds,
|
||||
inc = 50.milliseconds,
|
||||
initial = if (stricterThrottling) 2.seconds else 0.seconds,
|
||||
)
|
||||
|
||||
try {
|
||||
// Take wake + wifi locks
|
||||
ignore { wakeLock?.release() }
|
||||
wakeLock = ignore { context.createPartialWakeLock("tsy:RecommendationSearchWakelock") }
|
||||
ignore { wifiLock?.release() }
|
||||
wifiLock = ignore { context.createWifiLock("tsy:RecommendationSearchWifiLock") }
|
||||
|
||||
// Map of results grouped by recommendation source
|
||||
val resultsMap = Collections.synchronizedMap(mutableMapOf<String, SearchResults>())
|
||||
|
||||
mangaList.forEachIndexed { index, sourceManga ->
|
||||
// Check if the job has been cancelled
|
||||
coroutineContext.ensureActive()
|
||||
|
||||
status.value = SearchStatus.Processing(sourceManga.toSManga(), index + 1, mangaList.size)
|
||||
|
||||
val jobs = RecommendationPagingSource.createSources(
|
||||
sourceManga,
|
||||
sourceManager.get(sourceManga.source) as CatalogueSource,
|
||||
).mapNotNull { source ->
|
||||
// Apply source filters
|
||||
if (source is TrackerRecommendationPagingSource && !SearchFlags.hasIncludeTrackers(flags)) {
|
||||
return@mapNotNull null
|
||||
}
|
||||
|
||||
if (source.associatedSourceId != null && !SearchFlags.hasIncludeSources(flags)) {
|
||||
return@mapNotNull null
|
||||
}
|
||||
|
||||
// Parallelize fetching recommendations from all sources in the current context
|
||||
CoroutineScope(coroutineContext).async(Dispatchers.IO) {
|
||||
val recSourceId = source::class.qualifiedName!!
|
||||
|
||||
try {
|
||||
val page = source.requestNextPage(1)
|
||||
|
||||
// Try to filter out mangas that are already in the library
|
||||
val mangas = page.mangas
|
||||
.filterLibraryItemsIfEnabled(source, libraryManga, tracks)
|
||||
|
||||
// Add or update the result collection for the current source
|
||||
resultsMap.getOrPut(recSourceId) {
|
||||
SearchResults(
|
||||
recSourceName = source.name,
|
||||
recSourceCategoryResId = source.category.resourceId,
|
||||
recAssociatedSourceId = source.associatedSourceId,
|
||||
results = mutableListOf(),
|
||||
)
|
||||
}.results.addAll(mangas)
|
||||
} catch (_: NoResultsException) {
|
||||
} catch (e: Exception) {
|
||||
logger.e("Error while fetching recommendations for $recSourceId", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
jobs.awaitAll()
|
||||
|
||||
// Continuously slow down the search to avoid hitting rate limits
|
||||
throttleManager.throttle()
|
||||
}
|
||||
|
||||
val rankedMap = resultsMap.map {
|
||||
RankedSearchResults(
|
||||
recSourceName = it.value.recSourceName,
|
||||
recSourceCategoryResId = it.value.recSourceCategoryResId,
|
||||
recAssociatedSourceId = it.value.recAssociatedSourceId,
|
||||
results = it.value.results
|
||||
// Group by URL and count occurrences
|
||||
.groupingBy(SManga::url)
|
||||
.eachCount()
|
||||
.entries
|
||||
// Sort by occurrences desc
|
||||
.sortedByDescending(Map.Entry<String, Int>::value)
|
||||
// Resolve SManga instances from URL keys
|
||||
.associate { (url, count) ->
|
||||
val manga = it.value.results.first { manga -> manga.url == url }
|
||||
manga to count
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
status.value = when {
|
||||
rankedMap.isNotEmpty() -> SearchStatus.Finished.WithResults(rankedMap)
|
||||
else -> SearchStatus.Finished.WithoutResults
|
||||
}
|
||||
} catch (_: CancellationException) {
|
||||
} catch (e: Exception) {
|
||||
status.value = SearchStatus.Error(e.message.orEmpty())
|
||||
logger.e("Error during recommendation search", e)
|
||||
return
|
||||
} finally {
|
||||
// Release wake + wifi locks
|
||||
ignore {
|
||||
wakeLock?.release()
|
||||
wakeLock = null
|
||||
}
|
||||
ignore {
|
||||
wifiLock?.release()
|
||||
wifiLock = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun List<SManga>.filterLibraryItemsIfEnabled(
|
||||
recSource: RecommendationPagingSource,
|
||||
libraryManga: List<LibraryManga>,
|
||||
tracks: List<Track>,
|
||||
): List<SManga> {
|
||||
val flags = prefs.recommendationSearchFlags().get()
|
||||
|
||||
if (!SearchFlags.hasHideLibraryResults(flags)) {
|
||||
return this
|
||||
}
|
||||
|
||||
return filterNot { manga ->
|
||||
// Source recommendations can be directly resolved, if the recommendation is from the same source
|
||||
recSource.associatedSourceId?.let { srcId ->
|
||||
return@filterNot networkToLocalManga
|
||||
.await(manga.toDomainManga(srcId))
|
||||
.let { local -> libraryManga.any { it.id == local.id } }
|
||||
}
|
||||
|
||||
// Tracker recommendations can be resolved by checking if the tracker is attached to the recommendation
|
||||
if (recSource is TrackerRecommendationPagingSource) {
|
||||
recSource.associatedTrackerId?.let { trackerId ->
|
||||
return@filterNot tracks.any {
|
||||
it.trackerId == trackerId && it.remoteUrl.toUri().path == manga.url.toUri().path
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to smart search otherwise
|
||||
smartSearchEngine.smartSearch(libraryManga, manga.title) != null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Contains the search results for a single source
|
||||
private typealias SearchResults = Results<MutableList<SManga>>
|
||||
|
||||
// Contains the ranked search results for a single source
|
||||
typealias RankedSearchResults = Results<Map<SManga, Int>>
|
||||
|
||||
data class Results<T>(
|
||||
val recSourceName: String,
|
||||
@StringRes val recSourceCategoryResId: Int,
|
||||
val recAssociatedSourceId: Long?,
|
||||
val results: T,
|
||||
) : Serializable
|
||||
|
||||
sealed interface SearchStatus {
|
||||
data object Idle : SearchStatus
|
||||
data object Initializing : SearchStatus
|
||||
data class Processing(val manga: SManga, val current: Int, val total: Int) : SearchStatus
|
||||
data class Error(val message: String) : SearchStatus
|
||||
data object Cancelling : SearchStatus
|
||||
|
||||
sealed interface Finished : SearchStatus {
|
||||
data class WithResults(val results: List<RankedSearchResults>) : Finished
|
||||
data object WithoutResults : Finished
|
||||
}
|
||||
}
|
@ -0,0 +1,119 @@
|
||||
package exh.recs.batch
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import tachiyomi.core.common.i18n.stringResource
|
||||
import tachiyomi.i18n.MR
|
||||
import tachiyomi.i18n.sy.SYMR
|
||||
|
||||
data class RecommendationSearchProgressProperties(
|
||||
val title: String,
|
||||
val text: String,
|
||||
val positiveButtonText: String? = null,
|
||||
val positiveButton: (() -> Unit)? = null,
|
||||
val negativeButtonText: String? = null,
|
||||
val negativeButton: (() -> Unit)? = null,
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun RecommendationSearchProgressDialog(
|
||||
status: SearchStatus,
|
||||
setStatusIdle: () -> Unit,
|
||||
setStatusCancelling: () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val currentView = LocalView.current
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
currentView.keepScreenOn = true
|
||||
onDispose {
|
||||
currentView.keepScreenOn = false
|
||||
}
|
||||
}
|
||||
|
||||
val properties by produceState<RecommendationSearchProgressProperties?>(initialValue = null, status) {
|
||||
value = when (status) {
|
||||
is SearchStatus.Initializing -> {
|
||||
RecommendationSearchProgressProperties(
|
||||
title = context.stringResource(SYMR.strings.rec_collecting),
|
||||
text = context.stringResource(SYMR.strings.rec_initializing),
|
||||
negativeButtonText = context.stringResource(MR.strings.action_cancel),
|
||||
negativeButton = setStatusCancelling,
|
||||
)
|
||||
}
|
||||
is SearchStatus.Error -> {
|
||||
RecommendationSearchProgressProperties(
|
||||
title = context.stringResource(SYMR.strings.rec_error_title),
|
||||
text = context.stringResource(SYMR.strings.rec_error_string, status.message),
|
||||
positiveButtonText = context.stringResource(MR.strings.action_ok),
|
||||
positiveButton = setStatusIdle,
|
||||
)
|
||||
}
|
||||
is SearchStatus.Processing -> {
|
||||
RecommendationSearchProgressProperties(
|
||||
title = context.stringResource(SYMR.strings.rec_collecting),
|
||||
text = context.stringResource(SYMR.strings.rec_processing_state, status.current, status.total) + "\n\n" + status.manga.title,
|
||||
negativeButtonText = context.stringResource(MR.strings.action_cancel),
|
||||
negativeButton = setStatusCancelling,
|
||||
)
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
val dialog = properties
|
||||
if (dialog != null) {
|
||||
AlertDialog(
|
||||
onDismissRequest = {},
|
||||
confirmButton = {
|
||||
if (dialog.positiveButton != null && dialog.positiveButtonText != null) {
|
||||
TextButton(onClick = dialog.positiveButton) {
|
||||
Text(text = dialog.positiveButtonText)
|
||||
}
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
if (dialog.negativeButton != null && dialog.negativeButtonText != null) {
|
||||
TextButton(onClick = dialog.negativeButton) {
|
||||
Text(text = dialog.negativeButtonText)
|
||||
}
|
||||
}
|
||||
},
|
||||
title = {
|
||||
Text(text = dialog.title)
|
||||
},
|
||||
text = {
|
||||
Column(
|
||||
Modifier.verticalScroll(rememberScrollState()),
|
||||
) {
|
||||
Text(text = dialog.text)
|
||||
if (status is SearchStatus.Processing) {
|
||||
LinearProgressIndicator(
|
||||
progress = { status.current.toFloat() / status.total },
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 16.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
properties = DialogProperties(
|
||||
dismissOnClickOutside = false,
|
||||
dismissOnBackPress = false,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
20
app/src/main/java/exh/recs/batch/SearchFlags.kt
Normal file
20
app/src/main/java/exh/recs/batch/SearchFlags.kt
Normal file
@ -0,0 +1,20 @@
|
||||
package exh.recs.batch
|
||||
|
||||
object SearchFlags {
|
||||
|
||||
const val INCLUDE_SOURCES = 0b00001
|
||||
const val INCLUDE_TRACKERS = 0b00010
|
||||
const val HIDE_LIBRARY_RESULTS = 0b00100
|
||||
|
||||
fun hasIncludeSources(value: Int): Boolean {
|
||||
return value and INCLUDE_SOURCES != 0
|
||||
}
|
||||
|
||||
fun hasIncludeTrackers(value: Int): Boolean {
|
||||
return value and INCLUDE_TRACKERS != 0
|
||||
}
|
||||
|
||||
fun hasHideLibraryResults(value: Int): Boolean {
|
||||
return value and HIDE_LIBRARY_RESULTS != 0
|
||||
}
|
||||
}
|
@ -17,13 +17,12 @@ import exh.recs.sources.RecommendationPagingSource
|
||||
import kotlinx.collections.immutable.ImmutableMap
|
||||
import nl.adaptivity.xmlutil.core.impl.multiplatform.name
|
||||
import tachiyomi.domain.manga.model.Manga
|
||||
import tachiyomi.i18n.sy.SYMR
|
||||
import tachiyomi.presentation.core.components.material.Scaffold
|
||||
import tachiyomi.presentation.core.i18n.stringResource
|
||||
|
||||
@Composable
|
||||
fun RecommendsScreen(
|
||||
manga: Manga?,
|
||||
title: String,
|
||||
state: RecommendsScreenModel.State,
|
||||
navigateUp: () -> Unit,
|
||||
getManga: @Composable (Manga) -> State<Manga>,
|
||||
@ -34,7 +33,7 @@ fun RecommendsScreen(
|
||||
Scaffold(
|
||||
topBar = { scrollBehavior ->
|
||||
AppBar(
|
||||
title = stringResource(SYMR.strings.similar, manga?.title.orEmpty()),
|
||||
title = title,
|
||||
scrollBehavior = scrollBehavior,
|
||||
navigateUp = navigateUp,
|
||||
)
|
||||
@ -64,7 +63,7 @@ internal fun RecommendsContent(
|
||||
contentPadding = contentPadding,
|
||||
) {
|
||||
items.forEach { (source, recResult) ->
|
||||
item(key = source::class.name) {
|
||||
item(key = "${source::class.name}-${source.name}-${source.category.resourceId}") {
|
||||
GlobalSearchResultItem(
|
||||
title = source.name,
|
||||
subtitle = stringResource(source.category),
|
||||
|
@ -4,7 +4,6 @@ import dev.icerock.moko.resources.StringResource
|
||||
import eu.kanade.tachiyomi.network.POST
|
||||
import eu.kanade.tachiyomi.network.awaitSuccess
|
||||
import eu.kanade.tachiyomi.network.parseAs
|
||||
import eu.kanade.tachiyomi.source.CatalogueSource
|
||||
import eu.kanade.tachiyomi.source.model.SManga
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
@ -18,11 +17,12 @@ import kotlinx.serialization.json.put
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import tachiyomi.core.common.util.system.logcat
|
||||
import tachiyomi.data.source.NoResultsException
|
||||
import tachiyomi.domain.manga.model.Manga
|
||||
import tachiyomi.i18n.sy.SYMR
|
||||
|
||||
class AniListPagingSource(manga: Manga, source: CatalogueSource) : TrackerRecommendationPagingSource(
|
||||
"https://graphql.anilist.co/", source, manga,
|
||||
class AniListPagingSource(manga: Manga) : TrackerRecommendationPagingSource(
|
||||
"https://graphql.anilist.co/", manga,
|
||||
) {
|
||||
override val name: String
|
||||
get() = "AniList"
|
||||
@ -84,7 +84,7 @@ class AniListPagingSource(manga: Manga, source: CatalogueSource) : TrackerRecomm
|
||||
.jsonObject["Page"]!!
|
||||
.jsonObject["media"]!!
|
||||
.jsonArray
|
||||
.ifEmpty { throw Exception("'$queryParam' not found") }
|
||||
.ifEmpty { throw NoResultsException() }
|
||||
.filter()
|
||||
|
||||
return media.flatMap { it.jsonObject["recommendations"]!!.jsonObject["edges"]!!.jsonArray }.map {
|
||||
|
@ -28,7 +28,7 @@ fun CatalogueSource.isComickSource() = name == "Comick"
|
||||
class ComickPagingSource(
|
||||
manga: Manga,
|
||||
private val comickSource: CatalogueSource,
|
||||
) : RecommendationPagingSource(comickSource, manga) {
|
||||
) : RecommendationPagingSource(manga, comickSource) {
|
||||
|
||||
override val name: String
|
||||
get() = "Comick"
|
||||
|
@ -5,7 +5,6 @@ import eu.kanade.tachiyomi.network.GET
|
||||
import eu.kanade.tachiyomi.network.POST
|
||||
import eu.kanade.tachiyomi.network.awaitSuccess
|
||||
import eu.kanade.tachiyomi.network.parseAs
|
||||
import eu.kanade.tachiyomi.source.CatalogueSource
|
||||
import eu.kanade.tachiyomi.source.model.SManga
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
@ -20,11 +19,12 @@ import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import tachiyomi.core.common.util.system.logcat
|
||||
import tachiyomi.data.source.NoResultsException
|
||||
import tachiyomi.domain.manga.model.Manga
|
||||
import tachiyomi.i18n.sy.SYMR
|
||||
|
||||
abstract class MangaUpdatesPagingSource(manga: Manga, source: CatalogueSource) : TrackerRecommendationPagingSource(
|
||||
"https://api.mangaupdates.com/v1/", source, manga,
|
||||
abstract class MangaUpdatesPagingSource(manga: Manga) : TrackerRecommendationPagingSource(
|
||||
"https://api.mangaupdates.com/v1/", manga,
|
||||
) {
|
||||
override val name: String
|
||||
get() = "MangaUpdates"
|
||||
@ -89,7 +89,7 @@ abstract class MangaUpdatesPagingSource(manga: Manga, source: CatalogueSource) :
|
||||
return getRecsById(
|
||||
data["results"]!!
|
||||
.jsonArray
|
||||
.ifEmpty { throw Exception("'$search' not found") }
|
||||
.ifEmpty { throw NoResultsException() }
|
||||
.first()
|
||||
.jsonObject["record"]!!
|
||||
.jsonObject["series_id"]!!
|
||||
@ -98,14 +98,14 @@ abstract class MangaUpdatesPagingSource(manga: Manga, source: CatalogueSource) :
|
||||
}
|
||||
}
|
||||
|
||||
class MangaUpdatesCommunityPagingSource(manga: Manga, source: CatalogueSource) : MangaUpdatesPagingSource(manga, source) {
|
||||
class MangaUpdatesCommunityPagingSource(manga: Manga) : MangaUpdatesPagingSource(manga) {
|
||||
override val category: StringResource
|
||||
get() = SYMR.strings.community_recommendations
|
||||
override val recommendationJsonObjectName: String
|
||||
get() = "recommendations"
|
||||
}
|
||||
|
||||
class MangaUpdatesSimilarPagingSource(manga: Manga, source: CatalogueSource) : MangaUpdatesPagingSource(manga, source) {
|
||||
class MangaUpdatesSimilarPagingSource(manga: Manga) : MangaUpdatesPagingSource(manga) {
|
||||
override val category: StringResource
|
||||
get() = SYMR.strings.similar_titles
|
||||
override val recommendationJsonObjectName: String
|
||||
|
@ -4,7 +4,6 @@ import dev.icerock.moko.resources.StringResource
|
||||
import eu.kanade.tachiyomi.network.GET
|
||||
import eu.kanade.tachiyomi.network.awaitSuccess
|
||||
import eu.kanade.tachiyomi.network.parseAs
|
||||
import eu.kanade.tachiyomi.source.CatalogueSource
|
||||
import eu.kanade.tachiyomi.source.model.SManga
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
@ -17,8 +16,8 @@ import tachiyomi.core.common.util.system.logcat
|
||||
import tachiyomi.domain.manga.model.Manga
|
||||
import tachiyomi.i18n.sy.SYMR
|
||||
|
||||
class MyAnimeListPagingSource(manga: Manga, source: CatalogueSource) : TrackerRecommendationPagingSource(
|
||||
"https://api.jikan.moe/v4/", source, manga,
|
||||
class MyAnimeListPagingSource(manga: Manga) : TrackerRecommendationPagingSource(
|
||||
"https://api.jikan.moe/v4/", manga,
|
||||
) {
|
||||
override val name: String
|
||||
get() = "MyAnimeList"
|
||||
|
@ -18,6 +18,7 @@ import tachiyomi.data.source.NoResultsException
|
||||
import tachiyomi.data.source.SourcePagingSource
|
||||
import tachiyomi.domain.manga.model.Manga
|
||||
import tachiyomi.domain.track.interactor.GetTracks
|
||||
import tachiyomi.i18n.sy.SYMR
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
@ -26,14 +27,14 @@ import uy.kohesive.injekt.injectLazy
|
||||
* General class for recommendation sources.
|
||||
*/
|
||||
abstract class RecommendationPagingSource(
|
||||
source: CatalogueSource,
|
||||
protected val manga: Manga,
|
||||
source: CatalogueSource? = null,
|
||||
) : SourcePagingSource(source) {
|
||||
// Display name
|
||||
abstract val name: String
|
||||
|
||||
// Localized category name
|
||||
abstract val category: StringResource
|
||||
open val category: StringResource = SYMR.strings.similar_titles
|
||||
|
||||
/**
|
||||
* Recommendation sources that display results from a source extension,
|
||||
@ -46,10 +47,10 @@ abstract class RecommendationPagingSource(
|
||||
companion object {
|
||||
fun createSources(manga: Manga, source: CatalogueSource): List<RecommendationPagingSource> {
|
||||
return buildList {
|
||||
add(AniListPagingSource(manga, source))
|
||||
add(MangaUpdatesCommunityPagingSource(manga, source))
|
||||
add(MangaUpdatesSimilarPagingSource(manga, source))
|
||||
add(MyAnimeListPagingSource(manga, source))
|
||||
add(AniListPagingSource(manga))
|
||||
add(MangaUpdatesCommunityPagingSource(manga))
|
||||
add(MangaUpdatesSimilarPagingSource(manga))
|
||||
add(MyAnimeListPagingSource(manga))
|
||||
|
||||
// Only include MangaDex if the delegate sources are enabled and the source is MD-based
|
||||
if (source.isMdBasedSource() && Injekt.get<DelegateSourcePreferences>().delegateSources().get()) {
|
||||
@ -70,9 +71,8 @@ abstract class RecommendationPagingSource(
|
||||
*/
|
||||
abstract class TrackerRecommendationPagingSource(
|
||||
protected val endpoint: String,
|
||||
source: CatalogueSource,
|
||||
manga: Manga,
|
||||
) : RecommendationPagingSource(source, manga) {
|
||||
) : RecommendationPagingSource(manga) {
|
||||
private val getTracks: GetTracks by injectLazy()
|
||||
|
||||
protected val trackerManager: TrackerManager by injectLazy()
|
||||
@ -86,7 +86,7 @@ abstract class TrackerRecommendationPagingSource(
|
||||
* the remote id will be used to directly identify the manga on the tracker.
|
||||
* Otherwise, a search will be performed using the manga title.
|
||||
*/
|
||||
protected abstract val associatedTrackerId: Long?
|
||||
abstract val associatedTrackerId: Long?
|
||||
|
||||
abstract suspend fun getRecsBySearch(search: String): List<SManga>
|
||||
abstract suspend fun getRecsById(id: String): List<SManga>
|
||||
@ -105,7 +105,10 @@ abstract class TrackerRecommendationPagingSource(
|
||||
|
||||
results.ifEmpty { throw NoResultsException() }
|
||||
} catch (e: Exception) {
|
||||
// 'No results' should not be logged as it happens frequently and is expected
|
||||
if (e !is NoResultsException) {
|
||||
logcat(LogPriority.ERROR, e) { name }
|
||||
}
|
||||
throw e
|
||||
}
|
||||
|
||||
|
@ -0,0 +1,39 @@
|
||||
package exh.recs.sources
|
||||
|
||||
import dev.icerock.moko.resources.StringResource
|
||||
import eu.kanade.tachiyomi.source.model.MangasPage
|
||||
import eu.kanade.tachiyomi.source.model.MetadataMangasPage
|
||||
import exh.metadata.metadata.RankedSearchMetadata
|
||||
import exh.recs.batch.RankedSearchResults
|
||||
import tachiyomi.domain.manga.model.Manga
|
||||
|
||||
class StaticResultPagingSource(
|
||||
val data: RankedSearchResults,
|
||||
) : RecommendationPagingSource(Manga.create()) {
|
||||
|
||||
override val name: String get() = data.recSourceName
|
||||
override val category: StringResource get() = StringResource(data.recSourceCategoryResId)
|
||||
override val associatedSourceId: Long? get() = data.recAssociatedSourceId
|
||||
|
||||
override suspend fun requestNextPage(currentPage: Int): MangasPage =
|
||||
// Use virtual paging to improve performance for large lists
|
||||
data.results
|
||||
.entries
|
||||
.chunked(PAGE_SIZE)
|
||||
.getOrElse(currentPage - 1) { emptyList() }
|
||||
.let { chunk ->
|
||||
MetadataMangasPage(
|
||||
mangas = chunk.map { it.key },
|
||||
hasNextPage = data.results.size > currentPage * PAGE_SIZE,
|
||||
mangasMetadata = chunk
|
||||
.map { it.value }
|
||||
.map { count ->
|
||||
RankedSearchMetadata().also { it.rank = count }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val PAGE_SIZE = 25
|
||||
}
|
||||
}
|
@ -1,22 +1,22 @@
|
||||
package exh.smartsearch
|
||||
|
||||
import eu.kanade.domain.manga.model.toDomainManga
|
||||
import eu.kanade.tachiyomi.source.CatalogueSource
|
||||
import eu.kanade.tachiyomi.source.model.FilterList
|
||||
import eu.kanade.tachiyomi.source.model.SManga
|
||||
import info.debatty.java.stringsimilarity.NormalizedLevenshtein
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.supervisorScope
|
||||
import tachiyomi.domain.manga.model.Manga
|
||||
import java.util.Locale
|
||||
|
||||
class SmartSearchEngine(
|
||||
typealias SearchAction<T> = suspend (String) -> List<T>
|
||||
|
||||
abstract class BaseSmartSearchEngine<T>(
|
||||
private val extraSearchParams: String? = null,
|
||||
private val eligibleThreshold: Double = MIN_ELIGIBLE_THRESHOLD,
|
||||
) {
|
||||
private val normalizedLevenshtein = NormalizedLevenshtein()
|
||||
|
||||
suspend fun smartSearch(source: CatalogueSource, title: String): Manga? {
|
||||
protected abstract fun getTitle(result: T): String
|
||||
|
||||
protected suspend fun smartSearch(searchAction: SearchAction<T>, title: String): T? {
|
||||
val cleanedTitle = cleanSmartSearchTitle(title)
|
||||
|
||||
val queries = getSmartSearchQueries(cleanedTitle)
|
||||
@ -30,71 +30,42 @@ class SmartSearchEngine(
|
||||
query
|
||||
}
|
||||
|
||||
val searchResults = source.getSearchManga(1, builtQuery, FilterList())
|
||||
|
||||
searchResults.mangas.map {
|
||||
val cleanedMangaTitle = cleanSmartSearchTitle(it.originalTitle)
|
||||
searchAction(builtQuery).map {
|
||||
val cleanedMangaTitle = cleanSmartSearchTitle(getTitle(it))
|
||||
val normalizedDistance = normalizedLevenshtein.similarity(cleanedTitle, cleanedMangaTitle)
|
||||
SearchEntry(it, normalizedDistance)
|
||||
}.filter { (_, normalizedDistance) ->
|
||||
normalizedDistance >= MIN_SMART_ELIGIBLE_THRESHOLD
|
||||
normalizedDistance >= eligibleThreshold
|
||||
}
|
||||
}
|
||||
}.flatMap { it.await() }
|
||||
}
|
||||
|
||||
return eligibleManga.maxByOrNull { it.dist }?.manga?.toDomainManga(source.id)
|
||||
return eligibleManga.maxByOrNull { it.dist }?.manga
|
||||
}
|
||||
|
||||
suspend fun normalSearch(source: CatalogueSource, title: String): Manga? {
|
||||
protected suspend fun normalSearch(searchAction: SearchAction<T>, title: String): T? {
|
||||
val eligibleManga = supervisorScope {
|
||||
val searchQuery = if (extraSearchParams != null) {
|
||||
"$title ${extraSearchParams.trim()}"
|
||||
} else {
|
||||
title
|
||||
}
|
||||
val searchResults = source.getSearchManga(1, searchQuery, FilterList())
|
||||
val searchResults = searchAction(searchQuery)
|
||||
|
||||
if (searchResults.mangas.size == 1) {
|
||||
return@supervisorScope listOf(SearchEntry(searchResults.mangas.first(), 0.0))
|
||||
if (searchResults.size == 1) {
|
||||
return@supervisorScope listOf(SearchEntry(searchResults.first(), 0.0))
|
||||
}
|
||||
|
||||
searchResults.mangas.map {
|
||||
val normalizedDistance = normalizedLevenshtein.similarity(title, it.originalTitle)
|
||||
searchResults.map {
|
||||
val normalizedDistance = normalizedLevenshtein.similarity(title, getTitle(it))
|
||||
SearchEntry(it, normalizedDistance)
|
||||
}.filter { (_, normalizedDistance) ->
|
||||
normalizedDistance >= MIN_NORMAL_ELIGIBLE_THRESHOLD
|
||||
normalizedDistance >= eligibleThreshold
|
||||
}
|
||||
}
|
||||
|
||||
return eligibleManga.maxByOrNull { it.dist }?.manga?.toDomainManga(source.id)
|
||||
}
|
||||
|
||||
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()
|
||||
return eligibleManga.maxByOrNull { it.dist }?.manga
|
||||
}
|
||||
|
||||
private fun cleanSmartSearchTitle(title: String): String {
|
||||
@ -171,9 +142,35 @@ class SmartSearchEngine(
|
||||
return result.toString()
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val MIN_SMART_ELIGIBLE_THRESHOLD = 0.4
|
||||
const val MIN_NORMAL_ELIGIBLE_THRESHOLD = 0.4
|
||||
const val MIN_ELIGIBLE_THRESHOLD = 0.4
|
||||
|
||||
private val titleRegex = Regex("[^a-zA-Z0-9- ]")
|
||||
private val titleCyrillicRegex = Regex("[^\\p{L}0-9- ]")
|
||||
@ -182,4 +179,4 @@ class SmartSearchEngine(
|
||||
}
|
||||
}
|
||||
|
||||
data class SearchEntry(val manga: SManga, val dist: Double)
|
||||
data class SearchEntry<T>(val manga: T, val dist: Double)
|
@ -0,0 +1,18 @@
|
||||
package exh.smartsearch
|
||||
|
||||
import tachiyomi.domain.library.model.LibraryManga
|
||||
|
||||
class SmartLibrarySearchEngine(
|
||||
extraSearchParams: String? = null,
|
||||
) : BaseSmartSearchEngine<LibraryManga>(extraSearchParams, 0.7) {
|
||||
|
||||
override fun getTitle(result: LibraryManga) = result.manga.ogTitle
|
||||
|
||||
suspend fun smartSearch(library: List<LibraryManga>, title: String): LibraryManga? =
|
||||
smartSearch(
|
||||
{ query ->
|
||||
library.filter { it.manga.ogTitle.contains(query, true) }
|
||||
},
|
||||
title,
|
||||
)
|
||||
}
|
27
app/src/main/java/exh/smartsearch/SmartSourceSearchEngine.kt
Normal file
27
app/src/main/java/exh/smartsearch/SmartSourceSearchEngine.kt
Normal file
@ -0,0 +1,27 @@
|
||||
package exh.smartsearch
|
||||
|
||||
import eu.kanade.domain.manga.model.toDomainManga
|
||||
import eu.kanade.tachiyomi.source.CatalogueSource
|
||||
import eu.kanade.tachiyomi.source.model.FilterList
|
||||
import eu.kanade.tachiyomi.source.model.SManga
|
||||
import tachiyomi.domain.manga.model.Manga
|
||||
|
||||
class SmartSourceSearchEngine(
|
||||
extraSearchParams: String? = null,
|
||||
) : BaseSmartSearchEngine<SManga>(extraSearchParams) {
|
||||
|
||||
override fun getTitle(result: SManga) = result.originalTitle
|
||||
|
||||
suspend fun smartSearch(source: CatalogueSource, title: String): Manga? =
|
||||
smartSearch(makeSearchAction(source), title).let {
|
||||
it?.toDomainManga(source.id)
|
||||
}
|
||||
|
||||
suspend fun normalSearch(source: CatalogueSource, title: String): Manga? =
|
||||
normalSearch(makeSearchAction(source), title).let {
|
||||
it?.toDomainManga(source.id)
|
||||
}
|
||||
|
||||
private fun makeSearchAction(source: CatalogueSource): SearchAction<SManga> =
|
||||
{ query -> source.getSearchManga(1, query, FilterList()).mangas }
|
||||
}
|
@ -4,7 +4,7 @@ import cafe.adriel.voyager.core.model.StateScreenModel
|
||||
import cafe.adriel.voyager.core.model.screenModelScope
|
||||
import eu.kanade.tachiyomi.source.CatalogueSource
|
||||
import eu.kanade.tachiyomi.ui.browse.source.SourcesScreen
|
||||
import exh.smartsearch.SmartSearchEngine
|
||||
import exh.smartsearch.SmartSourceSearchEngine
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import tachiyomi.core.common.util.lang.launchIO
|
||||
import tachiyomi.domain.manga.interactor.NetworkToLocalManga
|
||||
@ -19,7 +19,7 @@ class SmartSearchScreenModel(
|
||||
private val networkToLocalManga: NetworkToLocalManga = Injekt.get(),
|
||||
private val sourceManager: SourceManager = Injekt.get(),
|
||||
) : StateScreenModel<SmartSearchScreenModel.SearchResults?>(null) {
|
||||
private val smartSearchEngine = SmartSearchEngine()
|
||||
private val smartSearchEngine = SmartSourceSearchEngine()
|
||||
|
||||
val source = sourceManager.get(sourceId) as CatalogueSource
|
||||
|
||||
|
@ -5,7 +5,10 @@ import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.content.res.Configuration
|
||||
import android.net.wifi.WifiManager
|
||||
import android.os.Build
|
||||
import android.os.PowerManager
|
||||
import androidx.core.content.getSystemService
|
||||
import eu.kanade.tachiyomi.util.system.powerManager
|
||||
|
||||
/**
|
||||
* Property to get the wifi manager from the context.
|
||||
@ -24,3 +27,23 @@ val Context.isInNightMode: Boolean
|
||||
val currentNightMode = resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK
|
||||
return currentNightMode == Configuration.UI_MODE_NIGHT_YES
|
||||
}
|
||||
|
||||
fun Context.createPartialWakeLock(tag: String): PowerManager.WakeLock =
|
||||
powerManager.newWakeLock(
|
||||
PowerManager.PARTIAL_WAKE_LOCK,
|
||||
tag,
|
||||
)
|
||||
|
||||
fun Context.createWifiLock(tag: String): WifiManager.WifiLock =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
wifiManager.createWifiLock(
|
||||
WifiManager.WIFI_MODE_FULL_LOW_LATENCY,
|
||||
tag,
|
||||
)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
wifiManager.createWifiLock(
|
||||
WifiManager.WIFI_MODE_FULL_HIGH_PERF,
|
||||
tag,
|
||||
)
|
||||
}
|
||||
|
@ -1,16 +1,18 @@
|
||||
package exh.eh
|
||||
package exh.util
|
||||
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
class EHentaiThrottleManager(
|
||||
class ThrottleManager(
|
||||
private val max: Duration = THROTTLE_MAX,
|
||||
private val inc: Duration = THROTTLE_INC,
|
||||
private val initial: Duration = Duration.ZERO,
|
||||
) {
|
||||
private var lastThrottleTime = Duration.ZERO
|
||||
var throttleTime = Duration.ZERO
|
||||
|
||||
var throttleTime = initial
|
||||
private set
|
||||
|
||||
suspend fun throttle() {
|
||||
@ -30,7 +32,7 @@ class EHentaiThrottleManager(
|
||||
|
||||
fun resetThrottle() {
|
||||
lastThrottleTime = Duration.ZERO
|
||||
throttleTime = Duration.ZERO
|
||||
throttleTime = initial
|
||||
}
|
||||
|
||||
companion object {
|
116
app/src/main/res/layout/recommendation_search_bottom_sheet.xml
Normal file
116
app/src/main/res/layout/recommendation_search_bottom_sheet.xml
Normal file
@ -0,0 +1,116 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:clipToPadding="false"
|
||||
android:paddingVertical="8dp"
|
||||
android:orientation="vertical">
|
||||
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
android:id="@+id/rec_options_layout"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@android:color/transparent"
|
||||
android:paddingHorizontal="16dp"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintVertical_bias="0.0">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/rec_sources_layout"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/rec_sources_label"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginVertical="8dp"
|
||||
android:text="@string/rec_services_to_search"
|
||||
android:textAppearance="?attr/textAppearanceTitleMedium"
|
||||
android:textColor="?attr/colorPrimary" />
|
||||
|
||||
<CheckBox
|
||||
android:id="@+id/rec_sources"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:checked="true"
|
||||
android:text="@string/rec_group_source" />
|
||||
|
||||
<CheckBox
|
||||
android:id="@+id/rec_trackers"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:checked="true"
|
||||
android:text="@string/rec_group_tracker" />
|
||||
</LinearLayout>
|
||||
|
||||
<View
|
||||
android:id="@+id/sourceGroup_divider"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="1dp"
|
||||
android:layout_marginTop="4dp"
|
||||
android:background="?android:attr/divider"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/rec_sources_layout"/>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/sourceGroup_divider">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/rec_options_label"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="16dp"
|
||||
android:text="@string/action_settings"
|
||||
android:textAppearance="?attr/textAppearanceTitleMedium"
|
||||
android:textColor="?attr/colorPrimary"
|
||||
app:layout_constraintStart_toStartOf="@+id/rec_sources_layout"
|
||||
app:layout_constraintTop_toBottomOf="@+id/migration_data_divider" />
|
||||
|
||||
|
||||
<com.google.android.material.materialswitch.MaterialSwitch
|
||||
android:id="@+id/rec_hide_library_entries"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_margin="8dp"
|
||||
android:text="@string/rec_hide_library_entries" />
|
||||
</LinearLayout>
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
|
||||
<View
|
||||
android:id="@+id/rec_search_btn_divider"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="1dp"
|
||||
android:layout_marginBottom="12dp"
|
||||
android:background="?android:attr/divider"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/rec_options_layout" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/rec_search_btn"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginHorizontal="16dp"
|
||||
android:layout_marginVertical="12dp"
|
||||
android:text="@string/rec_search"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/rec_search_btn_divider" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
@ -13,24 +13,24 @@ import tachiyomi.domain.source.repository.SourcePagingSourceType
|
||||
class SourceSearchPagingSource(source: CatalogueSource, val query: String, val filters: FilterList) :
|
||||
SourcePagingSource(source) {
|
||||
override suspend fun requestNextPage(currentPage: Int): MangasPage {
|
||||
return source.getSearchManga(currentPage, query, filters)
|
||||
return source!!.getSearchManga(currentPage, query, filters)
|
||||
}
|
||||
}
|
||||
|
||||
class SourcePopularPagingSource(source: CatalogueSource) : SourcePagingSource(source) {
|
||||
override suspend fun requestNextPage(currentPage: Int): MangasPage {
|
||||
return source.getPopularManga(currentPage)
|
||||
return source!!.getPopularManga(currentPage)
|
||||
}
|
||||
}
|
||||
|
||||
class SourceLatestPagingSource(source: CatalogueSource) : SourcePagingSource(source) {
|
||||
override suspend fun requestNextPage(currentPage: Int): MangasPage {
|
||||
return source.getLatestUpdates(currentPage)
|
||||
return source!!.getLatestUpdates(currentPage)
|
||||
}
|
||||
}
|
||||
|
||||
abstract class SourcePagingSource(
|
||||
protected open val source: CatalogueSource,
|
||||
protected open val source: CatalogueSource?,
|
||||
) : SourcePagingSourceType() {
|
||||
|
||||
abstract suspend fun requestNextPage(currentPage: Int): MangasPage
|
||||
|
@ -25,6 +25,8 @@ class UnsortedPreferences(
|
||||
|
||||
fun showOnlyUpdatesMigration() = preferenceStore.getBoolean("show_only_updates_migration", false)
|
||||
|
||||
fun recommendationSearchFlags() = preferenceStore.getInt("rec_search_flags", Int.MAX_VALUE)
|
||||
|
||||
fun isHentaiEnabled() = preferenceStore.getBoolean("eh_is_hentai_enabled", true)
|
||||
|
||||
fun enableExhentai() = preferenceStore.getBoolean(Preference.privateKey("enable_exhentai"), false)
|
||||
|
@ -480,6 +480,21 @@
|
||||
<string name="action_stop">Stop</string>
|
||||
<string name="skipping_">(skipping %1$d)</string>
|
||||
|
||||
<!-- Bulk recommendation search -->
|
||||
<string name="rec_search">Find common recommendations</string>
|
||||
<string name="rec_hide_library_entries">Hide results already in your library</string>
|
||||
<string name="rec_services_to_search">Recommendation services to search</string>
|
||||
<string name="rec_group_source">Source recommendations</string>
|
||||
<string name="rec_group_tracker">Tracker recommendations</string>
|
||||
<string name="rec_common_recommendations">Common recommendations</string>
|
||||
<string name="rec_search_short">Find recommendations</string>
|
||||
<string name="rec_no_results">No recommendations found</string>
|
||||
<string name="rec_error_title">Search failed</string>
|
||||
<string name="rec_error_string">An error occurred during the search process: %1$s</string>
|
||||
<string name="rec_processing_state">Processing entry %1$d of %2$d</string>
|
||||
<string name="rec_collecting">Collecting recommendations</string>
|
||||
<string name="rec_initializing">Initializing</string>
|
||||
|
||||
<!-- Library -->
|
||||
<!-- Library Actions -->
|
||||
<string name="no_valid_entry">No valid entry selected</string>
|
||||
|
@ -0,0 +1,13 @@
|
||||
package exh.metadata.metadata
|
||||
|
||||
import android.content.Context
|
||||
import eu.kanade.tachiyomi.source.model.SManga
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
class RankedSearchMetadata : RaisedSearchMetadata() {
|
||||
var rank: Int? = null
|
||||
|
||||
override fun createMangaInfo(manga: SManga) = manga
|
||||
override fun getExtraInfoPairs(context: Context): List<Pair<String, String>> = emptyList()
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user