diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaScreen.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaScreen.kt index fc0ec328c..25c347f35 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaScreen.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaScreen.kt @@ -40,7 +40,6 @@ import eu.kanade.presentation.manga.components.SetIntervalDialog import eu.kanade.presentation.util.AssistContentScreen import eu.kanade.presentation.util.Screen import eu.kanade.presentation.util.isTabletUi -import eu.kanade.tachiyomi.source.CatalogueSource import eu.kanade.tachiyomi.source.Source import eu.kanade.tachiyomi.source.isLocalOrStub import eu.kanade.tachiyomi.source.online.HttpSource @@ -59,13 +58,10 @@ import eu.kanade.tachiyomi.ui.webview.WebViewScreen import eu.kanade.tachiyomi.util.system.copyToClipboard import eu.kanade.tachiyomi.util.system.toShareIntent import eu.kanade.tachiyomi.util.system.toast -import exh.md.similar.MangaDexSimilarScreen import exh.pagepreview.PagePreviewScreen -import exh.pref.DelegateSourcePreferences import exh.recs.RecommendsScreen import exh.source.MERGED_SOURCE_ID import exh.source.getMainSource -import exh.source.isMdBasedSource import exh.ui.ifSourcesLoaded import exh.ui.metadata.MetadataViewScreen import kotlinx.coroutines.CancellationException @@ -214,7 +210,7 @@ class MangaScreen( onMetadataViewerClicked = { openMetadataViewer(navigator, successState.manga) }, onEditInfoClicked = screenModel::showEditMangaInfoDialog, onRecommendClicked = { - openRecommends(context, navigator, screenModel.source?.getMainSource(), successState.manga) + openRecommends(navigator, screenModel.source?.getMainSource(), successState.manga) }, onMergedSettingsClicked = screenModel::showEditMergedSettingsDialog, onMergeClicked = { openSmartSearch(navigator, successState.manga) }, @@ -550,28 +546,9 @@ class MangaScreen( // EXH <-- // AZ --> - private fun openRecommends(context: Context, navigator: Navigator, source: Source?, manga: Manga) { + private fun openRecommends(navigator: Navigator, source: Source?, manga: Manga) { source ?: return - if (source.isMdBasedSource() && Injekt.get().delegateSources().get()) { - MaterialAlertDialogBuilder(context) - .setTitle(SYMR.strings.az_recommends.getString(context)) - .setSingleChoiceItems( - arrayOf( - context.stringResource(SYMR.strings.mangadex_similar), - context.stringResource(SYMR.strings.community_recommendations), - ), - -1, - ) { dialog, index -> - dialog.dismiss() - when (index) { - 0 -> navigator.push(MangaDexSimilarScreen(manga.id, source.id)) - 1 -> navigator.push(RecommendsScreen(manga.id, source.id)) - } - } - .show() - } else if (source is CatalogueSource) { - navigator.push(RecommendsScreen(manga.id, source.id)) - } + navigator.push(RecommendsScreen(manga.id, source.id)) } // AZ <-- } diff --git a/app/src/main/java/exh/md/similar/MangaDexSimilarPagingSource.kt b/app/src/main/java/exh/md/similar/MangaDexSimilarPagingSource.kt index 00f800f16..7458ecfc2 100644 --- a/app/src/main/java/exh/md/similar/MangaDexSimilarPagingSource.kt +++ b/app/src/main/java/exh/md/similar/MangaDexSimilarPagingSource.kt @@ -1,24 +1,39 @@ package exh.md.similar +import dev.icerock.moko.resources.StringResource import eu.kanade.domain.manga.model.toSManga import eu.kanade.tachiyomi.source.model.MangasPage import eu.kanade.tachiyomi.source.model.MetadataMangasPage import eu.kanade.tachiyomi.source.online.all.MangaDex +import exh.recs.sources.RecommendationPagingSource +import exh.source.getMainSource import kotlinx.coroutines.async import kotlinx.coroutines.coroutineScope import tachiyomi.data.source.NoResultsException -import tachiyomi.data.source.SourcePagingSource import tachiyomi.domain.manga.model.Manga +import tachiyomi.i18n.sy.SYMR /** * MangaDexSimilarPagingSource inherited from the general Pager. */ -class MangaDexSimilarPagingSource(val manga: Manga, val mangadex: MangaDex) : SourcePagingSource(mangadex) { +class MangaDexSimilarPagingSource( + manga: Manga, + private val mangaDex: MangaDex, +) : RecommendationPagingSource(mangaDex, manga) { + + override val name: String + get() = "MangaDex" + + override val category: StringResource + get() = SYMR.strings.similar_titles + + override val associatedSourceId: Long + get() = mangaDex.getMainSource().id override suspend fun requestNextPage(currentPage: Int): MangasPage { val mangasPage = coroutineScope { - val similarPageDef = async { mangadex.getMangaSimilar(manga.toSManga()) } - val relatedPageDef = async { mangadex.getMangaRelated(manga.toSManga()) } + val similarPageDef = async { mangaDex.getMangaSimilar(manga.toSManga()) } + val relatedPageDef = async { mangaDex.getMangaRelated(manga.toSManga()) } val similarPage = similarPageDef.await() val relatedPage = relatedPageDef.await() diff --git a/app/src/main/java/exh/md/similar/MangaDexSimilarScreenModel.kt b/app/src/main/java/exh/md/similar/MangaDexSimilarScreenModel.kt deleted file mode 100644 index 1546070c4..000000000 --- a/app/src/main/java/exh/md/similar/MangaDexSimilarScreenModel.kt +++ /dev/null @@ -1,37 +0,0 @@ -package exh.md.similar - -import eu.kanade.tachiyomi.source.model.FilterList -import eu.kanade.tachiyomi.source.online.all.MangaDex -import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourceScreenModel -import exh.metadata.metadata.RaisedSearchMetadata -import exh.source.getMainSource -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.runBlocking -import tachiyomi.domain.manga.interactor.GetManga -import tachiyomi.domain.manga.model.Manga -import tachiyomi.domain.source.repository.SourcePagingSourceType -import uy.kohesive.injekt.Injekt -import uy.kohesive.injekt.api.get - -class MangaDexSimilarScreenModel( - val mangaId: Long, - sourceId: Long, - private val getManga: GetManga = Injekt.get(), -) : BrowseSourceScreenModel(sourceId, null) { - - val manga: Manga = runBlocking { getManga.await(mangaId) }!! - - override fun createSourcePagingSource(query: String, filters: FilterList): SourcePagingSourceType { - return MangaDexSimilarPagingSource(manga, source.getMainSource() as MangaDex) - } - - override fun Flow.combineMetadata(metadata: RaisedSearchMetadata?): Flow> { - return map { it to metadata } - } - - init { - mutableState.update { it.copy(filterable = false) } - } -} diff --git a/app/src/main/java/exh/md/similar/MangaDexSimilarScreen.kt b/app/src/main/java/exh/recs/BrowseRecommendsScreen.kt similarity index 62% rename from app/src/main/java/exh/md/similar/MangaDexSimilarScreen.kt rename to app/src/main/java/exh/recs/BrowseRecommendsScreen.kt index 4ab01684b..f8c3d3423 100644 --- a/app/src/main/java/exh/md/similar/MangaDexSimilarScreen.kt +++ b/app/src/main/java/exh/recs/BrowseRecommendsScreen.kt @@ -1,26 +1,33 @@ -package exh.md.similar +package exh.recs import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext import cafe.adriel.voyager.core.model.rememberScreenModel import cafe.adriel.voyager.navigator.LocalNavigator import cafe.adriel.voyager.navigator.currentOrThrow import eu.kanade.presentation.browse.BrowseSourceContent import eu.kanade.presentation.browse.components.BrowseSourceSimpleToolbar import eu.kanade.presentation.util.Screen +import eu.kanade.tachiyomi.ui.browse.source.SourcesScreen import eu.kanade.tachiyomi.ui.manga.MangaScreen +import eu.kanade.tachiyomi.ui.webview.WebViewActivity import exh.ui.ifSourcesLoaded import mihon.presentation.core.util.collectAsLazyPagingItems import tachiyomi.domain.manga.model.Manga -import tachiyomi.i18n.sy.SYMR import tachiyomi.presentation.core.components.material.Scaffold import tachiyomi.presentation.core.i18n.stringResource import tachiyomi.presentation.core.screens.LoadingScreen -class MangaDexSimilarScreen(val mangaId: Long, val sourceId: Long) : Screen() { +class BrowseRecommendsScreen( + private val mangaId: Long, + private val sourceId: Long, + private val recommendationSourceName: String, + private val isExternalSource: Boolean, +) : Screen() { @Composable override fun Content() { @@ -29,20 +36,37 @@ class MangaDexSimilarScreen(val mangaId: Long, val sourceId: Long) : Screen() { return } - val screenModel = rememberScreenModel { MangaDexSimilarScreenModel(mangaId, sourceId) } + val context = LocalContext.current val navigator = LocalNavigator.currentOrThrow - val onMangaClick: (Manga) -> Unit = { - navigator.push(MangaScreen(it.id, true)) + val screenModel = rememberScreenModel { + BrowseRecommendsScreenModel(mangaId, sourceId, recommendationSourceName) + } + val snackbarHostState = remember { SnackbarHostState() } + + val onClickItem = { manga: Manga -> + navigator.push( + when (isExternalSource) { + true -> SourcesScreen(SourcesScreen.SmartSearchConfig(manga.ogTitle)) + false -> MangaScreen(manga.id, true) + }, + ) } - val snackbarHostState = remember { SnackbarHostState() } + val onLongClickItem = { manga: Manga -> + when (isExternalSource) { + true -> WebViewActivity.newIntent(context, manga.url, title = manga.title).let(context::startActivity) + false -> onClickItem(manga) + } + } Scaffold( topBar = { scrollBehavior -> + val recSource = remember { screenModel.recommendationSource } + BrowseSourceSimpleToolbar( navigateUp = navigator::pop, - title = stringResource(SYMR.strings.similar, screenModel.manga.title), + title = "${recSource.name} (${stringResource(recSource.category)})", displayMode = screenModel.displayMode, onDisplayModeChange = { screenModel.displayMode = it }, scrollBehavior = scrollBehavior, @@ -63,8 +87,8 @@ class MangaDexSimilarScreen(val mangaId: Long, val sourceId: Long) : Screen() { onWebViewClick = null, onHelpClick = null, onLocalSourceHelpClick = null, - onMangaClick = onMangaClick, - onMangaLongClick = onMangaClick, + onMangaClick = onClickItem, + onMangaLongClick = onLongClickItem, ) } } diff --git a/app/src/main/java/exh/recs/BrowseRecommendsScreenModel.kt b/app/src/main/java/exh/recs/BrowseRecommendsScreenModel.kt new file mode 100644 index 000000000..b0b5074de --- /dev/null +++ b/app/src/main/java/exh/recs/BrowseRecommendsScreenModel.kt @@ -0,0 +1,32 @@ +package exh.recs + +import eu.kanade.tachiyomi.source.CatalogueSource +import eu.kanade.tachiyomi.source.model.FilterList +import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourceScreenModel +import exh.recs.sources.RecommendationPagingSource +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.runBlocking +import tachiyomi.domain.manga.interactor.GetManga +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get + +class BrowseRecommendsScreenModel( + val mangaId: Long, + sourceId: Long, + private val recommendationSourceName: String, + private val getManga: GetManga = Injekt.get(), +) : BrowseSourceScreenModel(sourceId, null) { + + val manga = runBlocking { getManga.await(mangaId) }!! + + val recommendationSource: RecommendationPagingSource + get() = RecommendationPagingSource.createSources(manga, source as CatalogueSource).first { + it::class.qualifiedName == recommendationSourceName + } + + override fun createSourcePagingSource(query: String, filters: FilterList) = recommendationSource + + init { + mutableState.update { it.copy(filterable = false) } + } +} diff --git a/app/src/main/java/exh/recs/RecommendsScreen.kt b/app/src/main/java/exh/recs/RecommendsScreen.kt index 4af42d409..fb34fe513 100644 --- a/app/src/main/java/exh/recs/RecommendsScreen.kt +++ b/app/src/main/java/exh/recs/RecommendsScreen.kt @@ -1,22 +1,19 @@ package exh.recs -import androidx.compose.material3.SnackbarHost -import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.platform.LocalContext import cafe.adriel.voyager.core.model.rememberScreenModel import cafe.adriel.voyager.navigator.LocalNavigator -import cafe.adriel.voyager.navigator.Navigator import cafe.adriel.voyager.navigator.currentOrThrow -import eu.kanade.presentation.browse.BrowseSourceContent -import eu.kanade.presentation.browse.components.BrowseSourceSimpleToolbar import eu.kanade.presentation.util.Screen import eu.kanade.tachiyomi.ui.browse.source.SourcesScreen +import eu.kanade.tachiyomi.ui.manga.MangaScreen +import eu.kanade.tachiyomi.ui.webview.WebViewActivity +import exh.recs.components.RecommendsScreen import exh.ui.ifSourcesLoaded -import mihon.presentation.core.util.collectAsLazyPagingItems import tachiyomi.domain.manga.model.Manga -import tachiyomi.presentation.core.components.material.Scaffold import tachiyomi.presentation.core.screens.LoadingScreen class RecommendsScreen(val mangaId: Long, val sourceId: Long) : Screen() { @@ -28,48 +25,48 @@ class RecommendsScreen(val mangaId: Long, val sourceId: Long) : Screen() { return } - val screenModel = rememberScreenModel { RecommendsScreenModel(mangaId, sourceId) } + val context = LocalContext.current val navigator = LocalNavigator.currentOrThrow - val onMangaClick: (Manga) -> Unit = { manga -> - openSmartSearch(navigator, manga.ogTitle) + val screenModel = rememberScreenModel { + RecommendsScreenModel(mangaId = mangaId, sourceId = sourceId) } + val state by screenModel.state.collectAsState() - val snackbarHostState = remember { SnackbarHostState() } - - Scaffold( - topBar = { scrollBehavior -> - BrowseSourceSimpleToolbar( - navigateUp = navigator::pop, - title = screenModel.manga.title, - displayMode = screenModel.displayMode, - onDisplayModeChange = { screenModel.displayMode = it }, - scrollBehavior = scrollBehavior, - ) - }, - snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, - ) { paddingValues -> - BrowseSourceContent( - source = screenModel.source, - mangaList = screenModel.mangaPagerFlowFlow.collectAsLazyPagingItems(), - columns = screenModel.getColumnsPreference(LocalConfiguration.current.orientation), - // SY --> - ehentaiBrowseDisplayMode = false, - // SY <-- - displayMode = screenModel.displayMode, - snackbarHostState = snackbarHostState, - contentPadding = paddingValues, - onWebViewClick = null, - onHelpClick = null, - onLocalSourceHelpClick = null, - onMangaClick = onMangaClick, - onMangaLongClick = onMangaClick, + val onClickItem = { manga: Manga -> + navigator.push( + when (manga.source) { + -1L -> SourcesScreen(SourcesScreen.SmartSearchConfig(manga.ogTitle)) + else -> MangaScreen(manga.id, true) + }, ) } - } - private fun openSmartSearch(navigator: Navigator, title: String) { - val smartSearchConfig = SourcesScreen.SmartSearchConfig(title) - navigator.push(SourcesScreen(smartSearchConfig)) + val onLongClickItem = { manga: Manga -> + when (manga.source) { + -1L -> WebViewActivity.newIntent(context, manga.url, title = manga.title).let(context::startActivity) + else -> onClickItem(manga) + } + } + + RecommendsScreen( + manga = screenModel.manga, + state = state, + navigateUp = navigator::pop, + getManga = @Composable { manga: Manga -> screenModel.getManga(manga) }, + onClickSource = { pagingSource -> + // Pass class name of paging source as screens need to be serializable + navigator.push( + BrowseRecommendsScreen( + mangaId, + sourceId, + pagingSource::class.qualifiedName!!, + pagingSource.associatedSourceId == null, + ), + ) + }, + onClickItem = onClickItem, + onLongClickItem = onLongClickItem, + ) } } diff --git a/app/src/main/java/exh/recs/RecommendsScreenModel.kt b/app/src/main/java/exh/recs/RecommendsScreenModel.kt index 618ed72a8..351ca0da0 100644 --- a/app/src/main/java/exh/recs/RecommendsScreenModel.kt +++ b/app/src/main/java/exh/recs/RecommendsScreenModel.kt @@ -1,23 +1,161 @@ package exh.recs +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.produceState +import cafe.adriel.voyager.core.model.StateScreenModel +import eu.kanade.domain.manga.model.toDomainManga +import eu.kanade.presentation.util.ioCoroutineScope import eu.kanade.tachiyomi.source.CatalogueSource -import eu.kanade.tachiyomi.source.model.FilterList -import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourceScreenModel +import exh.recs.sources.RecommendationPagingSource +import kotlinx.collections.immutable.PersistentMap +import kotlinx.collections.immutable.mutate +import kotlinx.collections.immutable.persistentMapOf +import kotlinx.collections.immutable.toImmutableMap +import kotlinx.collections.immutable.toPersistentMap +import kotlinx.coroutines.Job +import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext import tachiyomi.domain.manga.interactor.GetManga -import tachiyomi.domain.source.repository.SourcePagingSourceType +import tachiyomi.domain.manga.interactor.NetworkToLocalManga +import tachiyomi.domain.manga.model.Manga +import tachiyomi.domain.source.service.SourceManager import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get +import java.util.concurrent.Executors -class RecommendsScreenModel( +open class RecommendsScreenModel( val mangaId: Long, - sourceId: Long, + val sourceId: Long, + initialState: State = State(), + sourceManager: SourceManager = Injekt.get(), private val getManga: GetManga = Injekt.get(), -) : BrowseSourceScreenModel(sourceId, null) { + private val networkToLocalManga: NetworkToLocalManga = Injekt.get(), +) : StateScreenModel(initialState) { val manga = runBlocking { getManga.await(mangaId) }!! + val source = sourceManager.getOrStub(sourceId) as CatalogueSource - override fun createSourcePagingSource(query: String, filters: FilterList): SourcePagingSourceType { - return RecommendsPagingSource(source as CatalogueSource, manga) + private val coroutineDispatcher = Executors.newFixedThreadPool(5).asCoroutineDispatcher() + private var findRecsJob: Job? = null + + private val sortComparator = { map: Map -> + compareBy( + { (map[it] as? RecommendationItemResult.Success)?.isEmpty ?: true }, + { it.name }, + { it.category.resourceId }, + ) + } + + init { + findRecsJob?.cancel() + + val recommendationSources = RecommendationPagingSource.createSources(manga, source) + + updateItems( + recommendationSources + .associateWith { RecommendationItemResult.Loading } + .toPersistentMap(), + ) + + findRecsJob = ioCoroutineScope.launch { + recommendationSources.map { recSource -> + async { + if (state.value.items[recSource] !is RecommendationItemResult.Loading) { + return@async + } + + try { + val page = withContext(coroutineDispatcher) { + recSource.requestNextPage(1) + } + + val titles = page.mangas.map { + val recSourceId = recSource.associatedSourceId + if (recSourceId != null) { + // If the recommendation is associated with a source, resolve it + networkToLocalManga.await(it.toDomainManga(recSourceId)) + } else { + // Otherwise, skip this step. The user will be prompted to choose a source via SmartSearch + it.toDomainManga(-1) + } + } + + if (isActive) { + updateItem(recSource, RecommendationItemResult.Success(titles)) + } + } catch (e: Exception) { + if (isActive) { + updateItem(recSource, RecommendationItemResult.Error(e)) + } + } + } + }.awaitAll() + } + } + + @Composable + fun getManga(initialManga: Manga): androidx.compose.runtime.State { + return produceState(initialValue = initialManga) { + getManga.subscribe(initialManga.url, initialManga.source) + .filterNotNull() + .collectLatest { manga -> + value = manga + } + } + } + + private fun updateItems(items: PersistentMap) { + mutableState.update { + it.copy( + items = items + .toSortedMap(sortComparator(items)) + .toPersistentMap(), + ) + } + } + + private fun updateItem(source: RecommendationPagingSource, result: RecommendationItemResult) { + val newItems = state.value.items.mutate { + it[source] = result + } + updateItems(newItems) + } + + @Immutable + data class State( + val items: PersistentMap = persistentMapOf(), + ) { + val progress: Int = items.count { it.value !is RecommendationItemResult.Loading } + val total: Int = items.size + val filteredItems = items.filter { (_, result) -> result.isVisible(false) } + .toImmutableMap() + } +} + +sealed interface RecommendationItemResult { + data object Loading : RecommendationItemResult + + data class Error( + val throwable: Throwable, + ) : RecommendationItemResult + + data class Success( + val result: List, + ) : RecommendationItemResult { + val isEmpty: Boolean + get() = result.isEmpty() + } + + fun isVisible(onlyShowHasResults: Boolean): Boolean { + return !onlyShowHasResults || (this is Success && !this.isEmpty) } } diff --git a/app/src/main/java/exh/recs/components/RecommendsScreen.kt b/app/src/main/java/exh/recs/components/RecommendsScreen.kt new file mode 100644 index 000000000..14749c1a0 --- /dev/null +++ b/app/src/main/java/exh/recs/components/RecommendsScreen.kt @@ -0,0 +1,97 @@ +package exh.recs.components + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.ui.platform.LocalContext +import eu.kanade.presentation.browse.components.GlobalSearchCardRow +import eu.kanade.presentation.browse.components.GlobalSearchErrorResultItem +import eu.kanade.presentation.browse.components.GlobalSearchLoadingResultItem +import eu.kanade.presentation.browse.components.GlobalSearchResultItem +import eu.kanade.presentation.components.AppBar +import eu.kanade.presentation.util.formattedMessage +import exh.recs.RecommendationItemResult +import exh.recs.RecommendsScreenModel +import exh.recs.sources.RecommendationPagingSource +import kotlinx.collections.immutable.ImmutableMap +import nl.adaptivity.xmlutil.core.impl.multiplatform.name +import tachiyomi.domain.manga.model.Manga +import tachiyomi.i18n.sy.SYMR +import tachiyomi.presentation.core.components.material.Scaffold +import tachiyomi.presentation.core.i18n.stringResource + +@Composable +fun RecommendsScreen( + manga: Manga, + state: RecommendsScreenModel.State, + navigateUp: () -> Unit, + getManga: @Composable (Manga) -> State, + onClickSource: (RecommendationPagingSource) -> Unit, + onClickItem: (Manga) -> Unit, + onLongClickItem: (Manga) -> Unit, +) { + Scaffold( + topBar = { scrollBehavior -> + AppBar( + title = stringResource(SYMR.strings.similar, manga.title), + scrollBehavior = scrollBehavior, + navigateUp = navigateUp, + ) + }, + ) { paddingValues -> + RecommendsContent( + items = state.filteredItems, + contentPadding = paddingValues, + getManga = getManga, + onClickSource = onClickSource, + onClickItem = onClickItem, + onLongClickItem = onLongClickItem, + ) + } +} + +@Composable +internal fun RecommendsContent( + items: ImmutableMap, + contentPadding: PaddingValues, + getManga: @Composable (Manga) -> State, + onClickSource: (RecommendationPagingSource) -> Unit, + onClickItem: (Manga) -> Unit, + onLongClickItem: (Manga) -> Unit, +) { + LazyColumn( + contentPadding = contentPadding, + ) { + items.forEach { (source, recResult) -> + item(key = source::class.name) { + GlobalSearchResultItem( + title = source.name, + subtitle = stringResource(source.category), + onClick = { onClickSource(source) }, + ) { + when (recResult) { + RecommendationItemResult.Loading -> { + GlobalSearchLoadingResultItem() + } + is RecommendationItemResult.Success -> { + GlobalSearchCardRow( + titles = recResult.result, + getManga = getManga, + onClick = onClickItem, + onLongClick = onLongClickItem, + ) + } + is RecommendationItemResult.Error -> { + GlobalSearchErrorResultItem( + message = with(LocalContext.current) { + recResult.throwable.formattedMessage + }, + ) + } + } + } + } + } + } +} diff --git a/app/src/main/java/exh/recs/RecommendsPagingSource.kt b/app/src/main/java/exh/recs/sources/AniListPagingSource.kt similarity index 60% rename from app/src/main/java/exh/recs/RecommendsPagingSource.kt rename to app/src/main/java/exh/recs/sources/AniListPagingSource.kt index ec2cf80b3..66994cfd4 100644 --- a/app/src/main/java/exh/recs/RecommendsPagingSource.kt +++ b/app/src/main/java/exh/recs/sources/AniListPagingSource.kt @@ -1,17 +1,11 @@ -package exh.recs +package exh.recs.sources -import eu.kanade.tachiyomi.data.track.TrackerManager -import eu.kanade.tachiyomi.network.GET -import eu.kanade.tachiyomi.network.NetworkHelper +import dev.icerock.moko.resources.StringResource import eu.kanade.tachiyomi.network.POST import eu.kanade.tachiyomi.network.awaitSuccess import eu.kanade.tachiyomi.network.parseAs import eu.kanade.tachiyomi.source.CatalogueSource -import eu.kanade.tachiyomi.source.model.MangasPage import eu.kanade.tachiyomi.source.model.SManga -import exh.util.MangaType -import exh.util.mangaType -import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonObject @@ -21,84 +15,24 @@ import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.put -import logcat.LogPriority -import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.MediaType.Companion.toMediaType import okhttp3.RequestBody.Companion.toRequestBody import tachiyomi.core.common.util.system.logcat -import tachiyomi.data.source.NoResultsException -import tachiyomi.data.source.SourcePagingSource import tachiyomi.domain.manga.model.Manga -import tachiyomi.domain.track.interactor.GetTracks -import uy.kohesive.injekt.Injekt -import uy.kohesive.injekt.api.get -import uy.kohesive.injekt.injectLazy +import tachiyomi.i18n.sy.SYMR -abstract class API(val endpoint: String) { - val client by lazy { - Injekt.get().client - } - val json by injectLazy() +class AniListPagingSource(manga: Manga, source: CatalogueSource) : TrackerRecommendationPagingSource( + "https://graphql.anilist.co/", source, manga, +) { + override val name: String + get() = "AniList" - abstract suspend fun getRecsBySearch(search: String): List + override val category: StringResource + get() = SYMR.strings.community_recommendations - abstract suspend fun getRecsById(id: String): List -} + override val associatedTrackerId: Long + get() = trackerManager.aniList.id -class MyAnimeList : API("https://api.jikan.moe/v4/") { - override suspend fun getRecsById(id: String): List { - val apiUrl = endpoint.toHttpUrl() - .newBuilder() - .addPathSegment("manga") - .addPathSegment(id) - .addPathSegment("recommendations") - .build() - - val data = with(json) { client.newCall(GET(apiUrl)).awaitSuccess().parseAs() } - return data["data"]!!.jsonArray - .map { it.jsonObject["entry"]!!.jsonObject } - .map { rec -> - logcat { "MYANIMELIST > RECOMMENDATION: " + rec["title"]!!.jsonPrimitive.content } - SManga( - title = rec["title"]!!.jsonPrimitive.content, - url = rec["url"]!!.jsonPrimitive.content, - thumbnail_url = rec["images"] - ?.let(JsonElement::jsonObject) - ?.let(::getImage), - initialized = true, - ) - } - } - - fun getImage(imageObject: JsonObject): String? { - return imageObject["webp"] - ?.jsonObject - ?.get("image_url") - ?.jsonPrimitive - ?.contentOrNull - ?: imageObject["jpg"] - ?.jsonObject - ?.get("image_url") - ?.jsonPrimitive - ?.contentOrNull - } - - override suspend fun getRecsBySearch(search: String): List { - val url = endpoint.toHttpUrl() - .newBuilder() - .addPathSegment("manga") - .addQueryParameter("q", search) - .build() - - val data = with(json) { - client.newCall(GET(url)).awaitSuccess() - .parseAs() - } - return getRecsById(data["data"]!!.jsonArray.first().jsonObject["mal_id"]!!.jsonPrimitive.content) - } -} - -class Anilist : API("https://graphql.anilist.co/") { private fun countOccurrence(arr: JsonArray, search: String): Int { return arr.count { val synonym = it.jsonPrimitive.content @@ -261,52 +195,3 @@ class Anilist : API("https://graphql.anilist.co/") { ) } } - -open class RecommendsPagingSource( - source: CatalogueSource, - private val manga: Manga, - private val smart: Boolean = true, - private var preferredApi: API = API.MYANIMELIST, -) : SourcePagingSource(source) { - val trackerManager: TrackerManager by injectLazy() - val getTracks: GetTracks by injectLazy() - - override suspend fun requestNextPage(currentPage: Int): MangasPage { - if (smart) preferredApi = if (manga.mangaType() != MangaType.TYPE_MANGA) API.ANILIST else preferredApi - - val apiList = API_MAP.toList().sortedByDescending { it.first == preferredApi } - - val tracks = getTracks.await(manga.id) - - val recs = apiList.firstNotNullOfOrNull { (key, api) -> - try { - val id = when (key) { - API.MYANIMELIST -> tracks.find { it.trackerId == trackerManager.myAnimeList.id }?.remoteId - API.ANILIST -> tracks.find { it.trackerId == trackerManager.aniList.id }?.remoteId - } - - val recs = if (id != null) { - api.getRecsById(id.toString()) - } else { - api.getRecsBySearch(manga.ogTitle) - } - logcat { key.toString() + " > Results: " + recs.size } - recs.ifEmpty { null } - } catch (e: Exception) { - logcat(LogPriority.ERROR, e) { key.toString() } - null - } - } ?: throw NoResultsException() - - return MangasPage(recs, false) - } - - companion object { - val API_MAP = mapOf( - API.MYANIMELIST to MyAnimeList(), - API.ANILIST to Anilist(), - ) - - enum class API { MYANIMELIST, ANILIST } - } -} diff --git a/app/src/main/java/exh/recs/sources/ComickPagingSource.kt b/app/src/main/java/exh/recs/sources/ComickPagingSource.kt new file mode 100644 index 000000000..84e9dd960 --- /dev/null +++ b/app/src/main/java/exh/recs/sources/ComickPagingSource.kt @@ -0,0 +1,91 @@ +package exh.recs.sources + +import dev.icerock.moko.resources.StringResource +import eu.kanade.tachiyomi.network.GET +import eu.kanade.tachiyomi.network.NetworkHelper +import eu.kanade.tachiyomi.network.awaitSuccess +import eu.kanade.tachiyomi.network.parseAs +import eu.kanade.tachiyomi.source.CatalogueSource +import eu.kanade.tachiyomi.source.model.MangasPage +import eu.kanade.tachiyomi.source.model.SManga +import kotlinx.coroutines.coroutineScope +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import okhttp3.Headers +import okhttp3.HttpUrl.Companion.toHttpUrl +import tachiyomi.data.source.NoResultsException +import tachiyomi.domain.manga.model.Manga +import tachiyomi.i18n.sy.SYMR +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get +import uy.kohesive.injekt.injectLazy + +fun CatalogueSource.isComickSource() = name == "Comick" + +class ComickPagingSource( + manga: Manga, + private val comickSource: CatalogueSource, +) : RecommendationPagingSource(comickSource, manga) { + + override val name: String + get() = "Comick" + + override val category: StringResource + get() = SYMR.strings.community_recommendations + + override val associatedSourceId: Long + get() = comickSource.id + + private val client by lazy { Injekt.get().client } + private val json by injectLazy() + private val thumbnailBaseUrl = "https://meo.comick.pictures/" + + override suspend fun requestNextPage(currentPage: Int): MangasPage { + val mangasPage = coroutineScope { + val headers = Headers.Builder().apply { + add("Referer", "api.comick.fun/") + add("User-Agent", "Tachiyomi ${System.getProperty("http.agent")}") + } + + // Comick extension populates the URL field with: '/comic/{hid}#' + val url = "https://api.comick.fun/v1.0${manga.url}".toHttpUrl() + .newBuilder() + .addQueryParameter("tachiyomi", "true") + .build() + + val request = GET(url, headers.build()) + + val data = with(json) { + client.newCall(request).awaitSuccess() + .parseAs() + } + + val recs = data["comic"]!! + .jsonObject["recommendations"]!! + .jsonArray + .map { it.jsonObject["relates"]!! } + .map { it.jsonObject } + + MangasPage( + recs.map { rec -> + SManga( + title = rec["title"]!!.jsonPrimitive.content, + url = "/comic/${rec["hid"]!!.jsonPrimitive.content}#", + thumbnail_url = thumbnailBaseUrl + rec["md_covers"]!! + .jsonArray + .map { it.jsonObject["b2key"]!!.jsonPrimitive.content } + .first(), + // Mark as uninitialized to force fetching missing metadata + initialized = false, + ) + }, + false, + ) + } + + return mangasPage.takeIf { it.mangas.isNotEmpty() } ?: throw NoResultsException() + } +} diff --git a/app/src/main/java/exh/recs/sources/MangaUpdatesPagingSource.kt b/app/src/main/java/exh/recs/sources/MangaUpdatesPagingSource.kt new file mode 100644 index 000000000..bf0bdef5e --- /dev/null +++ b/app/src/main/java/exh/recs/sources/MangaUpdatesPagingSource.kt @@ -0,0 +1,113 @@ +package exh.recs.sources + +import dev.icerock.moko.resources.StringResource +import eu.kanade.tachiyomi.network.GET +import eu.kanade.tachiyomi.network.POST +import eu.kanade.tachiyomi.network.awaitSuccess +import eu.kanade.tachiyomi.network.parseAs +import eu.kanade.tachiyomi.source.CatalogueSource +import eu.kanade.tachiyomi.source.model.SManga +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.RequestBody.Companion.toRequestBody +import tachiyomi.core.common.util.system.logcat +import tachiyomi.domain.manga.model.Manga +import tachiyomi.i18n.sy.SYMR + +abstract class MangaUpdatesPagingSource(manga: Manga, source: CatalogueSource) : TrackerRecommendationPagingSource( + "https://api.mangaupdates.com/v1/", source, manga, +) { + override val name: String + get() = "MangaUpdates" + + override val associatedTrackerId: Long + get() = trackerManager.mangaUpdates.id + + protected abstract val recommendationJsonObjectName: String + + override suspend fun getRecsById(id: String): List { + val apiUrl = endpoint.toHttpUrl() + .newBuilder() + .addPathSegment("series") + .addPathSegment(id) + .build() + + val data = with(json) { client.newCall(GET(apiUrl)).awaitSuccess().parseAs() } + return getRecommendations(data[recommendationJsonObjectName]!!.jsonArray) + } + + private fun getRecommendations(recommendations: JsonArray): List { + return recommendations + .map(JsonElement::jsonObject) + .map { rec -> + logcat { "MANGAUPDATES > RECOMMENDATION: " + rec["series_name"]!!.jsonPrimitive.content } + SManga( + title = rec["series_name"]!!.jsonPrimitive.content, + url = rec["series_url"]!!.jsonPrimitive.content, + thumbnail_url = rec["series_image"] + ?.jsonObject + ?.get("url") + ?.jsonObject + ?.get("original") + ?.jsonPrimitive + ?.contentOrNull, + initialized = true, + ) + } + } + + override suspend fun getRecsBySearch(search: String): List { + val url = endpoint.toHttpUrl() + .newBuilder() + .addPathSegments("series/search") + .build() + .toString() + + val payload = buildJsonObject { + put("search", search) + put("stype", "title") + } + + val body = payload + .toString() + .toRequestBody("application/json; charset=utf-8".toMediaType()) + + val data = with(json) { + client.newCall(POST(url, body = body)) + .awaitSuccess() + .parseAs() + } + return getRecsById( + data["results"]!! + .jsonArray + .ifEmpty { throw Exception("'$search' not found") } + .first() + .jsonObject["record"]!! + .jsonObject["series_id"]!! + .jsonPrimitive.content, + ) + } +} + +class MangaUpdatesCommunityPagingSource(manga: Manga, source: CatalogueSource) : MangaUpdatesPagingSource(manga, source) { + override val category: StringResource + get() = SYMR.strings.community_recommendations + override val recommendationJsonObjectName: String + get() = "recommendations" +} + +class MangaUpdatesSimilarPagingSource(manga: Manga, source: CatalogueSource) : MangaUpdatesPagingSource(manga, source) { + override val category: StringResource + get() = SYMR.strings.similar_titles + override val recommendationJsonObjectName: String + get() = "category_recommendations" +} diff --git a/app/src/main/java/exh/recs/sources/MyAnimeListPagingSource.kt b/app/src/main/java/exh/recs/sources/MyAnimeListPagingSource.kt new file mode 100644 index 000000000..a4cb8a8e0 --- /dev/null +++ b/app/src/main/java/exh/recs/sources/MyAnimeListPagingSource.kt @@ -0,0 +1,82 @@ +package exh.recs.sources + +import dev.icerock.moko.resources.StringResource +import eu.kanade.tachiyomi.network.GET +import eu.kanade.tachiyomi.network.awaitSuccess +import eu.kanade.tachiyomi.network.parseAs +import eu.kanade.tachiyomi.source.CatalogueSource +import eu.kanade.tachiyomi.source.model.SManga +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import okhttp3.HttpUrl.Companion.toHttpUrl +import tachiyomi.core.common.util.system.logcat +import tachiyomi.domain.manga.model.Manga +import tachiyomi.i18n.sy.SYMR + +class MyAnimeListPagingSource(manga: Manga, source: CatalogueSource) : TrackerRecommendationPagingSource( + "https://api.jikan.moe/v4/", source, manga, +) { + override val name: String + get() = "MyAnimeList" + + override val category: StringResource + get() = SYMR.strings.community_recommendations + + override val associatedTrackerId: Long + get() = trackerManager.myAnimeList.id + + override suspend fun getRecsById(id: String): List { + val apiUrl = endpoint.toHttpUrl() + .newBuilder() + .addPathSegment("manga") + .addPathSegment(id) + .addPathSegment("recommendations") + .build() + + val data = with(json) { client.newCall(GET(apiUrl)).awaitSuccess().parseAs() } + return data["data"]!!.jsonArray + .map { it.jsonObject["entry"]!!.jsonObject } + .map { rec -> + logcat { "MYANIMELIST > RECOMMENDATION: " + rec["title"]!!.jsonPrimitive.content } + SManga( + title = rec["title"]!!.jsonPrimitive.content, + url = rec["url"]!!.jsonPrimitive.content, + thumbnail_url = rec["images"] + ?.let(JsonElement::jsonObject) + ?.let(::getImage), + initialized = true, + ) + } + } + + fun getImage(imageObject: JsonObject): String? { + return imageObject["webp"] + ?.jsonObject + ?.get("image_url") + ?.jsonPrimitive + ?.contentOrNull + ?: imageObject["jpg"] + ?.jsonObject + ?.get("image_url") + ?.jsonPrimitive + ?.contentOrNull + } + + override suspend fun getRecsBySearch(search: String): List { + val url = endpoint.toHttpUrl() + .newBuilder() + .addPathSegment("manga") + .addQueryParameter("q", search) + .build() + + val data = with(json) { + client.newCall(GET(url)).awaitSuccess() + .parseAs() + } + return getRecsById(data["data"]!!.jsonArray.first().jsonObject["mal_id"]!!.jsonPrimitive.content) + } +} diff --git a/app/src/main/java/exh/recs/sources/RecommendationPagingSource.kt b/app/src/main/java/exh/recs/sources/RecommendationPagingSource.kt new file mode 100644 index 000000000..52bdeb98a --- /dev/null +++ b/app/src/main/java/exh/recs/sources/RecommendationPagingSource.kt @@ -0,0 +1,114 @@ +package exh.recs.sources + +import dev.icerock.moko.resources.StringResource +import eu.kanade.tachiyomi.data.track.TrackerManager +import eu.kanade.tachiyomi.network.NetworkHelper +import eu.kanade.tachiyomi.source.CatalogueSource +import eu.kanade.tachiyomi.source.model.MangasPage +import eu.kanade.tachiyomi.source.model.SManga +import eu.kanade.tachiyomi.source.online.all.MangaDex +import exh.md.similar.MangaDexSimilarPagingSource +import exh.pref.DelegateSourcePreferences +import exh.source.getMainSource +import exh.source.isMdBasedSource +import kotlinx.serialization.json.Json +import logcat.LogPriority +import tachiyomi.core.common.util.system.logcat +import tachiyomi.data.source.NoResultsException +import tachiyomi.data.source.SourcePagingSource +import tachiyomi.domain.manga.model.Manga +import tachiyomi.domain.track.interactor.GetTracks +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get +import uy.kohesive.injekt.injectLazy + +/** + * General class for recommendation sources. + */ +abstract class RecommendationPagingSource( + source: CatalogueSource, + protected val manga: Manga, +) : SourcePagingSource(source) { + // Display name + abstract val name: String + + // Localized category name + abstract val category: StringResource + + /** + * Recommendation sources that display results from a source extension, + * can override this property to associate results with a specific source. + * This is used to redirect the user directly to the corresponding MangaScreen. + * If null, the user will be prompted to choose a source via SmartSearch when clicking on a recommendation. + */ + open val associatedSourceId: Long? = null + + companion object { + fun createSources(manga: Manga, source: CatalogueSource): List { + return buildList { + add(AniListPagingSource(manga, source)) + add(MangaUpdatesCommunityPagingSource(manga, source)) + add(MangaUpdatesSimilarPagingSource(manga, source)) + add(MyAnimeListPagingSource(manga, source)) + + // Only include MangaDex if the delegate sources are enabled and the source is MD-based + if (source.isMdBasedSource() && Injekt.get().delegateSources().get()) { + add(MangaDexSimilarPagingSource(manga, source.getMainSource() as MangaDex)) + } + + // Only include Comick if the source manga is from there + if (source.isComickSource()) { + add(ComickPagingSource(manga, source)) + } + }.sortedWith(compareBy({ it.name }, { it.category.resourceId })) + } + } +} + +/** + * General class for recommendation sources backed by trackers. + */ +abstract class TrackerRecommendationPagingSource( + protected val endpoint: String, + source: CatalogueSource, + manga: Manga, +) : RecommendationPagingSource(source, manga) { + private val getTracks: GetTracks by injectLazy() + + protected val trackerManager: TrackerManager by injectLazy() + protected val client by lazy { Injekt.get().client } + protected val json by injectLazy() + + /** + * Tracker id associated with the recommendation source. + * + * If not null and the tracker is attached to the source manga, + * the remote id will be used to directly identify the manga on the tracker. + * Otherwise, a search will be performed using the manga title. + */ + protected abstract val associatedTrackerId: Long? + + abstract suspend fun getRecsBySearch(search: String): List + abstract suspend fun getRecsById(id: String): List + + override suspend fun requestNextPage(currentPage: Int): MangasPage { + val tracks = getTracks.await(manga.id) + + val recs = try { + val id = tracks.find { it.trackerId == associatedTrackerId }?.remoteId + val results = if (id != null) { + getRecsById(id.toString()) + } else { + getRecsBySearch(manga.ogTitle) + } + logcat { name + " > Results: " + results.size } + + results.ifEmpty { throw NoResultsException() } + } catch (e: Exception) { + logcat(LogPriority.ERROR, e) { name } + throw e + } + + return MangasPage(recs, false) + } +} diff --git a/i18n-sy/src/commonMain/moko-resources/base/strings.xml b/i18n-sy/src/commonMain/moko-resources/base/strings.xml index 88cff9f7c..90abeb838 100644 --- a/i18n-sy/src/commonMain/moko-resources/base/strings.xml +++ b/i18n-sy/src/commonMain/moko-resources/base/strings.xml @@ -683,8 +683,8 @@ Random Sync library entries to MangaDex Syncs any non MdList tracked entries to MangaDex as reading. - MangaDex similar Community recommendations + Similar titles Alternative Titles