From b139f498a385ba4002406a0c95204103783b7c3b Mon Sep 17 00:00:00 2001 From: Kom1ch1 <267495674+Kom1ch1@users.noreply.github.com> Date: Thu, 30 Jul 2026 04:07:17 +0800 Subject: [PATCH 1/3] feat: add recommendation metadata and candidate infrastructure --- .../RecommendationExposureStore.kt | 102 ++++++++ .../recommendation/RecommendationMetadata.kt | 229 +++++++++++++++++ .../recommendation/RecommendationModels.kt | 123 ++++++++++ .../recommendation/RecommendationRanking.kt | 193 +++++++++++++++ .../RecommendationRequestScheduler.kt | 125 ++++++++++ .../recommendation/RecommendationSampler.kt | 89 +++++++ .../yokai/data/manga/MangaRepositoryImpl.kt | 5 + .../yokai/domain/manga/MangaRepository.kt | 1 + .../yokai/domain/manga/interactor/GetManga.kt | 2 + .../RecommendationMetadataTest.kt | 146 +++++++++++ .../RecommendationRankingTest.kt | 230 ++++++++++++++++++ .../RecommendationRequestSchedulerTest.kt | 132 ++++++++++ .../tachiyomi/network/OkHttpExtensions.kt | 9 +- .../kanade/tachiyomi/network/HttpException.kt | 6 +- .../sqldelight/tachiyomi/data/mangas.sq | 9 + 15 files changed, 1397 insertions(+), 4 deletions(-) create mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/recommendation/RecommendationExposureStore.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/recommendation/RecommendationMetadata.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/recommendation/RecommendationModels.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/recommendation/RecommendationRanking.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/recommendation/RecommendationRequestScheduler.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/recommendation/RecommendationSampler.kt create mode 100644 app/src/test/java/eu/kanade/tachiyomi/data/recommendation/RecommendationMetadataTest.kt create mode 100644 app/src/test/java/eu/kanade/tachiyomi/data/recommendation/RecommendationRankingTest.kt create mode 100644 app/src/test/java/eu/kanade/tachiyomi/data/recommendation/RecommendationRequestSchedulerTest.kt diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/recommendation/RecommendationExposureStore.kt b/app/src/main/java/eu/kanade/tachiyomi/data/recommendation/RecommendationExposureStore.kt new file mode 100644 index 0000000000..9b0da6ceae --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/recommendation/RecommendationExposureStore.kt @@ -0,0 +1,102 @@ +package eu.kanade.tachiyomi.data.recommendation + +/** Lightweight, process-local exposure history. It never retains manga or source objects. */ +internal class RecommendationExposureStore( + private val nowMillis: () -> Long = System::currentTimeMillis, + private val capacity: Int = DEFAULT_CAPACITY, + private val ttlMillis: Long = DEFAULT_TTL_MILLIS, +) { + private val buckets = mutableMapOf>() + private var nextSequence = 0L + + init { + require(capacity > 0) + require(ttlMillis > 0L) + } + + @Synchronized + fun snapshot(sourceId: Long, targetKey: String): RecommendationExposureSnapshot { + val now = nowMillis() + val records = activeRecords(BucketKey(sourceId, targetKey), now) + val exposures = buildMap { + records.forEach { record -> + record.identityKeys.forEach { key -> + val previous = get(key) + if (previous == null || previous.sequence < record.sequence) { + put(key, RecommendationExposure(record.shownAtMillis, record.sequence)) + } + } + } + } + return RecommendationExposureSnapshot(exposures) + } + + @Synchronized + fun record( + sourceId: Long, + targetKey: String, + cards: Collection, + ) { + val now = nowMillis() + val key = BucketKey(sourceId, targetKey) + val records = activeRecords(key, now).toMutableList() + cards.forEach { card -> + if (card.sourceId != sourceId) return@forEach + val identityKeys = card.identity.exposureKeys.ifEmpty { setOf(card.identity.exposureKey) } + records.removeAll { existing -> existing.identityKeys.any(identityKeys::contains) } + records += ExposureRecord(identityKeys, now, nextSequence++) + } + while (records.size > capacity) records.removeAt(0) + if (records.isEmpty()) buckets.remove(key) else buckets[key] = records + } + + @Synchronized + fun clear(sourceId: Long, targetKey: String) { + buckets.remove(BucketKey(sourceId, targetKey)) + } + + @Synchronized + fun clearSource(sourceId: Long) { + buckets.keys.removeAll { it.sourceId == sourceId } + } + + private fun activeRecords(key: BucketKey, now: Long): List { + val records = buckets[key] ?: return emptyList() + records.removeAll { now - it.shownAtMillis >= ttlMillis } + if (records.isEmpty()) buckets.remove(key) + return records + } + + private data class BucketKey(val sourceId: Long, val targetKey: String) + + private data class ExposureRecord( + val identityKeys: Set, + val shownAtMillis: Long, + val sequence: Long, + ) + + internal companion object { + const val DEFAULT_CAPACITY = 40 + const val DEFAULT_TTL_MILLIS = 30 * 60 * 1_000L + } +} + +internal data class RecommendationExposure( + val shownAtMillis: Long, + val sequence: Long, +) + +internal data class RecommendationExposureSnapshot( + private val exposures: Map, +) { + fun wasShown(identityKeys: Set): Boolean = identityKeys.any(exposures::containsKey) + + fun lastShown(identityKeys: Set): RecommendationExposure? { + return identityKeys.mapNotNull(exposures::get) + .maxWithOrNull(compareBy(RecommendationExposure::shownAtMillis, RecommendationExposure::sequence)) + } + + internal companion object { + val EMPTY = RecommendationExposureSnapshot(emptyMap()) + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/recommendation/RecommendationMetadata.kt b/app/src/main/java/eu/kanade/tachiyomi/data/recommendation/RecommendationMetadata.kt new file mode 100644 index 0000000000..6e33366f2e --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/recommendation/RecommendationMetadata.kt @@ -0,0 +1,229 @@ +package eu.kanade.tachiyomi.data.recommendation + +import eu.kanade.tachiyomi.source.model.Filter +import eu.kanade.tachiyomi.source.model.FilterList +import eu.kanade.tachiyomi.source.model.SManga +import java.net.URI +import java.text.Normalizer +import java.util.Locale + +internal object RecommendationMetadata { + private val creatorSeparator = Regex("""\s*(?:,|;|\r?\n|\u3001|\uFF0C|\uFF1B)\s*""") + private val tagSeparator = Regex("""\s*(?:,|;|\r?\n|\t|\u3001|\uFF0C|\uFF1B|\|)\s*""") + private val punctuationOrWhitespace = Regex("""[\p{P}\p{S}\s]+""") + private val whitespace = Regex("""\s+""") + private val explicitGroupLine = Regex( + """^\s*(?:circle|group|\u793E\u56E2|\u793E\u5718|\u30B5\u30FC\u30AF\u30EB)\s*[:\uFF1A]\s*(.+?)\s*$""", + RegexOption.IGNORE_CASE, + ) + private val trackingQueryNames = setOf("gclid", "fbclid") + + fun normalize(value: String): String { + return Normalizer.normalize(value, Normalizer.Form.NFKC) + .lowercase(Locale.ROOT) + .replace(punctuationOrWhitespace, " ") + .trim() + .replace(whitespace, " ") + } + + fun extractCreators(manga: SManga, groups: Collection = emptyList()): Set { + val creators = linkedMapOf() + addCreators(creators, manga.author, CreatorRole.AUTHOR) + addCreators(creators, manga.artist, CreatorRole.ARTIST) + (groups + extractExplicitGroups(manga.description)).forEach { + addCreators(creators, it, CreatorRole.GROUP) + } + return creators.values.mapTo(linkedSetOf()) { creator -> + CreatorIdentity(creator.displayName, creator.normalizedName, creator.roles) + } + } + + fun extractTags(manga: SManga): Set = extractTagIdentities(manga).mapTo(linkedSetOf()) { + it.normalizedName + } + + fun extractTagIdentities(manga: SManga): List { + val seen = hashSetOf() + return tagSeparator.split(manga.genre.orEmpty()) + .asSequence() + .map(String::trim) + .filter(String::isNotEmpty) + .mapNotNull { displayName -> + val normalized = normalize(displayName) + normalized.takeIf { it.isNotEmpty() && seen.add(it) } + ?.let { TagIdentity(displayName, it) } + } + .toList() + } + + fun extractExplicitGroups(description: String?): List { + return description.orEmpty().lineSequence() + .mapNotNull { line -> explicitGroupLine.matchEntire(line)?.groupValues?.get(1) } + .filter(String::isNotBlank) + .toList() + } + + fun identity( + sourceId: Long, + manga: SManga, + series: Collection = emptyList(), + ): RecommendationIdentity { + val url = normalizeUrl(manga.url) + val creators = extractCreators(manga).mapTo(linkedSetOf(), CreatorIdentity::normalizedName) + val exactTitle = normalize(manga.title) + return RecommendationIdentity( + sourceId = sourceId, + canonicalUrl = url.canonical, + urlHost = url.host, + urlPathAndQuery = url.pathAndQuery, + exactTitle = exactTitle, + // The generic implementation deliberately avoids stripping volume, language, or + // edition markers. Any broader identity needs independent series or cover evidence. + baseTitle = exactTitle, + creators = creators, + cover = manga.thumbnail_url?.takeIf(String::isNotBlank)?.let(::normalizeUrl)?.canonical, + series = series.mapTo(linkedSetOf(), ::normalize).filterTo(linkedSetOf(), String::isNotEmpty), + ) + } + + fun card( + sourceId: Long, + manga: SManga, + favorite: Boolean = false, + localId: Long? = null, + series: Collection = emptyList(), + ): RecommendationCard { + return RecommendationCard( + manga = manga, + sourceId = sourceId, + identity = identity(sourceId, manga, series), + creators = extractCreators(manga), + tags = extractTags(manga), + favorite = favorite, + localId = localId, + ) + } + + fun sameWork(left: RecommendationIdentity, right: RecommendationIdentity): Boolean { + if (left.sourceId != right.sourceId) return false + val compatibleHosts = left.urlHost == null || right.urlHost == null || left.urlHost == right.urlHost + if ( + compatibleHosts && + left.urlPathAndQuery.isNotBlank() && + left.urlPathAndQuery == right.urlPathAndQuery + ) { + return true + } + val sharedCreators = left.creators intersect right.creators + if (left.exactTitle.isNotBlank() && left.exactTitle == right.exactTitle && sharedCreators.isNotEmpty()) { + return true + } + if (left.baseTitle.isBlank() || left.baseTitle != right.baseTitle || sharedCreators.isEmpty()) return false + val sameSeries = left.series.isNotEmpty() && (left.series intersect right.series).isNotEmpty() + val sameCover = left.cover != null && left.cover == right.cover + return sameSeries || sameCover + } + + fun creatorsOverlap(left: Set, right: Set): Boolean { + if (left.isEmpty() || right.isEmpty()) return false + val names = left.mapTo(hashSetOf(), CreatorIdentity::normalizedName) + return right.any { it.normalizedName in names } + } + + /** + * Applies an exact tag value to generic source filters without guessing filter semantics. + * Text and sort filters are intentionally left untouched. + */ + fun applyExactTagFilter(filters: FilterList, rawTag: String): Boolean { + val target = normalize(rawTag) + if (target.isEmpty()) return false + return filters.any { applyExactTagFilter(it, target) } + } + + private fun applyExactTagFilter(filter: Filter<*>, target: String): Boolean { + return when (filter) { + is Filter.CheckBox -> { + if (normalize(filter.name) == target) { + filter.state = true + true + } else { + false + } + } + is Filter.TriState -> { + if (normalize(filter.name) == target) { + filter.state = Filter.TriState.STATE_INCLUDE + true + } else { + false + } + } + is Filter.Select<*> -> { + val index = filter.values.indexOfFirst { normalize(it.toString()) == target } + if (index >= 0) { + filter.state = index + true + } else { + false + } + } + is Filter.Group<*> -> filter.state.filterIsInstance>() + .any { applyExactTagFilter(it, target) } + else -> false + } + } + + private fun addCreators( + result: MutableMap, + rawNames: String?, + role: CreatorRole, + ) { + creatorSeparator.split(rawNames.orEmpty()).forEach { rawName -> + val displayName = rawName.trim() + val normalizedName = normalize(displayName) + if (normalizedName.isEmpty()) return@forEach + val creator = result.getOrPut(normalizedName) { + MutableCreator(displayName, normalizedName) + } + creator.roles += role + } + } + + private fun normalizeUrl(rawUrl: String): NormalizedUrl { + val trimmed = Normalizer.normalize(rawUrl.trim(), Normalizer.Form.NFKC) + if (trimmed.isEmpty()) return NormalizedUrl("", null, "") + val protocolRelative = trimmed.startsWith("//") + val parsed = runCatching { URI(if (protocolRelative) "https:$trimmed" else trimmed) }.getOrNull() + if (parsed == null) { + val fallback = trimmed.substringBefore('#') + return NormalizedUrl(fallback, null, fallback) + } + val host = parsed.host?.lowercase(Locale.ROOT) + val path = parsed.rawPath.orEmpty().ifBlank { "/" } + val query = parsed.rawQuery + ?.split('&') + ?.filter(String::isNotBlank) + ?.filterNot { part -> + val name = part.substringBefore('=').lowercase(Locale.ROOT) + name.startsWith("utm_") || name in trackingQueryNames + } + ?.sorted() + ?.joinToString("&") + .orEmpty() + val pathAndQuery = path + query.takeIf(String::isNotEmpty)?.let { "?$it" }.orEmpty() + val canonical = host?.let { "//$it$pathAndQuery" } ?: pathAndQuery + return NormalizedUrl(canonical, host, pathAndQuery) + } + + private data class MutableCreator( + val displayName: String, + val normalizedName: String, + val roles: MutableSet = linkedSetOf(), + ) + + private data class NormalizedUrl( + val canonical: String, + val host: String?, + val pathAndQuery: String, + ) +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/recommendation/RecommendationModels.kt b/app/src/main/java/eu/kanade/tachiyomi/data/recommendation/RecommendationModels.kt new file mode 100644 index 0000000000..34f9927687 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/recommendation/RecommendationModels.kt @@ -0,0 +1,123 @@ +package eu.kanade.tachiyomi.data.recommendation + +import eu.kanade.tachiyomi.source.model.SManga + +data class RecommendationRows( + val creatorWorks: List = emptyList(), + val similarManga: List = emptyList(), +) + +data class RecommendationCard( + val manga: SManga, + val sourceId: Long, + val identity: RecommendationIdentity, + val creators: Set = emptySet(), + val tags: Set = emptySet(), + val favorite: Boolean = false, + val localId: Long? = null, +) { + init { + require(identity.sourceId == sourceId) + } +} + +enum class CreatorRole { + AUTHOR, + ARTIST, + GROUP, +} + +data class CreatorIdentity( + val displayName: String, + val normalizedName: String, + val roles: Set, +) + +/** A deliberately conservative identity which is always isolated by source ID. */ +data class RecommendationIdentity( + val sourceId: Long, + val canonicalUrl: String, + val urlHost: String?, + val urlPathAndQuery: String, + val exactTitle: String, + val baseTitle: String, + val creators: Set, + val cover: String?, + val series: Set = emptySet(), +) { + val exposureKeys: Set + get() = buildSet { + if (urlPathAndQuery.isNotBlank()) add("$sourceId:url:$canonicalUrl") + if (exactTitle.isNotBlank()) { + creators.forEach { add("$sourceId:title:$exactTitle:$it") } + } + if (baseTitle.isNotBlank()) { + creators.forEach { creator -> + series.forEach { add("$sourceId:base:$baseTitle:$creator:series:$it") } + cover?.let { add("$sourceId:base:$baseTitle:$creator:cover:$it") } + } + } + } + + val exposureKey: String + get() = exposureKeys.minOrNull() ?: "$sourceId:unknown:$exactTitle" +} + +internal data class TagIdentity( + val displayName: String, + val normalizedName: String, +) + +internal data class TagProfile( + val allTags: Set, + val coreTags: Set, + val secondaryTags: Set, + val routeIdentities: List, +) { + init { + require(coreTags.size <= MAX_CORE_TAGS) + require(allTags.containsAll(coreTags)) + require(allTags.containsAll(secondaryTags)) + require((coreTags intersect secondaryTags).isEmpty()) + } + + internal companion object { + const val MAX_CORE_TAGS = 4 + } +} + +internal enum class RecommendationRoute(val weight: Double) { + ANILIST(1.0), + LOCAL(0.8), + SOURCE_FILTER(0.8), + SOURCE_SEARCH(0.6), +} + +internal data class RecommendationEvidence( + val ranks: Map, + val authoritative: Boolean = false, +) { + init { + require(ranks.values.all { it >= 0 }) + } + + fun merge(other: RecommendationEvidence): RecommendationEvidence { + val merged = (ranks.keys + other.ranks.keys).associateWith { route -> + minOf(ranks[route] ?: Int.MAX_VALUE, other.ranks[route] ?: Int.MAX_VALUE) + } + return RecommendationEvidence(merged, authoritative || other.authoritative) + } +} + +internal data class RecommendationCandidate( + val card: RecommendationCard, + val evidence: RecommendationEvidence, +) + +internal data class RankedRecommendation( + val card: RecommendationCard, + val tags: Set, + val evidence: RecommendationEvidence, + val contentScore: Double, + val score: Double, +) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/recommendation/RecommendationRanking.kt b/app/src/main/java/eu/kanade/tachiyomi/data/recommendation/RecommendationRanking.kt new file mode 100644 index 0000000000..dbd1718d71 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/recommendation/RecommendationRanking.kt @@ -0,0 +1,193 @@ +package eu.kanade.tachiyomi.data.recommendation + +import kotlin.math.ln + +internal object RecommendationRanking { + private const val RRF_K = 60.0 + private const val DEFAULT_MINIMUM_SCORE = 0.20 + private const val RARE_TAG_MAX_SHARE = 0.10 + + fun buildTagProfile( + targetTags: Collection, + documentFrequency: Map, + documentCount: Int, + routeIdentities: List = targetTags.map { TagIdentity(it, RecommendationMetadata.normalize(it)) }, + ): TagProfile { + require(documentCount >= 0) + val normalizedFrequencies = documentFrequency.entries.associate { (tag, count) -> + RecommendationMetadata.normalize(tag) to count.coerceAtLeast(0) + } + val allTags = targetTags.asSequence() + .map(RecommendationMetadata::normalize) + .filter(String::isNotEmpty) + .distinct() + .toCollection(linkedSetOf()) + val originalOrder = allTags.withIndex().associate { it.value to it.index } + val coreTags = allTags.sortedWith( + compareByDescending { tagWeight(it, normalizedFrequencies, documentCount) } + .thenBy { originalOrder.getValue(it) }, + ) + .take(TagProfile.MAX_CORE_TAGS) + .toCollection(linkedSetOf()) + val secondaryTags = allTags.filterNotTo(linkedSetOf(), coreTags::contains) + val routesByTag = routeIdentities.asSequence() + .map { TagIdentity(it.displayName, RecommendationMetadata.normalize(it.normalizedName)) } + .filter { it.normalizedName in coreTags } + .distinctBy(TagIdentity::normalizedName) + .associateBy(TagIdentity::normalizedName) + val routes = coreTags.mapNotNull(routesByTag::get) + return TagProfile(allTags, coreTags, secondaryTags, routes) + } + + fun rankSimilar( + profile: TagProfile, + candidates: Collection, + documentFrequency: Map, + documentCount: Int, + minimumScore: Double = DEFAULT_MINIMUM_SCORE, + ): List { + require(documentCount >= 0) + require(minimumScore in 0.0..1.0) + val normalizedFrequencies = documentFrequency.entries.associate { (tag, count) -> + RecommendationMetadata.normalize(tag) to count.coerceAtLeast(0) + } + val distinct = mutableListOf() + candidates.forEach { candidate -> + val duplicateIndex = distinct.indexOfFirst { + RecommendationMetadata.sameWork(it.card.identity, candidate.card.identity) + } + if (duplicateIndex < 0) { + distinct += candidate + } else { + val existing = distinct[duplicateIndex] + distinct[duplicateIndex] = existing.copy(evidence = existing.evidence.merge(candidate.evidence)) + } + } + return distinct.mapNotNull { candidate -> + val tags = candidate.card.tags + if (!isReliable(profile, tags, candidate.evidence, normalizedFrequencies, documentCount)) { + return@mapNotNull null + } + val contentScore = contentScore(profile, tags, normalizedFrequencies, documentCount) + val rrf = normalizedRrf(candidate.evidence) + val score = 0.60 * contentScore + 0.40 * rrf + score.takeIf { it >= minimumScore }?.let { + RankedRecommendation(candidate.card, tags, candidate.evidence, contentScore, score) + } + }.sortedWith( + compareByDescending(RankedRecommendation::score) + .thenBy { it.card.identity.exposureKey }, + ) + } + + fun weightedCoverage( + targetTags: Set, + candidateTags: Set, + documentFrequency: Map, + documentCount: Int, + ): Double { + if (targetTags.isEmpty()) return 0.0 + val total = targetTags.sumOf { tagWeight(it, documentFrequency, documentCount) } + if (total <= 0.0) return 0.0 + val matched = (targetTags intersect candidateTags).sumOf { + tagWeight(it, documentFrequency, documentCount) + } + return (matched / total).coerceIn(0.0, 1.0) + } + + fun weightedJaccard( + left: Set, + right: Set, + documentFrequency: Map, + documentCount: Int, + ): Double { + val union = left union right + if (union.isEmpty()) return 0.0 + val intersectionWeight = (left intersect right).sumOf { + tagWeight(it, documentFrequency, documentCount) + } + val unionWeight = union.sumOf { tagWeight(it, documentFrequency, documentCount) } + return if (unionWeight <= 0.0) 0.0 else (intersectionWeight / unionWeight).coerceIn(0.0, 1.0) + } + + fun contentScore( + profile: TagProfile, + candidateTags: Set, + documentFrequency: Map, + documentCount: Int, + ): Double { + val coverage = weightedCoverage(profile.coreTags, candidateTags, documentFrequency, documentCount) + val jaccard = weightedJaccard(profile.coreTags, candidateTags, documentFrequency, documentCount) + val secondaryBonus = weightedCoverage( + profile.secondaryTags, + candidateTags, + documentFrequency, + documentCount, + ) + return (0.70 * coverage + 0.20 * jaccard + 0.10 * secondaryBonus).coerceIn(0.0, 1.0) + } + + fun normalizedRrf(evidence: RecommendationEvidence): Double { + if (evidence.ranks.isEmpty()) return 0.0 + val actual = evidence.ranks.entries.sumOf { (route, rank) -> route.weight / (RRF_K + rank + 1.0) } + val ideal = evidence.ranks.keys.sumOf { route -> route.weight / (RRF_K + 1.0) } + return if (ideal <= 0.0) 0.0 else (actual / ideal).coerceIn(0.0, 1.0) + } + + private fun isReliable( + profile: TagProfile, + candidateTags: Set, + evidence: RecommendationEvidence, + documentFrequency: Map, + documentCount: Int, + ): Boolean { + if (evidence.authoritative) { + val isStructuredFilter = RecommendationRoute.SOURCE_FILTER in evidence.ranks + if (!isStructuredFilter || candidateTags.isEmpty()) return true + return (profile.allTags intersect candidateTags).isNotEmpty() + } + if (profile.coreTags.isEmpty() || candidateTags.isEmpty()) return false + val shared = profile.coreTags intersect candidateTags + if (shared.size >= 2) return true + if (profile.coreTags.size == 1 && shared.size == 1) return true + if (shared.size != 1 || documentCount <= 0) return false + val tag = shared.single() + val share = (documentFrequency[tag] ?: documentCount).toDouble() / documentCount.toDouble() + val coverage = weightedCoverage(profile.coreTags, candidateTags, documentFrequency, documentCount) + return share <= RARE_TAG_MAX_SHARE && coverage >= 0.25 + } + + private fun tagWeight( + tag: String, + documentFrequency: Map, + documentCount: Int, + ): Double { + if (documentCount <= 0) return 1.0 + val frequency = (documentFrequency[tag] ?: 0).coerceIn(0, documentCount) + return ln((documentCount + 1.0) / (frequency + 1.0)) + 1.0 + } +} + +internal object RecommendationCreators { + fun selectWorks( + target: RecommendationCard, + candidates: Collection, + excluded: Collection = emptyList(), + maxResults: Int = 10, + ): List { + if (maxResults <= 0 || target.creators.isEmpty()) return emptyList() + return candidates.asSequence() + .filter { it.sourceId == target.sourceId } + .filter { RecommendationMetadata.creatorsOverlap(target.creators, it.creators) } + .filterNot { RecommendationMetadata.sameWork(target.identity, it.identity) } + .filterNot { candidate -> excluded.any { RecommendationMetadata.sameWork(candidate.identity, it) } } + .distinctBy { it.identity.exposureKey } + .sortedWith( + compareByDescending { it.favorite } + .thenBy { it.identity.exactTitle } + .thenBy { it.identity.exposureKey }, + ) + .take(maxResults) + .toList() + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/recommendation/RecommendationRequestScheduler.kt b/app/src/main/java/eu/kanade/tachiyomi/data/recommendation/RecommendationRequestScheduler.kt new file mode 100644 index 0000000000..db1cf9dd6b --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/recommendation/RecommendationRequestScheduler.kt @@ -0,0 +1,125 @@ +package eu.kanade.tachiyomi.data.recommendation + +import eu.kanade.tachiyomi.network.HttpException +import kotlinx.coroutines.delay +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit +import java.time.ZonedDateTime +import java.time.format.DateTimeFormatter +import java.util.concurrent.ConcurrentHashMap + +/** Serializes recommendation-only requests without touching a source's shared HTTP client. */ +internal class RecommendationRequestScheduler( + private val minimumIntervalMillis: Long = DEFAULT_MINIMUM_INTERVAL_MILLIS, + private val delayMillis: suspend (Long) -> Unit = { delay(it) }, + private val monotonicNowNanos: () -> Long = System::nanoTime, + private val wallNowMillis: () -> Long = System::currentTimeMillis, +) { + private val states = ConcurrentHashMap() + + init { + require(minimumIntervalMillis >= DEFAULT_MINIMUM_INTERVAL_MILLIS) + } + + suspend fun execute( + sourceId: Long, + block: suspend () -> T, + ): RecommendationRequestResult { + val state = states.computeIfAbsent(sourceId) { SourceState() } + cooldownUntil(sourceId)?.let { return RecommendationRequestResult.RateLimited(it) } + return state.gate.withPermit { + cooldownUntil(sourceId)?.let { return@withPermit RecommendationRequestResult.RateLimited(it) } + val nowNanos = monotonicNowNanos() + val waitNanos = state.nextStartNanos?.let { (it - nowNanos).coerceAtLeast(0L) } ?: 0L + if (waitNanos > 0L) delayMillis(nanosToCeilMillis(waitNanos)) + val startedAt = maxOf(monotonicNowNanos(), state.nextStartNanos ?: Long.MIN_VALUE) + state.nextStartNanos = saturatingAdd(startedAt, millisToNanos(minimumIntervalMillis)) + try { + RecommendationRequestResult.Success(block()).also { recordSuccess(sourceId) } + } catch (error: HttpException) { + if (error.code != HTTP_TOO_MANY_REQUESTS) throw error + RecommendationRequestResult.RateLimited(record429(sourceId, error.retryAfter)) + } + } + } + + /** Records a 429 once. Callers decide when a new page or explicit refresh may try again. */ + fun record429( + sourceId: Long, + retryAfter: String? = null, + nowMillis: Long = wallNowMillis(), + ): Long { + val state = states.computeIfAbsent(sourceId) { SourceState() } + return synchronized(state) { + state.consecutiveRateLimits = (state.consecutiveRateLimits + 1).coerceAtMost(BACKOFF_MILLIS.size) + val retryAfterMillis = parseRetryAfterMillis(retryAfter, nowMillis) + val delay = retryAfterMillis ?: BACKOFF_MILLIS[state.consecutiveRateLimits - 1] + val retryAt = saturatingAdd(nowMillis, delay.coerceAtLeast(0L)) + state.cooldownUntilMillis = retryAt + retryAt + } + } + + fun recordSuccess(sourceId: Long) { + val state = states[sourceId] ?: return + synchronized(state) { + state.cooldownUntilMillis = 0L + state.consecutiveRateLimits = 0 + } + } + + fun cooldownUntil(sourceId: Long, nowMillis: Long = wallNowMillis()): Long? { + val retryAt = states[sourceId]?.cooldownUntilMillis ?: return null + return retryAt.takeIf { it > nowMillis } + } + + fun clear(sourceId: Long) { + states.remove(sourceId) + } + + internal companion object { + const val DEFAULT_MINIMUM_INTERVAL_MILLIS = 1_000L + const val HTTP_TOO_MANY_REQUESTS = 429 + private val BACKOFF_MILLIS = longArrayOf(15_000L, 30_000L, 60_000L, 120_000L, 300_000L) + + fun parseRetryAfterMillis(header: String?, nowMillis: Long): Long? { + val value = header?.trim()?.takeIf(String::isNotEmpty) ?: return null + value.toLongOrNull()?.takeIf { it >= 0L }?.let { seconds -> + return if (seconds > Long.MAX_VALUE / 1_000L) Long.MAX_VALUE else seconds * 1_000L + } + return runCatching { + val retryAt = ZonedDateTime.parse(value, DateTimeFormatter.RFC_1123_DATE_TIME) + .toInstant() + .toEpochMilli() + (retryAt - nowMillis).coerceAtLeast(0L) + }.getOrNull() + } + } + + private class SourceState { + val gate = Semaphore(1) + var nextStartNanos: Long? = null + + @Volatile + var cooldownUntilMillis: Long = 0L + + var consecutiveRateLimits: Int = 0 + } +} + +internal sealed interface RecommendationRequestResult { + data class Success(val value: T) : RecommendationRequestResult + data class RateLimited(val retryAtMillis: Long) : RecommendationRequestResult +} + +private fun nanosToCeilMillis(nanos: Long): Long { + return nanos / 1_000_000L + if (nanos % 1_000_000L == 0L) 0L else 1L +} + +private fun millisToNanos(millis: Long): Long { + return if (millis > Long.MAX_VALUE / 1_000_000L) Long.MAX_VALUE else millis * 1_000_000L +} + +private fun saturatingAdd(left: Long, right: Long): Long { + return if (right > 0L && left > Long.MAX_VALUE - right) Long.MAX_VALUE else left + right +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/recommendation/RecommendationSampler.kt b/app/src/main/java/eu/kanade/tachiyomi/data/recommendation/RecommendationSampler.kt new file mode 100644 index 0000000000..50fc8c0766 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/recommendation/RecommendationSampler.kt @@ -0,0 +1,89 @@ +package eu.kanade.tachiyomi.data.recommendation + +import java.util.Random +import kotlin.math.exp +import kotlin.math.ln + +/** Stable quality-weighted sampling with MMR diversity and exposure-aware refill. */ +internal object RecommendationSampler { + private const val MINIMUM_SCORE = 0.20 + private const val TEMPERATURE = 0.15 + private const val MMR_LAMBDA = 0.85 + private const val MIN_RANDOM_UNIT = 1.0e-12 + + fun sample( + candidates: List, + maxResults: Int, + seed: Long, + excludedKeys: Set = emptySet(), + exposureSnapshot: RecommendationExposureSnapshot = RecommendationExposureSnapshot.EMPTY, + ): List { + if (maxResults <= 0) return emptyList() + val eligible = mutableListOf() + candidates.asSequence() + .filter { it.score >= MINIMUM_SCORE } + .filterNot { candidate -> candidate.card.identity.identityKeys().any(excludedKeys::contains) } + .forEach { candidate -> + if (eligible.none { RecommendationMetadata.sameWork(it.card.identity, candidate.card.identity) }) { + eligible += candidate + } + } + if (eligible.isEmpty()) return emptyList() + + val random = Random(seed) + val priorities = eligible.associate { candidate -> + candidate.card.identity.exposureKey to random.nextDouble() + .coerceIn(MIN_RANDOM_UNIT, 1.0 - MIN_RANDOM_UNIT) + } + val selected = mutableListOf() + val unseen = eligible.filterNot { exposureSnapshot.wasShown(it.card.identity.identityKeys()) } + selectWeighted(unseen, selected, maxResults, priorities) + if (selected.size < maxResults) { + val selectedSet = selected.toSet() + eligible.asSequence() + .filterNot(selectedSet::contains) + .mapNotNull { candidate -> + exposureSnapshot.lastShown(candidate.card.identity.identityKeys())?.let { it to candidate } + } + .sortedWith( + compareBy> { it.first.shownAtMillis } + .thenBy { it.first.sequence } + .thenByDescending { it.second.score }, + ) + .take(maxResults - selected.size) + .mapTo(selected, Pair::second) + } + return selected.map(RankedRecommendation::card) + } + + private fun selectWeighted( + candidates: List, + selected: MutableList, + maxResults: Int, + priorities: Map, + ) { + val remaining = candidates.toMutableList() + while (remaining.isNotEmpty() && selected.size < maxResults) { + val utilities = remaining.associateWith { candidate -> + val redundancy = selected.maxOfOrNull { chosen -> jaccard(candidate.tags, chosen.tags) } ?: 0.0 + MMR_LAMBDA * candidate.score - (1.0 - MMR_LAMBDA) * redundancy + } + val bestUtility = utilities.values.maxOrNull() ?: break + val next = remaining.minByOrNull { candidate -> + val weight = exp((utilities.getValue(candidate) - bestUtility) / TEMPERATURE) + val uniform = priorities.getValue(candidate.card.identity.exposureKey) + -ln(uniform) / weight.coerceAtLeast(MIN_RANDOM_UNIT) + } ?: break + selected += next + remaining -= next + } + } + + private fun jaccard(left: Set, right: Set): Double { + val union = left union right + if (union.isEmpty()) return 0.0 + return (left intersect right).size.toDouble() / union.size.toDouble() + } + + private fun RecommendationIdentity.identityKeys(): Set = exposureKeys.ifEmpty { setOf(exposureKey) } +} diff --git a/app/src/main/java/yokai/data/manga/MangaRepositoryImpl.kt b/app/src/main/java/yokai/data/manga/MangaRepositoryImpl.kt index a1efba5a17..0cd8eaff04 100644 --- a/app/src/main/java/yokai/data/manga/MangaRepositoryImpl.kt +++ b/app/src/main/java/yokai/data/manga/MangaRepositoryImpl.kt @@ -21,6 +21,11 @@ class MangaRepositoryImpl(private val handler: DatabaseHandler) : MangaRepositor override suspend fun getMangaById(id: Long): Manga? = handler.awaitOneOrNull { mangasQueries.findById(id, Manga::mapper) } + override suspend fun getRecommendationCandidates(source: Long, excludedUrl: String, limit: Long): List = + handler.awaitList { + mangasQueries.findRecommendationCandidates(source, excludedUrl, limit, Manga::mapper) + } + override suspend fun getFavorites(): List = handler.awaitList { mangasQueries.findFavorites(Manga::mapper) } diff --git a/app/src/main/java/yokai/domain/manga/MangaRepository.kt b/app/src/main/java/yokai/domain/manga/MangaRepository.kt index c81f8ad478..487b159603 100644 --- a/app/src/main/java/yokai/domain/manga/MangaRepository.kt +++ b/app/src/main/java/yokai/domain/manga/MangaRepository.kt @@ -11,6 +11,7 @@ interface MangaRepository { suspend fun getMangaByUrlAndSource(url: String, source: Long): Manga? fun getMangaByUrlAndSourceAsFlow(url: String, source: Long): Flow suspend fun getMangaById(id: Long): Manga? + suspend fun getRecommendationCandidates(source: Long, excludedUrl: String, limit: Long = 200): List suspend fun getFavorites(): List suspend fun getReadNotFavorites(): List suspend fun getDuplicateFavorite(title: String, source: Long): Manga? diff --git a/app/src/main/java/yokai/domain/manga/interactor/GetManga.kt b/app/src/main/java/yokai/domain/manga/interactor/GetManga.kt index 9a89581fda..682e6e9162 100644 --- a/app/src/main/java/yokai/domain/manga/interactor/GetManga.kt +++ b/app/src/main/java/yokai/domain/manga/interactor/GetManga.kt @@ -11,6 +11,8 @@ class GetManga ( suspend fun awaitByUrlAndSource(url: String, source: Long) = mangaRepository.getMangaByUrlAndSource(url, source) suspend fun awaitById(id: Long) = mangaRepository.getMangaById(id) + suspend fun awaitRecommendationCandidates(source: Long, excludedUrl: String, limit: Long = 200) = + mangaRepository.getRecommendationCandidates(source, excludedUrl, limit) suspend fun awaitFavorites() = mangaRepository.getFavorites() suspend fun awaitReadNotFavorites() = mangaRepository.getReadNotFavorites() suspend fun awaitDuplicateFavorite(title: String, source: Long) = mangaRepository.getDuplicateFavorite(title, source) diff --git a/app/src/test/java/eu/kanade/tachiyomi/data/recommendation/RecommendationMetadataTest.kt b/app/src/test/java/eu/kanade/tachiyomi/data/recommendation/RecommendationMetadataTest.kt new file mode 100644 index 0000000000..caeff09951 --- /dev/null +++ b/app/src/test/java/eu/kanade/tachiyomi/data/recommendation/RecommendationMetadataTest.kt @@ -0,0 +1,146 @@ +package eu.kanade.tachiyomi.data.recommendation + +import eu.kanade.tachiyomi.source.model.Filter +import eu.kanade.tachiyomi.source.model.FilterList +import eu.kanade.tachiyomi.source.model.SManga +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class RecommendationMetadataTest { + + @Test + fun `normalization uses NFKC and root casing without translating tags`() { + assertEquals( + "romance school", + RecommendationMetadata.normalize("\uFF32\uFF2F\uFF2D\uFF21\uFF2E\uFF23\uFF25--School"), + ) + assertEquals("i", RecommendationMetadata.normalize("I")) + assertEquals("\u611B\u60C5", RecommendationMetadata.normalize("\u611B\u60C5")) + assertEquals("\u7231\u60C5", RecommendationMetadata.normalize("\u7231\u60C5")) + assertEquals("\u604B\u611B", RecommendationMetadata.normalize("\u604B\u611B")) + assertEquals("\uB85C\uB9E8\uC2A4", RecommendationMetadata.normalize("\uB85C\uB9E8\uC2A4")) + } + + @Test + fun `unicode delimiters produce independent source native tags`() { + val manga = manga("work", "Title").apply { + genre = "romance\uFF0Cschool\u3001\u30DF\u30B9\u30C6\u30EA\u30FC\uFF1B\uC561\uC158" + } + + assertEquals( + setOf("romance", "school", "\u30DF\u30B9\u30C6\u30EA\u30FC", "\uC561\uC158"), + RecommendationMetadata.extractTags(manga), + ) + } + + @Test + fun `creator parsing merges exact author and artist roles`() { + val manga = manga("work", "Title").apply { + author = "Alice; Bob" + artist = "\uFF21\uFF2C\uFF29\uFF23\uFF25" + } + + val creators = RecommendationMetadata.extractCreators(manga).associateBy(CreatorIdentity::normalizedName) + + assertEquals(setOf(CreatorRole.AUTHOR, CreatorRole.ARTIST), creators.getValue("alice").roles) + assertEquals(setOf(CreatorRole.AUTHOR), creators.getValue("bob").roles) + } + + @Test + fun `only explicit group metadata lines are extracted from descriptions`() { + val manga = manga("work", "Title").apply { + description = listOf( + "A story that mentions Circle: Not Metadata in ordinary prose.", + "Group: Studio A", + "\u793E\u5718\uFF1A Studio B", + "\u30B5\u30FC\u30AF\u30EB: Studio C", + ).joinToString("\n") + } + + val groups = RecommendationMetadata.extractCreators(manga) + .filter { CreatorRole.GROUP in it.roles } + .map(CreatorIdentity::normalizedName) + + assertEquals(listOf("studio a", "studio b", "studio c"), groups) + assertFalse(groups.contains("not metadata in ordinary prose")) + } + + @Test + fun `URL identity keeps identity query and removes only tracking parameters`() { + val absolute = manga( + "https://Example.com/item?utm_source=test&lang=en&id=7&fbclid=x", + "First", + ).apply { author = "Creator" } + val relative = manga("/item?id=7&lang=en", "Alias").apply { author = "Other" } + val otherId = manga("/item?id=8&lang=en", "Different").apply { author = "Creator" } + val left = RecommendationMetadata.identity(10L, absolute) + val alias = RecommendationMetadata.identity(10L, relative) + + assertEquals("//example.com/item?id=7&lang=en", left.canonicalUrl) + assertTrue(RecommendationMetadata.sameWork(left, alias)) + assertFalse(RecommendationMetadata.sameWork(left, RecommendationMetadata.identity(10L, otherId))) + assertFalse(RecommendationMetadata.sameWork(left, left.copy(sourceId = 11L))) + } + + @Test + fun `query-only URLs retain their identity parameter`() { + val first = RecommendationMetadata.identity(1L, manga("?id=7&utm_medium=test", "One")) + val alias = RecommendationMetadata.identity(1L, manga("/?id=7", "Alias")) + val other = RecommendationMetadata.identity(1L, manga("?id=8", "One")) + + assertEquals("/?id=7", first.canonicalUrl) + assertTrue(RecommendationMetadata.sameWork(first, alias)) + assertFalse(RecommendationMetadata.sameWork(first, other)) + } + + @Test + fun `same title needs a creator and different volumes remain distinct`() { + val first = manga("/one", "Series (1)").apply { + author = "Creator" + thumbnail_url = "/cover" + } + val second = manga("/two", "Series (2)").apply { + author = "Creator" + thumbnail_url = "/cover" + } + val alias = manga("/alias", "Series (1)").apply { author = "Creator" } + val unrelated = manga("/unrelated", "Series (1)").apply { author = "Someone Else" } + val target = RecommendationMetadata.identity(1L, first) + + assertTrue(RecommendationMetadata.sameWork(target, RecommendationMetadata.identity(1L, alias))) + assertFalse(RecommendationMetadata.sameWork(target, RecommendationMetadata.identity(1L, second))) + assertFalse(RecommendationMetadata.sameWork(target, RecommendationMetadata.identity(1L, unrelated))) + } + + @Test + fun `exact generic filters are applied recursively without changing text or sort`() { + val checkbox = TestCheckBox("Romance") + val triState = TestTriState("School") + val select = TestSelect("Genre", arrayOf("Any", "Mystery")) + val text = TestText("Query") + val sort = TestSort("Order", arrayOf("Newest")) + val filters = FilterList(TestGroup("Tags", listOf(checkbox, triState, select, text, sort))) + + assertTrue(RecommendationMetadata.applyExactTagFilter(filters, "\uFF2D\uFF39\uFF33\uFF34\uFF25\uFF32\uFF39")) + assertEquals(1, select.state) + assertEquals("", text.state) + assertEquals(null, sort.state) + assertTrue(RecommendationMetadata.applyExactTagFilter(filters, "school")) + assertEquals(Filter.TriState.STATE_INCLUDE, triState.state) + assertFalse(RecommendationMetadata.applyExactTagFilter(filters, "unrelated")) + } + + private fun manga(url: String, title: String): SManga = SManga.create().apply { + this.url = url + this.title = title + } + + private class TestCheckBox(name: String) : Filter.CheckBox(name) + private class TestTriState(name: String) : Filter.TriState(name) + private class TestSelect(name: String, values: Array) : Filter.Select(name, values) + private class TestText(name: String) : Filter.Text(name) + private class TestSort(name: String, values: Array) : Filter.Sort(name, values) + private class TestGroup(name: String, filters: List>) : Filter.Group>(name, filters) +} diff --git a/app/src/test/java/eu/kanade/tachiyomi/data/recommendation/RecommendationRankingTest.kt b/app/src/test/java/eu/kanade/tachiyomi/data/recommendation/RecommendationRankingTest.kt new file mode 100644 index 0000000000..bd72276fe2 --- /dev/null +++ b/app/src/test/java/eu/kanade/tachiyomi/data/recommendation/RecommendationRankingTest.kt @@ -0,0 +1,230 @@ +package eu.kanade.tachiyomi.data.recommendation + +import eu.kanade.tachiyomi.source.model.SManga +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class RecommendationRankingTest { + + @Test + fun `tag profile keeps four highest IDF tags with stable ties`() { + val tags = linkedSetOf("common", "rare", "second", "third", "fourth", "fifth") + val profile = RecommendationRanking.buildTagProfile( + targetTags = tags, + documentFrequency = mapOf( + "common" to 90, + "rare" to 1, + "second" to 5, + "third" to 5, + "fourth" to 10, + "fifth" to 20, + ), + documentCount = 100, + ) + + assertEquals(listOf("rare", "second", "third", "fourth"), profile.coreTags.toList()) + assertEquals(setOf("common", "fifth"), profile.secondaryTags) + } + + @Test + fun `profile adapts at zero one four and many tags`() { + listOf(0, 1, 4, 20).forEach { count -> + val tags = (1..count).map { "tag_$it" } + val profile = RecommendationRanking.buildTagProfile(tags, emptyMap(), 0) + + assertEquals(count, profile.allTags.size) + assertEquals(minOf(count, TagProfile.MAX_CORE_TAGS), profile.coreTags.size) + assertEquals((count - TagProfile.MAX_CORE_TAGS).coerceAtLeast(0), profile.secondaryTags.size) + } + } + + @Test + fun `coverage does not punish candidate extra tags`() { + val target = setOf("romance") + val candidate = target + (1..20).map { "extra_$it" } + + assertEquals( + 1.0, + RecommendationRanking.weightedCoverage(target, candidate, emptyMap(), 0), + 1.0e-12, + ) + assertTrue(RecommendationRanking.weightedJaccard(target, candidate, emptyMap(), 0) < 0.05) + } + + @Test + fun `ranking requires tag evidence unless route is authoritative`() { + val profile = RecommendationRanking.buildTagProfile(setOf("romance", "school"), emptyMap(), 0) + val verified = candidate("verified", setOf("romance", "school", "comedy")) + val sparse = candidate("sparse", setOf("romance")) + val authoritative = candidate("authoritative", emptySet(), authoritative = true) + + val ranked = RecommendationRanking.rankSimilar( + profile, + listOf(verified, sparse, authoritative), + emptyMap(), + 0, + ) + + assertEquals(setOf("verified", "authoritative"), ranked.map { it.card.manga.url }.toSet()) + } + + @Test + fun `zero tag target only accepts authoritative evidence`() { + val profile = RecommendationRanking.buildTagProfile(emptySet(), emptyMap(), 0) + val ranked = RecommendationRanking.rankSimilar( + profile, + listOf( + candidate("local", emptySet()), + candidate("external", emptySet(), authoritative = true), + ), + emptyMap(), + 0, + ) + + assertEquals(listOf("external"), ranked.map { it.card.manga.url }) + } + + @Test + fun `structured filter accepts missing metadata but rejects explicit tag conflicts`() { + val profile = RecommendationRanking.buildTagProfile(setOf("romance"), emptyMap(), 0) + val evidence = RecommendationEvidence( + ranks = mapOf(RecommendationRoute.SOURCE_FILTER to 0), + authoritative = true, + ) + + val ranked = RecommendationRanking.rankSimilar( + profile = profile, + candidates = listOf( + RecommendationCandidate(card("missing", emptySet()), evidence), + RecommendationCandidate(card("matching", setOf("romance", "school")), evidence), + RecommendationCandidate(card("conflicting", setOf("horror")), evidence), + ), + documentFrequency = emptyMap(), + documentCount = 0, + ) + + assertEquals(setOf("missing", "matching"), ranked.map { it.card.manga.url }.toSet()) + } + + @Test + fun `RRF is normalized and decreases with route rank`() { + val first = RecommendationRanking.normalizedRrf( + RecommendationEvidence(mapOf(RecommendationRoute.ANILIST to 0)), + ) + val later = RecommendationRanking.normalizedRrf( + RecommendationEvidence(mapOf(RecommendationRoute.ANILIST to 20)), + ) + + assertEquals(1.0, first, 1.0e-12) + assertTrue(later in 0.0.. + val urls = RecommendationSampler.sample(candidates, 2, seed.toLong()).map { it.manga.url }.toSet() + if (urls == setOf("a", "b")) homogeneous++ else diversified++ + } + assertTrue(diversified > homogeneous) + } + + @Test + fun `sampler prefers unseen then refills the oldest exposure`() { + var now = 0L + val store = RecommendationExposureStore(nowMillis = { now }) + val a = ranked("a", setOf("romance"), 0.8) + val b = ranked("b", setOf("romance"), 0.8) + val c = ranked("c", setOf("romance"), 0.8) + store.record(1L, "target", listOf(a.card)) + now = 1L + store.record(1L, "target", listOf(b.card)) + + val sampled = RecommendationSampler.sample( + listOf(a, b, c), + maxResults = 2, + seed = 7L, + exposureSnapshot = store.snapshot(1L, "target"), + ) + + assertEquals("c", sampled.first().manga.url) + assertEquals("a", sampled.last().manga.url) + } + + @Test + fun `exposure store is bounded isolated and expires keys`() { + var now = 0L + val store = RecommendationExposureStore(nowMillis = { now }, capacity = 2, ttlMillis = 100L) + val a = card("a", emptySet()) + val b = card("b", emptySet()) + val c = card("c", emptySet()) + store.record(1L, "target", listOf(a, b, c)) + + val snapshot = store.snapshot(1L, "target") + assertFalse(snapshot.wasShown(a.identity.exposureKeys)) + assertTrue(snapshot.wasShown(b.identity.exposureKeys)) + assertFalse(store.snapshot(2L, "target").wasShown(b.identity.exposureKeys)) + now = 100L + assertFalse(store.snapshot(1L, "target").wasShown(c.identity.exposureKeys)) + } + + private fun candidate( + url: String, + tags: Set, + authoritative: Boolean = false, + ): RecommendationCandidate { + return RecommendationCandidate( + card(url, tags), + RecommendationEvidence(mapOf(RecommendationRoute.LOCAL to 0), authoritative), + ) + } + + private fun ranked(url: String, tags: Set, score: Double): RankedRecommendation { + return RankedRecommendation( + card = card(url, tags), + tags = tags, + evidence = RecommendationEvidence(mapOf(RecommendationRoute.LOCAL to 0)), + contentScore = score, + score = score, + ) + } + + private fun card( + url: String, + tags: Set, + author: String? = null, + sourceId: Long = 1L, + ): RecommendationCard { + val manga = SManga.create().apply { + this.url = url + title = url + genre = tags.joinToString(", ") + this.author = author + } + return RecommendationMetadata.card(sourceId, manga) + } +} diff --git a/app/src/test/java/eu/kanade/tachiyomi/data/recommendation/RecommendationRequestSchedulerTest.kt b/app/src/test/java/eu/kanade/tachiyomi/data/recommendation/RecommendationRequestSchedulerTest.kt new file mode 100644 index 0000000000..228f079936 --- /dev/null +++ b/app/src/test/java/eu/kanade/tachiyomi/data/recommendation/RecommendationRequestSchedulerTest.kt @@ -0,0 +1,132 @@ +package eu.kanade.tachiyomi.data.recommendation + +import eu.kanade.tachiyomi.network.HttpException +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertInstanceOf +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class RecommendationRequestSchedulerTest { + + @Test + fun `requests are serial and starts are spaced by one second`() = runTest { + var active = 0 + var maxActive = 0 + val starts = mutableListOf() + val scheduler = RecommendationRequestScheduler( + monotonicNowNanos = { testScheduler.currentTime * 1_000_000L }, + wallNowMillis = { testScheduler.currentTime }, + ) + + repeat(3) { + launch { + scheduler.execute(1L) { + starts += testScheduler.currentTime + active++ + maxActive = maxOf(maxActive, active) + delay(250L) + active-- + } + } + } + advanceUntilIdle() + + assertEquals(listOf(0L, 1_000L, 2_000L), starts) + assertEquals(1, maxActive) + } + + @Test + fun `waiting request is promptly cancellable`() = runTest { + var invocations = 0 + val scheduler = RecommendationRequestScheduler( + monotonicNowNanos = { testScheduler.currentTime * 1_000_000L }, + wallNowMillis = { testScheduler.currentTime }, + ) + val first = launch { + scheduler.execute(1L) { + invocations++ + delay(2_000L) + } + } + val waiting = launch { + scheduler.execute(1L) { invocations++ } + } + + runCurrent() + waiting.cancel() + advanceUntilIdle() + + assertTrue(waiting.isCancelled) + assertTrue(first.isCompleted) + assertEquals(1, invocations) + } + + @Test + fun `429 uses Retry-After and never retries the block`() = runTest { + var now = 1_000L + var calls = 0 + val scheduler = RecommendationRequestScheduler( + monotonicNowNanos = { testScheduler.currentTime * 1_000_000L }, + wallNowMillis = { now }, + ) + + val limited = scheduler.execute(1L) { + calls++ + throw HttpException(429, "3") + } + val blocked = scheduler.execute(1L) { calls++ } + + assertEquals(1, calls) + assertEquals( + 4_000L, + assertInstanceOf(RecommendationRequestResult.RateLimited::class.java, limited).retryAtMillis, + ) + assertEquals( + 4_000L, + assertInstanceOf(RecommendationRequestResult.RateLimited::class.java, blocked).retryAtMillis, + ) + now = 4_000L + assertNull(scheduler.cooldownUntil(1L)) + } + + @Test + fun `fallback backoff is truncated and success clears failure count`() { + val scheduler = RecommendationRequestScheduler() + val expected = listOf(15_000L, 30_000L, 60_000L, 120_000L, 300_000L, 300_000L) + + expected.forEachIndexed { index, duration -> + assertEquals(index + duration, scheduler.record429(1L, nowMillis = index.toLong())) + } + scheduler.recordSuccess(1L) + + assertNull(scheduler.cooldownUntil(1L, 0L)) + assertEquals(15_000L, scheduler.record429(1L, nowMillis = 0L)) + } + + @Test + fun `Retry-After supports seconds and HTTP dates`() { + val now = 1_000L + + assertEquals(5_000L, RecommendationRequestScheduler.parseRetryAfterMillis("5", now)) + assertEquals( + 1_445_498_879_000L, + RecommendationRequestScheduler.parseRetryAfterMillis("Thu, 22 Oct 2015 07:28:00 GMT", now), + ) + assertNull(RecommendationRequestScheduler.parseRetryAfterMillis("invalid", now)) + } + + @Test + fun `cooldown is isolated by source ID`() { + val scheduler = RecommendationRequestScheduler() + scheduler.record429(1L, "30", nowMillis = 1_000L) + + assertEquals(31_000L, scheduler.cooldownUntil(1L, 1_001L)) + assertNull(scheduler.cooldownUntil(2L, 1_001L)) + } +} diff --git a/core/main/src/androidMain/kotlin/eu/kanade/tachiyomi/network/OkHttpExtensions.kt b/core/main/src/androidMain/kotlin/eu/kanade/tachiyomi/network/OkHttpExtensions.kt index 9b9a02b759..be8b90dd29 100644 --- a/core/main/src/androidMain/kotlin/eu/kanade/tachiyomi/network/OkHttpExtensions.kt +++ b/core/main/src/androidMain/kotlin/eu/kanade/tachiyomi/network/OkHttpExtensions.kt @@ -64,8 +64,10 @@ fun Call.asObservable(): Observable { fun Call.asObservableSuccess(): Observable { return asObservable().doOnNext { response -> if (!response.isSuccessful) { + val retryAfter = response.header("Retry-After") + val rateLimit = response.header("X-RateLimit-Limit")?.trim()?.toIntOrNull() response.close() - throw HttpException(response.code) + throw HttpException(response.code, retryAfter, rateLimit) } } } @@ -110,8 +112,10 @@ suspend fun Call.awaitSuccess(): Response { val callStack = Exception().stackTrace.run { copyOfRange(1, size) } val response = await(callStack) if (!response.isSuccessful) { + val retryAfter = response.header("Retry-After") + val rateLimit = response.header("X-RateLimit-Limit")?.trim()?.toIntOrNull() response.close() - throw HttpException(response.code).apply { stackTrace = callStack } + throw HttpException(response.code, retryAfter, rateLimit).apply { stackTrace = callStack } } return response } @@ -142,4 +146,3 @@ fun Json.decodeFromJsonResponse( decodeFromBufferedSource(deserializer, it) } } - diff --git a/core/main/src/commonMain/kotlin/eu/kanade/tachiyomi/network/HttpException.kt b/core/main/src/commonMain/kotlin/eu/kanade/tachiyomi/network/HttpException.kt index 98e38939e4..10b51f6651 100644 --- a/core/main/src/commonMain/kotlin/eu/kanade/tachiyomi/network/HttpException.kt +++ b/core/main/src/commonMain/kotlin/eu/kanade/tachiyomi/network/HttpException.kt @@ -7,4 +7,8 @@ package eu.kanade.tachiyomi.network * @since extensions-lib 1.5 * @param code [Int] the HTTP status code */ -class HttpException(val code: Int) : IllegalStateException("HTTP error $code") +class HttpException( + val code: Int, + val retryAfter: String? = null, + val rateLimit: Int? = null, +) : IllegalStateException("HTTP error $code") diff --git a/data/src/commonMain/sqldelight/tachiyomi/data/mangas.sq b/data/src/commonMain/sqldelight/tachiyomi/data/mangas.sq index a5a17ff2f4..102a14d9b0 100644 --- a/data/src/commonMain/sqldelight/tachiyomi/data/mangas.sq +++ b/data/src/commonMain/sqldelight/tachiyomi/data/mangas.sq @@ -40,6 +40,15 @@ SELECT * FROM mangas WHERE _id = :mangaId; +findRecommendationCandidates: +SELECT * +FROM mangas +WHERE source = :source + AND url != :excludedUrl + AND initialized = 1 +ORDER BY favorite DESC, last_update DESC, _id DESC +LIMIT :candidateLimit; + findDuplicateFavorite: SELECT * FROM mangas From 4fa4da56f08533ea0d1ace50c0ec02264883d9f6 Mon Sep 17 00:00:00 2001 From: Kom1ch1 <267495674+Kom1ch1@users.noreply.github.com> Date: Thu, 30 Jul 2026 04:07:28 +0800 Subject: [PATCH 2/3] feat: add conservative source-scoped recommendation engine --- .../data/preference/PreferencesHelper.kt | 3 + .../MangaRecommendationRepository.kt | 382 ++++++++++++++++++ .../tachiyomi/data/track/anilist/Anilist.kt | 3 + .../data/track/anilist/AnilistApi.kt | 50 +++ .../track/anilist/dto/ALRecommendations.kt | 87 ++++ .../RecommendationSourceSettingsController.kt | 39 ++ .../controllers/SettingsLibraryController.kt | 9 + .../MangaRecommendationRepositoryTest.kt | 226 +++++++++++ 8 files changed, 799 insertions(+) create mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/recommendation/MangaRecommendationRepository.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/dto/ALRecommendations.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/setting/controllers/RecommendationSourceSettingsController.kt create mode 100644 app/src/test/java/eu/kanade/tachiyomi/data/recommendation/MangaRecommendationRepositoryTest.kt diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferencesHelper.kt b/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferencesHelper.kt index 5d63f59b83..a3006be27f 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferencesHelper.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferencesHelper.kt @@ -60,6 +60,9 @@ class PreferencesHelper(val context: Context, val preferenceStore: PreferenceSto fun getStringPref(key: String, default: String = "") = preferenceStore.getString(key, default) fun getStringSet(key: String, default: Set) = preferenceStore.getStringSet(key, default) + fun recommendationSourceNetworkEnabled(sourceId: Long) = + preferenceStore.getBoolean("recommendation_source_${sourceId}_network_enabled_v1", false) + fun startingTab() = preferenceStore.getInt(Keys.startingTab, 0) fun backReturnsToStart() = preferenceStore.getBoolean(Keys.backToStart, true) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/recommendation/MangaRecommendationRepository.kt b/app/src/main/java/eu/kanade/tachiyomi/data/recommendation/MangaRecommendationRepository.kt new file mode 100644 index 0000000000..82cf2af311 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/recommendation/MangaRecommendationRepository.kt @@ -0,0 +1,382 @@ +package eu.kanade.tachiyomi.data.recommendation + +import co.touchlab.kermit.Logger +import eu.kanade.tachiyomi.data.track.anilist.dto.ALRecommendation +import eu.kanade.tachiyomi.source.CatalogueSource +import eu.kanade.tachiyomi.source.LocalSource +import eu.kanade.tachiyomi.source.Source +import eu.kanade.tachiyomi.source.model.FilterList +import eu.kanade.tachiyomi.source.model.SManga +import eu.kanade.tachiyomi.domain.manga.models.Manga +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import java.security.SecureRandom + +/** + * Builds conservative recommendations from source-scoped local metadata and, when explicitly + * enabled, a small number of recommendation-only source requests. + */ +internal class MangaRecommendationRepository( + private val localCandidateLoader: suspend (sourceId: Long, excludedUrl: String) -> List, + private val aniListLoader: suspend (mediaId: Long) -> List, + private val requestScheduler: RecommendationRequestScheduler = RecommendationRequestScheduler(), + private val exposureStore: RecommendationExposureStore = RecommendationExposureStore(), + private val seedProvider: () -> Long = SecureRandom()::nextLong, +) { + + fun observe( + source: Source, + manga: SManga, + aniListId: Long?, + allowNetwork: Boolean, + excludedKeys: Set = emptySet(), + ): Flow = flow { + val target = RecommendationMetadata.card(source.id, manga.copy()) + if (manga.url.isBlank() || source.isLocalOrStub()) { + emit(RecommendationRows()) + return@flow + } + + val localCards = loadLocalCards(source.id, manga.url) + .filterNot { RecommendationMetadata.sameWork(target.identity, it.identity) } + .filterNot { it.identity.exposureKeys.any(excludedKeys::contains) } + val documentFrequency = documentFrequency(localCards) + val targetTags = RecommendationMetadata.extractTagIdentities(manga) + val profile = RecommendationRanking.buildTagProfile( + targetTags = targetTags.map(TagIdentity::normalizedName), + documentFrequency = documentFrequency, + documentCount = localCards.size, + routeIdentities = targetTags, + ) + val exposureSnapshot = exposureStore.snapshot(source.id, target.identity.exposureKey) + val seed = seedProvider() + + val creatorWorks = RecommendationCreators.selectWorks( + target = target, + candidates = localCards, + maxResults = MAX_RESULTS, + ).toMutableList() + val creatorKeys = creatorWorks.flatMapTo(linkedSetOf()) { it.identity.exposureKeys } + val localSimilar = localCards.mapIndexed { index, card -> + RecommendationCandidate( + card = card, + evidence = RecommendationEvidence(mapOf(RecommendationRoute.LOCAL to index)), + ) + } + val similarManga = RecommendationSampler.sample( + candidates = RecommendationRanking.rankSimilar( + profile = profile, + candidates = localSimilar, + documentFrequency = documentFrequency, + documentCount = localCards.size, + ), + maxResults = MAX_RESULTS, + seed = seed, + excludedKeys = excludedKeys + creatorKeys, + exposureSnapshot = exposureSnapshot, + ).toMutableList() + + var rows = RecommendationRows(creatorWorks.toList(), similarManga.toList()) + emit(rows) + if (!allowNetwork) { + exposureStore.record(source.id, target.identity.exposureKey, rows.allCards()) + return@flow + } + + val budget = SourceRequestBudget(source.id, requestScheduler) + var detailRequests = 0 + + suspend fun publishCreator(card: RecommendationCard) { + if (creatorWorks.size >= MAX_RESULTS || !isNewCard(card, target, rows, excludedKeys)) return + creatorWorks += card + rows = rows.copy(creatorWorks = creatorWorks.toList()) + emit(rows) + } + + suspend fun publishSimilar(card: RecommendationCard) { + if (similarManga.size >= MAX_RESULTS || !isNewCard(card, target, rows, excludedKeys)) return + similarManga += card + rows = rows.copy(similarManga = similarManga.toList()) + emit(rows) + } + + val strongestCreator = target.creators.sortedWith( + compareBy { creator -> creator.roles.minOfOrNull(CreatorRole::ordinal) ?: Int.MAX_VALUE } + .thenBy(CreatorIdentity::normalizedName), + ).firstOrNull() + if (creatorWorks.size < MAX_RESULTS && strongestCreator != null) { + val results = budget.call { + source.getSearchManga(1, strongestCreator.displayName, freshFilters(source)).mangas + }.orEmpty().take(MAX_ROUTE_RESULTS) + for (candidate in results) { + var card = mergeLocalMetadata( + RecommendationMetadata.card(source.id, candidate.copy()), + localCards, + ) + if (card.creators.isEmpty() && detailRequests < MAX_DETAIL_REQUESTS) { + detailRequests += 1 + val hydrated = budget.call { loadDetails(source, candidate) } + if (hydrated != null) { + card = mergeLocalMetadata(RecommendationMetadata.card(source.id, hydrated), localCards) + } + } + if (RecommendationMetadata.creatorsOverlap(target.creators, card.creators)) { + publishCreator(card) + } + if (creatorWorks.size >= MAX_RESULTS || budget.stopped) break + } + } + + if (similarManga.size < MAX_RESULTS && !budget.stopped) { + if (aniListId != null && aniListId > 0L) { + val recommendations = loadAniList(aniListId).take(MAX_ANILIST_MAPPINGS) + recommendations.forEachIndexed { index, recommendation -> + if (similarManga.size >= MAX_RESULTS || budget.stopped) return@forEachIndexed + val variants = recommendation.media.title.variants(recommendation.media.synonyms) + .filter(String::isNotBlank) + val query = variants.firstOrNull() ?: return@forEachIndexed + val matches = budget.call { + source.getSearchManga(1, query, freshFilters(source)).mangas + }.orEmpty() + .filter { result -> + val normalizedTitle = RecommendationMetadata.normalize(result.title) + variants.any { RecommendationMetadata.normalize(it) == normalizedTitle } + } + .map { mergeLocalMetadata(RecommendationMetadata.card(source.id, it.copy()), localCards) } + .distinctWorks() + if (matches.size == 1) { + val candidate = RecommendationCandidate( + card = matches.single(), + evidence = RecommendationEvidence( + ranks = mapOf(RecommendationRoute.ANILIST to index), + authoritative = true, + ), + ) + val ranked = RecommendationRanking.rankSimilar( + profile = profile, + candidates = listOf(candidate), + documentFrequency = documentFrequency, + documentCount = localCards.size, + ) + ranked.singleOrNull()?.card?.let { publishSimilar(it) } + } + } + } else { + val route = profile.routeIdentities.firstOrNull() + if (route != null) { + val filters = freshFilters(source) + val structured = RecommendationMetadata.applyExactTagFilter(filters, route.displayName) + val results = budget.call { + source.getSearchManga( + page = 1, + query = if (structured) "" else route.displayName, + filters = filters, + ).mangas + }.orEmpty().take(MAX_ROUTE_RESULTS) + val candidates = mutableListOf() + for ((index, candidate) in results.withIndex()) { + var card = mergeLocalMetadata( + RecommendationMetadata.card(source.id, candidate.copy()), + localCards, + ) + if (!structured && card.tags.isEmpty() && detailRequests < MAX_DETAIL_REQUESTS) { + detailRequests += 1 + val hydrated = budget.call { loadDetails(source, candidate) } + if (hydrated != null) { + card = mergeLocalMetadata(RecommendationMetadata.card(source.id, hydrated), localCards) + } + } + val recommendationRoute = if (structured) { + RecommendationRoute.SOURCE_FILTER + } else { + RecommendationRoute.SOURCE_SEARCH + } + candidates += RecommendationCandidate( + card = card, + evidence = RecommendationEvidence( + ranks = mapOf(recommendationRoute to index), + authoritative = structured, + ), + ) + if (budget.stopped) break + } + val ranked = RecommendationRanking.rankSimilar( + profile = profile, + candidates = candidates, + documentFrequency = documentFrequency, + documentCount = localCards.size, + ) + RecommendationSampler.sample( + candidates = ranked, + maxResults = MAX_RESULTS - similarManga.size, + seed = seed xor NETWORK_SEED_SALT, + excludedKeys = excludedKeys + rows.allCards().flatMap { it.identity.exposureKeys }, + exposureSnapshot = exposureSnapshot, + ).forEach { publishSimilar(it) } + } + } + } + + exposureStore.record(source.id, target.identity.exposureKey, rows.allCards()) + } + + fun invalidate(sourceId: Long, targetKey: String) { + exposureStore.clear(sourceId, targetKey) + requestScheduler.clear(sourceId) + } + + private suspend fun loadLocalCards(sourceId: Long, excludedUrl: String): List { + return try { + localCandidateLoader(sourceId, excludedUrl) + .asSequence() + .filter { it.source == sourceId && it.initialized } + .take(MAX_LOCAL_CANDIDATES) + .map { local -> + RecommendationMetadata.card( + sourceId = sourceId, + manga = local.copy(), + favorite = local.favorite, + localId = local.id, + ) + } + .toList() + .distinctWorks() + } catch (error: CancellationException) { + throw error + } catch (error: Exception) { + Logger.d(error) { "Unable to load local recommendation candidates" } + emptyList() + } + } + + private suspend fun loadAniList(mediaId: Long): List { + return try { + aniListLoader(mediaId) + } catch (error: CancellationException) { + throw error + } catch (error: Exception) { + Logger.d(error) { "Unable to load AniList recommendations" } + emptyList() + } + } + + private suspend fun loadDetails(source: Source, identity: SManga): SManga { + val details = source.getMangaDetails(identity.copy()) + return identity.copy().apply { + details.author?.takeIf(String::isNotBlank)?.let { author = it } + details.artist?.takeIf(String::isNotBlank)?.let { artist = it } + details.description?.takeIf(String::isNotBlank)?.let { description = it } + details.genre?.takeIf(String::isNotBlank)?.let { genre = it } + details.thumbnail_url?.takeIf(String::isNotBlank)?.let { thumbnail_url = it } + initialized = initialized || details.initialized + } + } + + private fun mergeLocalMetadata( + network: RecommendationCard, + localCards: List, + ): RecommendationCard { + val local = localCards.firstOrNull { + RecommendationMetadata.sameWork(network.identity, it.identity) + } ?: return network + val merged = network.manga.copy().apply { + if (author.isNullOrBlank()) author = local.manga.author + if (artist.isNullOrBlank()) artist = local.manga.artist + if (description.isNullOrBlank()) description = local.manga.description + if (genre.isNullOrBlank()) genre = local.manga.genre + if (thumbnail_url.isNullOrBlank()) thumbnail_url = local.manga.thumbnail_url + initialized = initialized || local.manga.initialized + } + return RecommendationMetadata.card( + sourceId = network.sourceId, + manga = merged, + favorite = local.favorite, + localId = local.localId, + ) + } + + private fun documentFrequency(cards: List): Map { + val frequencies = mutableMapOf() + cards.forEach { card -> + card.tags.forEach { tag -> frequencies[tag] = (frequencies[tag] ?: 0) + 1 } + } + return frequencies + } + + private fun isNewCard( + candidate: RecommendationCard, + target: RecommendationCard, + rows: RecommendationRows, + excludedKeys: Set, + ): Boolean { + if (candidate.sourceId != target.sourceId) return false + if (candidate.identity.exposureKeys.any(excludedKeys::contains)) return false + if (RecommendationMetadata.sameWork(candidate.identity, target.identity)) return false + return rows.allCards().none { + RecommendationMetadata.sameWork(candidate.identity, it.identity) + } + } + + private fun List.distinctWorks(): List { + val result = mutableListOf() + forEach { card -> + if (result.none { RecommendationMetadata.sameWork(it.identity, card.identity) }) result += card + } + return result + } + + private fun freshFilters(source: Source): FilterList { + return runCatching(source::getFilterList).getOrElse { FilterList() } + } + + private class SourceRequestBudget( + private val sourceId: Long, + private val scheduler: RecommendationRequestScheduler, + ) { + var stopped = false + private set + private var requests = 0 + + suspend fun call(block: suspend () -> T): T? { + if (stopped || requests >= MAX_SOURCE_REQUESTS) return null + requests += 1 + return try { + when (val result = scheduler.execute(sourceId, block)) { + is RecommendationRequestResult.Success -> result.value + is RecommendationRequestResult.RateLimited -> { + stopped = true + null + } + } + } catch (error: CancellationException) { + throw error + } catch (error: Exception) { + Logger.d(error) { "Recommendation source request failed" } + null + } + } + } + + private companion object { + const val MAX_LOCAL_CANDIDATES = 200 + const val MAX_RESULTS = 10 + const val MAX_ROUTE_RESULTS = 12 + const val MAX_SOURCE_REQUESTS = 4 + const val MAX_DETAIL_REQUESTS = 2 + const val MAX_ANILIST_MAPPINGS = 2 + const val NETWORK_SEED_SALT = 0x2A72B17C4D9E3051L + } +} + +private fun RecommendationRows.allCards(): List = creatorWorks + similarManga + +private fun Source.isLocalOrStub(): Boolean = this is LocalSource || this !is CatalogueSource + +private fun Source.getFilterList(): FilterList = (this as CatalogueSource).getFilterList() + +private suspend fun Source.getSearchManga( + page: Int, + query: String, + filters: FilterList, +) = (this as CatalogueSource).getSearchManga(page, query, filters) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/Anilist.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/Anilist.kt index 423a8438c1..659cf4b9d8 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/Anilist.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/Anilist.kt @@ -7,6 +7,7 @@ import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.database.models.Track import eu.kanade.tachiyomi.data.track.TrackService import eu.kanade.tachiyomi.data.track.anilist.dto.ALOAuth +import eu.kanade.tachiyomi.data.track.anilist.dto.ALRecommendation import eu.kanade.tachiyomi.data.track.updateNewTrackInfo import eu.kanade.tachiyomi.util.system.e import kotlinx.collections.immutable.ImmutableList @@ -43,6 +44,8 @@ class Anilist(private val context: Context, id: Long) : TrackService(id) { private val api by lazy { AnilistApi(client, interceptor) } + suspend fun recommendations(mediaId: Long): List = api.recommendations(mediaId) + override val supportsReadingDates: Boolean = true private val scorePreference = trackPreferences.anilistScoreType() diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/AnilistApi.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/AnilistApi.kt index 69e554f1aa..eab5b22128 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/AnilistApi.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/AnilistApi.kt @@ -5,7 +5,9 @@ import eu.kanade.tachiyomi.data.database.models.Track import eu.kanade.tachiyomi.data.track.anilist.dto.ALAddMangaResult import eu.kanade.tachiyomi.data.track.anilist.dto.ALCurrentUserResult import eu.kanade.tachiyomi.data.track.anilist.dto.ALOAuth +import eu.kanade.tachiyomi.data.track.anilist.dto.ALRecommendation import eu.kanade.tachiyomi.data.track.anilist.dto.ALSearchResult +import eu.kanade.tachiyomi.data.track.anilist.dto.ALRecommendationsResult import eu.kanade.tachiyomi.data.track.anilist.dto.ALUserListMangaQueryResult import eu.kanade.tachiyomi.data.track.model.TrackSearch import eu.kanade.tachiyomi.network.POST @@ -94,6 +96,23 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) { } } + suspend fun recommendations(mediaId: Long): List { + return withIOContext { + val payload = buildJsonObject { + put("query", recommendationsQuery()) + putJsonObject("variables") { + put("id", mediaId) + } + } + with(json) { + authClient.newCall(POST(API_URL, body = payload.toString().toRequestBody(jsonMime))) + .awaitSuccess() + .parseAs() + .recommendations() + } + } + } + suspend fun findLibManga(track: Track, userid: Int): Track? { return withIOContext { val payload = buildJsonObject { @@ -259,6 +278,37 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) { | """.trimMargin() + fun recommendationsQuery() = + """ + |query Recommendations(${'$'}id: Int) { + |Media(id: ${'$'}id, type: MANGA) { + |recommendations(page: 1, perPage: 4, sort: RATING_DESC) { + |edges { + |node { + |rating + |mediaRecommendation { + |id + |type + |title { + |userPreferred + |romaji + |english + |native + |} + |synonyms + |genres + |tags { + |name + |rank + |} + |} + |} + |} + |} + |} + |} + """.trimMargin() + fun findLibraryMangaQuery() = """ |query (${'$'}id: Int!, ${'$'}manga_id: Int!) { diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/dto/ALRecommendations.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/dto/ALRecommendations.kt new file mode 100644 index 0000000000..291e2c9b6f --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/dto/ALRecommendations.kt @@ -0,0 +1,87 @@ +package eu.kanade.tachiyomi.data.track.anilist.dto + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +private const val MAX_RECOMMENDATIONS = 4 + +@Serializable +data class ALRecommendationsResult( + val data: ALRecommendationsData, +) { + fun recommendations(): List { + return data.media + ?.recommendations + ?.edges + .orEmpty() + .mapNotNull { edge -> + edge.node.mediaRecommendation + ?.takeIf { it.type == "MANGA" } + ?.let { ALRecommendation(rating = edge.node.rating, media = it) } + } + .take(MAX_RECOMMENDATIONS) + } +} + +@Serializable +data class ALRecommendationsData( + @SerialName("Media") + val media: ALRecommendationSourceMedia? = null, +) + +@Serializable +data class ALRecommendationSourceMedia( + val recommendations: ALRecommendationsConnection? = null, +) + +@Serializable +data class ALRecommendationsConnection( + val edges: List = emptyList(), +) + +@Serializable +data class ALRecommendationEdge( + val node: ALRecommendationNode, +) + +@Serializable +data class ALRecommendationNode( + val rating: Int = 0, + val mediaRecommendation: ALRecommendationMedia? = null, +) + +data class ALRecommendation( + val rating: Int, + val media: ALRecommendationMedia, +) + +@Serializable +data class ALRecommendationMedia( + val id: Long, + val type: String, + val title: ALRecommendationTitle, + val synonyms: List = emptyList(), + val genres: List = emptyList(), + val tags: List = emptyList(), +) + +@Serializable +data class ALRecommendationTitle( + val userPreferred: String? = null, + val romaji: String? = null, + val english: String? = null, + val native: String? = null, +) { + fun variants(synonyms: List = emptyList()): List { + return listOfNotNull(userPreferred, romaji, english, native) + .plus(synonyms) + .filter(String::isNotBlank) + .distinct() + } +} + +@Serializable +data class ALRecommendationTag( + val name: String, + val rank: Int = 0, +) diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/controllers/RecommendationSourceSettingsController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/controllers/RecommendationSourceSettingsController.kt new file mode 100644 index 0000000000..1fb2485aa2 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/controllers/RecommendationSourceSettingsController.kt @@ -0,0 +1,39 @@ +package eu.kanade.tachiyomi.ui.setting.controllers + +import androidx.preference.PreferenceScreen +import eu.kanade.tachiyomi.source.SourceManager +import eu.kanade.tachiyomi.ui.setting.SettingsLegacyController +import eu.kanade.tachiyomi.ui.setting.defaultValue +import eu.kanade.tachiyomi.ui.setting.preferenceCategory +import eu.kanade.tachiyomi.ui.setting.switchPreference +import java.util.Locale +import uy.kohesive.injekt.injectLazy +import yokai.i18n.MR +import yokai.util.lang.getString +import eu.kanade.tachiyomi.ui.setting.titleMRes as titleRes + +class RecommendationSourceSettingsController : SettingsLegacyController() { + private val sourceManager: SourceManager by injectLazy() + + override fun setupPreferenceScreen(screen: PreferenceScreen) = screen.apply { + titleRes = MR.strings.recommendation_source_settings + + sourceManager.getOnlineSources() + .distinctBy { it.id } + .sortedWith(compareBy({ it.lang }, { it.name.lowercase(Locale.ROOT) })) + .groupBy { it.lang } + .forEach { (language, sources) -> + preferenceCategory { + title = language.uppercase(Locale.ROOT) + sources.forEach { source -> + switchPreference { + key = "recommendation_source_${source.id}_network_enabled_v1" + title = source.name + summary = context.getString(MR.strings.recommendation_source_enabled_summary) + defaultValue = false + } + } + } + } + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/controllers/SettingsLibraryController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/controllers/SettingsLibraryController.kt index 77ad3199ba..776a163b63 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/controllers/SettingsLibraryController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/controllers/SettingsLibraryController.kt @@ -55,6 +55,15 @@ class SettingsLibraryController : SettingsLegacyController() { titleRes = MR.strings.library preferenceCategory { titleRes = MR.strings.general + preference { + key = "recommendation_source_settings" + isPersistent = false + titleRes = MR.strings.recommendation_source_settings + summaryRes = MR.strings.recommendation_source_settings_summary + onClick { + router.pushController(RecommendationSourceSettingsController().withFadeTransaction()) + } + } switchPreference { key = Keys.removeArticles titleRes = MR.strings.sort_by_ignoring_articles diff --git a/app/src/test/java/eu/kanade/tachiyomi/data/recommendation/MangaRecommendationRepositoryTest.kt b/app/src/test/java/eu/kanade/tachiyomi/data/recommendation/MangaRecommendationRepositoryTest.kt new file mode 100644 index 0000000000..0d606e9872 --- /dev/null +++ b/app/src/test/java/eu/kanade/tachiyomi/data/recommendation/MangaRecommendationRepositoryTest.kt @@ -0,0 +1,226 @@ +package eu.kanade.tachiyomi.data.recommendation + +import eu.kanade.tachiyomi.network.HttpException +import eu.kanade.tachiyomi.data.database.models.create +import eu.kanade.tachiyomi.domain.manga.models.Manga +import eu.kanade.tachiyomi.source.CatalogueSource +import eu.kanade.tachiyomi.source.model.FilterList +import eu.kanade.tachiyomi.source.model.MangasPage +import eu.kanade.tachiyomi.source.model.Page +import eu.kanade.tachiyomi.source.model.SChapter +import eu.kanade.tachiyomi.source.model.SManga +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class MangaRecommendationRepositoryTest { + + @Test + fun `network disabled never calls the source`() = runTest { + val source = FakeSource() + val repository = repository(local = listOf(domainManga("local", tags = listOf("romance")))) + + repository.observe(source, sourceManga("target", tags = "romance"), null, false).toList() + + assertEquals(0, source.searchCalls) + assertEquals(0, source.detailCalls) + } + + @Test + fun `AniList is never queried without an existing track id`() = runTest { + var aniListCalls = 0 + val repository = MangaRecommendationRepository( + localCandidateLoader = { _, _ -> emptyList() }, + aniListLoader = { + aniListCalls++ + emptyList() + }, + seedProvider = { 1L }, + ) + + repository.observe( + source = FakeSource(), + manga = sourceManga("target", tags = "romance"), + aniListId = null, + allowNetwork = true, + ).toList() + + assertEquals(0, aniListCalls) + } + + @Test + fun `network budget is four requests with at most two details`() = runTest { + val source = FakeSource().apply { + search = { _, query, _ -> + if (query == "Creator") { + MangasPage((1..12).map { sourceManga("creator-$it") }, false) + } else { + MangasPage(listOf(sourceManga("similar", tags = "romance, school")), false) + } + } + details = { manga -> manga.copy().apply { author = "Creator" } } + } + val repository = repository( + local = listOf(domainManga("local", tags = listOf("romance", "school"))), + ) + val target = sourceManga("target", author = "Creator", tags = "romance, school") + + repository.observe(source, target, null, true).toList() + + assertEquals(2, source.searchCalls) + assertEquals(2, source.detailCalls) + assertEquals(4, source.searchCalls + source.detailCalls) + } + + @Test + fun `429 stops the page without a retry loop`() = runTest { + val source = FakeSource().apply { + search = { _, _, _ -> + throw HttpException(429, "30") + } + } + val repository = repository() + + repository.observe( + source, + sourceManga("target", author = "Creator", tags = "romance, school"), + null, + true, + ).toList() + + assertEquals(1, source.searchCalls) + assertEquals(0, source.detailCalls) + } + + @Test + fun `progressive emissions only append cards`() = runTest { + val source = FakeSource().apply { + search = { _, query, _ -> + when (query) { + "Creator" -> MangasPage( + listOf(sourceManga("network-creator", author = "Creator", tags = "romance, school")), + false, + ) + else -> MangasPage( + listOf(sourceManga("network-similar", author = "Other", tags = "romance, school")), + false, + ) + } + } + } + val repository = repository( + local = listOf(domainManga("local-similar", author = "Other", tags = listOf("romance", "school"))), + ) + + val emissions = repository.observe( + source, + sourceManga("target", author = "Creator", tags = "romance, school"), + null, + true, + ).toList() + + assertTrue(emissions.size >= 3) + emissions.zipWithNext().forEach { (previous, next) -> + assertTrue(next.creatorWorks.containsAll(previous.creatorWorks)) + assertTrue(next.similarManga.containsAll(previous.similarManga)) + } + } + + @Test + fun `local rows filter current excluded other sources and cross row duplicates`() = runTest { + val source = FakeSource() + val excluded = domainManga("excluded", author = "Other", tags = listOf("romance", "school")) + val repository = repository( + local = listOf( + domainManga("target", author = "Creator", tags = listOf("romance", "school")), + domainManga("creator", author = "Creator", tags = listOf("romance", "school")), + domainManga("similar", author = "Other", tags = listOf("romance", "school")), + excluded, + domainManga("other-source", sourceId = 2L, tags = listOf("romance", "school")), + ), + ) + val excludedKeys = RecommendationMetadata.card(1L, excluded.toSourceManga()).identity.exposureKeys + + val rows = repository.observe( + source, + sourceManga("target", author = "Creator", tags = "romance, school"), + null, + false, + excludedKeys, + ).toList().last() + val creatorUrls = rows.creatorWorks.map { it.manga.url } + val similarUrls = rows.similarManga.map { it.manga.url } + + assertEquals(listOf("creator"), creatorUrls) + assertEquals(listOf("similar"), similarUrls) + assertTrue(rows.creatorWorks.all { it.sourceId == source.id }) + assertTrue(rows.similarManga.all { it.sourceId == source.id }) + assertTrue((creatorUrls intersect similarUrls.toSet()).isEmpty()) + } + + private fun repository(local: List = emptyList()): MangaRecommendationRepository { + return MangaRecommendationRepository( + localCandidateLoader = { _, _ -> local }, + aniListLoader = { emptyList() }, + seedProvider = { 1L }, + ) + } + + private fun sourceManga( + url: String, + author: String? = null, + tags: String? = null, + ): SManga = SManga.create().apply { + this.url = url + title = url + this.author = author + genre = tags + initialized = true + } + + private fun domainManga( + url: String, + sourceId: Long = 1L, + author: String? = null, + tags: List? = null, + ): Manga = Manga.create(url, url, sourceId).apply { + id = url.hashCode().toLong() + this.author = author + genre = tags?.joinToString(", ") + initialized = true + } + + private fun Manga.toSourceManga(): SManga = sourceManga(url, author, genre) + + private class FakeSource : CatalogueSource { + override val id = 1L + override val name = "Fake" + override val lang = "en" + override val supportsLatest = false + var searchCalls = 0 + var detailCalls = 0 + var search: suspend (Int, String, FilterList) -> MangasPage = { _, _, _ -> MangasPage(emptyList(), false) } + var details: suspend (SManga) -> SManga = { it } + + override suspend fun getPopularManga(page: Int): MangasPage = error("Not used") + override suspend fun getLatestUpdates(page: Int): MangasPage = error("Not used") + + override suspend fun getSearchManga(page: Int, query: String, filters: FilterList): MangasPage { + searchCalls++ + return search(page, query, filters) + } + + override suspend fun getMangaDetails(manga: SManga): SManga { + detailCalls++ + return details(manga) + } + + override fun getFilterList(): FilterList = FilterList() + + override suspend fun getChapterList(manga: SManga): List = error("Not used") + + override suspend fun getPageList(chapter: SChapter): List = error("Not used") + } +} From 5110af97abe199e3bc23449ccfb4e0e514a6876d Mon Sep 17 00:00:00 2001 From: Kom1ch1 <267495674+Kom1ch1@users.noreply.github.com> Date: Thu, 30 Jul 2026 04:07:42 +0800 Subject: [PATCH 3/3] feat: show recommendations on manga details --- .../RecommendationNavigationTrail.kt | 39 +++++ .../tachiyomi/ui/manga/MangaDetailsAdapter.kt | 2 + .../ui/manga/MangaDetailsController.kt | 16 ++ .../ui/manga/MangaDetailsPresenter.kt | 145 +++++++++++++++++- .../tachiyomi/ui/manga/MangaHeaderHolder.kt | 17 ++ .../ui/manga/MangaRecommendationRowsView.kt | 141 +++++++++++++++++ .../util/manga/MangaCoverMetadata.kt | 5 +- .../layout-sw600dp-land/manga_header_item.xml | 12 +- .../layout-sw600dp-port/manga_header_item.xml | 12 +- app/src/main/res/layout/manga_header_item.xml | 12 +- .../res/layout/manga_recommendation_rows.xml | 65 ++++++++ .../moko-resources/base/strings.xml | 7 + 12 files changed, 465 insertions(+), 8 deletions(-) create mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/recommendation/RecommendationNavigationTrail.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaRecommendationRowsView.kt create mode 100644 app/src/main/res/layout/manga_recommendation_rows.xml diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/recommendation/RecommendationNavigationTrail.kt b/app/src/main/java/eu/kanade/tachiyomi/data/recommendation/RecommendationNavigationTrail.kt new file mode 100644 index 0000000000..cb9fd22292 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/recommendation/RecommendationNavigationTrail.kt @@ -0,0 +1,39 @@ +package eu.kanade.tachiyomi.data.recommendation + +import eu.kanade.tachiyomi.source.model.SManga +import java.util.ArrayDeque +import java.util.concurrent.ConcurrentHashMap + +internal object RecommendationNavigationTrail { + private val trails = ConcurrentHashMap>() + + @Synchronized + fun record(sourceId: Long, manga: SManga) { + val identity = RecommendationMetadata.identity(sourceId, manga) + val keys = identity.exposureKeys + if (identity.canonicalUrl.isBlank() && keys.isEmpty()) return + val trail = trails.getOrPut(sourceId) { ArrayDeque() } + trail.removeAll { entry -> + entry.canonicalUrl == identity.canonicalUrl || + entry.workKeys.intersect(keys).isNotEmpty() + } + trail.addFirst(Entry(identity.canonicalUrl, keys)) + while (trail.size > 8) trail.removeLast() + } + + @Synchronized + fun urls(sourceId: Long): Set = trails[sourceId] + .orEmpty() + .mapTo(linkedSetOf(), Entry::canonicalUrl) + .filterTo(linkedSetOf(), String::isNotBlank) + + @Synchronized + fun workKeys(sourceId: Long): Set = trails[sourceId] + .orEmpty() + .flatMapTo(linkedSetOf(), Entry::workKeys) + + private data class Entry( + val canonicalUrl: String, + val workKeys: Set, + ) +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaDetailsAdapter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaDetailsAdapter.kt index 0b95234433..5e49ca7c01 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaDetailsAdapter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaDetailsAdapter.kt @@ -7,6 +7,7 @@ import androidx.recyclerview.widget.ItemTouchHelper import dev.icerock.moko.resources.StringResource import eu.davidea.flexibleadapter.items.IFlexible import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.source.model.SManga import yokai.i18n.MR import yokai.util.lang.getString import dev.icerock.moko.resources.compose.stringResource @@ -150,5 +151,6 @@ class MangaDetailsAdapter( fun showTrackingSheet() fun updateScroll() fun setFavButtonPopup(popupView: View) + fun onRecommendationClick(manga: SManga) } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaDetailsController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaDetailsController.kt index 9e0a15e894..56a97070af 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaDetailsController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaDetailsController.kt @@ -69,11 +69,13 @@ import eu.kanade.tachiyomi.data.database.models.vibrantCoverColor import eu.kanade.tachiyomi.data.download.DownloadJob import eu.kanade.tachiyomi.data.download.model.Download import eu.kanade.tachiyomi.data.notification.NotificationReceiver +import eu.kanade.tachiyomi.data.recommendation.RecommendationNavigationTrail import eu.kanade.tachiyomi.data.track.model.TrackSearch import eu.kanade.tachiyomi.databinding.MangaDetailsControllerBinding import eu.kanade.tachiyomi.domain.manga.models.Manga import eu.kanade.tachiyomi.source.CatalogueSource import eu.kanade.tachiyomi.source.icon +import eu.kanade.tachiyomi.source.model.SManga import eu.kanade.tachiyomi.source.online.HttpSource import eu.kanade.tachiyomi.ui.base.MaterialMenuSheet import eu.kanade.tachiyomi.ui.base.SmallToolbarInterface @@ -918,6 +920,10 @@ class MangaDetailsController : updateMenuVisibility(activityBinding?.toolbar?.menu) } + fun updateRecommendations() { + getHeader()?.bindRecommendations() + } + private fun addMangaHeader() { val tabletHeader = presenter.tabletChapterHeaderItem if (tabletHeader != null && tabletAdapter?.scrollableHeaders?.isEmpty() == true) { @@ -1771,6 +1777,16 @@ class MangaDetailsController : snack = view.snack(view.context.getString(MR.strings.added_to_library)) } + override fun onRecommendationClick(manga: SManga) { + viewScope.launchIO { + val local = presenter.recommendationToLocal(manga) + RecommendationNavigationTrail.record(presenter.manga.source, presenter.manga) + withUIContext { + router.pushController(MangaDetailsController(local, true).withFadeTransaction()) + } + } + } + override fun mangaPresenter(): MangaDetailsPresenter = presenter /** diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaDetailsPresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaDetailsPresenter.kt index fa950d9661..cfd75fea04 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaDetailsPresenter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaDetailsPresenter.kt @@ -20,6 +20,7 @@ import eu.kanade.tachiyomi.data.database.models.bookmarkedFilter import eu.kanade.tachiyomi.data.database.models.chapterOrder import eu.kanade.tachiyomi.data.database.models.downloadedFilter import eu.kanade.tachiyomi.data.database.models.prepareCoverUpdate +import eu.kanade.tachiyomi.data.database.models.create import eu.kanade.tachiyomi.data.database.models.readFilter import eu.kanade.tachiyomi.data.database.models.removeCover import eu.kanade.tachiyomi.data.database.models.sortDescending @@ -30,6 +31,11 @@ import eu.kanade.tachiyomi.data.download.model.DownloadQueue import eu.kanade.tachiyomi.data.library.CustomMangaManager import eu.kanade.tachiyomi.data.library.LibraryUpdateJob import eu.kanade.tachiyomi.data.preference.PreferencesHelper +import eu.kanade.tachiyomi.data.recommendation.MangaRecommendationRepository +import eu.kanade.tachiyomi.data.recommendation.RecommendationCard +import eu.kanade.tachiyomi.data.recommendation.RecommendationMetadata +import eu.kanade.tachiyomi.data.recommendation.RecommendationNavigationTrail +import eu.kanade.tachiyomi.data.recommendation.RecommendationRows import eu.kanade.tachiyomi.data.track.EnhancedTrackService import eu.kanade.tachiyomi.data.track.TrackManager import eu.kanade.tachiyomi.data.track.TrackService @@ -74,6 +80,7 @@ import java.io.File import java.io.FileOutputStream import java.io.OutputStream import java.util.Locale +import java.util.concurrent.atomic.AtomicLong import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -81,6 +88,7 @@ import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach @@ -97,6 +105,7 @@ import yokai.domain.chapter.interactor.UpdateChapter import yokai.domain.history.interactor.GetHistory import yokai.domain.library.custom.model.CustomMangaInfo import yokai.domain.manga.interactor.GetManga +import yokai.domain.manga.interactor.InsertManga import yokai.domain.manga.interactor.UpdateManga import yokai.domain.manga.models.MangaUpdate import yokai.domain.manga.models.cover @@ -127,6 +136,7 @@ class MangaDetailsPresenter( private val getTrack: GetTrack by injectLazy() private val insertTrack: InsertTrack by injectLazy() private val getHistory: GetHistory by injectLazy() + private val insertManga: InsertManga by injectLazy() private val networkPreferences: NetworkPreferences by injectLazy() @@ -138,6 +148,23 @@ class MangaDetailsPresenter( private val customMangaManager: CustomMangaManager by injectLazy() private val mangaShortcutManager: MangaShortcutManager by injectLazy() + private val trackManager: TrackManager by injectLazy() + private val recommendationRepository by lazy { + MangaRecommendationRepository( + localCandidateLoader = { sourceId, excludedUrl -> + getManga.awaitRecommendationCandidates(sourceId, excludedUrl) + }, + aniListLoader = trackManager.aniList::recommendations, + ) + } + private var recommendationJob: Job? = null + private var recommendationTarget: String? = null + private var recommendationCompletedTarget: String? = null + private val recommendationGeneration = AtomicLong() + var recommendationRows: RecommendationRows = RecommendationRows() + private set + var recommendationFavoriteUrls: Set = emptySet() + private set val source: Source by lazy { sourceManager.getOrStub(manga.source) } @@ -149,7 +176,7 @@ class MangaDetailsPresenter( var isLoading = false var scrollType = 0 - private val loggedServices by lazy { Injekt.get().services.filter { it.isLogged } } + private val loggedServices by lazy { trackManager.services.filter { it.isLogged } } private var tracks = emptyList() var trackList: List = emptyList() @@ -224,6 +251,12 @@ class MangaDetailsPresenter( .onEach { onUpdateManga() } .launchIn(presenterScope) + preferences.recommendationSourceNetworkEnabled(manga.source) + .changes() + .drop(1) + .onEach { loadRecommendations(forceRefresh = true) } + .launchIn(presenterScope) + val fetchMangaNeeded = !manga.initialized val fetchChaptersNeeded = runBlocking { getChaptersNow() }.isEmpty() @@ -245,6 +278,7 @@ class MangaDetailsPresenter( } setTrackItems() + loadRecommendations() } refreshTracking(false) @@ -565,9 +599,84 @@ class MangaDetailsPresenter( withUIContext { view?.updateChapters() } + loadRecommendations(forceRefresh = true) } } + fun loadRecommendations(forceRefresh: Boolean = false) { + if (!::manga.isInitialized) return + val metadataFingerprint = listOf( + manga.initialized, + manga.author, + manga.artist, + manga.genre, + manga.description, + ).joinToString("|").hashCode() + val target = "${manga.source}:${manga.url}:$metadataFingerprint" + if (!forceRefresh && recommendationTarget == target && recommendationJob?.isActive == true) return + if (!forceRefresh && recommendationCompletedTarget == target) { + view?.updateRecommendations() + return + } + recommendationJob?.cancel() + recommendationTarget = target + recommendationCompletedTarget = null + val generation = recommendationGeneration.incrementAndGet() + val targetIdentity = RecommendationMetadata.identity(manga.source, manga.copy()) + if (forceRefresh) { + recommendationRepository.invalidate(manga.source, targetIdentity.exposureKey) + } + recommendationJob = presenterScope.launchIO { + try { + recommendationRepository.observe( + source = source, + manga = manga.copy(), + aniListId = tracks.firstOrNull { it.sync_id == TrackManager.ANILIST }?.media_id, + allowNetwork = preferences.recommendationSourceNetworkEnabled(manga.source).get(), + excludedKeys = RecommendationNavigationTrail.workKeys(manga.source), + ).collectLatest { freshRows -> + if ( + recommendationTarget != target || + recommendationGeneration.get() != generation + ) { + return@collectLatest + } + recommendationRows = recommendationRows.append(freshRows) + recommendationFavoriteUrls = recommendationFavoriteUrls + + (recommendationRows.creatorWorks + recommendationRows.similarManga) + .mapNotNull { item -> + getManga.awaitByUrlAndSource(item.manga.url, manga.source) + ?.takeIf(Manga::favorite) + ?.url + } + withUIContext { view?.updateRecommendations() } + } + } finally { + if (recommendationTarget == target && recommendationGeneration.get() == generation) { + recommendationCompletedTarget = target + } + } + } + } + + suspend fun recommendationToLocal(sourceManga: SManga): Manga { + check(source.id == manga.source) { "Recommendation source changed while opening a card" } + var local = getManga.awaitByUrlAndSource(sourceManga.url, source.id) + if (local == null) { + local = Manga.create(sourceManga.url, sourceManga.title, source.id).apply { + copyFrom(sourceManga) + initialized = sourceManga.hasUsableRecommendationDetails() + } + local.id = insertManga.await(local) + } else if (!local.favorite) { + local.title = sourceManga.title + local.copyFrom(sourceManga) + local.initialized = sourceManga.hasUsableRecommendationDetails() + updateManga.await(local.toMangaUpdate()) + } + return local + } + private fun trimException(e: java.lang.Exception): String { return ( if (e !is SourceNotFoundException && @@ -1196,3 +1305,37 @@ class MangaDetailsPresenter( const val MULTIPLE_SEASONS = 3 } } + +private fun RecommendationRows.append(update: RecommendationRows): RecommendationRows { + val creators = creatorWorks.appendDistinct(update.creatorWorks) + val similar = similarManga.appendDistinct(update.similarManga) + .filterNot { similarCard -> + creators.any { creatorCard -> + RecommendationMetadata.sameWork(similarCard.identity, creatorCard.identity) + } + } + .take(MAX_RECOMMENDATIONS_PER_ROW) + return RecommendationRows(creatorWorks = creators, similarManga = similar) +} + +private fun List.appendDistinct( + update: List, +): List { + val result = toMutableList() + update.forEach { candidate -> + if (result.none { RecommendationMetadata.sameWork(it.identity, candidate.identity) }) { + result += candidate + } + } + return result.take(MAX_RECOMMENDATIONS_PER_ROW) +} + +private fun SManga.hasUsableRecommendationDetails(): Boolean { + if (!initialized) return false + return !author.isNullOrBlank() || + !artist.isNullOrBlank() || + !description.isNullOrBlank() || + !genre.isNullOrBlank() +} + +private const val MAX_RECOMMENDATIONS_PER_ROW = 10 diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaHeaderHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaHeaderHolder.kt index d05ca1b95e..374edea38b 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaHeaderHolder.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaHeaderHolder.kt @@ -171,6 +171,11 @@ class MangaHeaderHolder( applyBlur() mangaCover.setOnClickListener { adapter.delegate.zoomImageFromThumb(coverCard) } trackButton.setOnClickListener { adapter.delegate.showTrackingSheet() } + recommendationRows.callbacks = object : MangaRecommendationRowsView.Callbacks { + override fun onRecommendationClick(manga: SManga) { + adapter.delegate.onRecommendationClick(manga) + } + } if (startExpanded) { expandDesc() } else { @@ -456,6 +461,7 @@ class MangaHeaderHolder( } binding.filtersText.text = presenter.currentFilters() + bindRecommendations() if (manga.isLocal()) { binding.webviewButton.isVisible = false @@ -469,6 +475,17 @@ class MangaHeaderHolder( } } + fun bindRecommendations() { + val binding = binding ?: return + val presenter = adapter.delegate.mangaPresenter() + binding.recommendationRows.bind( + sourceId = presenter.manga.source, + creatorWorks = presenter.recommendationRows.creatorWorks, + similarManga = presenter.recommendationRows.similarManga, + favoriteUrls = presenter.recommendationFavoriteUrls, + ) + } + private fun setGenreTags(binding: MangaHeaderItemBinding, manga: Manga) { with(binding.mangaGenresTags) { removeAllViews() diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaRecommendationRowsView.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaRecommendationRowsView.kt new file mode 100644 index 0000000000..62683ab757 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaRecommendationRowsView.kt @@ -0,0 +1,141 @@ +package eu.kanade.tachiyomi.ui.manga + +import android.content.Context +import android.util.AttributeSet +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.LinearLayout +import androidx.core.view.isVisible +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import coil3.dispose +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.database.models.create +import eu.kanade.tachiyomi.data.recommendation.RecommendationCard +import eu.kanade.tachiyomi.databinding.SourceGlobalSearchControllerCardItemBinding +import eu.kanade.tachiyomi.domain.manga.models.Manga +import eu.kanade.tachiyomi.source.model.SManga +import yokai.domain.manga.models.cover +import yokai.util.coil.loadManga + +class MangaRecommendationRowsView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, +) : LinearLayout(context, attrs) { + private val creatorRow: View + private val similarRow: View + private val creatorAdapter = RecommendationCardAdapter() + private val similarAdapter = RecommendationCardAdapter() + + var callbacks: Callbacks? = null + set(value) { + field = value + creatorAdapter.callbacks = value + similarAdapter.callbacks = value + } + + init { + orientation = VERTICAL + LayoutInflater.from(context).inflate(R.layout.manga_recommendation_rows, this, true) + creatorRow = findViewById(R.id.creator_recommendation_row) + similarRow = findViewById(R.id.similar_recommendation_row) + findViewById(R.id.creator_recommendation_list).apply { + layoutManager = LinearLayoutManager(context, RecyclerView.HORIZONTAL, false) + adapter = creatorAdapter + itemAnimator = null + } + findViewById(R.id.similar_recommendation_list).apply { + layoutManager = LinearLayoutManager(context, RecyclerView.HORIZONTAL, false) + adapter = similarAdapter + itemAnimator = null + } + isVisible = false + } + + fun bind( + sourceId: Long, + creatorWorks: List, + similarManga: List, + favoriteUrls: Set, + ) { + creatorAdapter.submit(sourceId, creatorWorks, favoriteUrls) + similarAdapter.submit(sourceId, similarManga, favoriteUrls) + creatorRow.isVisible = creatorWorks.isNotEmpty() + similarRow.isVisible = similarManga.isNotEmpty() + isVisible = creatorWorks.isNotEmpty() || similarManga.isNotEmpty() + } + + interface Callbacks { + fun onRecommendationClick(manga: SManga) + } + + private class RecommendationCardAdapter : RecyclerView.Adapter() { + private var sourceId = 0L + private var items = emptyList() + private var favoriteUrls = emptySet() + var callbacks: Callbacks? = null + + init { + setHasStableIds(true) + } + + fun submit(sourceId: Long, next: List, favorites: Set) { + val previous = items + this.sourceId = sourceId + items = next + favoriteUrls = favorites + DiffUtil.calculateDiff(object : DiffUtil.Callback() { + override fun getOldListSize() = previous.size + override fun getNewListSize() = next.size + override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean = + previous[oldItemPosition].identity.exposureKey == next[newItemPosition].identity.exposureKey + override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean = + previous[oldItemPosition].manga.title == next[newItemPosition].manga.title && + previous[oldItemPosition].manga.thumbnail_url == next[newItemPosition].manga.thumbnail_url && + isFavorite(previous[oldItemPosition]) == isFavorite(next[newItemPosition]) + }).dispatchUpdatesTo(this) + } + + override fun getItemId(position: Int): Long = + 31L * sourceId + items[position].identity.exposureKey.hashCode() + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): CardHolder { + val binding = SourceGlobalSearchControllerCardItemBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false, + ) + return CardHolder(binding) + } + + override fun getItemCount() = items.size + + override fun onBindViewHolder(holder: CardHolder, position: Int) { + val item = items[position] + holder.bind(sourceId, item.manga, isFavorite(item)) + holder.itemView.setOnClickListener { callbacks?.onRecommendationClick(item.manga) } + } + + private fun isFavorite(item: RecommendationCard): Boolean = + item.favorite || item.manga.url in favoriteUrls + } + + private class CardHolder( + private val binding: SourceGlobalSearchControllerCardItemBinding, + ) : RecyclerView.ViewHolder(binding.root) { + fun bind(sourceId: Long, item: SManga, favorite: Boolean) { + binding.title.text = item.title + binding.favoriteButton.isVisible = favorite + binding.itemImage.dispose() + binding.itemImage.setImageDrawable(null) + if (!item.thumbnail_url.isNullOrBlank()) { + val coverManga = Manga.create(item.url, item.title, sourceId).apply { + copyFrom(item) + } + binding.itemImage.loadManga(coverManga.cover(), binding.progress) + } + } + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/manga/MangaCoverMetadata.kt b/app/src/main/java/eu/kanade/tachiyomi/util/manga/MangaCoverMetadata.kt index d8be785576..98b7cfaaab 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/util/manga/MangaCoverMetadata.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/util/manga/MangaCoverMetadata.kt @@ -120,11 +120,13 @@ object MangaCoverMetadata { fun getColors(manga: Manga): Pair? = getColors(manga.id) fun getColors(mangaId: Long?): Pair? { + mangaId ?: return null return coverColorMap[mangaId] } fun getRatio(manga: Manga): Float? { - return coverRatioMap[manga.id] + val mangaId = manga.id ?: return null + return coverRatioMap[mangaId] } fun setVibrantColor(mangaId: Long?, @ColorInt color: Int?) { @@ -139,6 +141,7 @@ object MangaCoverMetadata { } fun getVibrantColor(mangaId: Long?): Int? { + mangaId ?: return null return vibrantCoverColorMap[mangaId] } diff --git a/app/src/main/res/layout-sw600dp-land/manga_header_item.xml b/app/src/main/res/layout-sw600dp-land/manga_header_item.xml index 04213ee8c6..83b510f010 100644 --- a/app/src/main/res/layout-sw600dp-land/manga_header_item.xml +++ b/app/src/main/res/layout-sw600dp-land/manga_header_item.xml @@ -166,7 +166,7 @@ android:clipToPadding="false" android:requiresFadingEdge="horizontal" android:scrollbars="none" - app:layout_constraintBottom_toTopOf="@id/start_reading_button" + app:layout_constraintBottom_toTopOf="@id/recommendation_rows" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@id/bottom_line"> @@ -305,6 +305,14 @@ app:layout_constraintEnd_toEndOf="parent" app:rippleColor="@null" /> + + + app:layout_constraintTop_toBottomOf="@id/recommendation_rows"> + + + app:layout_constraintTop_toBottomOf="@id/recommendation_rows"> + + + app:layout_constraintTop_toBottomOf="@id/recommendation_rows"> + + + + + + + + + + + + + + + + + diff --git a/i18n/src/commonMain/moko-resources/base/strings.xml b/i18n/src/commonMain/moko-resources/base/strings.xml index cc9ec86838..05b9edc3ec 100644 --- a/i18n/src/commonMain/moko-resources/base/strings.xml +++ b/i18n/src/commonMain/moko-resources/base/strings.xml @@ -1201,4 +1201,11 @@ Restart the application Refresh + + + More by the same creators + Similar manga + Source recommendations + Allow optional recommendation searches for individual sources + Use this source’s search API for additional recommendations