mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-08 15:50:16 +02:00
Rewrite SuggestionsCache & limit parallelism (#851)
## Description A rewrite of the `SuggestionsCache` to simplify it as a read-through cache. The downside of this is that updated suggestions won't be reflected in the UI unless the user reloads the page, but I think that's an okay trade off. Also adds parallelism limits to fetching suggestions to reduce the number of simultaneous API calls. And moved the combining logic to use the `Default` dispatcher to free up an IO one. Also adds an initial delay to both the suggestions & tv provider workers, so a cold start of the app isn't starved. ### Related issues Related to #849 ### Testing Emulator & unit testing ## Screenshots N/A ## AI or LLM usage None
This commit is contained in:
parent
28ed1a491b
commit
b06761eaa5
12 changed files with 99 additions and 223 deletions
|
|
@ -285,6 +285,8 @@ dependencies {
|
||||||
ksp(libs.auto.service.ksp)
|
ksp(libs.auto.service.ksp)
|
||||||
implementation(platform(libs.okhttp.bom))
|
implementation(platform(libs.okhttp.bom))
|
||||||
implementation(libs.okhttp)
|
implementation(libs.okhttp)
|
||||||
|
implementation(libs.kache)
|
||||||
|
implementation(libs.kache.file)
|
||||||
|
|
||||||
androidTestImplementation(platform(libs.androidx.compose.bom))
|
androidTestImplementation(platform(libs.androidx.compose.bom))
|
||||||
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
|
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,6 @@ import androidx.lifecycle.DefaultLifecycleObserver
|
||||||
import androidx.lifecycle.LifecycleOwner
|
import androidx.lifecycle.LifecycleOwner
|
||||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||||
import dagger.hilt.android.scopes.ActivityRetainedScoped
|
import dagger.hilt.android.scopes.ActivityRetainedScoped
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -19,7 +16,6 @@ class PlaybackLifecycleObserver
|
||||||
private val navigationManager: NavigationManager,
|
private val navigationManager: NavigationManager,
|
||||||
private val playerFactory: PlayerFactory,
|
private val playerFactory: PlayerFactory,
|
||||||
private val themeSongPlayer: ThemeSongPlayer,
|
private val themeSongPlayer: ThemeSongPlayer,
|
||||||
private val suggestionsCache: SuggestionsCache,
|
|
||||||
) : DefaultLifecycleObserver {
|
) : DefaultLifecycleObserver {
|
||||||
private var wasPlaying: Boolean? = null
|
private var wasPlaying: Boolean? = null
|
||||||
|
|
||||||
|
|
@ -52,6 +48,5 @@ class PlaybackLifecycleObserver
|
||||||
|
|
||||||
override fun onStop(owner: LifecycleOwner) {
|
override fun onStop(owner: LifecycleOwner) {
|
||||||
themeSongPlayer.stop()
|
themeSongPlayer.stop()
|
||||||
CoroutineScope(Dispatchers.Main).launch { suggestionsCache.save() }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,6 @@ import com.github.damontecres.wholphin.data.model.BaseItem
|
||||||
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
|
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
|
||||||
import kotlinx.coroutines.flow.flatMapLatest
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
import kotlinx.coroutines.flow.flow
|
import kotlinx.coroutines.flow.flow
|
||||||
import kotlinx.coroutines.flow.flowOf
|
import kotlinx.coroutines.flow.flowOf
|
||||||
|
|
@ -50,32 +49,31 @@ class SuggestionService
|
||||||
.asFlow()
|
.asFlow()
|
||||||
.flatMapLatest { user ->
|
.flatMapLatest { user ->
|
||||||
val userId = user?.id ?: return@flatMapLatest flowOf(SuggestionsResource.Empty)
|
val userId = user?.id ?: return@flatMapLatest flowOf(SuggestionsResource.Empty)
|
||||||
|
val cachedIds = cache.get(userId, parentId, itemKind)?.ids.orEmpty()
|
||||||
cache.cacheVersion
|
if (cachedIds.isNotEmpty()) {
|
||||||
.map { cache.get(userId, parentId, itemKind)?.ids.orEmpty() }
|
flow {
|
||||||
.distinctUntilChanged()
|
try {
|
||||||
.flatMapLatest { cachedIds ->
|
emit(
|
||||||
if (cachedIds.isNotEmpty()) {
|
SuggestionsResource.Success(
|
||||||
flow {
|
fetchItemsByIds(cachedIds, itemKind),
|
||||||
try {
|
),
|
||||||
emit(SuggestionsResource.Success(fetchItemsByIds(cachedIds, itemKind)))
|
)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Timber.e(e, "Failed to fetch items")
|
Timber.e(e, "Failed to fetch items")
|
||||||
emit(SuggestionsResource.Empty)
|
emit(SuggestionsResource.Empty)
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
workManager
|
|
||||||
.getWorkInfosForUniqueWorkFlow(SuggestionsWorker.WORK_NAME)
|
|
||||||
.map { workInfos ->
|
|
||||||
val isActive =
|
|
||||||
workInfos.any {
|
|
||||||
it.state == WorkInfo.State.RUNNING || it.state == WorkInfo.State.ENQUEUED
|
|
||||||
}
|
|
||||||
if (isActive) SuggestionsResource.Loading else SuggestionsResource.Empty
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
workManager
|
||||||
|
.getWorkInfosForUniqueWorkFlow(SuggestionsWorker.WORK_NAME)
|
||||||
|
.map { workInfos ->
|
||||||
|
val isActive =
|
||||||
|
workInfos.any {
|
||||||
|
it.state == WorkInfo.State.RUNNING || it.state == WorkInfo.State.ENQUEUED
|
||||||
|
}
|
||||||
|
if (isActive) SuggestionsResource.Loading else SuggestionsResource.Empty
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,15 +4,13 @@ package com.github.damontecres.wholphin.services
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import com.github.damontecres.wholphin.ui.toServerString
|
import com.github.damontecres.wholphin.ui.toServerString
|
||||||
|
import com.mayakapps.kache.InMemoryKache
|
||||||
|
import com.mayakapps.kache.ObjectKache
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
|
||||||
import kotlinx.coroutines.flow.update
|
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import kotlinx.serialization.ExperimentalSerializationApi
|
import kotlinx.serialization.ExperimentalSerializationApi
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import kotlinx.serialization.UseSerializers
|
import kotlinx.serialization.UseSerializers
|
||||||
|
|
@ -38,39 +36,15 @@ class SuggestionsCache
|
||||||
constructor(
|
constructor(
|
||||||
@param:ApplicationContext private val context: Context,
|
@param:ApplicationContext private val context: Context,
|
||||||
) {
|
) {
|
||||||
|
private val cacheDir: File
|
||||||
|
get() = File(context.cacheDir, "suggestions")
|
||||||
private val json = Json { ignoreUnknownKeys = true }
|
private val json = Json { ignoreUnknownKeys = true }
|
||||||
private val _cacheVersion = MutableStateFlow(0L)
|
|
||||||
val cacheVersion: StateFlow<Long> = _cacheVersion.asStateFlow()
|
|
||||||
|
|
||||||
private val memoryCache: MutableMap<String, CachedSuggestions> =
|
|
||||||
LinkedHashMap(MAX_MEMORY_CACHE_SIZE, 0.75f, true)
|
|
||||||
|
|
||||||
@Volatile
|
|
||||||
private var diskCacheLoadedUserId: UUID? = null
|
|
||||||
private val dirtyKeys: MutableSet<String> = mutableSetOf()
|
|
||||||
private val mutex = Mutex()
|
private val mutex = Mutex()
|
||||||
|
|
||||||
@OptIn(ExperimentalSerializationApi::class)
|
private val memoryCache =
|
||||||
private fun writeEntryToDisk(
|
InMemoryKache<String, CachedSuggestions>(maxSize = 8) {
|
||||||
key: String,
|
creationScope = CoroutineScope(Dispatchers.IO)
|
||||||
cached: CachedSuggestions,
|
|
||||||
) {
|
|
||||||
runCatching {
|
|
||||||
val suggestionsDir = cacheDir.apply { mkdirs() }
|
|
||||||
File(suggestionsDir, "$key.json")
|
|
||||||
.outputStream()
|
|
||||||
.use { json.encodeToStream(cached, it) }
|
|
||||||
}.onFailure { Timber.w(it, "Failed to write evicted cache: $key") }
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun checkForEviction(newKey: String): Pair<String, CachedSuggestions>? {
|
|
||||||
if (memoryCache.containsKey(newKey) || memoryCache.size < MAX_MEMORY_CACHE_SIZE) {
|
|
||||||
return null
|
|
||||||
}
|
}
|
||||||
val eldest = memoryCache.entries.firstOrNull() ?: return null
|
|
||||||
memoryCache.remove(eldest.key)
|
|
||||||
return if (dirtyKeys.remove(eldest.key)) eldest.key to eldest.value else null
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun cacheKey(
|
private fun cacheKey(
|
||||||
userId: UUID,
|
userId: UUID,
|
||||||
|
|
@ -78,63 +52,28 @@ class SuggestionsCache
|
||||||
itemKind: BaseItemKind,
|
itemKind: BaseItemKind,
|
||||||
) = "${userId.toServerString()}_${libraryId.toServerString()}_${itemKind.serialName}"
|
) = "${userId.toServerString()}_${libraryId.toServerString()}_${itemKind.serialName}"
|
||||||
|
|
||||||
private val cacheDir: File
|
|
||||||
get() = File(context.cacheDir, "suggestions")
|
|
||||||
|
|
||||||
@OptIn(ExperimentalSerializationApi::class)
|
|
||||||
private suspend fun loadFromDisk(userId: UUID) {
|
|
||||||
if (diskCacheLoadedUserId == userId) return
|
|
||||||
mutex.withLock {
|
|
||||||
if (diskCacheLoadedUserId == userId) return@withLock
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
val suggestionsDir = cacheDir
|
|
||||||
if (!suggestionsDir.exists()) {
|
|
||||||
diskCacheLoadedUserId = userId
|
|
||||||
return@withContext
|
|
||||||
}
|
|
||||||
memoryCache.clear()
|
|
||||||
suggestionsDir
|
|
||||||
.listFiles {
|
|
||||||
it.name.startsWith(userId.toServerString())
|
|
||||||
}.orEmpty()
|
|
||||||
.take(MAX_MEMORY_CACHE_SIZE)
|
|
||||||
.forEach { file ->
|
|
||||||
runCatching {
|
|
||||||
val key = file.nameWithoutExtension
|
|
||||||
val cached =
|
|
||||||
file
|
|
||||||
.inputStream()
|
|
||||||
.use { json.decodeFromStream<CachedSuggestions>(it) }
|
|
||||||
memoryCache[key] = cached
|
|
||||||
}.onFailure { Timber.w(it, "Failed to read cache file: ${file.name}") }
|
|
||||||
}
|
|
||||||
diskCacheLoadedUserId = userId
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@OptIn(ExperimentalSerializationApi::class)
|
@OptIn(ExperimentalSerializationApi::class)
|
||||||
suspend fun get(
|
suspend fun get(
|
||||||
userId: UUID,
|
userId: UUID,
|
||||||
libraryId: UUID,
|
libraryId: UUID,
|
||||||
itemKind: BaseItemKind,
|
itemKind: BaseItemKind,
|
||||||
): CachedSuggestions? {
|
): CachedSuggestions? {
|
||||||
loadFromDisk(userId)
|
|
||||||
val key = cacheKey(userId, libraryId, itemKind)
|
val key = cacheKey(userId, libraryId, itemKind)
|
||||||
memoryCache[key]?.let { return it }
|
return memoryCache.getOrPut(key) {
|
||||||
return withContext(Dispatchers.IO) {
|
try {
|
||||||
runCatching {
|
mutex.withLock {
|
||||||
File(cacheDir, "$key.json")
|
File(cacheDir, "$key.json").inputStream().use {
|
||||||
.takeIf { it.exists() }
|
|
||||||
?.inputStream()
|
|
||||||
?.use {
|
|
||||||
json.decodeFromStream<CachedSuggestions>(it)
|
json.decodeFromStream<CachedSuggestions>(it)
|
||||||
}?.also { memoryCache[key] = it }
|
}
|
||||||
}.onFailure { Timber.w(it, "Failed to read cache: $key") }
|
}
|
||||||
.getOrNull()
|
} catch (ex: Exception) {
|
||||||
|
Timber.e(ex, "Exception reading from disk cache")
|
||||||
|
null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalSerializationApi::class)
|
||||||
suspend fun put(
|
suspend fun put(
|
||||||
userId: UUID,
|
userId: UUID,
|
||||||
libraryId: UUID,
|
libraryId: UUID,
|
||||||
|
|
@ -142,75 +81,23 @@ class SuggestionsCache
|
||||||
ids: List<UUID>,
|
ids: List<UUID>,
|
||||||
) {
|
) {
|
||||||
val key = cacheKey(userId, libraryId, itemKind)
|
val key = cacheKey(userId, libraryId, itemKind)
|
||||||
val cached = CachedSuggestions(ids)
|
val suggestions = CachedSuggestions(ids)
|
||||||
val evictedEntry =
|
memoryCache.put(key, suggestions)
|
||||||
|
try {
|
||||||
|
cacheDir.mkdirs()
|
||||||
mutex.withLock {
|
mutex.withLock {
|
||||||
val evicted = checkForEviction(key)
|
File(cacheDir, "$key.json").outputStream().use {
|
||||||
memoryCache[key] = cached
|
json.encodeToStream(suggestions, it)
|
||||||
dirtyKeys.add(key)
|
|
||||||
_cacheVersion.update { it + 1 }
|
|
||||||
evicted
|
|
||||||
}
|
|
||||||
evictedEntry?.let { (evictedKey, evictedValue) ->
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
writeEntryToDisk(evictedKey, evictedValue)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun isEmpty(): Boolean =
|
|
||||||
mutex.withLock {
|
|
||||||
if (memoryCache.isNotEmpty() || dirtyKeys.isNotEmpty()) {
|
|
||||||
return@withLock false
|
|
||||||
}
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
val files = cacheDir.listFiles()
|
|
||||||
files == null || files.isEmpty()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@OptIn(ExperimentalSerializationApi::class)
|
|
||||||
suspend fun save() {
|
|
||||||
val entriesToSave =
|
|
||||||
mutex.withLock {
|
|
||||||
if (dirtyKeys.isEmpty()) return
|
|
||||||
val entries =
|
|
||||||
dirtyKeys.mapNotNull { key ->
|
|
||||||
memoryCache[key]?.let { key to it }
|
|
||||||
}
|
|
||||||
dirtyKeys.clear()
|
|
||||||
entries
|
|
||||||
}
|
|
||||||
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
val suggestionsDir =
|
|
||||||
cacheDir.apply {
|
|
||||||
if (!mkdirs() && !exists()) Timber.w("Failed to create suggestions cache directory")
|
|
||||||
}
|
}
|
||||||
entriesToSave.forEach { (key, value) ->
|
|
||||||
runCatching {
|
|
||||||
File(suggestionsDir, "$key.json")
|
|
||||||
.outputStream()
|
|
||||||
.use { json.encodeToStream(value, it) }
|
|
||||||
}.onFailure { Timber.w(it, "Failed to write cache: $key") }
|
|
||||||
}
|
}
|
||||||
|
} catch (ex: Exception) {
|
||||||
|
Timber.e(ex, "Exception writing to disk cache")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun clear() {
|
|
||||||
mutex.withLock {
|
|
||||||
memoryCache.clear()
|
|
||||||
dirtyKeys.clear()
|
|
||||||
_cacheVersion.update { it + 1 }
|
|
||||||
diskCacheLoadedUserId = null
|
|
||||||
}
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
runCatching { cacheDir.deleteRecursively() }
|
|
||||||
.onFailure { Timber.w(it, "Failed to clear suggestions cache") }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
private const val MAX_MEMORY_CACHE_SIZE = 8
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun ObjectKache<*, *>.isEmpty(): Boolean = this.size == 0L
|
||||||
|
|
||||||
|
fun ObjectKache<*, *>.isNotEmpty(): Boolean = !isEmpty()
|
||||||
|
|
||||||
|
suspend fun <T : Any> ObjectKache<T, *>.containsKey(key: T): Boolean = get(key) != null
|
||||||
|
|
|
||||||
|
|
@ -80,13 +80,7 @@ class SuggestionsSchedulerService
|
||||||
BackoffPolicy.EXPONENTIAL,
|
BackoffPolicy.EXPONENTIAL,
|
||||||
15.minutes.toJavaDuration(),
|
15.minutes.toJavaDuration(),
|
||||||
).setInputData(inputData)
|
).setInputData(inputData)
|
||||||
|
.setInitialDelay(60.seconds.toJavaDuration())
|
||||||
if (cache.isEmpty()) {
|
|
||||||
Timber.i("Suggestions cache empty, scheduling periodic fetch with 30s delay")
|
|
||||||
periodicWorkRequestBuilder.setInitialDelay(30.seconds.toJavaDuration())
|
|
||||||
} else {
|
|
||||||
Timber.i("Scheduling periodic SuggestionsWorker")
|
|
||||||
}
|
|
||||||
|
|
||||||
workManager.enqueueUniquePeriodicWork(
|
workManager.enqueueUniquePeriodicWork(
|
||||||
uniqueWorkName = SuggestionsWorker.WORK_NAME,
|
uniqueWorkName = SuggestionsWorker.WORK_NAME,
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ import kotlinx.coroutines.coroutineScope
|
||||||
import kotlinx.coroutines.ensureActive
|
import kotlinx.coroutines.ensureActive
|
||||||
import kotlinx.coroutines.flow.firstOrNull
|
import kotlinx.coroutines.flow.firstOrNull
|
||||||
import kotlinx.coroutines.supervisorScope
|
import kotlinx.coroutines.supervisorScope
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import org.jellyfin.sdk.api.client.ApiClient
|
import org.jellyfin.sdk.api.client.ApiClient
|
||||||
import org.jellyfin.sdk.api.client.exception.ApiClientException
|
import org.jellyfin.sdk.api.client.exception.ApiClientException
|
||||||
import org.jellyfin.sdk.api.client.extensions.userViewsApi
|
import org.jellyfin.sdk.api.client.extensions.userViewsApi
|
||||||
|
|
@ -31,6 +32,7 @@ import org.jellyfin.sdk.model.api.request.GetItemsRequest
|
||||||
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
import kotlin.coroutines.CoroutineContext
|
||||||
|
|
||||||
private val BaseItemDto.relevantId: UUID get() = seriesId ?: id
|
private val BaseItemDto.relevantId: UUID get() = seriesId ?: id
|
||||||
|
|
||||||
|
|
@ -79,6 +81,7 @@ class SuggestionsWorker
|
||||||
}
|
}
|
||||||
val results =
|
val results =
|
||||||
supervisorScope {
|
supervisorScope {
|
||||||
|
val context = Dispatchers.IO.limitedParallelism(2, "fetchSuggestions")
|
||||||
views
|
views
|
||||||
.mapNotNull { view ->
|
.mapNotNull { view ->
|
||||||
val itemKind =
|
val itemKind =
|
||||||
|
|
@ -90,7 +93,14 @@ class SuggestionsWorker
|
||||||
async(Dispatchers.IO) {
|
async(Dispatchers.IO) {
|
||||||
runCatching {
|
runCatching {
|
||||||
Timber.v("Fetching suggestions for view %s", view.id)
|
Timber.v("Fetching suggestions for view %s", view.id)
|
||||||
val suggestions = fetchSuggestions(view.id, userId, itemKind, itemsPerRow)
|
val suggestions =
|
||||||
|
fetchSuggestions(
|
||||||
|
context,
|
||||||
|
view.id,
|
||||||
|
userId,
|
||||||
|
itemKind,
|
||||||
|
itemsPerRow,
|
||||||
|
)
|
||||||
ensureActive()
|
ensureActive()
|
||||||
cache.put(
|
cache.put(
|
||||||
userId,
|
userId,
|
||||||
|
|
@ -110,7 +120,6 @@ class SuggestionsWorker
|
||||||
}
|
}
|
||||||
val successCount = results.count { it.isSuccess }
|
val successCount = results.count { it.isSuccess }
|
||||||
val failureCount = results.count { it.isFailure }
|
val failureCount = results.count { it.isFailure }
|
||||||
cache.save()
|
|
||||||
if (failureCount > 0 && successCount == 0) {
|
if (failureCount > 0 && successCount == 0) {
|
||||||
Timber.w("All attempts failed ($failureCount views), scheduling retry")
|
Timber.w("All attempts failed ($failureCount views), scheduling retry")
|
||||||
return Result.retry()
|
return Result.retry()
|
||||||
|
|
@ -127,6 +136,7 @@ class SuggestionsWorker
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun fetchSuggestions(
|
private suspend fun fetchSuggestions(
|
||||||
|
coroutineContext: CoroutineContext,
|
||||||
parentId: UUID,
|
parentId: UUID,
|
||||||
userId: UUID,
|
userId: UUID,
|
||||||
itemKind: BaseItemKind,
|
itemKind: BaseItemKind,
|
||||||
|
|
@ -141,7 +151,7 @@ class SuggestionsWorker
|
||||||
val freshLimit = (itemsPerRow * 0.3).toInt().coerceAtLeast(1)
|
val freshLimit = (itemsPerRow * 0.3).toInt().coerceAtLeast(1)
|
||||||
|
|
||||||
val historyDeferred =
|
val historyDeferred =
|
||||||
async(Dispatchers.IO) {
|
async(coroutineContext) {
|
||||||
fetchItems(
|
fetchItems(
|
||||||
parentId = parentId,
|
parentId = parentId,
|
||||||
userId = userId,
|
userId = userId,
|
||||||
|
|
@ -163,7 +173,7 @@ class SuggestionsWorker
|
||||||
val excludeIds = seedItems.mapTo(HashSet()) { it.relevantId }
|
val excludeIds = seedItems.mapTo(HashSet()) { it.relevantId }
|
||||||
|
|
||||||
val contextualDeferred =
|
val contextualDeferred =
|
||||||
async(Dispatchers.IO) {
|
async(coroutineContext) {
|
||||||
if (allGenreIds.isEmpty()) {
|
if (allGenreIds.isEmpty()) {
|
||||||
emptyList()
|
emptyList()
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -181,7 +191,7 @@ class SuggestionsWorker
|
||||||
}
|
}
|
||||||
|
|
||||||
val randomDeferred =
|
val randomDeferred =
|
||||||
async(Dispatchers.IO) {
|
async(coroutineContext) {
|
||||||
fetchItems(
|
fetchItems(
|
||||||
parentId = parentId,
|
parentId = parentId,
|
||||||
userId = userId,
|
userId = userId,
|
||||||
|
|
@ -193,7 +203,7 @@ class SuggestionsWorker
|
||||||
}
|
}
|
||||||
|
|
||||||
val freshDeferred =
|
val freshDeferred =
|
||||||
async(Dispatchers.IO) {
|
async(coroutineContext) {
|
||||||
fetchItems(
|
fetchItems(
|
||||||
parentId = parentId,
|
parentId = parentId,
|
||||||
userId = userId,
|
userId = userId,
|
||||||
|
|
@ -204,18 +214,19 @@ class SuggestionsWorker
|
||||||
limit = freshLimit,
|
limit = freshLimit,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
withContext(Dispatchers.Default) {
|
||||||
|
val contextual = contextualDeferred.await()
|
||||||
|
val random = randomDeferred.await()
|
||||||
|
val fresh = freshDeferred.await()
|
||||||
|
|
||||||
val contextual = contextualDeferred.await()
|
(contextual + fresh + random)
|
||||||
val random = randomDeferred.await()
|
.asSequence()
|
||||||
val fresh = freshDeferred.await()
|
.distinctBy { it.id }
|
||||||
|
.filterNot { excludeIds.contains(it.relevantId) }
|
||||||
(contextual + fresh + random)
|
.toList()
|
||||||
.asSequence()
|
.shuffled()
|
||||||
.distinctBy { it.id }
|
.take(itemsPerRow)
|
||||||
.filterNot { excludeIds.contains(it.relevantId) }
|
}
|
||||||
.toList()
|
|
||||||
.shuffled()
|
|
||||||
.take(itemsPerRow)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun fetchItems(
|
private suspend fun fetchItems(
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ import timber.log.Timber
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import kotlin.time.Duration.Companion.hours
|
import kotlin.time.Duration.Companion.hours
|
||||||
import kotlin.time.Duration.Companion.minutes
|
import kotlin.time.Duration.Companion.minutes
|
||||||
|
import kotlin.time.Duration.Companion.seconds
|
||||||
import kotlin.time.toJavaDuration
|
import kotlin.time.toJavaDuration
|
||||||
|
|
||||||
@ActivityScoped
|
@ActivityScoped
|
||||||
|
|
@ -60,7 +61,8 @@ class TvProviderSchedulerService
|
||||||
TvProviderWorker.PARAM_USER_ID to user.user.id.toString(),
|
TvProviderWorker.PARAM_USER_ID to user.user.id.toString(),
|
||||||
TvProviderWorker.PARAM_SERVER_ID to user.server.id.toString(),
|
TvProviderWorker.PARAM_SERVER_ID to user.server.id.toString(),
|
||||||
),
|
),
|
||||||
).build(),
|
).setInitialDelay(60.seconds.toJavaDuration())
|
||||||
|
.build(),
|
||||||
).await()
|
).await()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,6 @@ import io.mockk.every
|
||||||
import io.mockk.mockk
|
import io.mockk.mockk
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.flow.flowOf
|
import kotlinx.coroutines.flow.flowOf
|
||||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||||
|
|
@ -86,7 +85,6 @@ class SuggestionServiceTest {
|
||||||
runTest {
|
runTest {
|
||||||
val currentUser = MutableLiveData<JellyfinUser?>(null)
|
val currentUser = MutableLiveData<JellyfinUser?>(null)
|
||||||
every { mockServerRepository.currentUser } returns currentUser
|
every { mockServerRepository.currentUser } returns currentUser
|
||||||
every { mockCache.cacheVersion } returns MutableStateFlow(0L)
|
|
||||||
every { mockWorkManager.getWorkInfosForUniqueWorkFlow(any()) } returns flowOf(emptyList())
|
every { mockWorkManager.getWorkInfosForUniqueWorkFlow(any()) } returns flowOf(emptyList())
|
||||||
|
|
||||||
val service = createService()
|
val service = createService()
|
||||||
|
|
@ -104,7 +102,6 @@ class SuggestionServiceTest {
|
||||||
val currentUser = MutableLiveData<JellyfinUser?>(mockUser(userId))
|
val currentUser = MutableLiveData<JellyfinUser?>(mockUser(userId))
|
||||||
|
|
||||||
every { mockServerRepository.currentUser } returns currentUser
|
every { mockServerRepository.currentUser } returns currentUser
|
||||||
every { mockCache.cacheVersion } returns MutableStateFlow(0L)
|
|
||||||
coEvery { mockCache.get(userId, parentId, BaseItemKind.MOVIE) } returns null
|
coEvery { mockCache.get(userId, parentId, BaseItemKind.MOVIE) } returns null
|
||||||
every { mockWorkManager.getWorkInfosForUniqueWorkFlow(any()) } returns
|
every { mockWorkManager.getWorkInfosForUniqueWorkFlow(any()) } returns
|
||||||
flowOf(listOf(mockWorkInfo(state)))
|
flowOf(listOf(mockWorkInfo(state)))
|
||||||
|
|
@ -125,7 +122,6 @@ class SuggestionServiceTest {
|
||||||
val currentUser = MutableLiveData<JellyfinUser?>(mockUser(userId))
|
val currentUser = MutableLiveData<JellyfinUser?>(mockUser(userId))
|
||||||
|
|
||||||
every { mockServerRepository.currentUser } returns currentUser
|
every { mockServerRepository.currentUser } returns currentUser
|
||||||
every { mockCache.cacheVersion } returns MutableStateFlow(0L)
|
|
||||||
coEvery { mockCache.get(userId, parentId, BaseItemKind.MOVIE) } returns null
|
coEvery { mockCache.get(userId, parentId, BaseItemKind.MOVIE) } returns null
|
||||||
every { mockWorkManager.getWorkInfosForUniqueWorkFlow(any()) } returns
|
every { mockWorkManager.getWorkInfosForUniqueWorkFlow(any()) } returns
|
||||||
flowOf(listOf(mockWorkInfo(state)))
|
flowOf(listOf(mockWorkInfo(state)))
|
||||||
|
|
@ -145,7 +141,6 @@ class SuggestionServiceTest {
|
||||||
val currentUser = MutableLiveData<JellyfinUser?>(mockUser(userId))
|
val currentUser = MutableLiveData<JellyfinUser?>(mockUser(userId))
|
||||||
|
|
||||||
every { mockServerRepository.currentUser } returns currentUser
|
every { mockServerRepository.currentUser } returns currentUser
|
||||||
every { mockCache.cacheVersion } returns MutableStateFlow(0L)
|
|
||||||
coEvery { mockCache.get(userId, parentId, BaseItemKind.MOVIE) } returns null
|
coEvery { mockCache.get(userId, parentId, BaseItemKind.MOVIE) } returns null
|
||||||
every { mockWorkManager.getWorkInfosForUniqueWorkFlow(any()) } returns flowOf(emptyList())
|
every { mockWorkManager.getWorkInfosForUniqueWorkFlow(any()) } returns flowOf(emptyList())
|
||||||
|
|
||||||
|
|
@ -164,7 +159,6 @@ class SuggestionServiceTest {
|
||||||
val currentUser = MutableLiveData<JellyfinUser?>(mockUser(userId))
|
val currentUser = MutableLiveData<JellyfinUser?>(mockUser(userId))
|
||||||
|
|
||||||
every { mockServerRepository.currentUser } returns currentUser
|
every { mockServerRepository.currentUser } returns currentUser
|
||||||
every { mockCache.cacheVersion } returns MutableStateFlow(0L)
|
|
||||||
every { mockWorkManager.getWorkInfosForUniqueWorkFlow(any()) } returns flowOf(emptyList())
|
every { mockWorkManager.getWorkInfosForUniqueWorkFlow(any()) } returns flowOf(emptyList())
|
||||||
|
|
||||||
coEvery { mockCache.get(userId, libraryId, BaseItemKind.MOVIE) } returns null
|
coEvery { mockCache.get(userId, libraryId, BaseItemKind.MOVIE) } returns null
|
||||||
|
|
@ -199,7 +193,6 @@ class SuggestionServiceTest {
|
||||||
val currentUser = MutableLiveData<JellyfinUser?>(mockUser(userId))
|
val currentUser = MutableLiveData<JellyfinUser?>(mockUser(userId))
|
||||||
|
|
||||||
every { mockServerRepository.currentUser } returns currentUser
|
every { mockServerRepository.currentUser } returns currentUser
|
||||||
every { mockCache.cacheVersion } returns MutableStateFlow(0L)
|
|
||||||
|
|
||||||
val cachedId = UUID.randomUUID()
|
val cachedId = UUID.randomUUID()
|
||||||
coEvery {
|
coEvery {
|
||||||
|
|
@ -237,7 +230,6 @@ class SuggestionServiceTest {
|
||||||
val currentUser = MutableLiveData<JellyfinUser?>(mockUser(userId))
|
val currentUser = MutableLiveData<JellyfinUser?>(mockUser(userId))
|
||||||
|
|
||||||
every { mockServerRepository.currentUser } returns currentUser
|
every { mockServerRepository.currentUser } returns currentUser
|
||||||
every { mockCache.cacheVersion } returns MutableStateFlow(0L)
|
|
||||||
|
|
||||||
val cachedId = UUID.randomUUID()
|
val cachedId = UUID.randomUUID()
|
||||||
coEvery {
|
coEvery {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package com.github.damontecres.wholphin.services
|
package com.github.damontecres.wholphin.services
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import com.mayakapps.kache.ObjectKache
|
||||||
import io.mockk.every
|
import io.mockk.every
|
||||||
import io.mockk.mockk
|
import io.mockk.mockk
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
|
|
@ -28,11 +29,11 @@ class SuggestionsCacheTest {
|
||||||
return SuggestionsCache(mockContext)
|
return SuggestionsCache(mockContext)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun memoryCacheOf(cache: SuggestionsCache): MutableMap<String, CachedSuggestions> {
|
private fun memoryCacheOf(cache: SuggestionsCache): ObjectKache<String, CachedSuggestions> {
|
||||||
val field = SuggestionsCache::class.java.getDeclaredField("memoryCache")
|
val field = SuggestionsCache::class.java.getDeclaredField("memoryCache")
|
||||||
field.isAccessible = true
|
field.isAccessible = true
|
||||||
@Suppress("UNCHECKED_CAST")
|
@Suppress("UNCHECKED_CAST")
|
||||||
return field.get(cache) as MutableMap<String, CachedSuggestions>
|
return field.get(cache) as ObjectKache<String, CachedSuggestions>
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
@ -57,7 +58,6 @@ class SuggestionsCacheTest {
|
||||||
val libId = UUID.randomUUID()
|
val libId = UUID.randomUUID()
|
||||||
|
|
||||||
cache1.put(userId, libId, BaseItemKind.MOVIE, emptyList())
|
cache1.put(userId, libId, BaseItemKind.MOVIE, emptyList())
|
||||||
cache1.save()
|
|
||||||
|
|
||||||
// Create a fresh instance which won't have the memory entry
|
// Create a fresh instance which won't have the memory entry
|
||||||
val cache2 = testCacheWithTempDir()
|
val cache2 = testCacheWithTempDir()
|
||||||
|
|
@ -192,7 +192,6 @@ class SuggestionsCacheTest {
|
||||||
val cache1 = testCacheWithTempDir()
|
val cache1 = testCacheWithTempDir()
|
||||||
cache1.put(userId, lib1, BaseItemKind.MOVIE, ids1)
|
cache1.put(userId, lib1, BaseItemKind.MOVIE, ids1)
|
||||||
cache1.put(userId, lib2, BaseItemKind.SERIES, ids2)
|
cache1.put(userId, lib2, BaseItemKind.SERIES, ids2)
|
||||||
cache1.save()
|
|
||||||
|
|
||||||
// Read with fresh cache instance (empty memory cache, reads from disk)
|
// Read with fresh cache instance (empty memory cache, reads from disk)
|
||||||
val cache2 = testCacheWithTempDir()
|
val cache2 = testCacheWithTempDir()
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,6 @@ import com.github.damontecres.wholphin.data.CurrentUser
|
||||||
import com.github.damontecres.wholphin.data.ServerRepository
|
import com.github.damontecres.wholphin.data.ServerRepository
|
||||||
import com.github.damontecres.wholphin.data.model.JellyfinServer
|
import com.github.damontecres.wholphin.data.model.JellyfinServer
|
||||||
import com.github.damontecres.wholphin.data.model.JellyfinUser
|
import com.github.damontecres.wholphin.data.model.JellyfinUser
|
||||||
import io.mockk.coEvery
|
|
||||||
import io.mockk.every
|
import io.mockk.every
|
||||||
import io.mockk.mockk
|
import io.mockk.mockk
|
||||||
import io.mockk.slot
|
import io.mockk.slot
|
||||||
|
|
@ -30,7 +29,6 @@ import org.junit.Before
|
||||||
import org.junit.Rule
|
import org.junit.Rule
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import java.util.concurrent.TimeUnit
|
|
||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
class SuggestionsSchedulerServiceTest {
|
class SuggestionsSchedulerServiceTest {
|
||||||
|
|
@ -65,7 +63,6 @@ class SuggestionsSchedulerServiceTest {
|
||||||
@Test
|
@Test
|
||||||
fun schedules_periodic_work_when_user_present() =
|
fun schedules_periodic_work_when_user_present() =
|
||||||
runTest {
|
runTest {
|
||||||
coEvery { mockCache.isEmpty() } returns false
|
|
||||||
createService()
|
createService()
|
||||||
currentLiveData.value =
|
currentLiveData.value =
|
||||||
CurrentUser(
|
CurrentUser(
|
||||||
|
|
@ -79,7 +76,6 @@ class SuggestionsSchedulerServiceTest {
|
||||||
@Test
|
@Test
|
||||||
fun cancels_work_when_user_null() =
|
fun cancels_work_when_user_null() =
|
||||||
runTest {
|
runTest {
|
||||||
coEvery { mockCache.isEmpty() } returns false
|
|
||||||
createService()
|
createService()
|
||||||
currentLiveData.value =
|
currentLiveData.value =
|
||||||
CurrentUser(
|
CurrentUser(
|
||||||
|
|
@ -95,7 +91,6 @@ class SuggestionsSchedulerServiceTest {
|
||||||
@Test
|
@Test
|
||||||
fun schedules_periodic_work_with_delay_when_cache_empty() =
|
fun schedules_periodic_work_with_delay_when_cache_empty() =
|
||||||
runTest {
|
runTest {
|
||||||
coEvery { mockCache.isEmpty() } returns true
|
|
||||||
val workRequestSlot = slot<PeriodicWorkRequest>()
|
val workRequestSlot = slot<PeriodicWorkRequest>()
|
||||||
every {
|
every {
|
||||||
mockWorkManager.enqueueUniquePeriodicWork(
|
mockWorkManager.enqueueUniquePeriodicWork(
|
||||||
|
|
@ -114,13 +109,12 @@ class SuggestionsSchedulerServiceTest {
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
|
|
||||||
verify { mockWorkManager.enqueueUniquePeriodicWork(SuggestionsWorker.WORK_NAME, any(), any()) }
|
verify { mockWorkManager.enqueueUniquePeriodicWork(SuggestionsWorker.WORK_NAME, any(), any()) }
|
||||||
assertEquals(30000L, workRequestSlot.captured.workSpec.initialDelay)
|
assertEquals(60000L, workRequestSlot.captured.workSpec.initialDelay)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun schedules_periodic_work_without_delay_when_cache_not_empty() =
|
fun schedules_periodic_work_with_delay_when_cache_not_empty() =
|
||||||
runTest {
|
runTest {
|
||||||
coEvery { mockCache.isEmpty() } returns false
|
|
||||||
val workRequestSlot = slot<PeriodicWorkRequest>()
|
val workRequestSlot = slot<PeriodicWorkRequest>()
|
||||||
every {
|
every {
|
||||||
mockWorkManager.enqueueUniquePeriodicWork(
|
mockWorkManager.enqueueUniquePeriodicWork(
|
||||||
|
|
@ -139,6 +133,6 @@ class SuggestionsSchedulerServiceTest {
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
|
|
||||||
verify { mockWorkManager.enqueueUniquePeriodicWork(SuggestionsWorker.WORK_NAME, any(), any()) }
|
verify { mockWorkManager.enqueueUniquePeriodicWork(SuggestionsWorker.WORK_NAME, any(), any()) }
|
||||||
assertEquals(0L, workRequestSlot.captured.workSpec.initialDelay)
|
assertEquals(60000L, workRequestSlot.captured.workSpec.initialDelay)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -132,7 +132,6 @@ class SuggestionsWorkerTest {
|
||||||
|
|
||||||
assertEquals(ListenableWorker.Result.success(), result)
|
assertEquals(ListenableWorker.Result.success(), result)
|
||||||
coVerify { mockCache.put(testUserId, viewId, itemKind, any()) }
|
coVerify { mockCache.put(testUserId, viewId, itemKind, any()) }
|
||||||
coVerify { mockCache.save() }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ desugar_jdk_libs = "2.1.5"
|
||||||
hiltCompiler = "1.3.0"
|
hiltCompiler = "1.3.0"
|
||||||
hiltNavigationCompose = "1.3.0"
|
hiltNavigationCompose = "1.3.0"
|
||||||
hiltWork = "1.3.0"
|
hiltWork = "1.3.0"
|
||||||
|
kache = "2.1.1"
|
||||||
kotlin = "2.3.0"
|
kotlin = "2.3.0"
|
||||||
kotlinxCoroutinesCore = "1.10.2"
|
kotlinxCoroutinesCore = "1.10.2"
|
||||||
ksp = "2.3.0"
|
ksp = "2.3.0"
|
||||||
|
|
@ -80,6 +81,8 @@ auto-service-ksp = { module = "dev.zacsweers.autoservice:auto-service-ksp", vers
|
||||||
desugar_jdk_libs = { module = "com.android.tools:desugar_jdk_libs", version.ref = "desugar_jdk_libs" }
|
desugar_jdk_libs = { module = "com.android.tools:desugar_jdk_libs", version.ref = "desugar_jdk_libs" }
|
||||||
hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" }
|
hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" }
|
||||||
hilt-android-compiler = { module = "com.google.dagger:hilt-android-compiler", version.ref = "hilt" }
|
hilt-android-compiler = { module = "com.google.dagger:hilt-android-compiler", version.ref = "hilt" }
|
||||||
|
kache = { module = "com.mayakapps.kache:kache", version.ref = "kache" }
|
||||||
|
kache-file = { module = "com.mayakapps.kache:file-kache", version.ref = "kache" }
|
||||||
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinxCoroutinesCore" }
|
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinxCoroutinesCore" }
|
||||||
mockk-agent = { module = "io.mockk:mockk-agent", version.ref = "mockk" }
|
mockk-agent = { module = "io.mockk:mockk-agent", version.ref = "mockk" }
|
||||||
mockk-android = { module = "io.mockk:mockk-android", version.ref = "mockk" }
|
mockk-android = { module = "io.mockk:mockk-android", version.ref = "mockk" }
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue