Merge branch 'feature/related-mangas'

This commit is contained in:
Cuong M. Tran 2024-04-26 11:32:31 +07:00
commit af4e58a789
No known key found for this signature in database
GPG key ID: 733AA7624B9315C2
12 changed files with 389 additions and 1 deletions

View file

@ -12,6 +12,7 @@ Mihon/Tachiyomi is a free and open source manga reader for Android 6.0 and above
Features of Komikku include: Features of Komikku include:
- [x] Built-in & official extensions repository - [x] Built-in & official extensions repository
- [x] Show list of related titles (must enable in Settings/Browse) for all sources. Some sources might need fix for it to work.
- [x] Bulk selection to add to library & change categories of multiple entries all at once, everywhere. - [x] Bulk selection to add to library & change categories of multiple entries all at once, everywhere.
It can detect duplication being added and give option to allow/skip one by one or allow/skip all duplication. It can detect duplication being added and give option to allow/skip one by one or allow/skip all duplication.
Also allow long-click to add/remove single entry to/from library, everywhere. Also allow long-click to add/remove single entry to/from library, everywhere.

View file

@ -82,4 +82,8 @@ class SourcePreferences(
WSRV_NL, WSRV_NL,
} }
// SY <-- // SY <--
// KMK -->
fun relatedMangas() = preferenceStore.getBoolean("related_mangas", false)
// KMK <--
} }

View file

@ -31,6 +31,7 @@ import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
@ -49,6 +50,8 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.util.fastAll import androidx.compose.ui.util.fastAll
import androidx.compose.ui.util.fastAny import androidx.compose.ui.util.fastAny
import androidx.compose.ui.util.fastMap import androidx.compose.ui.util.fastMap
import eu.kanade.presentation.browse.components.GlobalSearchCardRow
import eu.kanade.presentation.browse.components.GlobalSearchResultItem
import eu.kanade.presentation.components.relativeDateText import eu.kanade.presentation.components.relativeDateText
import eu.kanade.presentation.manga.components.ChapterDownloadAction import eu.kanade.presentation.manga.components.ChapterDownloadAction
import eu.kanade.presentation.manga.components.ChapterHeader import eu.kanade.presentation.manga.components.ChapterHeader
@ -167,6 +170,12 @@ fun MangaScreen(
onChapterSelected: (ChapterList.Item, Boolean, Boolean, Boolean) -> Unit, onChapterSelected: (ChapterList.Item, Boolean, Boolean, Boolean) -> Unit,
onAllChapterSelected: (Boolean) -> Unit, onAllChapterSelected: (Boolean) -> Unit,
onInvertSelection: () -> Unit, onInvertSelection: () -> Unit,
// KMK -->
getMangaState: @Composable ((Manga) -> State<Manga>),
onRelatedMangaClick: (Manga) -> Unit,
onRelatedMangaLongClick: (Manga) -> Unit,
// KMK <--
) { ) {
val context = LocalContext.current val context = LocalContext.current
val onCopyTagToClipboard: (tag: String) -> Unit = { val onCopyTagToClipboard: (tag: String) -> Unit = {
@ -220,6 +229,11 @@ fun MangaScreen(
onChapterSelected = onChapterSelected, onChapterSelected = onChapterSelected,
onAllChapterSelected = onAllChapterSelected, onAllChapterSelected = onAllChapterSelected,
onInvertSelection = onInvertSelection, onInvertSelection = onInvertSelection,
// KMK -->
getMangaState = getMangaState,
onRelatedMangaClick = onRelatedMangaClick,
onRelatedMangaLongClick = onRelatedMangaLongClick,
// KMK <--
) )
} else { } else {
MangaScreenLargeImpl( MangaScreenLargeImpl(
@ -266,6 +280,11 @@ fun MangaScreen(
onChapterSelected = onChapterSelected, onChapterSelected = onChapterSelected,
onAllChapterSelected = onAllChapterSelected, onAllChapterSelected = onAllChapterSelected,
onInvertSelection = onInvertSelection, onInvertSelection = onInvertSelection,
// KMK -->
getMangaState = getMangaState,
onRelatedMangaClick = onRelatedMangaClick,
onRelatedMangaLongClick = onRelatedMangaLongClick,
// KMK <--
) )
} }
} }
@ -328,6 +347,12 @@ private fun MangaScreenSmallImpl(
onChapterSelected: (ChapterList.Item, Boolean, Boolean, Boolean) -> Unit, onChapterSelected: (ChapterList.Item, Boolean, Boolean, Boolean) -> Unit,
onAllChapterSelected: (Boolean) -> Unit, onAllChapterSelected: (Boolean) -> Unit,
onInvertSelection: () -> Unit, onInvertSelection: () -> Unit,
// KMK -->
getMangaState: @Composable ((Manga) -> State<Manga>),
onRelatedMangaClick: (Manga) -> Unit,
onRelatedMangaLongClick: (Manga) -> Unit,
// KMK <--
) { ) {
val chapterListState = rememberLazyListState() val chapterListState = rememberLazyListState()
@ -534,6 +559,29 @@ private fun MangaScreenSmallImpl(
) )
} }
// KMK -->
if (state.relatedMangas?.isNotEmpty() == true) {
item(
key = MangaScreenItem.RELATED_TITLES,
contentType = MangaScreenItem.RELATED_TITLES,
) {
GlobalSearchResultItem(
title = "Related titles",
subtitle = null,
onLongClick = null,
onClick = { /* Should show a page with grid/list of all the related manga */ },
) {
RelatedMangas(
mangas = state.relatedMangas,
getMangaState = { getMangaState(it) },
onClickManga = onRelatedMangaClick,
onLongClickManga = onRelatedMangaLongClick,
)
}
}
}
// KMK <--
// SY --> // SY -->
if (!state.showRecommendationsInOverflow || state.showMergeWithAnother) { if (!state.showRecommendationsInOverflow || state.showMergeWithAnother) {
item( item(
@ -654,6 +702,12 @@ fun MangaScreenLargeImpl(
onChapterSelected: (ChapterList.Item, Boolean, Boolean, Boolean) -> Unit, onChapterSelected: (ChapterList.Item, Boolean, Boolean, Boolean) -> Unit,
onAllChapterSelected: (Boolean) -> Unit, onAllChapterSelected: (Boolean) -> Unit,
onInvertSelection: () -> Unit, onInvertSelection: () -> Unit,
// KMK -->
getMangaState: @Composable ((Manga) -> State<Manga>),
onRelatedMangaClick: (Manga) -> Unit,
onRelatedMangaLongClick: (Manga) -> Unit,
// KMK <--
) { ) {
val layoutDirection = LocalLayoutDirection.current val layoutDirection = LocalLayoutDirection.current
val density = LocalDensity.current val density = LocalDensity.current
@ -863,6 +917,29 @@ fun MangaScreenLargeImpl(
bottom = contentPadding.calculateBottomPadding(), bottom = contentPadding.calculateBottomPadding(),
), ),
) { ) {
// KMK -->
if (state.relatedMangas?.isNotEmpty() == true) {
item(
key = MangaScreenItem.RELATED_TITLES,
contentType = MangaScreenItem.RELATED_TITLES,
) {
GlobalSearchResultItem(
title = "Related titles",
subtitle = null,
onLongClick = null,
onClick = { /* Should show a page with grid/list of all the related manga */ },
) {
RelatedMangas(
mangas = state.relatedMangas,
getMangaState = { getMangaState(it) },
onClickManga = onRelatedMangaClick,
onLongClickManga = onRelatedMangaLongClick,
)
}
}
}
// KMK <--
item( item(
key = MangaScreenItem.CHAPTER_HEADER, key = MangaScreenItem.CHAPTER_HEADER,
contentType = MangaScreenItem.CHAPTER_HEADER, contentType = MangaScreenItem.CHAPTER_HEADER,
@ -900,6 +977,24 @@ fun MangaScreenLargeImpl(
} }
} }
// KMK -->
@Composable
fun RelatedMangas(
mangas: List<Manga>,
getMangaState: @Composable ((Manga) -> State<Manga>),
onClickManga: (Manga) -> Unit,
onLongClickManga: (Manga) -> Unit,
) {
GlobalSearchCardRow(
titles = mangas,
getManga = getMangaState,
onClick = onClickManga,
onLongClick = onLongClickManga,
selection = emptyList(),
)
}
// KMK <--
@Composable @Composable
private fun SharedMangaBottomActionMenu( private fun SharedMangaBottomActionMenu(
selected: List<ChapterList.Item>, selected: List<ChapterList.Item>,

View file

@ -32,4 +32,8 @@ enum class MangaScreenItem {
// SY <-- // SY <--
CHAPTER_HEADER, CHAPTER_HEADER,
CHAPTER, CHAPTER,
// KMK -->
RELATED_TITLES,
// KMK <--
} }

View file

@ -52,6 +52,13 @@ object SettingsBrowseScreen : SearchableSettings {
Preference.PreferenceGroup( Preference.PreferenceGroup(
title = stringResource(MR.strings.label_sources), title = stringResource(MR.strings.label_sources),
preferenceItems = persistentListOf( preferenceItems = persistentListOf(
// KMK -->
Preference.PreferenceItem.SwitchPreference(
pref = sourcePreferences.relatedMangas(),
title = stringResource(SYMR.strings.pref_source_related_mangas),
subtitle = stringResource(SYMR.strings.pref_source_related_mangas_summary),
),
// KMK <--
kotlin.run { kotlin.run {
val count by sourcePreferences.sourcesTabCategories().collectAsState() val count by sourcePreferences.sourcesTabCategories().collectAsState()
Preference.PreferenceItem.TextPreference( Preference.PreferenceItem.TextPreference(

View file

@ -252,7 +252,7 @@ class BulkFavoriteScreenModel(
.orEmpty() .orEmpty()
} }
suspend fun getDuplicateLibraryManga(manga: Manga): Manga? { private suspend fun getDuplicateLibraryManga(manga: Manga): Manga? {
return getDuplicateLibraryManga.await(manga).getOrNull(0) return getDuplicateLibraryManga.await(manga).getOrNull(0)
} }

View file

@ -42,6 +42,12 @@ import eu.kanade.tachiyomi.source.CatalogueSource
import eu.kanade.tachiyomi.source.Source import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.isLocalOrStub import eu.kanade.tachiyomi.source.isLocalOrStub
import eu.kanade.tachiyomi.source.online.HttpSource import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.ui.browse.AddDuplicateMangaDialog
import eu.kanade.tachiyomi.ui.browse.AllowDuplicateDialog
import eu.kanade.tachiyomi.ui.browse.BulkFavoriteScreenModel
import eu.kanade.tachiyomi.ui.browse.ChangeMangaCategoryDialog
import eu.kanade.tachiyomi.ui.browse.ChangeMangasCategoryDialog
import eu.kanade.tachiyomi.ui.browse.RemoveMangaDialog
import eu.kanade.tachiyomi.ui.browse.migration.advanced.design.PreMigrationScreen import eu.kanade.tachiyomi.ui.browse.migration.advanced.design.PreMigrationScreen
import eu.kanade.tachiyomi.ui.browse.source.SourcesScreen import eu.kanade.tachiyomi.ui.browse.source.SourcesScreen
import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourceScreen import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourceScreen
@ -121,6 +127,11 @@ class MangaScreen(
val successState = state as MangaScreenModel.State.Success val successState = state as MangaScreenModel.State.Success
val isHttpSource = remember { successState.source is HttpSource } val isHttpSource = remember { successState.source is HttpSource }
// KMK -->
val bulkFavoriteScreenModel = rememberScreenModel { BulkFavoriteScreenModel() }
val bulkFavoriteState by bulkFavoriteScreenModel.state.collectAsState()
// KMK <--
LaunchedEffect(successState.manga, screenModel.source) { LaunchedEffect(successState.manga, screenModel.source) {
if (isHttpSource) { if (isHttpSource) {
try { try {
@ -225,6 +236,11 @@ class MangaScreen(
onChapterSelected = screenModel::toggleSelection, onChapterSelected = screenModel::toggleSelection,
onAllChapterSelected = screenModel::toggleAllSelection, onAllChapterSelected = screenModel::toggleAllSelection,
onInvertSelection = screenModel::invertSelection, onInvertSelection = screenModel::invertSelection,
// KMK -->
getMangaState = { screenModel.getManga(initialManga = it) },
onRelatedMangaClick = { navigator.push(MangaScreen(it.id, true)) },
onRelatedMangaLongClick = { bulkFavoriteScreenModel.addRemoveManga(it, haptic) },
// KMK <--
) )
var showScanlatorsDialog by remember { mutableStateOf(false) } var showScanlatorsDialog by remember { mutableStateOf(false) }
@ -342,6 +358,22 @@ class MangaScreen(
onConfirm = screenModel::setExcludedScanlators, onConfirm = screenModel::setExcludedScanlators,
) )
} }
// KMK -->
when (bulkFavoriteState.dialog) {
is BulkFavoriteScreenModel.Dialog.AddDuplicateManga ->
AddDuplicateMangaDialog(bulkFavoriteScreenModel)
is BulkFavoriteScreenModel.Dialog.RemoveManga ->
RemoveMangaDialog(bulkFavoriteScreenModel)
is BulkFavoriteScreenModel.Dialog.ChangeMangaCategory ->
ChangeMangaCategoryDialog(bulkFavoriteScreenModel)
is BulkFavoriteScreenModel.Dialog.ChangeMangasCategory ->
ChangeMangasCategoryDialog(bulkFavoriteScreenModel)
is BulkFavoriteScreenModel.Dialog.AllowDuplicate ->
AllowDuplicateDialog(bulkFavoriteScreenModel)
else -> {}
}
// KMK <--
} }
private fun continueReading(context: Context, unreadChapter: Chapter?) { private fun continueReading(context: Context, unreadChapter: Chapter?) {

View file

@ -3,8 +3,10 @@ package eu.kanade.tachiyomi.ui.manga
import android.content.Context import android.content.Context
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult import androidx.compose.material3.SnackbarResult
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import androidx.compose.ui.util.fastAny import androidx.compose.ui.util.fastAny
import cafe.adriel.voyager.core.model.StateScreenModel import cafe.adriel.voyager.core.model.StateScreenModel
import cafe.adriel.voyager.core.model.screenModelScope import cafe.adriel.voyager.core.model.screenModelScope
@ -22,6 +24,7 @@ import eu.kanade.domain.manga.model.PagePreview
import eu.kanade.domain.manga.model.chaptersFiltered import eu.kanade.domain.manga.model.chaptersFiltered
import eu.kanade.domain.manga.model.copyFrom import eu.kanade.domain.manga.model.copyFrom
import eu.kanade.domain.manga.model.downloadedFilter import eu.kanade.domain.manga.model.downloadedFilter
import eu.kanade.domain.manga.model.toDomainManga
import eu.kanade.domain.manga.model.toSManga import eu.kanade.domain.manga.model.toSManga
import eu.kanade.domain.source.service.SourcePreferences import eu.kanade.domain.source.service.SourcePreferences
import eu.kanade.domain.track.interactor.AddTracks import eu.kanade.domain.track.interactor.AddTracks
@ -72,6 +75,7 @@ import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.distinctUntilChangedBy import kotlinx.coroutines.flow.distinctUntilChangedBy
import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flatMapConcat import kotlinx.coroutines.flow.flatMapConcat
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
@ -135,6 +139,7 @@ import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get import uy.kohesive.injekt.api.get
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
import kotlin.math.floor import kotlin.math.floor
import androidx.compose.runtime.State as RuntimeState
class MangaScreenModel( class MangaScreenModel(
val context: Context, val context: Context,
@ -204,6 +209,11 @@ class MangaScreenModel(
private val filteredChapters: List<ChapterList.Item>? private val filteredChapters: List<ChapterList.Item>?
get() = successState?.processedChapters get() = successState?.processedChapters
// KMK -->
private val relatedMangas: List<Manga>
get() = successState?.relatedMangas ?: emptyList()
// KMK <--
val chapterSwipeStartAction = libraryPreferences.swipeToEndAction().get() val chapterSwipeStartAction = libraryPreferences.swipeToEndAction().get()
val chapterSwipeEndAction = libraryPreferences.swipeToStartAction().get() val chapterSwipeEndAction = libraryPreferences.swipeToStartAction().get()
@ -431,6 +441,9 @@ class MangaScreenModel(
val fetchFromSourceTasks = listOf( val fetchFromSourceTasks = listOf(
async { if (needRefreshInfo) fetchMangaFromSource() }, async { if (needRefreshInfo) fetchMangaFromSource() },
async { if (needRefreshChapter) fetchChaptersFromSource() }, async { if (needRefreshChapter) fetchChaptersFromSource() },
// KMK -->
async { fetchRelatedMangasFromSource() },
// KMK <--
) )
fetchFromSourceTasks.awaitAll() fetchFromSourceTasks.awaitAll()
} }
@ -446,6 +459,9 @@ class MangaScreenModel(
val fetchFromSourceTasks = listOf( val fetchFromSourceTasks = listOf(
async { fetchMangaFromSource(manualFetch) }, async { fetchMangaFromSource(manualFetch) },
async { fetchChaptersFromSource(manualFetch) }, async { fetchChaptersFromSource(manualFetch) },
// KMK -->
async { fetchRelatedMangasFromSource() },
// KMK <--
) )
fetchFromSourceTasks.awaitAll() fetchFromSourceTasks.awaitAll()
updateSuccessState { it.copy(isRefreshingData = false) } updateSuccessState { it.copy(isRefreshingData = false) }
@ -553,6 +569,19 @@ class MangaScreenModel(
} }
} }
// KMK -->
@Composable
fun getManga(initialManga: Manga): RuntimeState<Manga> {
return produceState(initialValue = initialManga) {
getManga.subscribe(initialManga.url, initialManga.source)
.filterNotNull()
.collectLatest { manga ->
value = manga
}
}
}
// KMK <--
suspend fun smartSearchMerge(manga: Manga, originalMangaId: Long): Manga { suspend fun smartSearchMerge(manga: Manga, originalMangaId: Long): Manga {
val originalManga = getManga.await(originalMangaId) val originalManga = getManga.await(originalMangaId)
?: throw IllegalArgumentException(context.stringResource(SYMR.strings.merge_unknown_entry, originalMangaId)) ?: throw IllegalArgumentException(context.stringResource(SYMR.strings.merge_unknown_entry, originalMangaId))
@ -1068,6 +1097,41 @@ class MangaScreenModel(
} }
} }
// KMK -->
/**
* Requests an list of related mangas from the source.
*/
private suspend fun fetchRelatedMangasFromSource() {
val state = successState ?: return
try {
withIOContext {
if (state.source !is MergedSource) {
val relatedMangas = if (Injekt.get<SourcePreferences>().relatedMangas().get())
withIOContext {
state.source.getRelatedMangaList(state.manga.toSManga())
.map {
networkToLocalManga.await(it.toDomainManga(state.source.id))
}
}
else null
updateSuccessState { it.copy(relatedMangas = relatedMangas) }
} else {
throw UnsupportedOperationException("Fetching related titles for merged entry is not supported")
}
}
} catch (e: Throwable) {
logcat(LogPriority.ERROR, e)
val message = with(context) { e.formattedMessage }
screenModelScope.launch {
snackbarHostState.showSnackbar(message = message)
}
val newManga = mangaRepository.getMangaById(mangaId)
updateSuccessState { it.copy(manga = newManga) }
}
}
// KMK <--
/** /**
* @throws IllegalStateException if the swipe action is [LibraryPreferences.ChapterSwipeAction.Disabled] * @throws IllegalStateException if the swipe action is [LibraryPreferences.ChapterSwipeAction.Disabled]
*/ */
@ -1637,6 +1701,9 @@ class MangaScreenModel(
val alwaysShowReadingProgress: Boolean, val alwaysShowReadingProgress: Boolean,
val previewsRowCount: Int, val previewsRowCount: Int,
// SY <-- // SY <--
// KMK -->
val relatedMangas: List<Manga>? = null,
// KMK <--
) : State { ) : State {
val processedChapters by lazy { val processedChapters by lazy {
chapters.applyFilters(manga).toList() chapters.applyFilters(manga).toList()

View file

@ -198,6 +198,8 @@
<string name="pref_source_navigation_summery">Replace latest button with a custom browse view that includes both latest and browse</string> <string name="pref_source_navigation_summery">Replace latest button with a custom browse view that includes both latest and browse</string>
<string name="pref_local_source_hidden_folders">Local source hidden folders</string> <string name="pref_local_source_hidden_folders">Local source hidden folders</string>
<string name="pref_local_source_hidden_folders_summery">Allow local source to read hidden folders</string> <string name="pref_local_source_hidden_folders_summery">Allow local source to read hidden folders</string>
<string name="pref_source_related_mangas">Related mangas</string>
<string name="pref_source_related_mangas_summary">Show related mangas provided by extensions</string>
<!-- Backup settings --> <!-- Backup settings -->
<string name="custom_entry_info">Custom entry info</string> <string name="custom_entry_info">Custom entry info</string>

View file

@ -48,6 +48,20 @@ interface Source {
return fetchChapterList(manga).awaitSingle() return fetchChapterList(manga).awaitSingle()
} }
// KMK -->
/**
* Get all the available related mangas for a manga.
*
* @since extensions-lib 1.6
* @param manga the current manga to get related mangas.
* @return the related mangas for the current manga.
*/
@Suppress("DEPRECATION")
suspend fun getRelatedMangaList(manga: SManga): List<SManga> {
return fetchRelatedMangaList(manga).awaitSingle()
}
// KMK <--
/** /**
* Get the list of pages a chapter has. Pages should be returned * Get the list of pages a chapter has. Pages should be returned
* in the expected order; the index is ignored. * in the expected order; the index is ignored.
@ -75,6 +89,22 @@ interface Source {
fun fetchChapterList(manga: SManga): Observable<List<SChapter>> = fun fetchChapterList(manga: SManga): Observable<List<SChapter>> =
throw IllegalStateException("Not used") throw IllegalStateException("Not used")
// KMK -->
/**
* Get all the available related mangas for a manga.
*
* @since extensions-lib 1.6
* @param manga the current manga to get related mangas.
* @return the related mangas for the current manga.
*/
@Deprecated(
"Use the non-RxJava API instead",
ReplaceWith("getRelatedMangaList"),
)
fun fetchRelatedMangaList(manga: SManga): Observable<List<SManga>> =
throw IllegalStateException("Not used")
// KMK <--
@Deprecated( @Deprecated(
"Use the non-RxJava API instead", "Use the non-RxJava API instead",
ReplaceWith("getPageList"), ReplaceWith("getPageList"),

View file

@ -16,17 +16,24 @@ import eu.kanade.tachiyomi.source.model.SManga
import exh.log.maybeInjectEHLogger import exh.log.maybeInjectEHLogger
import exh.pref.DelegateSourcePreferences import exh.pref.DelegateSourcePreferences
import exh.source.DelegatedHttpSource import exh.source.DelegatedHttpSource
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import logcat.LogPriority
import okhttp3.Headers import okhttp3.Headers
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.Response import okhttp3.Response
import rx.Observable import rx.Observable
import tachiyomi.core.common.util.lang.awaitSingle import tachiyomi.core.common.util.lang.awaitSingle
import tachiyomi.core.common.util.system.logcat
import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get import uy.kohesive.injekt.api.get
import java.net.URI import java.net.URI
import java.net.URISyntaxException import java.net.URISyntaxException
import java.security.MessageDigest import java.security.MessageDigest
import java.util.Locale
/** /**
* A simple implementation for sources from a website. * A simple implementation for sources from a website.
@ -274,6 +281,118 @@ abstract class HttpSource : CatalogueSource {
*/ */
protected abstract fun mangaDetailsParse(response: Response): SManga protected abstract fun mangaDetailsParse(response: Response): SManga
// KMK -->
/**
* Whether parsing related mangas in manga page or extension provide custom related mangas request (true)
* or using search keyword to search for related mangas (false - default)
* @default false
* @since extensions-lib 1.6
*/
protected open val supportsRelatedMangas: Boolean get() = false
protected open val disableRelatedMangas: Boolean get() = false
/**
* Get all the available related mangas for a manga.
* Normally it's not needed to override this method.
*
* @param manga the current manga to get related mangas.
* @return the related mangas for the current manga.
* @throws UnsupportedOperationException if a source doesn't support related mangas.
*/
@Suppress("DEPRECATION")
override suspend fun getRelatedMangaList(manga: SManga): List<SManga> {
return when {
supportsRelatedMangas -> fetchRelatedMangaList(manga).awaitSingle()
disableRelatedMangas -> emptyList()
else -> {
try {
fetchRelatedMangaListBySearch(manga)
} catch (e: Exception) {
logcat(LogPriority.ERROR, e)
throw UnsupportedOperationException("Error getting related titles.")
}
}
}
}
/**
* Fetch related mangas by searching for each keywords from manga's title
*/
protected open suspend fun fetchRelatedMangaListBySearch(manga: SManga): List<SManga> {
fun String.stripKeyword(): List<String> {
val regexWhitespace = Regex("\\s+")
val regexSpecialCharacters = Regex("[\\[(!~@#$%^&*|,?:\"<>)\\]]")
val regexNumberOnly = Regex("^\\d+$")
return replace(regexSpecialCharacters, " ")
.split(regexWhitespace)
.map {
// remove number only
it.replace(regexNumberOnly, "")
.lowercase(Locale.getDefault())
}
// exclude single character
.filter { it.length > 1 }
}
val scope = CoroutineScope(Dispatchers.IO)
val words = HashSet<String>()
words += manga.title.stripKeyword()
manga.originalTitle.let { title ->
words += title.stripKeyword()
}
if (words.isEmpty()) {
return emptyList()
}
return words.map { keyword ->
scope.async {
client.newCall(searchMangaRequest(0, keyword, FilterList()))
.execute()
.let { response ->
searchMangaParse(response).mangas
.filter {
it.url != manga.url &&
it.title.lowercase().contains(keyword)
}
}
}
}.awaitAll()
.minBy { if (it.isEmpty()) Int.MAX_VALUE else it.size }
}
/**
* Fetch related mangas found in manga's page which is provided by sites.
* If using this, must also: 'override val supportsRelatedMangas = true'
*/
@Deprecated("Use the non-RxJava API instead", replaceWith = ReplaceWith("getRelatedMangaList"))
override fun fetchRelatedMangaList(manga: SManga): Observable<List<SManga>> {
return client.newCall(relatedMangaListRequest(manga))
.asObservableSuccess()
.map { response ->
relatedMangaListParse(response)
}
}
/**
* Returns the request for get related manga list. Override only if it's needed to override
* the url, send different headers or request method like POST.
* If using this, must also: 'override val supportsRelatedMangas = true'
*
* @param manga the manga to look for related mangas.
*/
protected open fun relatedMangaListRequest(manga: SManga): Request {
return GET(baseUrl + manga.url, headers)
}
/**
* Parses the response from the site and returns a list of related mangas.
* If using this, must also: 'override val supportsRelatedMangas = true'
*
* @param response the response from the site.
*/
protected open fun relatedMangaListParse(response: Response): List<SManga> = popularMangaParse(response).mangas
// KMK <--
/** /**
* Get all the available chapters for a manga. * Get all the available chapters for a manga.
* Normally it's not needed to override this method. * Normally it's not needed to override this method.

View file

@ -145,6 +145,33 @@ abstract class ParsedHttpSource : HttpSource() {
*/ */
protected abstract fun mangaDetailsParse(document: Document): SManga protected abstract fun mangaDetailsParse(document: Document): SManga
// KMK -->
/**
* Parses the response from the site and returns a list of related mangas.
* If using this, must also: 'override val supportsRelatedMangas = true'
*
* @param response the response from the site.
*/
override fun relatedMangaListParse(response: Response): List<SManga> {
return response.asJsoup()
.select(relatedMangaListSelector()).map { relatedMangaFromElement(it) }
}
/**
* Returns the Jsoup selector that returns a list of [Element] corresponding to each related mangas.
* If using this, must also: 'override val supportsRelatedMangas = true'
*/
protected open fun relatedMangaListSelector(): String = popularMangaSelector()
/**
* Returns a manga from the given element.
* If using this, must also: 'override val supportsRelatedMangas = true'
*
* @param element an element obtained from [relatedMangaListSelector].
*/
protected open fun relatedMangaFromElement(element: Element): SManga = popularMangaFromElement(element)
// KMK <--
/** /**
* Parses the response from the site and returns a list of chapters. * Parses the response from the site and returns a list of chapters.
* *