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:
Ray 2026-02-09 17:39:40 -05:00 committed by GitHub
parent 28ed1a491b
commit b06761eaa5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 99 additions and 223 deletions

View file

@ -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 {

View file

@ -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()

View file

@ -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)
}
}

View file

@ -132,7 +132,6 @@ class SuggestionsWorkerTest {
assertEquals(ListenableWorker.Result.success(), result)
coVerify { mockCache.put(testUserId, viewId, itemKind, any()) }
coVerify { mockCache.save() }
}
}