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
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
37 changed files with 1028 additions and 167 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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,16 +33,23 @@ class MangaDexSimilarPagingSource(
override suspend fun requestNextPage(currentPage: Int): MangasPage {
val mangasPage = coroutineScope {
val similarPageDef = async { mangaDex.getMangaSimilar(manga.toSManga()) }
val relatedPageDef = async { mangaDex.getMangaRelated(manga.toSManga()) }
val similarPage = similarPageDef.await()
val relatedPage = relatedPageDef.await()
try {
val similarPageDef = async { mangaDex.getMangaSimilar(manga.toSManga()) }
val relatedPageDef = async { mangaDex.getMangaRelated(manga.toSManga()) }
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()

View File

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

View File

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

View File

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

View File

@ -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) {
val newItems = state.value.items.mutate {
it[source] = result
synchronized(state.value.items) {
val newItems = state.value.items.mutate {
it[source] = result
}
updateItems(newItems)
}
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 }

View File

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

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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