248 lines
9.9 KiB
Kotlin
248 lines
9.9 KiB
Kotlin
|
|
package exh.recs.batch
|
||
|
|
|
||
|
|
import android.content.Context
|
||
|
|
import android.net.wifi.WifiManager
|
||
|
|
import android.os.PowerManager
|
||
|
|
import androidx.annotation.StringRes
|
||
|
|
import androidx.core.net.toUri
|
||
|
|
import eu.kanade.domain.manga.model.toSManga
|
||
|
|
import eu.kanade.tachiyomi.source.model.SManga
|
||
|
|
import exh.log.xLog
|
||
|
|
import exh.recs.sources.RecommendationPagingSource
|
||
|
|
import exh.recs.sources.SourceCatalogue
|
||
|
|
import exh.recs.sources.TrackerRecommendationPagingSource
|
||
|
|
import exh.smartsearch.SmartLibrarySearchEngine
|
||
|
|
import exh.util.ThrottleManager
|
||
|
|
import exh.util.createPartialWakeLock
|
||
|
|
import exh.util.createWifiLock
|
||
|
|
import exh.util.ignore
|
||
|
|
import kotlinx.coroutines.CancellationException
|
||
|
|
import kotlinx.coroutines.CoroutineScope
|
||
|
|
import kotlinx.coroutines.Dispatchers
|
||
|
|
import kotlinx.coroutines.Job
|
||
|
|
import kotlinx.coroutines.async
|
||
|
|
import kotlinx.coroutines.awaitAll
|
||
|
|
import kotlinx.coroutines.ensureActive
|
||
|
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||
|
|
import kotlinx.coroutines.launch
|
||
|
|
import mihon.domain.manga.model.toDomainManga
|
||
|
|
import tachiyomi.data.source.NoResultsException
|
||
|
|
import tachiyomi.domain.UnsortedPreferences
|
||
|
|
import tachiyomi.domain.library.model.LibraryManga
|
||
|
|
import tachiyomi.domain.manga.interactor.GetLibraryManga
|
||
|
|
import tachiyomi.domain.manga.interactor.NetworkToLocalManga
|
||
|
|
import tachiyomi.domain.manga.model.Manga
|
||
|
|
import tachiyomi.domain.track.interactor.GetTracks
|
||
|
|
import tachiyomi.domain.track.model.Track
|
||
|
|
import uy.kohesive.injekt.injectLazy
|
||
|
|
import java.io.Serializable
|
||
|
|
import java.util.Collections
|
||
|
|
import kotlin.coroutines.coroutineContext
|
||
|
|
import kotlin.time.Duration.Companion.milliseconds
|
||
|
|
import kotlin.time.Duration.Companion.seconds
|
||
|
|
|
||
|
|
class RecommendationSearchHelper(val context: Context) {
|
||
|
|
private val getLibraryManga: GetLibraryManga by injectLazy()
|
||
|
|
private val getTracks: GetTracks by injectLazy()
|
||
|
|
private val networkToLocalManga: NetworkToLocalManga by injectLazy()
|
||
|
|
private val prefs: UnsortedPreferences by injectLazy()
|
||
|
|
|
||
|
|
private var wifiLock: WifiManager.WifiLock? = null
|
||
|
|
private var wakeLock: PowerManager.WakeLock? = null
|
||
|
|
|
||
|
|
private val smartSearchEngine by lazy { SmartLibrarySearchEngine() }
|
||
|
|
|
||
|
|
private val logger by lazy { xLog() }
|
||
|
|
|
||
|
|
val status: MutableStateFlow<SearchStatus> = MutableStateFlow(SearchStatus.Idle)
|
||
|
|
|
||
|
|
@Synchronized
|
||
|
|
fun runSearch(scope: CoroutineScope, mangaList: List<Manga>): Job? {
|
||
|
|
if (status.value !is SearchStatus.Idle) {
|
||
|
|
return null
|
||
|
|
}
|
||
|
|
|
||
|
|
status.value = SearchStatus.Initializing
|
||
|
|
|
||
|
|
return scope.launch(Dispatchers.IO) { beginSearch(mangaList) }
|
||
|
|
}
|
||
|
|
|
||
|
|
private suspend fun beginSearch(mangaList: List<Manga>) {
|
||
|
|
val flags = prefs.recommendationSearchFlags().get()
|
||
|
|
val libraryManga = getLibraryManga.await()
|
||
|
|
val tracks = getTracks.await()
|
||
|
|
|
||
|
|
// Trackers such as MAL need to be throttled more strictly
|
||
|
|
val stricterThrottling = SearchFlags.hasIncludeTrackers(flags)
|
||
|
|
|
||
|
|
val throttleManager =
|
||
|
|
ThrottleManager(
|
||
|
|
max = 3.seconds,
|
||
|
|
inc = 50.milliseconds,
|
||
|
|
initial = if (stricterThrottling) 2.seconds else 0.seconds,
|
||
|
|
)
|
||
|
|
|
||
|
|
try {
|
||
|
|
// Take wake + wifi locks
|
||
|
|
ignore { wakeLock?.release() }
|
||
|
|
wakeLock = ignore { context.createPartialWakeLock("tsy:RecommendationSearchWakelock") }
|
||
|
|
ignore { wifiLock?.release() }
|
||
|
|
wifiLock = ignore { context.createWifiLock("tsy:RecommendationSearchWifiLock") }
|
||
|
|
|
||
|
|
// Map of results grouped by recommendation source
|
||
|
|
val resultsMap = Collections.synchronizedMap(mutableMapOf<String, SearchResults>())
|
||
|
|
|
||
|
|
mangaList.forEachIndexed { index, sourceManga ->
|
||
|
|
// Check if the job has been cancelled
|
||
|
|
coroutineContext.ensureActive()
|
||
|
|
|
||
|
|
status.value = SearchStatus.Processing(sourceManga.toSManga(), index + 1, mangaList.size)
|
||
|
|
|
||
|
|
val jobs = RecommendationPagingSource.createSources(
|
||
|
|
sourceManga,
|
||
|
|
// KMK -->
|
||
|
|
SourceCatalogue(sourceManga.source),
|
||
|
|
// KMK <--
|
||
|
|
).mapNotNull { source ->
|
||
|
|
// Apply source filters
|
||
|
|
if (source is TrackerRecommendationPagingSource && !SearchFlags.hasIncludeTrackers(flags)) {
|
||
|
|
return@mapNotNull null
|
||
|
|
}
|
||
|
|
|
||
|
|
if (source.associatedSourceId != null && !SearchFlags.hasIncludeSources(flags)) {
|
||
|
|
return@mapNotNull null
|
||
|
|
}
|
||
|
|
|
||
|
|
// Parallelize fetching recommendations from all sources in the current context
|
||
|
|
CoroutineScope(coroutineContext).async(Dispatchers.IO) {
|
||
|
|
val recSourceId = source::class.qualifiedName!!
|
||
|
|
|
||
|
|
try {
|
||
|
|
val page = source.requestNextPage(1)
|
||
|
|
|
||
|
|
// Try to filter out mangas that are already in the library
|
||
|
|
val mangas = page.mangas
|
||
|
|
.filterLibraryItemsIfEnabled(source, libraryManga, tracks)
|
||
|
|
|
||
|
|
// Add or update the result collection for the current source
|
||
|
|
resultsMap.getOrPut(recSourceId) {
|
||
|
|
SearchResults(
|
||
|
|
recSourceName = source.name,
|
||
|
|
recSourceCategoryResId = source.category.resourceId,
|
||
|
|
recAssociatedSourceId = source.associatedSourceId,
|
||
|
|
results = mutableListOf(),
|
||
|
|
)
|
||
|
|
}.results.addAll(mangas)
|
||
|
|
} catch (_: NoResultsException) {
|
||
|
|
} catch (e: Exception) {
|
||
|
|
logger.e("Error while fetching recommendations for $recSourceId", e)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
jobs.awaitAll()
|
||
|
|
|
||
|
|
// Continuously slow down the search to avoid hitting rate limits
|
||
|
|
throttleManager.throttle()
|
||
|
|
}
|
||
|
|
|
||
|
|
val rankedMap = resultsMap.map {
|
||
|
|
RankedSearchResults(
|
||
|
|
recSourceName = it.value.recSourceName,
|
||
|
|
recSourceCategoryResId = it.value.recSourceCategoryResId,
|
||
|
|
recAssociatedSourceId = it.value.recAssociatedSourceId,
|
||
|
|
results = it.value.results
|
||
|
|
// Group by URL and count occurrences
|
||
|
|
.groupingBy(SManga::url)
|
||
|
|
.eachCount()
|
||
|
|
.entries
|
||
|
|
// Sort by occurrences desc
|
||
|
|
.sortedByDescending(Map.Entry<String, Int>::value)
|
||
|
|
// Resolve SManga instances from URL keys
|
||
|
|
.associate { (url, count) ->
|
||
|
|
val manga = it.value.results.first { manga -> manga.url == url }
|
||
|
|
manga to count
|
||
|
|
},
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
status.value = when {
|
||
|
|
rankedMap.isNotEmpty() -> SearchStatus.Finished.WithResults(rankedMap)
|
||
|
|
else -> SearchStatus.Finished.WithoutResults
|
||
|
|
}
|
||
|
|
} catch (_: CancellationException) {
|
||
|
|
} catch (e: Exception) {
|
||
|
|
status.value = SearchStatus.Error(e.message.orEmpty())
|
||
|
|
logger.e("Error during recommendation search", e)
|
||
|
|
return
|
||
|
|
} finally {
|
||
|
|
// Release wake + wifi locks
|
||
|
|
ignore {
|
||
|
|
wakeLock?.release()
|
||
|
|
wakeLock = null
|
||
|
|
}
|
||
|
|
ignore {
|
||
|
|
wifiLock?.release()
|
||
|
|
wifiLock = null
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private suspend fun List<SManga>.filterLibraryItemsIfEnabled(
|
||
|
|
recSource: RecommendationPagingSource,
|
||
|
|
libraryManga: List<LibraryManga>,
|
||
|
|
tracks: List<Track>,
|
||
|
|
): List<SManga> {
|
||
|
|
val flags = prefs.recommendationSearchFlags().get()
|
||
|
|
|
||
|
|
if (!SearchFlags.hasHideLibraryResults(flags)) {
|
||
|
|
return this
|
||
|
|
}
|
||
|
|
|
||
|
|
return filterNot { manga ->
|
||
|
|
// Source recommendations can be directly resolved, if the recommendation is from the same source
|
||
|
|
recSource.associatedSourceId?.let { srcId ->
|
||
|
|
return@filterNot networkToLocalManga(manga.toDomainManga(srcId))
|
||
|
|
.let { local -> libraryManga.any { it.id == local.id } }
|
||
|
|
}
|
||
|
|
|
||
|
|
// Tracker recommendations can be resolved by checking if the tracker is attached to the recommendation
|
||
|
|
if (recSource is TrackerRecommendationPagingSource) {
|
||
|
|
recSource.associatedTrackerId?.let { trackerId ->
|
||
|
|
return@filterNot tracks.any {
|
||
|
|
it.trackerId == trackerId && it.remoteUrl.toUri().path == manga.url.toUri().path
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Fallback to smart search otherwise
|
||
|
|
smartSearchEngine.smartSearch(libraryManga, manga.title) != null
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Contains the search results for a single source
|
||
|
|
private typealias SearchResults = Results<MutableList<SManga>>
|
||
|
|
|
||
|
|
// Contains the ranked search results for a single source
|
||
|
|
typealias RankedSearchResults = Results<Map<SManga, Int>>
|
||
|
|
|
||
|
|
data class Results<T>(
|
||
|
|
val recSourceName: String,
|
||
|
|
@StringRes val recSourceCategoryResId: Int,
|
||
|
|
val recAssociatedSourceId: Long?,
|
||
|
|
val results: T,
|
||
|
|
) : Serializable
|
||
|
|
|
||
|
|
sealed interface SearchStatus {
|
||
|
|
data object Idle : SearchStatus
|
||
|
|
data object Initializing : SearchStatus
|
||
|
|
data class Processing(val manga: SManga, val current: Int, val total: Int) : SearchStatus
|
||
|
|
data class Error(val message: String) : SearchStatus
|
||
|
|
data object Cancelling : SearchStatus
|
||
|
|
|
||
|
|
sealed interface Finished : SearchStatus {
|
||
|
|
data class WithResults(val results: List<RankedSearchResults>) : Finished
|
||
|
|
data object WithoutResults : Finished
|
||
|
|
}
|
||
|
|
}
|