From 52655cad2c08dedfbddcfb65f99968e8dd81b429 Mon Sep 17 00:00:00 2001 From: Koitharu Date: Tue, 9 May 2023 18:35:33 +0300 Subject: [PATCH 1/4] New suggestions algorithm --- .../org/koitharu/kotatsu/core/model/Manga.kt | 2 + .../kotatsu/core/prefs/AppSettings.kt | 15 ++ .../kotatsu/favourites/data/EntityMapping.kt | 10 +- .../kotatsu/favourites/data/FavouritesDao.kt | 4 + .../favourites/domain/FavouritesRepository.kt | 17 +- .../domain/SuggestionRepository.kt | 2 +- .../suggestions/domain/TagsBlacklist.kt | 23 ++ .../suggestions/ui/SuggestionsWorker.kt | 240 +++++++++++++----- .../kotatsu/utils/ext/CollectionExt.kt | 15 ++ .../org/koitharu/kotatsu/utils/ext/FlowExt.kt | 9 + .../koitharu/kotatsu/utils/ext/StringExt.kt | 13 + .../ic_stat_suggestion.xml | 17 ++ .../res/drawable-hdpi/ic_stat_suggestion.png | Bin 0 -> 542 bytes .../res/drawable-mdpi/ic_stat_suggestion.png | Bin 0 -> 362 bytes .../res/drawable-xhdpi/ic_stat_suggestion.png | Bin 0 -> 719 bytes .../drawable-xxhdpi/ic_stat_suggestion.png | Bin 0 -> 967 bytes app/src/main/res/values/strings.xml | 3 + app/src/main/res/xml/pref_suggestions.xml | 13 +- 18 files changed, 313 insertions(+), 70 deletions(-) create mode 100644 app/src/main/java/org/koitharu/kotatsu/suggestions/domain/TagsBlacklist.kt create mode 100644 app/src/main/res/drawable-anydpi-v24/ic_stat_suggestion.xml create mode 100644 app/src/main/res/drawable-hdpi/ic_stat_suggestion.png create mode 100644 app/src/main/res/drawable-mdpi/ic_stat_suggestion.png create mode 100644 app/src/main/res/drawable-xhdpi/ic_stat_suggestion.png create mode 100644 app/src/main/res/drawable-xxhdpi/ic_stat_suggestion.png diff --git a/app/src/main/java/org/koitharu/kotatsu/core/model/Manga.kt b/app/src/main/java/org/koitharu/kotatsu/core/model/Manga.kt index d1c3f849e..f9720bb13 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/model/Manga.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/model/Manga.kt @@ -9,6 +9,8 @@ import org.koitharu.kotatsu.utils.ext.iterator fun Collection.ids() = mapToSet { it.id } +fun Collection.distinctById() = distinctBy { it.id } + fun Collection.countChaptersByBranch(): Int { if (size <= 1) { return size diff --git a/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSettings.kt b/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSettings.kt index 01b79b675..33956dd1a 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSettings.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSettings.kt @@ -16,6 +16,7 @@ import org.koitharu.kotatsu.core.model.ZoomMode import org.koitharu.kotatsu.core.network.DoHProvider import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.SortOrder +import org.koitharu.kotatsu.parsers.util.mapToSet import org.koitharu.kotatsu.shelf.domain.ShelfSection import org.koitharu.kotatsu.utils.ext.connectivityManager import org.koitharu.kotatsu.utils.ext.filterToSet @@ -250,6 +251,18 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) { val isSuggestionsExcludeNsfw: Boolean get() = prefs.getBoolean(KEY_SUGGESTIONS_EXCLUDE_NSFW, false) + val isSuggestionsNotificationAvailable: Boolean + get() = prefs.getBoolean(KEY_SUGGESTIONS_NOTIFICATIONS, true) + + val suggestionsTagsBlacklist: Set + get() { + val string = prefs.getString(KEY_SUGGESTIONS_EXCLUDE_TAGS, null)?.trimEnd(' ', ',') + if (string.isNullOrEmpty()) { + return emptySet() + } + return string.split(',').mapToSet { it.trim() } + } + val isReaderBarEnabled: Boolean get() = prefs.getBoolean(KEY_READER_BAR, true) @@ -279,6 +292,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) { return policy.isNetworkAllowed(connectivityManager) } + @Deprecated("") fun getSuggestionsTagsBlacklistRegex(): Regex? { val string = prefs.getString(KEY_SUGGESTIONS_EXCLUDE_TAGS, null)?.trimEnd(' ', ',') if (string.isNullOrEmpty()) { @@ -381,6 +395,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) { const val KEY_SUGGESTIONS = "suggestions" const val KEY_SUGGESTIONS_EXCLUDE_NSFW = "suggestions_exclude_nsfw" const val KEY_SUGGESTIONS_EXCLUDE_TAGS = "suggestions_exclude_tags" + const val KEY_SUGGESTIONS_NOTIFICATIONS = "suggestions_notifications" const val KEY_SHIKIMORI = "shikimori" const val KEY_ANILIST = "anilist" const val KEY_MAL = "mal" diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/data/EntityMapping.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/data/EntityMapping.kt index 36308d4c6..af468c3e8 100644 --- a/app/src/main/java/org/koitharu/kotatsu/favourites/data/EntityMapping.kt +++ b/app/src/main/java/org/koitharu/kotatsu/favourites/data/EntityMapping.kt @@ -1,9 +1,11 @@ package org.koitharu.kotatsu.favourites.data import org.koitharu.kotatsu.core.db.entity.SortOrder +import org.koitharu.kotatsu.core.db.entity.toManga +import org.koitharu.kotatsu.core.db.entity.toMangaTags import org.koitharu.kotatsu.core.model.FavouriteCategory import org.koitharu.kotatsu.parsers.model.SortOrder -import java.util.* +import java.util.Date fun FavouriteCategoryEntity.toFavouriteCategory(id: Long = categoryId.toLong()) = FavouriteCategory( id = id, @@ -13,4 +15,8 @@ fun FavouriteCategoryEntity.toFavouriteCategory(id: Long = categoryId.toLong()) createdAt = Date(createdAt), isTrackingEnabled = track, isVisibleInLibrary = isVisibleInLibrary, -) \ No newline at end of file +) + +fun FavouriteManga.toManga() = manga.toManga(tags.toMangaTags()) + +fun Collection.toMangaList() = map { it.toManga() } diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/data/FavouritesDao.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/data/FavouritesDao.kt index 0e0ee05de..590b66436 100644 --- a/app/src/main/java/org/koitharu/kotatsu/favourites/data/FavouritesDao.kt +++ b/app/src/main/java/org/koitharu/kotatsu/favourites/data/FavouritesDao.kt @@ -17,6 +17,10 @@ abstract class FavouritesDao { @Query("SELECT * FROM favourites WHERE deleted_at = 0 GROUP BY manga_id ORDER BY created_at DESC") abstract suspend fun findAll(): List + @Transaction + @Query("SELECT * FROM favourites WHERE deleted_at = 0 GROUP BY manga_id ORDER BY created_at DESC LIMIT :limit") + abstract suspend fun findLast(limit: Int): List + fun observeAll(order: SortOrder): Flow> { val orderBy = getOrderBy(order) diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/domain/FavouritesRepository.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/domain/FavouritesRepository.kt index 3875eb2e5..72fb8d48c 100644 --- a/app/src/main/java/org/koitharu/kotatsu/favourites/domain/FavouritesRepository.kt +++ b/app/src/main/java/org/koitharu/kotatsu/favourites/domain/FavouritesRepository.kt @@ -12,12 +12,12 @@ import org.koitharu.kotatsu.core.db.MangaDatabase import org.koitharu.kotatsu.core.db.entity.SortOrder import org.koitharu.kotatsu.core.db.entity.toEntities import org.koitharu.kotatsu.core.db.entity.toEntity -import org.koitharu.kotatsu.core.db.entity.toManga -import org.koitharu.kotatsu.core.db.entity.toMangaTags import org.koitharu.kotatsu.core.model.FavouriteCategory import org.koitharu.kotatsu.favourites.data.FavouriteCategoryEntity import org.koitharu.kotatsu.favourites.data.FavouriteEntity import org.koitharu.kotatsu.favourites.data.toFavouriteCategory +import org.koitharu.kotatsu.favourites.data.toManga +import org.koitharu.kotatsu.favourites.data.toMangaList import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.tracker.work.TrackerNotificationChannels @@ -32,22 +32,27 @@ class FavouritesRepository @Inject constructor( suspend fun getAllManga(): List { val entities = db.favouritesDao.findAll() - return entities.map { it.manga.toManga(it.tags.toMangaTags()) } + return entities.toMangaList() + } + + suspend fun getLastManga(limit: Int): List { + val entities = db.favouritesDao.findLast(limit) + return entities.toMangaList() } fun observeAll(order: SortOrder): Flow> { return db.favouritesDao.observeAll(order) - .mapItems { it.manga.toManga(it.tags.toMangaTags()) } + .mapItems { it.toManga() } } suspend fun getManga(categoryId: Long): List { val entities = db.favouritesDao.findAll(categoryId) - return entities.map { it.manga.toManga(it.tags.toMangaTags()) } + return entities.toMangaList() } fun observeAll(categoryId: Long, order: SortOrder): Flow> { return db.favouritesDao.observeAll(categoryId, order) - .mapItems { it.manga.toManga(it.tags.toMangaTags()) } + .mapItems { it.toManga() } } fun observeAll(categoryId: Long): Flow> { diff --git a/app/src/main/java/org/koitharu/kotatsu/suggestions/domain/SuggestionRepository.kt b/app/src/main/java/org/koitharu/kotatsu/suggestions/domain/SuggestionRepository.kt index f0afaf429..50cd7c7f9 100644 --- a/app/src/main/java/org/koitharu/kotatsu/suggestions/domain/SuggestionRepository.kt +++ b/app/src/main/java/org/koitharu/kotatsu/suggestions/domain/SuggestionRepository.kt @@ -1,7 +1,6 @@ package org.koitharu.kotatsu.suggestions.domain import androidx.room.withTransaction -import javax.inject.Inject import kotlinx.coroutines.flow.Flow import org.koitharu.kotatsu.core.db.MangaDatabase import org.koitharu.kotatsu.core.db.entity.toEntities @@ -11,6 +10,7 @@ import org.koitharu.kotatsu.core.db.entity.toMangaTags import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.suggestions.data.SuggestionEntity import org.koitharu.kotatsu.utils.ext.mapItems +import javax.inject.Inject class SuggestionRepository @Inject constructor( private val db: MangaDatabase, diff --git a/app/src/main/java/org/koitharu/kotatsu/suggestions/domain/TagsBlacklist.kt b/app/src/main/java/org/koitharu/kotatsu/suggestions/domain/TagsBlacklist.kt new file mode 100644 index 000000000..baa88d725 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/suggestions/domain/TagsBlacklist.kt @@ -0,0 +1,23 @@ +package org.koitharu.kotatsu.suggestions.domain + +import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.utils.ext.almostEquals + +class TagsBlacklist( + private val tags: Set, + private val threshold: Float, +) { + + fun isNotEmpty() = tags.isNotEmpty() + + operator fun contains(manga: Manga): Boolean { + for (mangaTag in manga.tags) { + for (tagTitle in tags) { + if (mangaTag.title.almostEquals(tagTitle, threshold)) { + return true + } + } + } + return false + } +} diff --git a/app/src/main/java/org/koitharu/kotatsu/suggestions/ui/SuggestionsWorker.kt b/app/src/main/java/org/koitharu/kotatsu/suggestions/ui/SuggestionsWorker.kt index 64d77f505..3f136eb26 100644 --- a/app/src/main/java/org/koitharu/kotatsu/suggestions/ui/SuggestionsWorker.kt +++ b/app/src/main/java/org/koitharu/kotatsu/suggestions/ui/SuggestionsWorker.kt @@ -2,11 +2,15 @@ package org.koitharu.kotatsu.suggestions.ui import android.app.NotificationChannel import android.app.NotificationManager +import android.app.PendingIntent import android.content.Context import android.os.Build import androidx.annotation.FloatRange import androidx.core.app.NotificationCompat -import androidx.core.content.ContextCompat +import androidx.core.app.PendingIntentCompat +import androidx.core.text.HtmlCompat +import androidx.core.text.buildSpannedString +import androidx.core.text.parseAsHtml import androidx.hilt.work.HiltWorker import androidx.work.BackoffPolicy import androidx.work.Constraints @@ -20,39 +24,56 @@ import androidx.work.PeriodicWorkRequestBuilder import androidx.work.WorkManager import androidx.work.WorkerParameters import androidx.work.workDataOf +import coil.ImageLoader +import coil.request.ImageRequest import dagger.assisted.Assisted import dagger.assisted.AssistedInject -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll -import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.channelFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.launch import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.model.distinctById import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.prefs.AppSettings +import org.koitharu.kotatsu.details.ui.DetailsActivity +import org.koitharu.kotatsu.favourites.domain.FavouritesRepository import org.koitharu.kotatsu.history.domain.HistoryRepository +import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.SortOrder +import org.koitharu.kotatsu.reader.ui.ReaderActivity import org.koitharu.kotatsu.suggestions.domain.MangaSuggestion import org.koitharu.kotatsu.suggestions.domain.SuggestionRepository +import org.koitharu.kotatsu.suggestions.domain.TagsBlacklist +import org.koitharu.kotatsu.utils.ext.almostEquals import org.koitharu.kotatsu.utils.ext.asArrayList +import org.koitharu.kotatsu.utils.ext.flatten import org.koitharu.kotatsu.utils.ext.printStackTraceDebug import org.koitharu.kotatsu.utils.ext.runCatchingCancellable +import org.koitharu.kotatsu.utils.ext.takeMostFrequent +import org.koitharu.kotatsu.utils.ext.toBitmapOrNull import org.koitharu.kotatsu.utils.ext.trySetForeground import java.util.concurrent.TimeUnit import kotlin.math.pow +import kotlin.random.Random @HiltWorker class SuggestionsWorker @AssistedInject constructor( @Assisted appContext: Context, @Assisted params: WorkerParameters, + private val coil: ImageLoader, private val suggestionRepository: SuggestionRepository, private val historyRepository: HistoryRepository, + private val favouritesRepository: FavouritesRepository, private val appSettings: AppSettings, private val mangaRepositoryFactory: MangaRepository.Factory, ) : CoroutineWorker(appContext, params) { override suspend fun doWork(): Result { + trySetForeground() val count = doWorkImpl() val outputData = workDataOf(DATA_COUNT to count) return Result.success(outputData) @@ -79,7 +100,6 @@ class SuggestionsWorker @AssistedInject constructor( .setPriority(NotificationCompat.PRIORITY_MIN) .setCategory(NotificationCompat.CATEGORY_SERVICE) .setDefaults(0) - .setColor(ContextCompat.getColor(applicationContext, R.color.blue_primary_dark)) .setSilent(true) .setProgress(0, 0, true) .setSmallIcon(android.R.drawable.stat_notify_sync) @@ -94,83 +114,185 @@ class SuggestionsWorker @AssistedInject constructor( suggestionRepository.clear() return 0 } - val blacklistTagRegex = appSettings.getSuggestionsTagsBlacklistRegex() - val allTags = historyRepository.getPopularTags(TAGS_LIMIT).filterNot { - blacklistTagRegex?.containsMatchIn(it.title) ?: false - } - if (allTags.isEmpty()) { + val seed = ( + historyRepository.getList(0, 20) + + favouritesRepository.getLastManga(20) + ).distinctById() + val sources = appSettings.getMangaSources(includeHidden = false) + if (seed.isEmpty() || sources.isEmpty()) { return 0 } - if (TAG in tags) { // not expedited - trySetForeground() - } - val tagsBySources = allTags.groupBy { x -> x.source } - val dispatcher = Dispatchers.Default.limitedParallelism(MAX_PARALLELISM) - val rawResults = coroutineScope { - tagsBySources.flatMap { (source, tags) -> - val repo = mangaRepositoryFactory.tryCreate(source) ?: return@flatMap emptyList() - tags.map { tag -> - async(dispatcher) { - repo.getListSafe(tag) - } + val tagsBlacklist = TagsBlacklist(appSettings.suggestionsTagsBlacklist, 0.3f) + val tags = seed.flatMap { it.tags.map { x -> x.title } }.takeMostFrequent(10) + + val producer = channelFlow { + for (it in sources.shuffled()) { + launch { + send(getList(it, tags, tagsBlacklist)) } - }.awaitAll().flatten().asArrayList() - } - if (appSettings.isSuggestionsExcludeNsfw) { - rawResults.removeAll { it.isNsfw } - } - if (blacklistTagRegex != null) { - rawResults.removeAll { - it.tags.any { x -> blacklistTagRegex.containsMatchIn(x.title) } } } - if (rawResults.isEmpty()) { - return 0 - } - val suggestions = rawResults.distinctBy { manga -> - manga.id - }.map { manga -> - MangaSuggestion( - manga = manga, - relevance = computeRelevance(manga.tags, allTags), - ) - }.sortedBy { it.relevance }.take(LIMIT) + val suggestions = producer + .flatten() + .take(MAX_RAW_RESULTS) + .map { manga -> + MangaSuggestion( + manga = manga, + relevance = computeRelevance(manga.tags, tags), + ) + }.toList() + .sortedBy { it.relevance } + .take(MAX_RESULTS) suggestionRepository.replace(suggestions) + if (appSettings.isSuggestionsNotificationAvailable) { + runCatchingCancellable { + val manga = suggestions[Random.nextInt(0, suggestions.size / 3)] + val details = mangaRepositoryFactory.create(manga.manga.source) + .getDetails(manga.manga) + showNotification(details) + }.onFailure { + it.printStackTraceDebug() + } + } return suggestions.size } + private suspend fun getList( + source: MangaSource, + tags: List, + blacklist: TagsBlacklist, + ): List = runCatchingCancellable { + val repository = mangaRepositoryFactory.create(source) + val availableOrders = repository.sortOrders + val order = preferredSortOrders.first { it in availableOrders } + val availableTags = repository.getTags() + val tag = tags.firstNotNullOfOrNull { title -> + availableTags.find { x -> x.title.almostEquals(title, threshold = 0.3f) } + } + val list = repository.getList(0, setOfNotNull(tag), order).asArrayList() + if (appSettings.isSuggestionsExcludeNsfw) { + list.removeAll { it.isNsfw } + } + if (blacklist.isNotEmpty()) { + list.removeAll { manga -> manga in blacklist } + } + list + }.onFailure { + it.printStackTraceDebug() + }.getOrDefault(emptyList()) + + private suspend fun showNotification(manga: Manga) { + val manager = applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = NotificationChannel( + MANGA_CHANNEL_ID, + applicationContext.getString(R.string.suggestions), + NotificationManager.IMPORTANCE_DEFAULT, + ) + channel.description = applicationContext.getString(R.string.suggestions_summary) + channel.enableLights(true) + channel.setShowBadge(true) + manager.createNotificationChannel(channel) + } + val id = manga.url.hashCode() + val title = applicationContext.getString(R.string.suggestion_manga, manga.title) + val builder = NotificationCompat.Builder(applicationContext, MANGA_CHANNEL_ID) + val tagsText = manga.tags.joinToString(", ") { it.title } + with(builder) { + setContentText(tagsText) + setContentTitle(title) + setLargeIcon( + coil.execute( + ImageRequest.Builder(applicationContext) + .data(manga.coverUrl) + .tag(manga.source) + .build(), + ).toBitmapOrNull(), + ) + setSmallIcon(R.drawable.ic_stat_suggestion) + val description = manga.description?.parseAsHtml(HtmlCompat.FROM_HTML_MODE_COMPACT) + if (!description.isNullOrBlank()) { + val style = NotificationCompat.BigTextStyle() + style.bigText( + buildSpannedString { + append(tagsText) + appendLine() + append(description) + }, + ) + style.setBigContentTitle(title) + setStyle(style) + } + val intent = DetailsActivity.newIntent(applicationContext, manga) + setContentIntent( + PendingIntentCompat.getActivity( + applicationContext, + id, + intent, + PendingIntent.FLAG_UPDATE_CURRENT, + false, + ), + ) + setAutoCancel(true) + setCategory(NotificationCompat.CATEGORY_RECOMMENDATION) + setVisibility(if (manga.isNsfw) NotificationCompat.VISIBILITY_SECRET else NotificationCompat.VISIBILITY_PUBLIC) + setShortcutId(manga.id.toString()) + priority = NotificationCompat.PRIORITY_DEFAULT + + addAction( + R.drawable.ic_read, + applicationContext.getString(R.string.read), + PendingIntentCompat.getActivity( + applicationContext, + id + 2, + ReaderActivity.newIntent(applicationContext, manga), + 0, + false, + ), + ) + + addAction( + R.drawable.ic_suggestion, + applicationContext.getString(R.string.more), + PendingIntentCompat.getActivity( + applicationContext, + 0, + SuggestionsActivity.newIntent(applicationContext), + 0, + false, + ), + ) + } + manager.notify(TAG, id, builder.build()) + } + @FloatRange(from = 0.0, to = 1.0) - private fun computeRelevance(mangaTags: Set, allTags: List): Float { + private fun computeRelevance(mangaTags: Set, allTags: List): Float { val maxWeight = (allTags.size + allTags.size + 1 - mangaTags.size) * mangaTags.size / 2.0 val weight = mangaTags.sumOf { tag -> - val index = allTags.indexOf(tag) + val index = allTags.indexOf(tag.title) if (index < 0) 0 else allTags.size - index } return (weight / maxWeight).pow(2.0).toFloat() } - private suspend fun MangaRepository.getListSafe(tag: MangaTag) = runCatchingCancellable { - getList(offset = 0, sortOrder = SortOrder.UPDATED, tags = setOf(tag)) - }.onFailure { error -> - error.printStackTraceDebug() - }.getOrDefault(emptyList()) - - private fun MangaRepository.Factory.tryCreate(source: MangaSource) = runCatching { - create(source) - }.onFailure { error -> - error.printStackTraceDebug() - }.getOrNull() - companion object { private const val TAG = "suggestions" private const val TAG_ONESHOT = "suggestions_oneshot" - private const val LIMIT = 140 - private const val TAGS_LIMIT = 20 - private const val MAX_PARALLELISM = 4 private const val DATA_COUNT = "count" private const val WORKER_CHANNEL_ID = "suggestion_worker" + private const val MANGA_CHANNEL_ID = "suggestions" private const val WORKER_NOTIFICATION_ID = 36 + private const val MAX_RESULTS = 80 + private const val MAX_RAW_RESULTS = 200 + + private val preferredSortOrders = listOf( + SortOrder.UPDATED, + SortOrder.NEWEST, + SortOrder.POPULARITY, + SortOrder.RATING, + ) fun setup(context: Context) { val constraints = Constraints.Builder() diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ext/CollectionExt.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ext/CollectionExt.kt index 6f6513707..879704445 100644 --- a/app/src/main/java/org/koitharu/kotatsu/utils/ext/CollectionExt.kt +++ b/app/src/main/java/org/koitharu/kotatsu/utils/ext/CollectionExt.kt @@ -1,5 +1,6 @@ package org.koitharu.kotatsu.utils.ext +import androidx.collection.ArrayMap import androidx.collection.ArraySet import java.util.Collections @@ -45,3 +46,17 @@ inline fun Collection.filterToSet(predicate: (T) -> Boolean): Set { fun Sequence.toListSorted(comparator: Comparator): List { return toMutableList().apply { sortWith(comparator) } } + +fun List.takeMostFrequent(limit: Int): List { + val map = ArrayMap(size) + for (item in this) { + map[item] = map.getOrDefault(item, 0) + 1 + } + val entries = map.entries.sortedByDescending { it.value } + val count = minOf(limit, entries.size) + return buildList(count) { + repeat(count) { i -> + add(entries[i].key) + } + } +} diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ext/FlowExt.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ext/FlowExt.kt index 4c08eec67..0ed270991 100644 --- a/app/src/main/java/org/koitharu/kotatsu/utils/ext/FlowExt.kt +++ b/app/src/main/java/org/koitharu/kotatsu/utils/ext/FlowExt.kt @@ -4,6 +4,7 @@ import android.os.SystemClock import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onCompletion import kotlinx.coroutines.flow.onEach @@ -43,3 +44,11 @@ fun Flow.throttle(timeoutMillis: (T) -> Long): Flow { fun StateFlow.requireValue(): T = checkNotNull(value) { "StateFlow value is null" } + +fun Flow>.flatten(): Flow = flow { + collect { value -> + for (item in value) { + emit(item) + } + } +} diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ext/StringExt.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ext/StringExt.kt index a5054b5e0..bfb763325 100644 --- a/app/src/main/java/org/koitharu/kotatsu/utils/ext/StringExt.kt +++ b/app/src/main/java/org/koitharu/kotatsu/utils/ext/StringExt.kt @@ -1,5 +1,7 @@ package org.koitharu.kotatsu.utils.ext +import androidx.annotation.FloatRange +import org.koitharu.kotatsu.parsers.util.levenshteinDistance import java.util.UUID inline fun String?.ifNullOrEmpty(defaultValue: () -> String): String { @@ -21,3 +23,14 @@ fun String.toUUIDOrNull(): UUID? = try { e.printStackTraceDebug() null } + +/** + * @param threshold 0 = exact match + */ +fun String.almostEquals(other: String, @FloatRange(from = 0.0) threshold: Float): Boolean { + if (threshold == 0f) { + return equals(other) + } + val diff = levenshteinDistance(other) / ((length + other.length) / 2f) + return diff < threshold +} diff --git a/app/src/main/res/drawable-anydpi-v24/ic_stat_suggestion.xml b/app/src/main/res/drawable-anydpi-v24/ic_stat_suggestion.xml new file mode 100644 index 000000000..3da7d00e0 --- /dev/null +++ b/app/src/main/res/drawable-anydpi-v24/ic_stat_suggestion.xml @@ -0,0 +1,17 @@ + + + + + diff --git a/app/src/main/res/drawable-hdpi/ic_stat_suggestion.png b/app/src/main/res/drawable-hdpi/ic_stat_suggestion.png new file mode 100644 index 0000000000000000000000000000000000000000..76ab52d8a5a2ee8dde37a4b59e7b420fa29bd2f3 GIT binary patch literal 542 zcmV+(0^$9MP)s-F)7(03mc(WSs~(2u#oaA;!oh8u)|)lkW%tTD5WfHlw?7PXRt+3HRg*_^w_EVP6pGeqe1be$|G*D(AgPm748Q5c=1{UVnVZqfwk_Glw5w$nQ zxVSt>Qe<-%Z#2O5L6RQTT;gd1d>qux0qwjIf$M|J4@eAVdDDW+gBIDVfrV{$7|;&F z*S%05y%y)7ebr-yT?VQL;p2+Xi{>Q1pbqX!3_>pT{6t)H zUTP4UrMQ+s)f0 z!Y`TR<$W{rlKtCiof|W|g?i3@54zUZQ56%-MSIK6W3_qxQPMcqIi@ zTxvcF1gF~MFx6Z%w*Sj~fi8l$Cdd6U2?}n^|LJ!*)BTh41H>53_5;sM^8f$<07*qo IM6N<$f`hK4aR2}S literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-xhdpi/ic_stat_suggestion.png b/app/src/main/res/drawable-xhdpi/ic_stat_suggestion.png new file mode 100644 index 0000000000000000000000000000000000000000..e85724bb9d26eabd7cc24999533ba3881a65381f GIT binary patch literal 719 zcmV;=0xW)iKP`kvss3^cV0yXnhIwqai`@4*B`bI;;x0$8_+w_fs20vty z4tPNS+R@M*rs#kj`qzR6jY(IfRRrh;&eM61ac(7K)GrN7x20drlOo|}-m)x1Kl6D} z(^}3hEo;o@MNMz$l91QT=S5A==#r49%;!Z-GjvJFBj)p>rfIq)WQzH`sA+;O3AxC8 zUet7!E*^eLL-Zl48I`us>*EZ4yr}5{T|B&`M;WSJV6%tw^k|714qEuPG)JE@RQsIG z9$c0|`V^wVL(*>;v_qW>I9w+0zo;|cWM`Yyy?|qrUO80oksSfVSirGJzg=|U6)OXY zwSZ%up3ZchRe{7@-~~N*(S`S{3?SA5@93991>f1xCNUP+pw~aBXwa%1VCr7L-Rbw! zs}RdhW3C~D*#e&tX}I9?&;@>2=&4K%r|9D0?*f+H$yY2p_&Y622b!*jE^xBZ+cwtv zp;Zxc(xkP1i1kIfZs7}@V?Hlxx=fdZOfa7pHQk^~LZ+F|i~V0m${FVKqNZ88B&5=z z{w-Y+Qt7eEI$aX7%6widyO|A1*Q9TGzbLqwZnn!1b`vW@Mu#nXTG7e5p^Qc8k~G4( zwP4+OxzS*Y{7sOl(pZixiRItq>tL3h5M06Y=&9BoHhV)Ax zig*K23!8|DLDA!L*je5Eo86f?=gem3aDMQD?94pxc>^Z9vokX=kR(YGIb+OL)n>l# z%4XS2zFu1zW=Zv1^;NZ#+l$h%yRraNkbszS%J}&wXn}0}QKv zaw@o|8eieq@oZZ+HhY-I)`k{;hZA-2UbQDH`|qM|vu`bE;afOKg@XM7=ou~gEvKl^ zYxeJJTJRB0&;tj#j=rRY4w?L4%vG>Ghq#`;qeWig%6sTM*R$%hs6o{M)fLst_Vuoc z7plvuX|A<$YPv?XQFT=HtYuOC?yhRLYK$`lq2ODpkE&m7ZR=@S^-8tCIh`r&5t_u{ z3FmaCuBTg%rA?1o{lrW6a`F~#+P)#ut! z7sv$rkUV9&oy`T<#;e?XQ=|HZn~PHRp2C7sWtKl9z0^hPnCNgp-xDc~ei zeK5Z)=6Uk_u-asLaJoaAW>p`un&tMUwlF_oPIojh-4!xNRA;$Q3%hLu#{|)u83Y@+ z%ChVwGxjo-BER44KF?Kyq1O=<8+drf7fN`;7hG{ZZ){L^oX?B$*0VtbMmeW5h3#N^ z1cH9`yr1b22zv3tF{Vc#=;bfn-mBDA&on`aF-WL5NRqgBt_!Yxzm!2(y! z3g>3*YpQG5m+Jjs0n4oXXo_#DQ{1-`yKi<3)%(#DP3GUjO}?nMaeGlZc6*i)&%;eD p(|A12Ccf^<#P)ccBuSDE{s4!HuyPNODyjeg002ovPDHLkV1j(P;eY@D literal 0 HcmV?d00001 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0148884e4..4a430f6d2 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -448,4 +448,7 @@ Cancel all Download only via Wi-Fi Stop downloading when switching to a mobile network + Suggestion: %s + Sometimes show notifications with suggested manga + More diff --git a/app/src/main/res/xml/pref_suggestions.xml b/app/src/main/res/xml/pref_suggestions.xml index b9712a556..c754b220f 100644 --- a/app/src/main/res/xml/pref_suggestions.xml +++ b/app/src/main/res/xml/pref_suggestions.xml @@ -9,13 +9,22 @@ android:layout="@layout/preference_toggle_header" android:title="@string/suggestions_enable" /> + + + android:title="@string/suggestions_excluded_genres" + app:allowDividerAbove="true" /> @@ -28,4 +37,4 @@ android:summary="@string/suggestions_info" app:allowDividerAbove="true" /> - \ No newline at end of file + From 2169ee7a5b2b0fe3dd16c8bf2db2b2c5d0613c9b Mon Sep 17 00:00:00 2001 From: Koitharu Date: Wed, 10 May 2023 19:16:17 +0300 Subject: [PATCH 2/4] Tune suggestions --- .../suggestions/ui/SuggestionsWorker.kt | 20 +++++++++++++++---- .../koitharu/kotatsu/utils/ext/StringExt.kt | 4 ++-- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/org/koitharu/kotatsu/suggestions/ui/SuggestionsWorker.kt b/app/src/main/java/org/koitharu/kotatsu/suggestions/ui/SuggestionsWorker.kt index 3f136eb26..77d1c8bd1 100644 --- a/app/src/main/java/org/koitharu/kotatsu/suggestions/ui/SuggestionsWorker.kt +++ b/app/src/main/java/org/koitharu/kotatsu/suggestions/ui/SuggestionsWorker.kt @@ -122,7 +122,7 @@ class SuggestionsWorker @AssistedInject constructor( if (seed.isEmpty() || sources.isEmpty()) { return 0 } - val tagsBlacklist = TagsBlacklist(appSettings.suggestionsTagsBlacklist, 0.3f) + val tagsBlacklist = TagsBlacklist(appSettings.suggestionsTagsBlacklist, TAG_EQ_THRESHOLD) val tags = seed.flatMap { it.tags.map { x -> x.title } }.takeMostFrequent(10) val producer = channelFlow { @@ -149,7 +149,9 @@ class SuggestionsWorker @AssistedInject constructor( val manga = suggestions[Random.nextInt(0, suggestions.size / 3)] val details = mangaRepositoryFactory.create(manga.manga.source) .getDetails(manga.manga) - showNotification(details) + if (details !in tagsBlacklist) { + showNotification(details) + } }.onFailure { it.printStackTraceDebug() } @@ -167,7 +169,7 @@ class SuggestionsWorker @AssistedInject constructor( val order = preferredSortOrders.first { it in availableOrders } val availableTags = repository.getTags() val tag = tags.firstNotNullOfOrNull { title -> - availableTags.find { x -> x.title.almostEquals(title, threshold = 0.3f) } + availableTags.find { x -> x.title.almostEquals(title, TAG_EQ_THRESHOLD) } } val list = repository.getList(0, setOfNotNull(tag), order).asArrayList() if (appSettings.isSuggestionsExcludeNsfw) { @@ -270,12 +272,21 @@ class SuggestionsWorker @AssistedInject constructor( private fun computeRelevance(mangaTags: Set, allTags: List): Float { val maxWeight = (allTags.size + allTags.size + 1 - mangaTags.size) * mangaTags.size / 2.0 val weight = mangaTags.sumOf { tag -> - val index = allTags.indexOf(tag.title) + val index = allTags.inexactIndexOf(tag.title, TAG_EQ_THRESHOLD) if (index < 0) 0 else allTags.size - index } return (weight / maxWeight).pow(2.0).toFloat() } + private fun Iterable.inexactIndexOf(element: String, threshold: Float): Int { + forEachIndexed { i, t -> + if (t.almostEquals(element, threshold)) { + return i + } + } + return -1 + } + companion object { private const val TAG = "suggestions" @@ -286,6 +297,7 @@ class SuggestionsWorker @AssistedInject constructor( private const val WORKER_NOTIFICATION_ID = 36 private const val MAX_RESULTS = 80 private const val MAX_RAW_RESULTS = 200 + private const val TAG_EQ_THRESHOLD = 0.4f private val preferredSortOrders = listOf( SortOrder.UPDATED, diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ext/StringExt.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ext/StringExt.kt index bfb763325..5e4056a5d 100644 --- a/app/src/main/java/org/koitharu/kotatsu/utils/ext/StringExt.kt +++ b/app/src/main/java/org/koitharu/kotatsu/utils/ext/StringExt.kt @@ -29,8 +29,8 @@ fun String.toUUIDOrNull(): UUID? = try { */ fun String.almostEquals(other: String, @FloatRange(from = 0.0) threshold: Float): Boolean { if (threshold == 0f) { - return equals(other) + return equals(other, ignoreCase = true) } - val diff = levenshteinDistance(other) / ((length + other.length) / 2f) + val diff = lowercase().levenshteinDistance(other.lowercase()) / ((length + other.length) / 2f) return diff < threshold } From 5f38b01fd1b43ad35e0fa871c596fbdd908810dd Mon Sep 17 00:00:00 2001 From: Koitharu Date: Wed, 10 May 2023 19:39:38 +0300 Subject: [PATCH 3/4] Suggestions enable tip --- .../koitharu/kotatsu/core/prefs/AppSettings.kt | 16 ++-------------- .../explore/domain/ExploreRepository.kt | 9 +++++---- .../kotatsu/explore/ui/ExploreFragment.kt | 18 ++++++++++++++++++ .../kotatsu/explore/ui/ExploreViewModel.kt | 16 ++++++++++++++++ .../suggestions/domain/TagsBlacklist.kt | 8 ++++++++ app/src/main/res/values/strings.xml | 1 + 6 files changed, 50 insertions(+), 18 deletions(-) diff --git a/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSettings.kt b/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSettings.kt index 33956dd1a..6dab7e362 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSettings.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSettings.kt @@ -245,8 +245,9 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) { val isDownloadsWiFiOnly: Boolean get() = prefs.getBoolean(KEY_DOWNLOADS_WIFI, false) - val isSuggestionsEnabled: Boolean + var isSuggestionsEnabled: Boolean get() = prefs.getBoolean(KEY_SUGGESTIONS, false) + set(value) = prefs.edit { putBoolean(KEY_SUGGESTIONS, value) } val isSuggestionsExcludeNsfw: Boolean get() = prefs.getBoolean(KEY_SUGGESTIONS_EXCLUDE_NSFW, false) @@ -292,19 +293,6 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) { return policy.isNetworkAllowed(connectivityManager) } - @Deprecated("") - fun getSuggestionsTagsBlacklistRegex(): Regex? { - val string = prefs.getString(KEY_SUGGESTIONS_EXCLUDE_TAGS, null)?.trimEnd(' ', ',') - if (string.isNullOrEmpty()) { - return null - } - val tags = string.split(',') - val regex = tags.joinToString(prefix = "(", separator = "|", postfix = ")") { tag -> - Regex.escape(tag.trim()) - } - return Regex(regex, RegexOption.IGNORE_CASE) - } - fun getMangaSources(includeHidden: Boolean): List { val list = remoteSources.toMutableList() val order = sourcesOrder diff --git a/app/src/main/java/org/koitharu/kotatsu/explore/domain/ExploreRepository.kt b/app/src/main/java/org/koitharu/kotatsu/explore/domain/ExploreRepository.kt index f8bc28e98..4bded9335 100644 --- a/app/src/main/java/org/koitharu/kotatsu/explore/domain/ExploreRepository.kt +++ b/app/src/main/java/org/koitharu/kotatsu/explore/domain/ExploreRepository.kt @@ -1,11 +1,12 @@ package org.koitharu.kotatsu.explore.domain -import javax.inject.Inject import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.history.domain.HistoryRepository import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.SortOrder +import org.koitharu.kotatsu.suggestions.domain.TagsBlacklist +import javax.inject.Inject class ExploreRepository @Inject constructor( private val settings: AppSettings, @@ -14,9 +15,9 @@ class ExploreRepository @Inject constructor( ) { suspend fun findRandomManga(tagsLimit: Int): Manga { - val blacklistTagRegex = settings.getSuggestionsTagsBlacklistRegex() + val blacklistTagRegex = TagsBlacklist(settings.suggestionsTagsBlacklist, 0.4f) val allTags = historyRepository.getPopularTags(tagsLimit).filterNot { - blacklistTagRegex?.containsMatchIn(it.title) ?: false + it in blacklistTagRegex } val tag = allTags.randomOrNull() val source = checkNotNull(tag?.source ?: settings.getMangaSources(includeHidden = false).randomOrNull()) { @@ -32,7 +33,7 @@ class ExploreRepository @Inject constructor( if (settings.isSuggestionsExcludeNsfw && item.isNsfw) { continue } - if (blacklistTagRegex != null && item.tags.any { x -> blacklistTagRegex.containsMatchIn(x.title) }) { + if (item in blacklistTagRegex) { continue } return item diff --git a/app/src/main/java/org/koitharu/kotatsu/explore/ui/ExploreFragment.kt b/app/src/main/java/org/koitharu/kotatsu/explore/ui/ExploreFragment.kt index 7d8e326b8..618dc4763 100644 --- a/app/src/main/java/org/koitharu/kotatsu/explore/ui/ExploreFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/explore/ui/ExploreFragment.kt @@ -1,5 +1,6 @@ package org.koitharu.kotatsu.explore.ui +import android.content.DialogInterface import android.os.Bundle import android.view.LayoutInflater import android.view.MenuItem @@ -16,6 +17,7 @@ import coil.ImageLoader import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.R import org.koitharu.kotatsu.base.ui.BaseFragment +import org.koitharu.kotatsu.base.ui.dialog.TwoButtonsAlertDialog import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener import org.koitharu.kotatsu.base.ui.util.RecyclerViewOwner import org.koitharu.kotatsu.base.ui.util.ReversibleActionObserver @@ -76,6 +78,9 @@ class ExploreFragment : viewModel.onOpenManga.observe(viewLifecycleOwner, ::onOpenManga) viewModel.onActionDone.observe(viewLifecycleOwner, ReversibleActionObserver(binding.recyclerView)) viewModel.isGrid.observe(viewLifecycleOwner, ::onGridModeChanged) + viewModel.onShowSuggestionsTip.observe(viewLifecycleOwner) { + showSuggestionsTip() + } } override fun onDestroyView() { @@ -143,6 +148,19 @@ class ExploreFragment : activity?.invalidateOptionsMenu() } + private fun showSuggestionsTip() { + val listener = DialogInterface.OnClickListener { _, which -> + viewModel.respondSuggestionTip(which == DialogInterface.BUTTON_POSITIVE) + } + TwoButtonsAlertDialog.Builder(requireContext()) + .setIcon(R.drawable.ic_suggestion) + .setTitle(R.string.suggestions_enable_prompt) + .setPositiveButton(R.string.enable, listener) + .setNegativeButton(R.string.no_thanks, listener) + .create() + .show() + } + private inner class SourceMenuListener( private val sourceItem: ExploreItem.Source, ) : PopupMenu.OnMenuItemClickListener { diff --git a/app/src/main/java/org/koitharu/kotatsu/explore/ui/ExploreViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/explore/ui/ExploreViewModel.kt index 4b87979f7..42e3847aa 100644 --- a/app/src/main/java/org/koitharu/kotatsu/explore/ui/ExploreViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/explore/ui/ExploreViewModel.kt @@ -27,6 +27,8 @@ import org.koitharu.kotatsu.utils.SingleLiveEvent import org.koitharu.kotatsu.utils.asFlowLiveData import javax.inject.Inject +private const val TIP_SUGGESTIONS = "suggestions" + @HiltViewModel class ExploreViewModel @Inject constructor( private val settings: AppSettings, @@ -41,6 +43,7 @@ class ExploreViewModel @Inject constructor( val onOpenManga = SingleLiveEvent() val onActionDone = SingleLiveEvent() + val onShowSuggestionsTip = SingleLiveEvent() val isGrid = gridMode.asFlowLiveData(viewModelScope.coroutineContext) val content: LiveData> = isLoading.asFlow().flatMapLatest { loading -> @@ -51,6 +54,14 @@ class ExploreViewModel @Inject constructor( } }.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, listOf(ExploreItem.Loading)) + init { + launchJob(Dispatchers.Default) { + if (!settings.isSuggestionsEnabled && settings.isTipEnabled(TIP_SUGGESTIONS)) { + onShowSuggestionsTip.emitCall(Unit) + } + } + } + fun openRandom() { launchLoadingJob(Dispatchers.Default) { val manga = exploreRepository.findRandomManga(tagsLimit = 8) @@ -72,6 +83,11 @@ class ExploreViewModel @Inject constructor( settings.isSourcesGridMode = value } + fun respondSuggestionTip(isAccepted: Boolean) { + settings.isSuggestionsEnabled = isAccepted + settings.closeTip(TIP_SUGGESTIONS) + } + private fun createContentFlow() = settings.observe() .filter { it == AppSettings.KEY_SOURCES_HIDDEN || diff --git a/app/src/main/java/org/koitharu/kotatsu/suggestions/domain/TagsBlacklist.kt b/app/src/main/java/org/koitharu/kotatsu/suggestions/domain/TagsBlacklist.kt index baa88d725..691928fa8 100644 --- a/app/src/main/java/org/koitharu/kotatsu/suggestions/domain/TagsBlacklist.kt +++ b/app/src/main/java/org/koitharu/kotatsu/suggestions/domain/TagsBlacklist.kt @@ -1,6 +1,7 @@ package org.koitharu.kotatsu.suggestions.domain import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.utils.ext.almostEquals class TagsBlacklist( @@ -11,6 +12,9 @@ class TagsBlacklist( fun isNotEmpty() = tags.isNotEmpty() operator fun contains(manga: Manga): Boolean { + if (tags.isEmpty()) { + return false + } for (mangaTag in manga.tags) { for (tagTitle in tags) { if (mangaTag.title.almostEquals(tagTitle, threshold)) { @@ -20,4 +24,8 @@ class TagsBlacklist( } return false } + + operator fun contains(tag: MangaTag): Boolean = tags.any { + it.almostEquals(tag.title, threshold) + } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index aa2fb3888..63decf878 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -453,4 +453,5 @@ More Enable No thanks + Do you want to receive personalized manga suggestions? From 090f7a585821e9cd02f9992ba05b96905c83dc5f Mon Sep 17 00:00:00 2001 From: Koitharu Date: Wed, 10 May 2023 20:01:15 +0300 Subject: [PATCH 4/4] Update random manga selecting --- .../explore/domain/ExploreRepository.kt | 62 +++++++++++++------ .../kotatsu/explore/ui/ExploreViewModel.kt | 2 - 2 files changed, 42 insertions(+), 22 deletions(-) diff --git a/app/src/main/java/org/koitharu/kotatsu/explore/domain/ExploreRepository.kt b/app/src/main/java/org/koitharu/kotatsu/explore/domain/ExploreRepository.kt index 4bded9335..5fbce6f56 100644 --- a/app/src/main/java/org/koitharu/kotatsu/explore/domain/ExploreRepository.kt +++ b/app/src/main/java/org/koitharu/kotatsu/explore/domain/ExploreRepository.kt @@ -4,8 +4,12 @@ import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.history.domain.HistoryRepository import org.koitharu.kotatsu.parsers.model.Manga -import org.koitharu.kotatsu.parsers.model.SortOrder +import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.suggestions.domain.TagsBlacklist +import org.koitharu.kotatsu.utils.ext.almostEquals +import org.koitharu.kotatsu.utils.ext.asArrayList +import org.koitharu.kotatsu.utils.ext.printStackTraceDebug +import org.koitharu.kotatsu.utils.ext.runCatchingCancellable import javax.inject.Inject class ExploreRepository @Inject constructor( @@ -16,28 +20,46 @@ class ExploreRepository @Inject constructor( suspend fun findRandomManga(tagsLimit: Int): Manga { val blacklistTagRegex = TagsBlacklist(settings.suggestionsTagsBlacklist, 0.4f) - val allTags = historyRepository.getPopularTags(tagsLimit).filterNot { - it in blacklistTagRegex + val tags = historyRepository.getPopularTags(tagsLimit).mapNotNull { + if (it in blacklistTagRegex) null else it.title } - val tag = allTags.randomOrNull() - val source = checkNotNull(tag?.source ?: settings.getMangaSources(includeHidden = false).randomOrNull()) { - "No sources found" - } - val repo = mangaRepositoryFactory.create(source) - val list = repo.getList( - offset = 0, - sortOrder = if (SortOrder.UPDATED in repo.sortOrders) SortOrder.UPDATED else null, - tags = setOfNotNull(tag), - ).shuffled() - for (item in list) { - if (settings.isSuggestionsExcludeNsfw && item.isNsfw) { + val sources = settings.getMangaSources(includeHidden = false) + check(sources.isNotEmpty()) { "No sources available" } + for (i in 0..4) { + val list = getList(sources.random(), tags, blacklistTagRegex) + val manga = list.randomOrNull() ?: continue + val details = runCatchingCancellable { + mangaRepositoryFactory.create(manga.source).getDetails(manga) + }.getOrNull() ?: continue + if ((settings.isSuggestionsExcludeNsfw && details.isNsfw) || details in blacklistTagRegex) { continue } - if (item in blacklistTagRegex) { - continue - } - return item + return details } - return list.random() + throw NoSuchElementException() } + + private suspend fun getList( + source: MangaSource, + tags: List, + blacklist: TagsBlacklist, + ): List = runCatchingCancellable { + val repository = mangaRepositoryFactory.create(source) + val order = repository.sortOrders.random() + val availableTags = repository.getTags() + val tag = tags.firstNotNullOfOrNull { title -> + availableTags.find { x -> x.title.almostEquals(title, 0.4f) } + } + val list = repository.getList(0, setOfNotNull(tag), order).asArrayList() + if (settings.isSuggestionsExcludeNsfw) { + list.removeAll { it.isNsfw } + } + if (blacklist.isNotEmpty()) { + list.removeAll { manga -> manga in blacklist } + } + list.shuffle() + list + }.onFailure { + it.printStackTraceDebug() + }.getOrDefault(emptyList()) } diff --git a/app/src/main/java/org/koitharu/kotatsu/explore/ui/ExploreViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/explore/ui/ExploreViewModel.kt index 42e3847aa..d81472ba3 100644 --- a/app/src/main/java/org/koitharu/kotatsu/explore/ui/ExploreViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/explore/ui/ExploreViewModel.kt @@ -6,7 +6,6 @@ import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf @@ -96,7 +95,6 @@ class ExploreViewModel @Inject constructor( } .onStart { emit("") } .map { settings.getMangaSources(includeHidden = false) } - .distinctUntilChanged() .combine(gridMode) { content, grid -> buildList(content, grid) } private fun buildList(sources: List, isGrid: Boolean): List {