New suggestions algorithm

This commit is contained in:
Koitharu
2023-05-09 18:35:33 +03:00
parent 023605e246
commit 52655cad2c
18 changed files with 313 additions and 70 deletions

View File

@@ -9,6 +9,8 @@ import org.koitharu.kotatsu.utils.ext.iterator
fun Collection<Manga>.ids() = mapToSet { it.id } fun Collection<Manga>.ids() = mapToSet { it.id }
fun Collection<Manga>.distinctById() = distinctBy { it.id }
fun Collection<ChapterListItem>.countChaptersByBranch(): Int { fun Collection<ChapterListItem>.countChaptersByBranch(): Int {
if (size <= 1) { if (size <= 1) {
return size return size

View File

@@ -16,6 +16,7 @@ import org.koitharu.kotatsu.core.model.ZoomMode
import org.koitharu.kotatsu.core.network.DoHProvider import org.koitharu.kotatsu.core.network.DoHProvider
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.SortOrder 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.shelf.domain.ShelfSection
import org.koitharu.kotatsu.utils.ext.connectivityManager import org.koitharu.kotatsu.utils.ext.connectivityManager
import org.koitharu.kotatsu.utils.ext.filterToSet import org.koitharu.kotatsu.utils.ext.filterToSet
@@ -250,6 +251,18 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
val isSuggestionsExcludeNsfw: Boolean val isSuggestionsExcludeNsfw: Boolean
get() = prefs.getBoolean(KEY_SUGGESTIONS_EXCLUDE_NSFW, false) get() = prefs.getBoolean(KEY_SUGGESTIONS_EXCLUDE_NSFW, false)
val isSuggestionsNotificationAvailable: Boolean
get() = prefs.getBoolean(KEY_SUGGESTIONS_NOTIFICATIONS, true)
val suggestionsTagsBlacklist: Set<String>
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 val isReaderBarEnabled: Boolean
get() = prefs.getBoolean(KEY_READER_BAR, true) get() = prefs.getBoolean(KEY_READER_BAR, true)
@@ -279,6 +292,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
return policy.isNetworkAllowed(connectivityManager) return policy.isNetworkAllowed(connectivityManager)
} }
@Deprecated("")
fun getSuggestionsTagsBlacklistRegex(): Regex? { fun getSuggestionsTagsBlacklistRegex(): Regex? {
val string = prefs.getString(KEY_SUGGESTIONS_EXCLUDE_TAGS, null)?.trimEnd(' ', ',') val string = prefs.getString(KEY_SUGGESTIONS_EXCLUDE_TAGS, null)?.trimEnd(' ', ',')
if (string.isNullOrEmpty()) { if (string.isNullOrEmpty()) {
@@ -381,6 +395,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_SUGGESTIONS = "suggestions" const val KEY_SUGGESTIONS = "suggestions"
const val KEY_SUGGESTIONS_EXCLUDE_NSFW = "suggestions_exclude_nsfw" const val KEY_SUGGESTIONS_EXCLUDE_NSFW = "suggestions_exclude_nsfw"
const val KEY_SUGGESTIONS_EXCLUDE_TAGS = "suggestions_exclude_tags" const val KEY_SUGGESTIONS_EXCLUDE_TAGS = "suggestions_exclude_tags"
const val KEY_SUGGESTIONS_NOTIFICATIONS = "suggestions_notifications"
const val KEY_SHIKIMORI = "shikimori" const val KEY_SHIKIMORI = "shikimori"
const val KEY_ANILIST = "anilist" const val KEY_ANILIST = "anilist"
const val KEY_MAL = "mal" const val KEY_MAL = "mal"

View File

@@ -1,9 +1,11 @@
package org.koitharu.kotatsu.favourites.data package org.koitharu.kotatsu.favourites.data
import org.koitharu.kotatsu.core.db.entity.SortOrder 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.core.model.FavouriteCategory
import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.model.SortOrder
import java.util.* import java.util.Date
fun FavouriteCategoryEntity.toFavouriteCategory(id: Long = categoryId.toLong()) = FavouriteCategory( fun FavouriteCategoryEntity.toFavouriteCategory(id: Long = categoryId.toLong()) = FavouriteCategory(
id = id, id = id,
@@ -13,4 +15,8 @@ fun FavouriteCategoryEntity.toFavouriteCategory(id: Long = categoryId.toLong())
createdAt = Date(createdAt), createdAt = Date(createdAt),
isTrackingEnabled = track, isTrackingEnabled = track,
isVisibleInLibrary = isVisibleInLibrary, isVisibleInLibrary = isVisibleInLibrary,
) )
fun FavouriteManga.toManga() = manga.toManga(tags.toMangaTags())
fun Collection<FavouriteManga>.toMangaList() = map { it.toManga() }

View File

@@ -17,6 +17,10 @@ abstract class FavouritesDao {
@Query("SELECT * FROM favourites WHERE deleted_at = 0 GROUP BY manga_id ORDER BY created_at DESC") @Query("SELECT * FROM favourites WHERE deleted_at = 0 GROUP BY manga_id ORDER BY created_at DESC")
abstract suspend fun findAll(): List<FavouriteManga> abstract suspend fun findAll(): List<FavouriteManga>
@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<FavouriteManga>
fun observeAll(order: SortOrder): Flow<List<FavouriteManga>> { fun observeAll(order: SortOrder): Flow<List<FavouriteManga>> {
val orderBy = getOrderBy(order) val orderBy = getOrderBy(order)

View File

@@ -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.SortOrder
import org.koitharu.kotatsu.core.db.entity.toEntities import org.koitharu.kotatsu.core.db.entity.toEntities
import org.koitharu.kotatsu.core.db.entity.toEntity 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.core.model.FavouriteCategory
import org.koitharu.kotatsu.favourites.data.FavouriteCategoryEntity import org.koitharu.kotatsu.favourites.data.FavouriteCategoryEntity
import org.koitharu.kotatsu.favourites.data.FavouriteEntity import org.koitharu.kotatsu.favourites.data.FavouriteEntity
import org.koitharu.kotatsu.favourites.data.toFavouriteCategory 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.Manga
import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.tracker.work.TrackerNotificationChannels import org.koitharu.kotatsu.tracker.work.TrackerNotificationChannels
@@ -32,22 +32,27 @@ class FavouritesRepository @Inject constructor(
suspend fun getAllManga(): List<Manga> { suspend fun getAllManga(): List<Manga> {
val entities = db.favouritesDao.findAll() val entities = db.favouritesDao.findAll()
return entities.map { it.manga.toManga(it.tags.toMangaTags()) } return entities.toMangaList()
}
suspend fun getLastManga(limit: Int): List<Manga> {
val entities = db.favouritesDao.findLast(limit)
return entities.toMangaList()
} }
fun observeAll(order: SortOrder): Flow<List<Manga>> { fun observeAll(order: SortOrder): Flow<List<Manga>> {
return db.favouritesDao.observeAll(order) return db.favouritesDao.observeAll(order)
.mapItems { it.manga.toManga(it.tags.toMangaTags()) } .mapItems { it.toManga() }
} }
suspend fun getManga(categoryId: Long): List<Manga> { suspend fun getManga(categoryId: Long): List<Manga> {
val entities = db.favouritesDao.findAll(categoryId) 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<List<Manga>> { fun observeAll(categoryId: Long, order: SortOrder): Flow<List<Manga>> {
return db.favouritesDao.observeAll(categoryId, order) return db.favouritesDao.observeAll(categoryId, order)
.mapItems { it.manga.toManga(it.tags.toMangaTags()) } .mapItems { it.toManga() }
} }
fun observeAll(categoryId: Long): Flow<List<Manga>> { fun observeAll(categoryId: Long): Flow<List<Manga>> {

View File

@@ -1,7 +1,6 @@
package org.koitharu.kotatsu.suggestions.domain package org.koitharu.kotatsu.suggestions.domain
import androidx.room.withTransaction import androidx.room.withTransaction
import javax.inject.Inject
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import org.koitharu.kotatsu.core.db.MangaDatabase import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.db.entity.toEntities 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.parsers.model.Manga
import org.koitharu.kotatsu.suggestions.data.SuggestionEntity import org.koitharu.kotatsu.suggestions.data.SuggestionEntity
import org.koitharu.kotatsu.utils.ext.mapItems import org.koitharu.kotatsu.utils.ext.mapItems
import javax.inject.Inject
class SuggestionRepository @Inject constructor( class SuggestionRepository @Inject constructor(
private val db: MangaDatabase, private val db: MangaDatabase,

View File

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

View File

@@ -2,11 +2,15 @@ package org.koitharu.kotatsu.suggestions.ui
import android.app.NotificationChannel import android.app.NotificationChannel
import android.app.NotificationManager import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context import android.content.Context
import android.os.Build import android.os.Build
import androidx.annotation.FloatRange import androidx.annotation.FloatRange
import androidx.core.app.NotificationCompat 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.hilt.work.HiltWorker
import androidx.work.BackoffPolicy import androidx.work.BackoffPolicy
import androidx.work.Constraints import androidx.work.Constraints
@@ -20,39 +24,56 @@ import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager import androidx.work.WorkManager
import androidx.work.WorkerParameters import androidx.work.WorkerParameters
import androidx.work.workDataOf import androidx.work.workDataOf
import coil.ImageLoader
import coil.request.ImageRequest
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.async import kotlinx.coroutines.flow.map
import kotlinx.coroutines.awaitAll import kotlinx.coroutines.flow.take
import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.launch
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.distinctById
import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.prefs.AppSettings 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.history.domain.HistoryRepository
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.model.SortOrder 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.MangaSuggestion
import org.koitharu.kotatsu.suggestions.domain.SuggestionRepository 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.asArrayList
import org.koitharu.kotatsu.utils.ext.flatten
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
import org.koitharu.kotatsu.utils.ext.runCatchingCancellable 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 org.koitharu.kotatsu.utils.ext.trySetForeground
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import kotlin.math.pow import kotlin.math.pow
import kotlin.random.Random
@HiltWorker @HiltWorker
class SuggestionsWorker @AssistedInject constructor( class SuggestionsWorker @AssistedInject constructor(
@Assisted appContext: Context, @Assisted appContext: Context,
@Assisted params: WorkerParameters, @Assisted params: WorkerParameters,
private val coil: ImageLoader,
private val suggestionRepository: SuggestionRepository, private val suggestionRepository: SuggestionRepository,
private val historyRepository: HistoryRepository, private val historyRepository: HistoryRepository,
private val favouritesRepository: FavouritesRepository,
private val appSettings: AppSettings, private val appSettings: AppSettings,
private val mangaRepositoryFactory: MangaRepository.Factory, private val mangaRepositoryFactory: MangaRepository.Factory,
) : CoroutineWorker(appContext, params) { ) : CoroutineWorker(appContext, params) {
override suspend fun doWork(): Result { override suspend fun doWork(): Result {
trySetForeground()
val count = doWorkImpl() val count = doWorkImpl()
val outputData = workDataOf(DATA_COUNT to count) val outputData = workDataOf(DATA_COUNT to count)
return Result.success(outputData) return Result.success(outputData)
@@ -79,7 +100,6 @@ class SuggestionsWorker @AssistedInject constructor(
.setPriority(NotificationCompat.PRIORITY_MIN) .setPriority(NotificationCompat.PRIORITY_MIN)
.setCategory(NotificationCompat.CATEGORY_SERVICE) .setCategory(NotificationCompat.CATEGORY_SERVICE)
.setDefaults(0) .setDefaults(0)
.setColor(ContextCompat.getColor(applicationContext, R.color.blue_primary_dark))
.setSilent(true) .setSilent(true)
.setProgress(0, 0, true) .setProgress(0, 0, true)
.setSmallIcon(android.R.drawable.stat_notify_sync) .setSmallIcon(android.R.drawable.stat_notify_sync)
@@ -94,83 +114,185 @@ class SuggestionsWorker @AssistedInject constructor(
suggestionRepository.clear() suggestionRepository.clear()
return 0 return 0
} }
val blacklistTagRegex = appSettings.getSuggestionsTagsBlacklistRegex() val seed = (
val allTags = historyRepository.getPopularTags(TAGS_LIMIT).filterNot { historyRepository.getList(0, 20) +
blacklistTagRegex?.containsMatchIn(it.title) ?: false favouritesRepository.getLastManga(20)
} ).distinctById()
if (allTags.isEmpty()) { val sources = appSettings.getMangaSources(includeHidden = false)
if (seed.isEmpty() || sources.isEmpty()) {
return 0 return 0
} }
if (TAG in tags) { // not expedited val tagsBlacklist = TagsBlacklist(appSettings.suggestionsTagsBlacklist, 0.3f)
trySetForeground() val tags = seed.flatMap { it.tags.map { x -> x.title } }.takeMostFrequent(10)
}
val tagsBySources = allTags.groupBy { x -> x.source } val producer = channelFlow {
val dispatcher = Dispatchers.Default.limitedParallelism(MAX_PARALLELISM) for (it in sources.shuffled()) {
val rawResults = coroutineScope { launch {
tagsBySources.flatMap { (source, tags) -> send(getList(it, tags, tagsBlacklist))
val repo = mangaRepositoryFactory.tryCreate(source) ?: return@flatMap emptyList()
tags.map { tag ->
async(dispatcher) {
repo.getListSafe(tag)
}
} }
}.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()) { val suggestions = producer
return 0 .flatten()
} .take(MAX_RAW_RESULTS)
val suggestions = rawResults.distinctBy { manga -> .map { manga ->
manga.id MangaSuggestion(
}.map { manga -> manga = manga,
MangaSuggestion( relevance = computeRelevance(manga.tags, tags),
manga = manga, )
relevance = computeRelevance(manga.tags, allTags), }.toList()
) .sortedBy { it.relevance }
}.sortedBy { it.relevance }.take(LIMIT) .take(MAX_RESULTS)
suggestionRepository.replace(suggestions) 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 return suggestions.size
} }
private suspend fun getList(
source: MangaSource,
tags: List<String>,
blacklist: TagsBlacklist,
): List<Manga> = 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) @FloatRange(from = 0.0, to = 1.0)
private fun computeRelevance(mangaTags: Set<MangaTag>, allTags: List<MangaTag>): Float { private fun computeRelevance(mangaTags: Set<MangaTag>, allTags: List<String>): Float {
val maxWeight = (allTags.size + allTags.size + 1 - mangaTags.size) * mangaTags.size / 2.0 val maxWeight = (allTags.size + allTags.size + 1 - mangaTags.size) * mangaTags.size / 2.0
val weight = mangaTags.sumOf { tag -> val weight = mangaTags.sumOf { tag ->
val index = allTags.indexOf(tag) val index = allTags.indexOf(tag.title)
if (index < 0) 0 else allTags.size - index if (index < 0) 0 else allTags.size - index
} }
return (weight / maxWeight).pow(2.0).toFloat() 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 { companion object {
private const val TAG = "suggestions" private const val TAG = "suggestions"
private const val TAG_ONESHOT = "suggestions_oneshot" 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 DATA_COUNT = "count"
private const val WORKER_CHANNEL_ID = "suggestion_worker" 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 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) { fun setup(context: Context) {
val constraints = Constraints.Builder() val constraints = Constraints.Builder()

View File

@@ -1,5 +1,6 @@
package org.koitharu.kotatsu.utils.ext package org.koitharu.kotatsu.utils.ext
import androidx.collection.ArrayMap
import androidx.collection.ArraySet import androidx.collection.ArraySet
import java.util.Collections import java.util.Collections
@@ -45,3 +46,17 @@ inline fun <T> Collection<T>.filterToSet(predicate: (T) -> Boolean): Set<T> {
fun <T> Sequence<T>.toListSorted(comparator: Comparator<T>): List<T> { fun <T> Sequence<T>.toListSorted(comparator: Comparator<T>): List<T> {
return toMutableList().apply { sortWith(comparator) } return toMutableList().apply { sortWith(comparator) }
} }
fun <T> List<T>.takeMostFrequent(limit: Int): List<T> {
val map = ArrayMap<T, Int>(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)
}
}
}

View File

@@ -4,6 +4,7 @@ import android.os.SystemClock
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onCompletion import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
@@ -43,3 +44,11 @@ fun <T> Flow<T>.throttle(timeoutMillis: (T) -> Long): Flow<T> {
fun <T> StateFlow<T?>.requireValue(): T = checkNotNull(value) { fun <T> StateFlow<T?>.requireValue(): T = checkNotNull(value) {
"StateFlow value is null" "StateFlow value is null"
} }
fun <T> Flow<Collection<T>>.flatten(): Flow<T> = flow {
collect { value ->
for (item in value) {
emit(item)
}
}
}

View File

@@ -1,5 +1,7 @@
package org.koitharu.kotatsu.utils.ext package org.koitharu.kotatsu.utils.ext
import androidx.annotation.FloatRange
import org.koitharu.kotatsu.parsers.util.levenshteinDistance
import java.util.UUID import java.util.UUID
inline fun String?.ifNullOrEmpty(defaultValue: () -> String): String { inline fun String?.ifNullOrEmpty(defaultValue: () -> String): String {
@@ -21,3 +23,14 @@ fun String.toUUIDOrNull(): UUID? = try {
e.printStackTraceDebug() e.printStackTraceDebug()
null 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
}

View File

@@ -0,0 +1,17 @@
<vector
xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="#FFFFFF"
android:viewportWidth="24"
android:viewportHeight="24">
<group
android:scaleX="1.0036364"
android:scaleY="1.0036364"
android:translateX="-0.043636363"
android:translateY="-0.043636363">
<path
android:fillColor="#FF000000"
android:pathData="M8.6,1.535L6.711,4.715L3.1,5.525L3.439,9.207L1,11.994L3.439,14.773L3.1,18.465L6.711,19.283L8.6,22.465L12,20.994L15.4,22.457L17.289,19.273L20.9,18.457L20.561,14.773L23,11.994L20.561,9.217L20.9,5.535L17.289,4.715L15.4,1.535L12,2.996L8.6,1.535zM10.068,8.521L13.932,8.521C14.357,8.521 14.705,8.866 14.705,9.295L14.705,15.479L12,14.318L9.295,15.479L9.295,9.295A0.773,0.773 0,0 1,10.068 8.521z" />
</group>
</vector>

Binary file not shown.

After

Width:  |  Height:  |  Size: 542 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 362 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 719 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 967 B

View File

@@ -448,4 +448,7 @@
<string name="cancel_all">Cancel all</string> <string name="cancel_all">Cancel all</string>
<string name="downloads_wifi_only">Download only via Wi-Fi</string> <string name="downloads_wifi_only">Download only via Wi-Fi</string>
<string name="downloads_wifi_only_summary">Stop downloading when switching to a mobile network</string> <string name="downloads_wifi_only_summary">Stop downloading when switching to a mobile network</string>
<string name="suggestion_manga">Suggestion: %s</string>
<string name="suggestions_notifications_summary">Sometimes show notifications with suggested manga</string>
<string name="more">More</string>
</resources> </resources>

View File

@@ -9,13 +9,22 @@
android:layout="@layout/preference_toggle_header" android:layout="@layout/preference_toggle_header"
android:title="@string/suggestions_enable" /> android:title="@string/suggestions_enable" />
<SwitchPreferenceCompat
android:defaultValue="true"
android:dependency="suggestions"
android:key="suggestions_notifications"
android:summary="@string/suggestions_notifications_summary"
android:title="@string/notifications_enable" />
<org.koitharu.kotatsu.settings.utils.MultiAutoCompleteTextViewPreference <org.koitharu.kotatsu.settings.utils.MultiAutoCompleteTextViewPreference
android:dependency="suggestions" android:dependency="suggestions"
android:key="suggestions_exclude_tags" android:key="suggestions_exclude_tags"
android:summary="@string/suggestions_excluded_genres_summary" android:summary="@string/suggestions_excluded_genres_summary"
android:title="@string/suggestions_excluded_genres" /> android:title="@string/suggestions_excluded_genres"
app:allowDividerAbove="true" />
<SwitchPreferenceCompat <SwitchPreferenceCompat
android:defaultValue="false"
android:dependency="suggestions" android:dependency="suggestions"
android:key="suggestions_exclude_nsfw" android:key="suggestions_exclude_nsfw"
android:title="@string/exclude_nsfw_from_suggestions" /> android:title="@string/exclude_nsfw_from_suggestions" />
@@ -28,4 +37,4 @@
android:summary="@string/suggestions_info" android:summary="@string/suggestions_info"
app:allowDividerAbove="true" /> app:allowDividerAbove="true" />
</PreferenceScreen> </PreferenceScreen>