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:
Tim Schneeberger 2025-01-21 20:29:32 +01:00 committed by GitHub
parent a7a3e5a2db
commit d80ad3f145
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 786 additions and 258 deletions

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

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

View File

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

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

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

View File

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

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

View File

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