From 9a31ffcc4172d9cc07f8f01b8524f0b1003c94de Mon Sep 17 00:00:00 2001 From: Tim Schneeberger Date: Sun, 2 Mar 2025 17:36:07 +0100 Subject: [PATCH] feat(recommendation): batch processing for recommendations & sort by relevancy (jobobby04/TachiyomiSY#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 * Avoid runBlocking in a property getter * Remove unnecessary `synchronized` * Fixes screen staying on in library tab. (jobobby04/TachiyomiSY#1451) (cherry picked from commit 254980695bc39fe064e7aeb181ecdbf70c1eff95) --------- Co-authored-by: Cuong-Tran Co-authored-by: NGB-Was-Taken <76197326+NGB-Was-Taken@users.noreply.github.com> --- .../components/BrowseSourceComfortableGrid.kt | 9 + .../components/BrowseSourceCompactGrid.kt | 9 + .../browse/components/BrowseSourceList.kt | 9 + .../manga/components/MangaBottomActionMenu.kt | 12 +- .../process/MigrationListScreenModel.kt | 8 +- .../ui/browse/source/SourcesScreen.kt | 2 +- .../source/browse/BrowseSourceScreenModel.kt | 3 +- .../ui/library/LibraryScreenModel.kt | 30 ++- .../kanade/tachiyomi/ui/library/LibraryTab.kt | 47 ++++ .../kanade/tachiyomi/ui/manga/MangaScreen.kt | 4 +- app/src/main/java/exh/debug/DebugFunctions.kt | 4 +- .../java/exh/favorites/FavoritesSyncHelper.kt | 31 +-- .../md/similar/MangaDexSimilarPagingSource.kt | 44 ++-- .../java/exh/recs/BrowseRecommendsScreen.kt | 19 +- .../exh/recs/BrowseRecommendsScreenModel.kt | 57 ++-- .../main/java/exh/recs/RecommendsScreen.kt | 39 ++- .../java/exh/recs/RecommendsScreenModel.kt | 34 +-- .../RecommendationSearchBottomSheetDialog.kt | 65 +++++ .../recs/batch/RecommendationSearchHelper.kt | 247 ++++++++++++++++++ .../RecommendationSearchProgressDialog.kt | 140 ++++++++++ .../main/java/exh/recs/batch/SearchFlags.kt | 20 ++ .../exh/recs/components/RecommendsScreen.kt | 7 +- .../exh/recs/sources/AniListPagingSource.kt | 7 +- .../exh/recs/sources/ComickPagingSource.kt | 2 +- .../recs/sources/MangaUpdatesPagingSource.kt | 11 +- .../recs/sources/MyAnimeListPagingSource.kt | 4 +- .../sources/RecommendationPagingSource.kt | 23 +- .../recs/sources/StaticResultPagingSource.kt | 39 +++ ...archEngine.kt => BaseSmartSearchEngine.kt} | 105 ++++---- .../smartsearch/SmartLibrarySearchEngine.kt | 18 ++ .../smartsearch/SmartSourceSearchEngine.kt | 27 ++ .../ui/smartsearch/SmartSearchScreenModel.kt | 8 +- .../main/java/exh/util/ContextExtensions.kt | 23 ++ .../ThrottleManager.kt} | 10 +- .../recommendation_search_bottom_sheet.xml | 116 ++++++++ .../data/source/EHentaiPagingSource.kt | 10 +- .../data/source/SourcePagingSource.kt | 17 +- .../tachiyomi/domain/UnsortedPreferences.kt | 2 + .../moko-resources/base/strings.xml | 15 ++ .../metadata/metadata/RankedSearchMetadata.kt | 13 + 40 files changed, 1080 insertions(+), 210 deletions(-) create mode 100644 app/src/main/java/exh/recs/batch/RecommendationSearchBottomSheetDialog.kt create mode 100644 app/src/main/java/exh/recs/batch/RecommendationSearchHelper.kt create mode 100644 app/src/main/java/exh/recs/batch/RecommendationSearchProgressDialog.kt create mode 100644 app/src/main/java/exh/recs/batch/SearchFlags.kt create mode 100644 app/src/main/java/exh/recs/sources/StaticResultPagingSource.kt rename app/src/main/java/exh/smartsearch/{SmartSearchEngine.kt => BaseSmartSearchEngine.kt} (78%) create mode 100644 app/src/main/java/exh/smartsearch/SmartLibrarySearchEngine.kt create mode 100644 app/src/main/java/exh/smartsearch/SmartSourceSearchEngine.kt rename app/src/main/java/exh/{eh/EHentaiThrottleManager.kt => util/ThrottleManager.kt} (85%) create mode 100644 app/src/main/res/layout/recommendation_search_bottom_sheet.xml create mode 100644 source-api/src/commonMain/kotlin/exh/metadata/metadata/RankedSearchMetadata.kt diff --git a/app/src/main/java/eu/kanade/presentation/browse/components/BrowseSourceComfortableGrid.kt b/app/src/main/java/eu/kanade/presentation/browse/components/BrowseSourceComfortableGrid.kt index 542897f81..545a98d59 100644 --- a/app/src/main/java/eu/kanade/presentation/browse/components/BrowseSourceComfortableGrid.kt +++ b/app/src/main/java/eu/kanade/presentation/browse/components/BrowseSourceComfortableGrid.kt @@ -20,6 +20,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 @@ -137,6 +138,14 @@ internal 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 <-- diff --git a/app/src/main/java/eu/kanade/presentation/browse/components/BrowseSourceCompactGrid.kt b/app/src/main/java/eu/kanade/presentation/browse/components/BrowseSourceCompactGrid.kt index 908ebe226..bc675dd3f 100644 --- a/app/src/main/java/eu/kanade/presentation/browse/components/BrowseSourceCompactGrid.kt +++ b/app/src/main/java/eu/kanade/presentation/browse/components/BrowseSourceCompactGrid.kt @@ -20,6 +20,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 @@ -132,6 +133,14 @@ internal 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 <-- diff --git a/app/src/main/java/eu/kanade/presentation/browse/components/BrowseSourceList.kt b/app/src/main/java/eu/kanade/presentation/browse/components/BrowseSourceList.kt index 652a580c5..f518eaa1a 100644 --- a/app/src/main/java/eu/kanade/presentation/browse/components/BrowseSourceList.kt +++ b/app/src/main/java/eu/kanade/presentation/browse/components/BrowseSourceList.kt @@ -17,6 +17,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 @@ -123,6 +124,14 @@ internal 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 <-- }, diff --git a/app/src/main/java/eu/kanade/presentation/manga/components/MangaBottomActionMenu.kt b/app/src/main/java/eu/kanade/presentation/manga/components/MangaBottomActionMenu.kt index 28301a5c2..35274bf4e 100644 --- a/app/src/main/java/eu/kanade/presentation/manga/components/MangaBottomActionMenu.kt +++ b/app/src/main/java/eu/kanade/presentation/manga/components/MangaBottomActionMenu.kt @@ -260,6 +260,7 @@ fun LibraryBottomActionMenu( // SY --> onClickCleanTitles: (() -> Unit)?, onClickMigrate: (() -> Unit)?, + onClickCollectRecommendations: (() -> Unit)?, onClickAddToMangaDex: (() -> Unit)?, onClickResetInfo: (() -> Unit)?, // SY <-- @@ -300,8 +301,9 @@ fun LibraryBottomActionMenu( onClickResetInfo != null || // KMK --> onClickMigrate != null || - onClickMerge != null - // KMK <-- + onClickMerge != null || + // KMK <-- + onClickCollectRecommendations != null val configuration = LocalConfiguration.current val isTabletUi = remember { configuration.isTabletUi() } var overFlowOpen by remember { mutableStateOf(false) } @@ -419,6 +421,12 @@ fun LibraryBottomActionMenu( onClick = onClickCleanTitles, ) } + 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)) }, diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/advanced/process/MigrationListScreenModel.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/advanced/process/MigrationListScreenModel.kt index 6d5b77b3e..cab0391ab 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/advanced/process/MigrationListScreenModel.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/advanced/process/MigrationListScreenModel.kt @@ -14,9 +14,9 @@ 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.ui.browse.migration.search.MigrateDialogScreenModel.Companion.migrateMangaInternal 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 @@ -60,8 +60,8 @@ class MigrationListScreenModel( private val getMergedReferencesById: GetMergedReferencesById = 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?>(null) val migrationDone = MutableStateFlow(false) diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/SourcesScreen.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/SourcesScreen.kt index 364a7a6d6..dac626497 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/SourcesScreen.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/SourcesScreen.kt @@ -24,7 +24,7 @@ class SourcesScreen(private val smartSearchConfig: SmartSearchConfig?) : Screen( } /** - * initialized when invoking [MangaScreen.openSmartSearch] or [RecommendsScreen.openSmartSearch] + * initialized when invoking [MangaScreen.openSmartSearch] or [RecommendsScreen] */ data class SmartSearchConfig(val origTitle: String, val origMangaId: Long? = null) : Serializable } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/BrowseSourceScreenModel.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/BrowseSourceScreenModel.kt index 32176b79b..86862063b 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/BrowseSourceScreenModel.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/BrowseSourceScreenModel.kt @@ -90,7 +90,8 @@ import java.time.Instant import eu.kanade.tachiyomi.source.model.Filter as SourceModelFilter open class BrowseSourceScreenModel( - private val sourceId: Long, + /* KMK --> */ + protected /* KMK <-- */ val sourceId: Long, listingQuery: String?, // SY --> private val filtersJson: String? = null, diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryScreenModel.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryScreenModel.kt index 90743ccfc..3f3d872ac 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryScreenModel.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryScreenModel.kt @@ -2,7 +2,6 @@ package eu.kanade.tachiyomi.ui.library import android.app.Application import android.content.Context -import android.util.Log import androidx.compose.runtime.Immutable import androidx.compose.runtime.getValue import androidx.compose.runtime.setValue @@ -40,10 +39,12 @@ import eu.kanade.tachiyomi.source.online.all.MergedSource import eu.kanade.tachiyomi.util.chapter.getNextUnread import eu.kanade.tachiyomi.util.removeCovers import exh.favorites.FavoritesSyncHelper +import exh.log.xLogE 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 @@ -66,6 +67,7 @@ import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableSet import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.asFlow import kotlinx.coroutines.flow.collectLatest @@ -160,7 +162,7 @@ class LibraryScreenModel( private val searchEngine: SearchEngine = Injekt.get(), private val setCustomMangaInfo: SetCustomMangaInfo = Injekt.get(), private val getMergedChaptersByMangaId: GetMergedChaptersByMangaId = Injekt.get(), - private val syncPreferences: SyncPreferences = Injekt.get(), + syncPreferences: SyncPreferences = Injekt.get(), // SY <-- // KMK --> private val smartSearchMerge: SmartSearchMerge = Injekt.get(), @@ -172,6 +174,9 @@ class LibraryScreenModel( // SY --> val favoritesSync = FavoritesSyncHelper(preferences.context) + val recommendationSearch = RecommendationSearchHelper(preferences.context) + + private var recommendationSearchJob: Job? = null // SY <-- init { @@ -1033,6 +1038,11 @@ 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, category: Category?, @@ -1359,8 +1369,12 @@ class LibraryScreenModel( val initialSelection: ImmutableList>, ) : Dialog data class DeleteManga(val manga: List) : Dialog + + // SY --> data object SyncFavoritesWarning : Dialog data object SyncFavoritesConfirm : Dialog + data class RecommendationSearchSheet(val manga: List) : Dialog + // SY <-- } // SY --> @@ -1462,6 +1476,16 @@ class LibraryScreenModel( }.toSortedMap(compareBy { it.order }) } + fun runRecommendationSearch(selection: List) { + recommendationSearch.runSearch(screenModelScope, selection)?.let { + recommendationSearchJob = it + } + } + + fun cancelRecommendationSearch() { + recommendationSearchJob?.cancel() + } + fun runSync() { favoritesSync.runSync(screenModelScope) } @@ -1647,7 +1671,7 @@ class LibraryScreenModel( } } catch (e: Exception) { // Log the error and return an empty set if the file cannot be read. - Log.e("LibraryScreenModel", "Error loading MangaDex DMCA UUIDs", e) + xLogE("Error loading MangaDex DMCA UUIDs", e) hashSetOf() } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryTab.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryTab.kt index 50fed4564..bf5cf6b7f 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryTab.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryTab.kt @@ -53,6 +53,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 @@ -209,6 +213,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 <-- @@ -359,6 +364,7 @@ data object LibraryTab : Tab { }, ) } + // SY --> LibraryScreenModel.Dialog.SyncFavoritesWarning -> { SyncFavoritesWarningDialog( onDismissRequest = onDismissRequest, @@ -377,6 +383,17 @@ data object LibraryTab : Tab { }, ) } + is LibraryScreenModel.Dialog.RecommendationSearchSheet -> { + RecommendationSearchBottomSheetDialog( + onDismissRequest = onDismissRequest, + onSearchRequest = { + onDismissRequest() + screenModel.clearSelection() + screenModel.runRecommendationSearch(dialog.manga) + }, + ) + } + // SY <-- null -> {} } @@ -386,6 +403,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) { @@ -405,6 +428,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() } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaScreen.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaScreen.kt index 1a4c7d389..757e307c5 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaScreen.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaScreen.kt @@ -838,7 +838,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 <-- } diff --git a/app/src/main/java/exh/debug/DebugFunctions.kt b/app/src/main/java/exh/debug/DebugFunctions.kt index 7b36dd7db..2f698a888 100644 --- a/app/src/main/java/exh/debug/DebugFunctions.kt +++ b/app/src/main/java/exh/debug/DebugFunctions.kt @@ -9,13 +9,13 @@ import eu.kanade.tachiyomi.data.library.LibraryUpdateJob import eu.kanade.tachiyomi.data.sync.SyncDataJob import eu.kanade.tachiyomi.source.AndroidSourceManager 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.NHENTAI_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 diff --git a/app/src/main/java/exh/favorites/FavoritesSyncHelper.kt b/app/src/main/java/exh/favorites/FavoritesSyncHelper.kt index 36fea0034..de65e8c8d 100644 --- a/app/src/main/java/exh/favorites/FavoritesSyncHelper.kt +++ b/app/src/main/java/exh/favorites/FavoritesSyncHelper.kt @@ -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) diff --git a/app/src/main/java/exh/md/similar/MangaDexSimilarPagingSource.kt b/app/src/main/java/exh/md/similar/MangaDexSimilarPagingSource.kt index d873ee410..87c207e2b 100644 --- a/app/src/main/java/exh/md/similar/MangaDexSimilarPagingSource.kt +++ b/app/src/main/java/exh/md/similar/MangaDexSimilarPagingSource.kt @@ -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.network.NetworkHelper import eu.kanade.tachiyomi.source.model.MangasPage import eu.kanade.tachiyomi.source.model.MetadataMangasPage @@ -28,10 +29,10 @@ internal class MangaDexSimilarPagingSource( private val sourceCatalogue: SourceCatalogue, // KMK <-- ) : RecommendationPagingSource( + manga, // KMK --> sourceCatalogue.source, // KMK <-- - manga, ) { override val name: String @@ -65,24 +66,31 @@ internal class MangaDexSimilarPagingSource( override suspend fun requestNextPage(currentPage: Int): MangasPage { val mangasPage = coroutineScope { - val similarPageDef = async { - // KMK --> - similarHandler.getSimilar(manga.toSManga()) - // KMK <-- - } - val relatedPageDef = async { - // KMK --> - similarHandler.getRelated(manga.toSManga()) - // KMK <-- - } - val similarPage = similarPageDef.await() - val relatedPage = relatedPageDef.await() + try { + val similarPageDef = async { + // KMK --> + similarHandler.getSimilar(manga.toSManga()) + // KMK <-- + } + val relatedPageDef = async { + // KMK --> + similarHandler.getRelated(manga.toSManga()) + // KMK <-- + } + val similarPage = similarPageDef.await() + val relatedPage = relatedPageDef.await() - MetadataMangasPage( - relatedPage.mangas + similarPage.mangas, - false, - relatedPage.mangasMetadata + similarPage.mangasMetadata, - ) + MetadataMangasPage( + relatedPage.mangas + similarPage.mangas, + 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() diff --git a/app/src/main/java/exh/recs/BrowseRecommendsScreen.kt b/app/src/main/java/exh/recs/BrowseRecommendsScreen.kt index c2e99aced..f7ac4af2f 100644 --- a/app/src/main/java/exh/recs/BrowseRecommendsScreen.kt +++ b/app/src/main/java/exh/recs/BrowseRecommendsScreen.kt @@ -23,18 +23,27 @@ import eu.kanade.tachiyomi.ui.browse.BulkFavoriteScreenModel 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 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()) { @@ -45,9 +54,7 @@ class BrowseRecommendsScreen( val context = LocalContext.current val navigator = LocalNavigator.currentOrThrow - val screenModel = rememberScreenModel { - BrowseRecommendsScreenModel(mangaId, sourceId, recommendationSourceName) - } + val screenModel = rememberScreenModel { BrowseRecommendsScreenModel(args) } // KMK --> val bulkFavoriteScreenModel = rememberScreenModel { BulkFavoriteScreenModel() } diff --git a/app/src/main/java/exh/recs/BrowseRecommendsScreenModel.kt b/app/src/main/java/exh/recs/BrowseRecommendsScreenModel.kt index 4f8e73922..830c68b94 100644 --- a/app/src/main/java/exh/recs/BrowseRecommendsScreenModel.kt +++ b/app/src/main/java/exh/recs/BrowseRecommendsScreenModel.kt @@ -1,40 +1,65 @@ package exh.recs +import cafe.adriel.voyager.core.model.screenModelScope 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.SourceCatalogue +import exh.recs.sources.StaticResultPagingSource +import kotlinx.coroutines.Dispatchers +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.core.common.util.lang.launchIO 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) { +) : BrowseSourceScreenModel( + sourceId = when (args) { + is BrowseRecommendsScreen.Args.SingleSourceManga -> args.sourceId + is BrowseRecommendsScreen.Args.MergedSourceMangas -> args.results.recAssociatedSourceId ?: RECOMMENDS_SOURCE + }, + listingQuery = null, +) { + private var manga: Manga? = null - val manga = runBlocking { getManga.await(mangaId) }!! - - // KMK --> - private val sourceCatalogue = SourceCatalogue(sourceId) - // KMK <-- + init { + screenModelScope.launchIO { + manga = when (args) { + is BrowseRecommendsScreen.Args.SingleSourceManga -> getManga.await(args.mangaId) + is BrowseRecommendsScreen.Args.MergedSourceMangas -> null + } + } + } val recommendationSource: RecommendationPagingSource - get() = RecommendationPagingSource.createSources( - manga, - // KMK --> - sourceCatalogue, - // KMK <-- - ).first { - it::class.qualifiedName == recommendationSourceName + get() = when (args) { + is BrowseRecommendsScreen.Args.MergedSourceMangas -> StaticResultPagingSource(args.results) + is BrowseRecommendsScreen.Args.SingleSourceManga -> RecommendationPagingSource.createSources( + manga ?: runBlocking(Dispatchers.IO) { getManga.await(args.mangaId)!! }, + // KMK --> + SourceCatalogue(sourceId), + // KMK <-- + ).first { + it::class.qualifiedName == args.recommendationSourceName + } } override fun createSourcePagingSource(query: String, filters: FilterList) = recommendationSource + override fun Flow.combineMetadata(metadata: RaisedSearchMetadata?): Flow> { + // 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) } } diff --git a/app/src/main/java/exh/recs/RecommendsScreen.kt b/app/src/main/java/exh/recs/RecommendsScreen.kt index caa60822e..8de0ae29d 100644 --- a/app/src/main/java/exh/recs/RecommendsScreen.kt +++ b/app/src/main/java/exh/recs/RecommendsScreen.kt @@ -16,11 +16,23 @@ import eu.kanade.tachiyomi.ui.browse.BulkFavoriteScreenModel 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 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) : Args + } @Composable override fun Content() { @@ -32,9 +44,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() // KMK --> @@ -73,7 +83,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) }, @@ -81,9 +95,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, - pagingSource::class.qualifiedName!!, + 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, ), ) diff --git a/app/src/main/java/exh/recs/RecommendsScreenModel.kt b/app/src/main/java/exh/recs/RecommendsScreenModel.kt index c63ed52bf..2eef74a94 100644 --- a/app/src/main/java/exh/recs/RecommendsScreenModel.kt +++ b/app/src/main/java/exh/recs/RecommendsScreenModel.kt @@ -7,6 +7,7 @@ import cafe.adriel.voyager.core.model.StateScreenModel import eu.kanade.presentation.util.ioCoroutineScope import exh.recs.sources.RecommendationPagingSource import exh.recs.sources.SourceCatalogue +import exh.recs.sources.StaticResultPagingSource import kotlinx.collections.immutable.PersistentMap import kotlinx.collections.immutable.mutate import kotlinx.collections.immutable.persistentMapOf @@ -29,16 +30,11 @@ 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, private val getManga: GetManga = Injekt.get(), protected val networkToLocalManga: NetworkToLocalManga = Injekt.get(), ) : StateScreenModel(State()) { - // KMK --> - private val sourceCatalogue = SourceCatalogue(sourceId) - // KMK <-- - private val coroutineDispatcher = Dispatchers.IO.limitedParallelism(5) private val sortComparator = { map: Map -> @@ -51,14 +47,22 @@ open class RecommendsScreenModel( init { ioCoroutineScope.launch { - val manga = getManga.await(mangaId)!! - mutableState.update { it.copy(manga = manga) } - val recommendationSources = RecommendationPagingSource.createSources( - manga, - // KMK --> - sourceCatalogue, - // KMK <-- - ) + val recommendationSources = when (args) { + is RecommendsScreen.Args.SingleSourceManga -> { + val manga = getManga.await(args.mangaId)!! + mutableState.update { it.copy(title = manga.title) } + + RecommendationPagingSource.createSources( + manga, + // KMK --> + SourceCatalogue(args.sourceId), + // KMK <-- + ) + } + is RecommendsScreen.Args.MergedSourceMangas -> { + args.mergedResults.map(::StaticResultPagingSource) + } + } updateItems( recommendationSources @@ -131,7 +135,7 @@ open class RecommendsScreenModel( @Immutable data class State( - val manga: Manga? = null, + val title: String? = null, val items: PersistentMap = persistentMapOf(), ) { val progress: Int = items.count { it.value !is RecommendationItemResult.Loading } diff --git a/app/src/main/java/exh/recs/batch/RecommendationSearchBottomSheetDialog.kt b/app/src/main/java/exh/recs/batch/RecommendationSearchBottomSheetDialog.kt new file mode 100644 index 000000000..535bed570 --- /dev/null +++ b/app/src/main/java/exh/recs/batch/RecommendationSearchBottomSheetDialog.kt @@ -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 + } +} diff --git a/app/src/main/java/exh/recs/batch/RecommendationSearchHelper.kt b/app/src/main/java/exh/recs/batch/RecommendationSearchHelper.kt new file mode 100644 index 000000000..ed17edd7e --- /dev/null +++ b/app/src/main/java/exh/recs/batch/RecommendationSearchHelper.kt @@ -0,0 +1,247 @@ +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.toSManga +import eu.kanade.tachiyomi.source.model.SManga +import exh.log.xLog +import exh.recs.sources.RecommendationPagingSource +import exh.recs.sources.SourceCatalogue +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 mihon.domain.manga.model.toDomainManga +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.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 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 = MutableStateFlow(SearchStatus.Idle) + + @Synchronized + fun runSearch(scope: CoroutineScope, mangaList: List): 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) { + 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()) + + 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, + // KMK --> + SourceCatalogue(sourceManga.source), + // KMK <-- + ).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::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.filterLibraryItemsIfEnabled( + recSource: RecommendationPagingSource, + libraryManga: List, + tracks: List, + ): List { + 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(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> + +// Contains the ranked search results for a single source +typealias RankedSearchResults = Results> + +data class Results( + 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) : Finished + data object WithoutResults : Finished + } +} diff --git a/app/src/main/java/exh/recs/batch/RecommendationSearchProgressDialog.kt b/app/src/main/java/exh/recs/batch/RecommendationSearchProgressDialog.kt new file mode 100644 index 000000000..816eadb7a --- /dev/null +++ b/app/src/main/java/exh/recs/batch/RecommendationSearchProgressDialog.kt @@ -0,0 +1,140 @@ +package exh.recs.batch + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.CircularProgressIndicator +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.Alignment +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(status) { + if (status != SearchStatus.Idle) { + currentView.keepScreenOn = true + } + onDispose { + currentView.keepScreenOn = false + } + } + + val properties by produceState(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), + ) + } + // KMK --> + if (status is SearchStatus.Initializing) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator( + modifier = Modifier + .size(40.dp), + strokeWidth = 3.dp, + ) + } + } + } + }, + properties = DialogProperties( + dismissOnClickOutside = false, + dismissOnBackPress = false, + ), + ) + } +} diff --git a/app/src/main/java/exh/recs/batch/SearchFlags.kt b/app/src/main/java/exh/recs/batch/SearchFlags.kt new file mode 100644 index 000000000..589459cc4 --- /dev/null +++ b/app/src/main/java/exh/recs/batch/SearchFlags.kt @@ -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 + } +} diff --git a/app/src/main/java/exh/recs/components/RecommendsScreen.kt b/app/src/main/java/exh/recs/components/RecommendsScreen.kt index 88128dd1e..688d41d7c 100644 --- a/app/src/main/java/exh/recs/components/RecommendsScreen.kt +++ b/app/src/main/java/exh/recs/components/RecommendsScreen.kt @@ -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, @@ -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), diff --git a/app/src/main/java/exh/recs/sources/AniListPagingSource.kt b/app/src/main/java/exh/recs/sources/AniListPagingSource.kt index 63d53c204..b580e15ef 100644 --- a/app/src/main/java/exh/recs/sources/AniListPagingSource.kt +++ b/app/src/main/java/exh/recs/sources/AniListPagingSource.kt @@ -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,12 +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( +class AniListPagingSource(manga: Manga) : TrackerRecommendationPagingSource( "https://graphql.anilist.co/", - source, manga, ) { override val name: String @@ -86,7 +85,7 @@ class AniListPagingSource(manga: Manga, source: CatalogueSource?) : TrackerRecom .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 { diff --git a/app/src/main/java/exh/recs/sources/ComickPagingSource.kt b/app/src/main/java/exh/recs/sources/ComickPagingSource.kt index 45722a612..b1b8bb4e4 100644 --- a/app/src/main/java/exh/recs/sources/ComickPagingSource.kt +++ b/app/src/main/java/exh/recs/sources/ComickPagingSource.kt @@ -31,10 +31,10 @@ internal class ComickPagingSource( private val sourceCatalogue: SourceCatalogue, // KMK <-- ) : RecommendationPagingSource( + manga, // KMK --> sourceCatalogue.source, // KMK <-- - manga, ) { override val name: String diff --git a/app/src/main/java/exh/recs/sources/MangaUpdatesPagingSource.kt b/app/src/main/java/exh/recs/sources/MangaUpdatesPagingSource.kt index ec5e697b9..bddd4002a 100644 --- a/app/src/main/java/exh/recs/sources/MangaUpdatesPagingSource.kt +++ b/app/src/main/java/exh/recs/sources/MangaUpdatesPagingSource.kt @@ -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,12 +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( +abstract class MangaUpdatesPagingSource(manga: Manga) : TrackerRecommendationPagingSource( "https://api.mangaupdates.com/v1/", - source, manga, ) { override val name: String @@ -91,7 +90,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"]!! @@ -100,14 +99,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 diff --git a/app/src/main/java/exh/recs/sources/MyAnimeListPagingSource.kt b/app/src/main/java/exh/recs/sources/MyAnimeListPagingSource.kt index f82de93d7..53c11ecad 100644 --- a/app/src/main/java/exh/recs/sources/MyAnimeListPagingSource.kt +++ b/app/src/main/java/exh/recs/sources/MyAnimeListPagingSource.kt @@ -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,9 +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( +class MyAnimeListPagingSource(manga: Manga) : TrackerRecommendationPagingSource( "https://api.jikan.moe/v4/", - source, manga, ) { override val name: String diff --git a/app/src/main/java/exh/recs/sources/RecommendationPagingSource.kt b/app/src/main/java/exh/recs/sources/RecommendationPagingSource.kt index 742022f79..6e23b382e 100644 --- a/app/src/main/java/exh/recs/sources/RecommendationPagingSource.kt +++ b/app/src/main/java/exh/recs/sources/RecommendationPagingSource.kt @@ -19,6 +19,7 @@ import tachiyomi.data.source.NoResultsException import tachiyomi.domain.manga.model.Manga import tachiyomi.domain.source.service.SourceManager 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 @@ -27,14 +28,14 @@ import uy.kohesive.injekt.injectLazy * General class for recommendation sources. */ abstract class RecommendationPagingSource( - source: CatalogueSource?, protected val manga: Manga, + source: CatalogueSource? = null, ) : BaseSourcePagingSource(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, @@ -55,10 +56,10 @@ abstract class RecommendationPagingSource( val source = sourceCatalogue.source // KMK <-- 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 ( @@ -104,9 +105,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() @@ -120,7 +120,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 abstract suspend fun getRecsById(id: String): List @@ -139,7 +139,10 @@ abstract class TrackerRecommendationPagingSource( results.ifEmpty { throw NoResultsException() } } catch (e: Exception) { - logcat(LogPriority.ERROR, e) { name } + // 'No results' should not be logged as it happens frequently and is expected + if (e !is NoResultsException) { + logcat(LogPriority.ERROR, e) { name } + } throw e } diff --git a/app/src/main/java/exh/recs/sources/StaticResultPagingSource.kt b/app/src/main/java/exh/recs/sources/StaticResultPagingSource.kt new file mode 100644 index 000000000..9eaa65276 --- /dev/null +++ b/app/src/main/java/exh/recs/sources/StaticResultPagingSource.kt @@ -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 + } +} diff --git a/app/src/main/java/exh/smartsearch/SmartSearchEngine.kt b/app/src/main/java/exh/smartsearch/BaseSmartSearchEngine.kt similarity index 78% rename from app/src/main/java/exh/smartsearch/SmartSearchEngine.kt rename to app/src/main/java/exh/smartsearch/BaseSmartSearchEngine.kt index c56038f73..ca111bc31 100644 --- a/app/src/main/java/exh/smartsearch/SmartSearchEngine.kt +++ b/app/src/main/java/exh/smartsearch/BaseSmartSearchEngine.kt @@ -1,23 +1,23 @@ package exh.smartsearch -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 mihon.domain.manga.model.toDomainManga import tachiyomi.core.common.util.QuerySanitizer.sanitize -import tachiyomi.domain.manga.model.Manga import java.util.Locale -class SmartSearchEngine( +typealias SearchAction = suspend (String) -> List + +abstract class BaseSmartSearchEngine( 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, title: String): T? { val cleanedTitle = cleanSmartSearchTitle(title) val queries = getSmartSearchQueries(cleanedTitle) @@ -31,71 +31,42 @@ class SmartSearchEngine( query } - val searchResults = source.getSearchManga(1, builtQuery.sanitize(), FilterList()) - - searchResults.mangas.map { - val cleanedMangaTitle = cleanSmartSearchTitle(it.originalTitle) + searchAction(builtQuery.sanitize()).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, title: String): T? { val eligibleManga = supervisorScope { val searchQuery = if (extraSearchParams != null) { "$title ${extraSearchParams.trim()}" } else { title } - val searchResults = source.getSearchManga(1, searchQuery.sanitize(), FilterList()) + val searchResults = searchAction(searchQuery.sanitize()) - 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 { - 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 { @@ -114,10 +85,10 @@ class SmartSearchEngine( val cleanedTitleEng = cleanedTitle.replace(titleRegex, " ") // Do not strip foreign language letters if cleanedTitle is too short - if (cleanedTitleEng.length <= 5) { - cleanedTitle = cleanedTitle.replace(titleCyrillicRegex, " ") + cleanedTitle = if (cleanedTitleEng.length <= 5) { + cleanedTitle.replace(titleCyrillicRegex, " ") } else { - cleanedTitle = cleanedTitleEng + cleanedTitleEng } // Strip splitters and consecutive spaces @@ -171,9 +142,35 @@ class SmartSearchEngine( return result.toString() } + private fun getSmartSearchQueries(cleanedTitle: String): List { + 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(val manga: T, val dist: Double) diff --git a/app/src/main/java/exh/smartsearch/SmartLibrarySearchEngine.kt b/app/src/main/java/exh/smartsearch/SmartLibrarySearchEngine.kt new file mode 100644 index 000000000..c0e0b956e --- /dev/null +++ b/app/src/main/java/exh/smartsearch/SmartLibrarySearchEngine.kt @@ -0,0 +1,18 @@ +package exh.smartsearch + +import tachiyomi.domain.library.model.LibraryManga + +class SmartLibrarySearchEngine( + extraSearchParams: String? = null, +) : BaseSmartSearchEngine(extraSearchParams, 0.7) { + + override fun getTitle(result: LibraryManga) = result.manga.ogTitle + + suspend fun smartSearch(library: List, title: String): LibraryManga? = + smartSearch( + { query -> + library.filter { it.manga.ogTitle.contains(query, true) } + }, + title, + ) +} diff --git a/app/src/main/java/exh/smartsearch/SmartSourceSearchEngine.kt b/app/src/main/java/exh/smartsearch/SmartSourceSearchEngine.kt new file mode 100644 index 000000000..766fb146d --- /dev/null +++ b/app/src/main/java/exh/smartsearch/SmartSourceSearchEngine.kt @@ -0,0 +1,27 @@ +package exh.smartsearch + +import eu.kanade.tachiyomi.source.CatalogueSource +import eu.kanade.tachiyomi.source.model.FilterList +import eu.kanade.tachiyomi.source.model.SManga +import mihon.domain.manga.model.toDomainManga +import tachiyomi.domain.manga.model.Manga + +class SmartSourceSearchEngine( + extraSearchParams: String? = null, +) : BaseSmartSearchEngine(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 = + { query -> source.getSearchManga(1, query, FilterList()).mangas } +} diff --git a/app/src/main/java/exh/ui/smartsearch/SmartSearchScreenModel.kt b/app/src/main/java/exh/ui/smartsearch/SmartSearchScreenModel.kt index 90044195e..710a479b5 100644 --- a/app/src/main/java/exh/ui/smartsearch/SmartSearchScreenModel.kt +++ b/app/src/main/java/exh/ui/smartsearch/SmartSearchScreenModel.kt @@ -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 @@ -14,12 +14,12 @@ import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get class SmartSearchScreenModel( - private val sourceId: Long, + sourceId: Long, private val config: SourcesScreen.SmartSearchConfig, private val networkToLocalManga: NetworkToLocalManga = Injekt.get(), - private val sourceManager: SourceManager = Injekt.get(), + sourceManager: SourceManager = Injekt.get(), ) : StateScreenModel(null) { - private val smartSearchEngine = SmartSearchEngine() + private val smartSearchEngine = SmartSourceSearchEngine() val source = sourceManager.get(sourceId) as CatalogueSource diff --git a/app/src/main/java/exh/util/ContextExtensions.kt b/app/src/main/java/exh/util/ContextExtensions.kt index 123e35eb1..59da5d1bd 100644 --- a/app/src/main/java/exh/util/ContextExtensions.kt +++ b/app/src/main/java/exh/util/ContextExtensions.kt @@ -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, + ) + } diff --git a/app/src/main/java/exh/eh/EHentaiThrottleManager.kt b/app/src/main/java/exh/util/ThrottleManager.kt similarity index 85% rename from app/src/main/java/exh/eh/EHentaiThrottleManager.kt rename to app/src/main/java/exh/util/ThrottleManager.kt index 30a5c3cb6..58fd8f769 100644 --- a/app/src/main/java/exh/eh/EHentaiThrottleManager.kt +++ b/app/src/main/java/exh/util/ThrottleManager.kt @@ -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 { diff --git a/app/src/main/res/layout/recommendation_search_bottom_sheet.xml b/app/src/main/res/layout/recommendation_search_bottom_sheet.xml new file mode 100644 index 000000000..12c745b8d --- /dev/null +++ b/app/src/main/res/layout/recommendation_search_bottom_sheet.xml @@ -0,0 +1,116 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +