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

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

View file

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

View file

@ -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,15 +49,15 @@ 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
.map { cache.get(userId, parentId, itemKind)?.ids.orEmpty() }
.distinctUntilChanged()
.flatMapLatest { cachedIds ->
if (cachedIds.isNotEmpty()) { if (cachedIds.isNotEmpty()) {
flow { flow {
try { try {
emit(SuggestionsResource.Success(fetchItemsByIds(cachedIds, itemKind))) 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)
@ -77,7 +76,6 @@ class SuggestionService
} }
} }
} }
}
private suspend fun fetchItemsByIds( private suspend fun fetchItemsByIds(
ids: List<UUID>, ids: List<UUID>,

View file

@ -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,38 +36,14 @@ 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(
@ -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) { } catch (ex: Exception) {
writeEntryToDisk(evictedKey, evictedValue) Timber.e(ex, "Exception writing to disk cache")
} }
} }
} }
suspend fun isEmpty(): Boolean = fun ObjectKache<*, *>.isEmpty(): Boolean = this.size == 0L
mutex.withLock {
if (memoryCache.isNotEmpty() || dirtyKeys.isNotEmpty()) {
return@withLock false
}
withContext(Dispatchers.IO) {
val files = cacheDir.listFiles()
files == null || files.isEmpty()
}
}
@OptIn(ExperimentalSerializationApi::class) fun ObjectKache<*, *>.isNotEmpty(): Boolean = !isEmpty()
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) { suspend fun <T : Any> ObjectKache<T, *>.containsKey(key: T): Boolean = get(key) != null
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") }
}
}
}
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
}
}

View file

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

View file

@ -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,7 +214,7 @@ class SuggestionsWorker
limit = freshLimit, limit = freshLimit,
) )
} }
withContext(Dispatchers.Default) {
val contextual = contextualDeferred.await() val contextual = contextualDeferred.await()
val random = randomDeferred.await() val random = randomDeferred.await()
val fresh = freshDeferred.await() val fresh = freshDeferred.await()
@ -217,6 +227,7 @@ class SuggestionsWorker
.shuffled() .shuffled()
.take(itemsPerRow) .take(itemsPerRow)
} }
}
private suspend fun fetchItems( private suspend fun fetchItems(
parentId: UUID, parentId: UUID,

View file

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

View file

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

View file

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

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

View file

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

View file

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