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)
|
||||
implementation(platform(libs.okhttp.bom))
|
||||
implementation(libs.okhttp)
|
||||
implementation(libs.kache)
|
||||
implementation(libs.kache.file)
|
||||
|
||||
androidTestImplementation(platform(libs.androidx.compose.bom))
|
||||
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
|
||||
|
|
|
|||
|
|
@ -4,9 +4,6 @@ import androidx.lifecycle.DefaultLifecycleObserver
|
|||
import androidx.lifecycle.LifecycleOwner
|
||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||
import dagger.hilt.android.scopes.ActivityRetainedScoped
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
|
|
@ -19,7 +16,6 @@ class PlaybackLifecycleObserver
|
|||
private val navigationManager: NavigationManager,
|
||||
private val playerFactory: PlayerFactory,
|
||||
private val themeSongPlayer: ThemeSongPlayer,
|
||||
private val suggestionsCache: SuggestionsCache,
|
||||
) : DefaultLifecycleObserver {
|
||||
private var wasPlaying: Boolean? = null
|
||||
|
||||
|
|
@ -52,6 +48,5 @@ class PlaybackLifecycleObserver
|
|||
|
||||
override fun onStop(owner: LifecycleOwner) {
|
||||
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 kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
|
|
@ -50,32 +49,31 @@ class SuggestionService
|
|||
.asFlow()
|
||||
.flatMapLatest { user ->
|
||||
val userId = user?.id ?: return@flatMapLatest flowOf(SuggestionsResource.Empty)
|
||||
|
||||
cache.cacheVersion
|
||||
.map { cache.get(userId, parentId, itemKind)?.ids.orEmpty() }
|
||||
.distinctUntilChanged()
|
||||
.flatMapLatest { cachedIds ->
|
||||
if (cachedIds.isNotEmpty()) {
|
||||
flow {
|
||||
try {
|
||||
emit(SuggestionsResource.Success(fetchItemsByIds(cachedIds, itemKind)))
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "Failed to fetch items")
|
||||
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
|
||||
}
|
||||
val cachedIds = cache.get(userId, parentId, itemKind)?.ids.orEmpty()
|
||||
if (cachedIds.isNotEmpty()) {
|
||||
flow {
|
||||
try {
|
||||
emit(
|
||||
SuggestionsResource.Success(
|
||||
fetchItemsByIds(cachedIds, itemKind),
|
||||
),
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "Failed to fetch items")
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,15 +4,13 @@ package com.github.damontecres.wholphin.services
|
|||
|
||||
import android.content.Context
|
||||
import com.github.damontecres.wholphin.ui.toServerString
|
||||
import com.mayakapps.kache.InMemoryKache
|
||||
import com.mayakapps.kache.ObjectKache
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
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.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.ExperimentalSerializationApi
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.UseSerializers
|
||||
|
|
@ -38,39 +36,15 @@ class SuggestionsCache
|
|||
constructor(
|
||||
@param:ApplicationContext private val context: Context,
|
||||
) {
|
||||
private val cacheDir: File
|
||||
get() = File(context.cacheDir, "suggestions")
|
||||
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()
|
||||
|
||||
@OptIn(ExperimentalSerializationApi::class)
|
||||
private fun writeEntryToDisk(
|
||||
key: String,
|
||||
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
|
||||
private val memoryCache =
|
||||
InMemoryKache<String, CachedSuggestions>(maxSize = 8) {
|
||||
creationScope = CoroutineScope(Dispatchers.IO)
|
||||
}
|
||||
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(
|
||||
userId: UUID,
|
||||
|
|
@ -78,63 +52,28 @@ class SuggestionsCache
|
|||
itemKind: BaseItemKind,
|
||||
) = "${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)
|
||||
suspend fun get(
|
||||
userId: UUID,
|
||||
libraryId: UUID,
|
||||
itemKind: BaseItemKind,
|
||||
): CachedSuggestions? {
|
||||
loadFromDisk(userId)
|
||||
val key = cacheKey(userId, libraryId, itemKind)
|
||||
memoryCache[key]?.let { return it }
|
||||
return withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
File(cacheDir, "$key.json")
|
||||
.takeIf { it.exists() }
|
||||
?.inputStream()
|
||||
?.use {
|
||||
return memoryCache.getOrPut(key) {
|
||||
try {
|
||||
mutex.withLock {
|
||||
File(cacheDir, "$key.json").inputStream().use {
|
||||
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(
|
||||
userId: UUID,
|
||||
libraryId: UUID,
|
||||
|
|
@ -142,75 +81,23 @@ class SuggestionsCache
|
|||
ids: List<UUID>,
|
||||
) {
|
||||
val key = cacheKey(userId, libraryId, itemKind)
|
||||
val cached = CachedSuggestions(ids)
|
||||
val evictedEntry =
|
||||
val suggestions = CachedSuggestions(ids)
|
||||
memoryCache.put(key, suggestions)
|
||||
try {
|
||||
cacheDir.mkdirs()
|
||||
mutex.withLock {
|
||||
val evicted = checkForEviction(key)
|
||||
memoryCache[key] = cached
|
||||
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")
|
||||
File(cacheDir, "$key.json").outputStream().use {
|
||||
json.encodeToStream(suggestions, it)
|
||||
}
|
||||
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,
|
||||
15.minutes.toJavaDuration(),
|
||||
).setInputData(inputData)
|
||||
|
||||
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")
|
||||
}
|
||||
.setInitialDelay(60.seconds.toJavaDuration())
|
||||
|
||||
workManager.enqueueUniquePeriodicWork(
|
||||
uniqueWorkName = SuggestionsWorker.WORK_NAME,
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import kotlinx.coroutines.coroutineScope
|
|||
import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import kotlinx.coroutines.supervisorScope
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.exception.ApiClientException
|
||||
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 timber.log.Timber
|
||||
import java.util.UUID
|
||||
import kotlin.coroutines.CoroutineContext
|
||||
|
||||
private val BaseItemDto.relevantId: UUID get() = seriesId ?: id
|
||||
|
||||
|
|
@ -79,6 +81,7 @@ class SuggestionsWorker
|
|||
}
|
||||
val results =
|
||||
supervisorScope {
|
||||
val context = Dispatchers.IO.limitedParallelism(2, "fetchSuggestions")
|
||||
views
|
||||
.mapNotNull { view ->
|
||||
val itemKind =
|
||||
|
|
@ -90,7 +93,14 @@ class SuggestionsWorker
|
|||
async(Dispatchers.IO) {
|
||||
runCatching {
|
||||
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()
|
||||
cache.put(
|
||||
userId,
|
||||
|
|
@ -110,7 +120,6 @@ class SuggestionsWorker
|
|||
}
|
||||
val successCount = results.count { it.isSuccess }
|
||||
val failureCount = results.count { it.isFailure }
|
||||
cache.save()
|
||||
if (failureCount > 0 && successCount == 0) {
|
||||
Timber.w("All attempts failed ($failureCount views), scheduling retry")
|
||||
return Result.retry()
|
||||
|
|
@ -127,6 +136,7 @@ class SuggestionsWorker
|
|||
}
|
||||
|
||||
private suspend fun fetchSuggestions(
|
||||
coroutineContext: CoroutineContext,
|
||||
parentId: UUID,
|
||||
userId: UUID,
|
||||
itemKind: BaseItemKind,
|
||||
|
|
@ -141,7 +151,7 @@ class SuggestionsWorker
|
|||
val freshLimit = (itemsPerRow * 0.3).toInt().coerceAtLeast(1)
|
||||
|
||||
val historyDeferred =
|
||||
async(Dispatchers.IO) {
|
||||
async(coroutineContext) {
|
||||
fetchItems(
|
||||
parentId = parentId,
|
||||
userId = userId,
|
||||
|
|
@ -163,7 +173,7 @@ class SuggestionsWorker
|
|||
val excludeIds = seedItems.mapTo(HashSet()) { it.relevantId }
|
||||
|
||||
val contextualDeferred =
|
||||
async(Dispatchers.IO) {
|
||||
async(coroutineContext) {
|
||||
if (allGenreIds.isEmpty()) {
|
||||
emptyList()
|
||||
} else {
|
||||
|
|
@ -181,7 +191,7 @@ class SuggestionsWorker
|
|||
}
|
||||
|
||||
val randomDeferred =
|
||||
async(Dispatchers.IO) {
|
||||
async(coroutineContext) {
|
||||
fetchItems(
|
||||
parentId = parentId,
|
||||
userId = userId,
|
||||
|
|
@ -193,7 +203,7 @@ class SuggestionsWorker
|
|||
}
|
||||
|
||||
val freshDeferred =
|
||||
async(Dispatchers.IO) {
|
||||
async(coroutineContext) {
|
||||
fetchItems(
|
||||
parentId = parentId,
|
||||
userId = userId,
|
||||
|
|
@ -204,18 +214,19 @@ class SuggestionsWorker
|
|||
limit = freshLimit,
|
||||
)
|
||||
}
|
||||
withContext(Dispatchers.Default) {
|
||||
val contextual = contextualDeferred.await()
|
||||
val random = randomDeferred.await()
|
||||
val fresh = freshDeferred.await()
|
||||
|
||||
val contextual = contextualDeferred.await()
|
||||
val random = randomDeferred.await()
|
||||
val fresh = freshDeferred.await()
|
||||
|
||||
(contextual + fresh + random)
|
||||
.asSequence()
|
||||
.distinctBy { it.id }
|
||||
.filterNot { excludeIds.contains(it.relevantId) }
|
||||
.toList()
|
||||
.shuffled()
|
||||
.take(itemsPerRow)
|
||||
(contextual + fresh + random)
|
||||
.asSequence()
|
||||
.distinctBy { it.id }
|
||||
.filterNot { excludeIds.contains(it.relevantId) }
|
||||
.toList()
|
||||
.shuffled()
|
||||
.take(itemsPerRow)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun fetchItems(
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import timber.log.Timber
|
|||
import javax.inject.Inject
|
||||
import kotlin.time.Duration.Companion.hours
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
import kotlin.time.toJavaDuration
|
||||
|
||||
@ActivityScoped
|
||||
|
|
@ -60,7 +61,8 @@ class TvProviderSchedulerService
|
|||
TvProviderWorker.PARAM_USER_ID to user.user.id.toString(),
|
||||
TvProviderWorker.PARAM_SERVER_ID to user.server.id.toString(),
|
||||
),
|
||||
).build(),
|
||||
).setInitialDelay(60.seconds.toJavaDuration())
|
||||
.build(),
|
||||
).await()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@ import io.mockk.every
|
|||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
|
|
@ -86,7 +85,6 @@ class SuggestionServiceTest {
|
|||
runTest {
|
||||
val currentUser = MutableLiveData<JellyfinUser?>(null)
|
||||
every { mockServerRepository.currentUser } returns currentUser
|
||||
every { mockCache.cacheVersion } returns MutableStateFlow(0L)
|
||||
every { mockWorkManager.getWorkInfosForUniqueWorkFlow(any()) } returns flowOf(emptyList())
|
||||
|
||||
val service = createService()
|
||||
|
|
@ -104,7 +102,6 @@ class SuggestionServiceTest {
|
|||
val currentUser = MutableLiveData<JellyfinUser?>(mockUser(userId))
|
||||
|
||||
every { mockServerRepository.currentUser } returns currentUser
|
||||
every { mockCache.cacheVersion } returns MutableStateFlow(0L)
|
||||
coEvery { mockCache.get(userId, parentId, BaseItemKind.MOVIE) } returns null
|
||||
every { mockWorkManager.getWorkInfosForUniqueWorkFlow(any()) } returns
|
||||
flowOf(listOf(mockWorkInfo(state)))
|
||||
|
|
@ -125,7 +122,6 @@ class SuggestionServiceTest {
|
|||
val currentUser = MutableLiveData<JellyfinUser?>(mockUser(userId))
|
||||
|
||||
every { mockServerRepository.currentUser } returns currentUser
|
||||
every { mockCache.cacheVersion } returns MutableStateFlow(0L)
|
||||
coEvery { mockCache.get(userId, parentId, BaseItemKind.MOVIE) } returns null
|
||||
every { mockWorkManager.getWorkInfosForUniqueWorkFlow(any()) } returns
|
||||
flowOf(listOf(mockWorkInfo(state)))
|
||||
|
|
@ -145,7 +141,6 @@ class SuggestionServiceTest {
|
|||
val currentUser = MutableLiveData<JellyfinUser?>(mockUser(userId))
|
||||
|
||||
every { mockServerRepository.currentUser } returns currentUser
|
||||
every { mockCache.cacheVersion } returns MutableStateFlow(0L)
|
||||
coEvery { mockCache.get(userId, parentId, BaseItemKind.MOVIE) } returns null
|
||||
every { mockWorkManager.getWorkInfosForUniqueWorkFlow(any()) } returns flowOf(emptyList())
|
||||
|
||||
|
|
@ -164,7 +159,6 @@ class SuggestionServiceTest {
|
|||
val currentUser = MutableLiveData<JellyfinUser?>(mockUser(userId))
|
||||
|
||||
every { mockServerRepository.currentUser } returns currentUser
|
||||
every { mockCache.cacheVersion } returns MutableStateFlow(0L)
|
||||
every { mockWorkManager.getWorkInfosForUniqueWorkFlow(any()) } returns flowOf(emptyList())
|
||||
|
||||
coEvery { mockCache.get(userId, libraryId, BaseItemKind.MOVIE) } returns null
|
||||
|
|
@ -199,7 +193,6 @@ class SuggestionServiceTest {
|
|||
val currentUser = MutableLiveData<JellyfinUser?>(mockUser(userId))
|
||||
|
||||
every { mockServerRepository.currentUser } returns currentUser
|
||||
every { mockCache.cacheVersion } returns MutableStateFlow(0L)
|
||||
|
||||
val cachedId = UUID.randomUUID()
|
||||
coEvery {
|
||||
|
|
@ -237,7 +230,6 @@ class SuggestionServiceTest {
|
|||
val currentUser = MutableLiveData<JellyfinUser?>(mockUser(userId))
|
||||
|
||||
every { mockServerRepository.currentUser } returns currentUser
|
||||
every { mockCache.cacheVersion } returns MutableStateFlow(0L)
|
||||
|
||||
val cachedId = UUID.randomUUID()
|
||||
coEvery {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package com.github.damontecres.wholphin.services
|
||||
|
||||
import android.content.Context
|
||||
import com.mayakapps.kache.ObjectKache
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.test.runTest
|
||||
|
|
@ -28,11 +29,11 @@ class SuggestionsCacheTest {
|
|||
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")
|
||||
field.isAccessible = true
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
return field.get(cache) as MutableMap<String, CachedSuggestions>
|
||||
return field.get(cache) as ObjectKache<String, CachedSuggestions>
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -57,7 +58,6 @@ class SuggestionsCacheTest {
|
|||
val libId = UUID.randomUUID()
|
||||
|
||||
cache1.put(userId, libId, BaseItemKind.MOVIE, emptyList())
|
||||
cache1.save()
|
||||
|
||||
// Create a fresh instance which won't have the memory entry
|
||||
val cache2 = testCacheWithTempDir()
|
||||
|
|
@ -192,7 +192,6 @@ class SuggestionsCacheTest {
|
|||
val cache1 = testCacheWithTempDir()
|
||||
cache1.put(userId, lib1, BaseItemKind.MOVIE, ids1)
|
||||
cache1.put(userId, lib2, BaseItemKind.SERIES, ids2)
|
||||
cache1.save()
|
||||
|
||||
// Read with fresh cache instance (empty memory cache, reads from disk)
|
||||
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.model.JellyfinServer
|
||||
import com.github.damontecres.wholphin.data.model.JellyfinUser
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.slot
|
||||
|
|
@ -30,7 +29,6 @@ import org.junit.Before
|
|||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class SuggestionsSchedulerServiceTest {
|
||||
|
|
@ -65,7 +63,6 @@ class SuggestionsSchedulerServiceTest {
|
|||
@Test
|
||||
fun schedules_periodic_work_when_user_present() =
|
||||
runTest {
|
||||
coEvery { mockCache.isEmpty() } returns false
|
||||
createService()
|
||||
currentLiveData.value =
|
||||
CurrentUser(
|
||||
|
|
@ -79,7 +76,6 @@ class SuggestionsSchedulerServiceTest {
|
|||
@Test
|
||||
fun cancels_work_when_user_null() =
|
||||
runTest {
|
||||
coEvery { mockCache.isEmpty() } returns false
|
||||
createService()
|
||||
currentLiveData.value =
|
||||
CurrentUser(
|
||||
|
|
@ -95,7 +91,6 @@ class SuggestionsSchedulerServiceTest {
|
|||
@Test
|
||||
fun schedules_periodic_work_with_delay_when_cache_empty() =
|
||||
runTest {
|
||||
coEvery { mockCache.isEmpty() } returns true
|
||||
val workRequestSlot = slot<PeriodicWorkRequest>()
|
||||
every {
|
||||
mockWorkManager.enqueueUniquePeriodicWork(
|
||||
|
|
@ -114,13 +109,12 @@ class SuggestionsSchedulerServiceTest {
|
|||
advanceUntilIdle()
|
||||
|
||||
verify { mockWorkManager.enqueueUniquePeriodicWork(SuggestionsWorker.WORK_NAME, any(), any()) }
|
||||
assertEquals(30000L, workRequestSlot.captured.workSpec.initialDelay)
|
||||
assertEquals(60000L, workRequestSlot.captured.workSpec.initialDelay)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun schedules_periodic_work_without_delay_when_cache_not_empty() =
|
||||
fun schedules_periodic_work_with_delay_when_cache_not_empty() =
|
||||
runTest {
|
||||
coEvery { mockCache.isEmpty() } returns false
|
||||
val workRequestSlot = slot<PeriodicWorkRequest>()
|
||||
every {
|
||||
mockWorkManager.enqueueUniquePeriodicWork(
|
||||
|
|
@ -139,6 +133,6 @@ class SuggestionsSchedulerServiceTest {
|
|||
advanceUntilIdle()
|
||||
|
||||
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)
|
||||
coVerify { mockCache.put(testUserId, viewId, itemKind, any()) }
|
||||
coVerify { mockCache.save() }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ desugar_jdk_libs = "2.1.5"
|
|||
hiltCompiler = "1.3.0"
|
||||
hiltNavigationCompose = "1.3.0"
|
||||
hiltWork = "1.3.0"
|
||||
kache = "2.1.1"
|
||||
kotlin = "2.3.0"
|
||||
kotlinxCoroutinesCore = "1.10.2"
|
||||
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" }
|
||||
hilt-android = { module = "com.google.dagger:hilt-android", 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" }
|
||||
mockk-agent = { module = "io.mockk:mockk-agent", version.ref = "mockk" }
|
||||
mockk-android = { module = "io.mockk:mockk-android", version.ref = "mockk" }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue