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:
- [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.
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.

View file

@ -82,4 +82,8 @@ class SourcePreferences(
WSRV_NL,
}
// 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.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
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.fastAny
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.manga.components.ChapterDownloadAction
import eu.kanade.presentation.manga.components.ChapterHeader
@ -167,6 +170,12 @@ fun MangaScreen(
onChapterSelected: (ChapterList.Item, Boolean, Boolean, Boolean) -> Unit,
onAllChapterSelected: (Boolean) -> Unit,
onInvertSelection: () -> Unit,
// KMK -->
getMangaState: @Composable ((Manga) -> State<Manga>),
onRelatedMangaClick: (Manga) -> Unit,
onRelatedMangaLongClick: (Manga) -> Unit,
// KMK <--
) {
val context = LocalContext.current
val onCopyTagToClipboard: (tag: String) -> Unit = {
@ -220,6 +229,11 @@ fun MangaScreen(
onChapterSelected = onChapterSelected,
onAllChapterSelected = onAllChapterSelected,
onInvertSelection = onInvertSelection,
// KMK -->
getMangaState = getMangaState,
onRelatedMangaClick = onRelatedMangaClick,
onRelatedMangaLongClick = onRelatedMangaLongClick,
// KMK <--
)
} else {
MangaScreenLargeImpl(
@ -266,6 +280,11 @@ fun MangaScreen(
onChapterSelected = onChapterSelected,
onAllChapterSelected = onAllChapterSelected,
onInvertSelection = onInvertSelection,
// KMK -->
getMangaState = getMangaState,
onRelatedMangaClick = onRelatedMangaClick,
onRelatedMangaLongClick = onRelatedMangaLongClick,
// KMK <--
)
}
}
@ -328,6 +347,12 @@ private fun MangaScreenSmallImpl(
onChapterSelected: (ChapterList.Item, Boolean, Boolean, Boolean) -> Unit,
onAllChapterSelected: (Boolean) -> Unit,
onInvertSelection: () -> Unit,
// KMK -->
getMangaState: @Composable ((Manga) -> State<Manga>),
onRelatedMangaClick: (Manga) -> Unit,
onRelatedMangaLongClick: (Manga) -> Unit,
// KMK <--
) {
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 -->
if (!state.showRecommendationsInOverflow || state.showMergeWithAnother) {
item(
@ -654,6 +702,12 @@ fun MangaScreenLargeImpl(
onChapterSelected: (ChapterList.Item, Boolean, Boolean, Boolean) -> Unit,
onAllChapterSelected: (Boolean) -> Unit,
onInvertSelection: () -> Unit,
// KMK -->
getMangaState: @Composable ((Manga) -> State<Manga>),
onRelatedMangaClick: (Manga) -> Unit,
onRelatedMangaLongClick: (Manga) -> Unit,
// KMK <--
) {
val layoutDirection = LocalLayoutDirection.current
val density = LocalDensity.current
@ -863,6 +917,29 @@ fun MangaScreenLargeImpl(
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(
key = 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
private fun SharedMangaBottomActionMenu(
selected: List<ChapterList.Item>,

View file

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

View file

@ -52,6 +52,13 @@ object SettingsBrowseScreen : SearchableSettings {
Preference.PreferenceGroup(
title = stringResource(MR.strings.label_sources),
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 {
val count by sourcePreferences.sourcesTabCategories().collectAsState()
Preference.PreferenceItem.TextPreference(

View file

@ -252,7 +252,7 @@ class BulkFavoriteScreenModel(
.orEmpty()
}
suspend fun getDuplicateLibraryManga(manga: Manga): Manga? {
private suspend fun getDuplicateLibraryManga(manga: Manga): Manga? {
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.isLocalOrStub
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.source.SourcesScreen
import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourceScreen
@ -121,6 +127,11 @@ class MangaScreen(
val successState = state as MangaScreenModel.State.Success
val isHttpSource = remember { successState.source is HttpSource }
// KMK -->
val bulkFavoriteScreenModel = rememberScreenModel { BulkFavoriteScreenModel() }
val bulkFavoriteState by bulkFavoriteScreenModel.state.collectAsState()
// KMK <--
LaunchedEffect(successState.manga, screenModel.source) {
if (isHttpSource) {
try {
@ -225,6 +236,11 @@ class MangaScreen(
onChapterSelected = screenModel::toggleSelection,
onAllChapterSelected = screenModel::toggleAllSelection,
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) }
@ -342,6 +358,22 @@ class MangaScreen(
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?) {

View file

@ -3,8 +3,10 @@ package eu.kanade.tachiyomi.ui.manga
import android.content.Context
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import androidx.compose.ui.util.fastAny
import cafe.adriel.voyager.core.model.StateScreenModel
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.copyFrom
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.source.service.SourcePreferences
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.distinctUntilChangedBy
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flatMapConcat
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
@ -135,6 +139,7 @@ import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import uy.kohesive.injekt.injectLazy
import kotlin.math.floor
import androidx.compose.runtime.State as RuntimeState
class MangaScreenModel(
val context: Context,
@ -204,6 +209,11 @@ class MangaScreenModel(
private val filteredChapters: List<ChapterList.Item>?
get() = successState?.processedChapters
// KMK -->
private val relatedMangas: List<Manga>
get() = successState?.relatedMangas ?: emptyList()
// KMK <--
val chapterSwipeStartAction = libraryPreferences.swipeToEndAction().get()
val chapterSwipeEndAction = libraryPreferences.swipeToStartAction().get()
@ -431,6 +441,9 @@ class MangaScreenModel(
val fetchFromSourceTasks = listOf(
async { if (needRefreshInfo) fetchMangaFromSource() },
async { if (needRefreshChapter) fetchChaptersFromSource() },
// KMK -->
async { fetchRelatedMangasFromSource() },
// KMK <--
)
fetchFromSourceTasks.awaitAll()
}
@ -446,6 +459,9 @@ class MangaScreenModel(
val fetchFromSourceTasks = listOf(
async { fetchMangaFromSource(manualFetch) },
async { fetchChaptersFromSource(manualFetch) },
// KMK -->
async { fetchRelatedMangasFromSource() },
// KMK <--
)
fetchFromSourceTasks.awaitAll()
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 {
val originalManga = getManga.await(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]
*/
@ -1637,6 +1701,9 @@ class MangaScreenModel(
val alwaysShowReadingProgress: Boolean,
val previewsRowCount: Int,
// SY <--
// KMK -->
val relatedMangas: List<Manga>? = null,
// KMK <--
) : State {
val processedChapters by lazy {
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_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_source_related_mangas">Related mangas</string>
<string name="pref_source_related_mangas_summary">Show related mangas provided by extensions</string>
<!-- Backup settings -->
<string name="custom_entry_info">Custom entry info</string>

View file

@ -48,6 +48,20 @@ interface Source {
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
* in the expected order; the index is ignored.
@ -75,6 +89,22 @@ interface Source {
fun fetchChapterList(manga: SManga): Observable<List<SChapter>> =
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(
"Use the non-RxJava API instead",
ReplaceWith("getPageList"),

View file

@ -16,17 +16,24 @@ import eu.kanade.tachiyomi.source.model.SManga
import exh.log.maybeInjectEHLogger
import exh.pref.DelegateSourcePreferences
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.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import rx.Observable
import tachiyomi.core.common.util.lang.awaitSingle
import tachiyomi.core.common.util.system.logcat
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.net.URI
import java.net.URISyntaxException
import java.security.MessageDigest
import java.util.Locale
/**
* A simple implementation for sources from a website.
@ -274,6 +281,118 @@ abstract class HttpSource : CatalogueSource {
*/
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.
* 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
// 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.
*