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