feat: unify recommendation screens and add more sources (#1376)
* feat(recommendations): add mangaupdates.com support * feat(recommendations): display all tracker recommendation sources * refactor(recommendations): apply spotless * refactor: split RecommendationPagingSources * feat(recommendations): unify MangaDex & community recommendations * refactor: remove old screen * fix: update comment * style: fix formatting * refactor: move onClick handlers * fix: handle external recommendation links correctly * fix: apply spotless * feat: add comick recommendation source * fix: mark recs from comick as not initialized to force fetching missing metadata * Update app/src/main/java/exh/recs/BrowseRecommendsScreen.kt --------- Co-authored-by: jobobby04 <jobobby04@users.noreply.github.com>
This commit is contained in:
parent
a7a3e5a2db
commit
d80ad3f145
@ -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<DelegateSourcePreferences>().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 <--
|
||||
}
|
||||
|
@ -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()
|
||||
|
||||
|
@ -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<Manga>.combineMetadata(metadata: RaisedSearchMetadata?): Flow<Pair<Manga, RaisedSearchMetadata?>> {
|
||||
return map { it to metadata }
|
||||
}
|
||||
|
||||
init {
|
||||
mutableState.update { it.copy(filterable = false) }
|
||||
}
|
||||
}
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
32
app/src/main/java/exh/recs/BrowseRecommendsScreenModel.kt
Normal file
32
app/src/main/java/exh/recs/BrowseRecommendsScreenModel.kt
Normal file
@ -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) }
|
||||
}
|
||||
}
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -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<RecommendsScreenModel.State>(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<RecommendationPagingSource, RecommendationItemResult> ->
|
||||
compareBy<RecommendationPagingSource>(
|
||||
{ (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<Manga> {
|
||||
return produceState(initialValue = initialManga) {
|
||||
getManga.subscribe(initialManga.url, initialManga.source)
|
||||
.filterNotNull()
|
||||
.collectLatest { manga ->
|
||||
value = manga
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateItems(items: PersistentMap<RecommendationPagingSource, RecommendationItemResult>) {
|
||||
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<RecommendationPagingSource, RecommendationItemResult> = 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<Manga>,
|
||||
) : RecommendationItemResult {
|
||||
val isEmpty: Boolean
|
||||
get() = result.isEmpty()
|
||||
}
|
||||
|
||||
fun isVisible(onlyShowHasResults: Boolean): Boolean {
|
||||
return !onlyShowHasResults || (this is Success && !this.isEmpty)
|
||||
}
|
||||
}
|
||||
|
97
app/src/main/java/exh/recs/components/RecommendsScreen.kt
Normal file
97
app/src/main/java/exh/recs/components/RecommendsScreen.kt
Normal file
@ -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<Manga>,
|
||||
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<RecommendationPagingSource, RecommendationItemResult>,
|
||||
contentPadding: PaddingValues,
|
||||
getManga: @Composable (Manga) -> State<Manga>,
|
||||
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
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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<NetworkHelper>().client
|
||||
}
|
||||
val json by injectLazy<Json>()
|
||||
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<SManga>
|
||||
override val category: StringResource
|
||||
get() = SYMR.strings.community_recommendations
|
||||
|
||||
abstract suspend fun getRecsById(id: String): List<SManga>
|
||||
}
|
||||
override val associatedTrackerId: Long
|
||||
get() = trackerManager.aniList.id
|
||||
|
||||
class MyAnimeList : API("https://api.jikan.moe/v4/") {
|
||||
override suspend fun getRecsById(id: String): List<SManga> {
|
||||
val apiUrl = endpoint.toHttpUrl()
|
||||
.newBuilder()
|
||||
.addPathSegment("manga")
|
||||
.addPathSegment(id)
|
||||
.addPathSegment("recommendations")
|
||||
.build()
|
||||
|
||||
val data = with(json) { client.newCall(GET(apiUrl)).awaitSuccess().parseAs<JsonObject>() }
|
||||
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<SManga> {
|
||||
val url = endpoint.toHttpUrl()
|
||||
.newBuilder()
|
||||
.addPathSegment("manga")
|
||||
.addQueryParameter("q", search)
|
||||
.build()
|
||||
|
||||
val data = with(json) {
|
||||
client.newCall(GET(url)).awaitSuccess()
|
||||
.parseAs<JsonObject>()
|
||||
}
|
||||
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 }
|
||||
}
|
||||
}
|
91
app/src/main/java/exh/recs/sources/ComickPagingSource.kt
Normal file
91
app/src/main/java/exh/recs/sources/ComickPagingSource.kt
Normal file
@ -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<NetworkHelper>().client }
|
||||
private val json by injectLazy<Json>()
|
||||
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<JsonObject>()
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
113
app/src/main/java/exh/recs/sources/MangaUpdatesPagingSource.kt
Normal file
113
app/src/main/java/exh/recs/sources/MangaUpdatesPagingSource.kt
Normal file
@ -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<SManga> {
|
||||
val apiUrl = endpoint.toHttpUrl()
|
||||
.newBuilder()
|
||||
.addPathSegment("series")
|
||||
.addPathSegment(id)
|
||||
.build()
|
||||
|
||||
val data = with(json) { client.newCall(GET(apiUrl)).awaitSuccess().parseAs<JsonObject>() }
|
||||
return getRecommendations(data[recommendationJsonObjectName]!!.jsonArray)
|
||||
}
|
||||
|
||||
private fun getRecommendations(recommendations: JsonArray): List<SManga> {
|
||||
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<SManga> {
|
||||
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<JsonObject>()
|
||||
}
|
||||
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"
|
||||
}
|
@ -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<SManga> {
|
||||
val apiUrl = endpoint.toHttpUrl()
|
||||
.newBuilder()
|
||||
.addPathSegment("manga")
|
||||
.addPathSegment(id)
|
||||
.addPathSegment("recommendations")
|
||||
.build()
|
||||
|
||||
val data = with(json) { client.newCall(GET(apiUrl)).awaitSuccess().parseAs<JsonObject>() }
|
||||
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<SManga> {
|
||||
val url = endpoint.toHttpUrl()
|
||||
.newBuilder()
|
||||
.addPathSegment("manga")
|
||||
.addQueryParameter("q", search)
|
||||
.build()
|
||||
|
||||
val data = with(json) {
|
||||
client.newCall(GET(url)).awaitSuccess()
|
||||
.parseAs<JsonObject>()
|
||||
}
|
||||
return getRecsById(data["data"]!!.jsonArray.first().jsonObject["mal_id"]!!.jsonPrimitive.content)
|
||||
}
|
||||
}
|
114
app/src/main/java/exh/recs/sources/RecommendationPagingSource.kt
Normal file
114
app/src/main/java/exh/recs/sources/RecommendationPagingSource.kt
Normal file
@ -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<RecommendationPagingSource> {
|
||||
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<DelegateSourcePreferences>().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<NetworkHelper>().client }
|
||||
protected val json by injectLazy<Json>()
|
||||
|
||||
/**
|
||||
* 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<SManga>
|
||||
abstract suspend fun getRecsById(id: String): List<SManga>
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
@ -683,8 +683,8 @@
|
||||
<string name="random">Random</string>
|
||||
<string name="mangadex_push_favorites_to_mangadex">Sync library entries to MangaDex</string>
|
||||
<string name="mangadex_push_favorites_to_mangadex_summary">Syncs any non MdList tracked entries to MangaDex as reading.</string>
|
||||
<string name="mangadex_similar">MangaDex similar</string>
|
||||
<string name="community_recommendations">Community recommendations</string>
|
||||
<string name="similar_titles">Similar titles</string>
|
||||
<string name="alt_titles">Alternative Titles</string>
|
||||
|
||||
<!-- Scanlator filters -->
|
||||
|
Loading…
x
Reference in New Issue
Block a user