New suggestions algorithm
This commit is contained in:
@@ -9,6 +9,8 @@ import org.koitharu.kotatsu.utils.ext.iterator
|
||||
|
||||
fun Collection<Manga>.ids() = mapToSet { it.id }
|
||||
|
||||
fun Collection<Manga>.distinctById() = distinctBy { it.id }
|
||||
|
||||
fun Collection<ChapterListItem>.countChaptersByBranch(): Int {
|
||||
if (size <= 1) {
|
||||
return size
|
||||
|
||||
@@ -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<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
|
||||
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"
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
)
|
||||
|
||||
fun FavouriteManga.toManga() = manga.toManga(tags.toMangaTags())
|
||||
|
||||
fun Collection<FavouriteManga>.toMangaList() = map { it.toManga() }
|
||||
|
||||
@@ -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<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>> {
|
||||
val orderBy = getOrderBy(order)
|
||||
|
||||
|
||||
@@ -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<Manga> {
|
||||
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>> {
|
||||
return db.favouritesDao.observeAll(order)
|
||||
.mapItems { it.manga.toManga(it.tags.toMangaTags()) }
|
||||
.mapItems { it.toManga() }
|
||||
}
|
||||
|
||||
suspend fun getManga(categoryId: Long): List<Manga> {
|
||||
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>> {
|
||||
return db.favouritesDao.observeAll(categoryId, order)
|
||||
.mapItems { it.manga.toManga(it.tags.toMangaTags()) }
|
||||
.mapItems { it.toManga() }
|
||||
}
|
||||
|
||||
fun observeAll(categoryId: Long): Flow<List<Manga>> {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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<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)
|
||||
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 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()
|
||||
|
||||
@@ -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 <T> Collection<T>.filterToSet(predicate: (T) -> Boolean): Set<T> {
|
||||
fun <T> Sequence<T>.toListSorted(comparator: Comparator<T>): List<T> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 <T> Flow<T>.throttle(timeoutMillis: (T) -> Long): Flow<T> {
|
||||
fun <T> StateFlow<T?>.requireValue(): T = checkNotNull(value) {
|
||||
"StateFlow value is null"
|
||||
}
|
||||
|
||||
fun <T> Flow<Collection<T>>.flatten(): Flow<T> = flow {
|
||||
collect { value ->
|
||||
for (item in value) {
|
||||
emit(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
17
app/src/main/res/drawable-anydpi-v24/ic_stat_suggestion.xml
Normal file
17
app/src/main/res/drawable-anydpi-v24/ic_stat_suggestion.xml
Normal 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>
|
||||
BIN
app/src/main/res/drawable-hdpi/ic_stat_suggestion.png
Normal file
BIN
app/src/main/res/drawable-hdpi/ic_stat_suggestion.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 542 B |
BIN
app/src/main/res/drawable-mdpi/ic_stat_suggestion.png
Normal file
BIN
app/src/main/res/drawable-mdpi/ic_stat_suggestion.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 362 B |
BIN
app/src/main/res/drawable-xhdpi/ic_stat_suggestion.png
Normal file
BIN
app/src/main/res/drawable-xhdpi/ic_stat_suggestion.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 719 B |
BIN
app/src/main/res/drawable-xxhdpi/ic_stat_suggestion.png
Normal file
BIN
app/src/main/res/drawable-xxhdpi/ic_stat_suggestion.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 967 B |
@@ -448,4 +448,7 @@
|
||||
<string name="cancel_all">Cancel all</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="suggestion_manga">Suggestion: %s</string>
|
||||
<string name="suggestions_notifications_summary">Sometimes show notifications with suggested manga</string>
|
||||
<string name="more">More</string>
|
||||
</resources>
|
||||
|
||||
@@ -9,13 +9,22 @@
|
||||
android:layout="@layout/preference_toggle_header"
|
||||
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
|
||||
android:dependency="suggestions"
|
||||
android:key="suggestions_exclude_tags"
|
||||
android:summary="@string/suggestions_excluded_genres_summary"
|
||||
android:title="@string/suggestions_excluded_genres" />
|
||||
android:title="@string/suggestions_excluded_genres"
|
||||
app:allowDividerAbove="true" />
|
||||
|
||||
<SwitchPreferenceCompat
|
||||
android:defaultValue="false"
|
||||
android:dependency="suggestions"
|
||||
android:key="suggestions_exclude_nsfw"
|
||||
android:title="@string/exclude_nsfw_from_suggestions" />
|
||||
@@ -28,4 +37,4 @@
|
||||
android:summary="@string/suggestions_info"
|
||||
app:allowDividerAbove="true" />
|
||||
|
||||
</PreferenceScreen>
|
||||
</PreferenceScreen>
|
||||
|
||||
Reference in New Issue
Block a user