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:
Tim Schneeberger
2025-03-02 17:36:07 +01:00
committed by GitHub
parent 28cca49635
commit 254980695b
37 changed files with 1028 additions and 167 deletions
@@ -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 <--
}