Fix suggestions cache miss handling for missing files (#880)

## Description
Fix a bug where movie suggestions would get stuck loading when the
on-disk cache file doesn't exist.

### Related issues
Fixes #876

### Testing
Ran unit tests locally. 

## Screenshots
N/A

## AI or LLM usage
Used AI to add testing for this scenario to unit test

---------

Co-authored-by: Ray <154766448+damontecres@users.noreply.github.com>
This commit is contained in:
Justin Caveda 2026-02-12 17:27:59 -06:00 committed by GitHub
parent 4161ea418c
commit 11fd780b31
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 76 additions and 21 deletions

View file

@ -6,6 +6,7 @@ import androidx.work.WorkManager
import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.BaseItem 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.CancellationException
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
@ -49,21 +50,8 @@ 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() val cachedSuggestions = cache.get(userId, parentId, itemKind)
if (cachedIds.isNotEmpty()) { if (cachedSuggestions == null) {
flow {
try {
emit(
SuggestionsResource.Success(
fetchItemsByIds(cachedIds, itemKind),
),
)
} catch (e: Exception) {
Timber.e(e, "Failed to fetch items")
emit(SuggestionsResource.Empty)
}
}
} else {
workManager workManager
.getWorkInfosForUniqueWorkFlow(SuggestionsWorker.WORK_NAME) .getWorkInfosForUniqueWorkFlow(SuggestionsWorker.WORK_NAME)
.map { workInfos -> .map { workInfos ->
@ -73,6 +61,23 @@ class SuggestionService
} }
if (isActive) SuggestionsResource.Loading else SuggestionsResource.Empty if (isActive) SuggestionsResource.Loading else SuggestionsResource.Empty
} }
} else if (cachedSuggestions.ids.isEmpty()) {
flowOf(SuggestionsResource.Empty)
} else {
flow {
try {
emit(
SuggestionsResource.Success(
fetchItemsByIds(cachedSuggestions.ids, itemKind),
),
)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Timber.e(e, "Failed to fetch items")
emit(SuggestionsResource.Empty)
}
}
} }
} }
} }

View file

@ -60,15 +60,20 @@ class SuggestionsCache
): CachedSuggestions? { ): CachedSuggestions? {
val key = cacheKey(userId, libraryId, itemKind) val key = cacheKey(userId, libraryId, itemKind)
return memoryCache.getOrPut(key) { return memoryCache.getOrPut(key) {
try { mutex.withLock {
mutex.withLock { try {
File(cacheDir, "$key.json").inputStream().use { val cacheFile = File(cacheDir, "$key.json")
if (!cacheFile.exists()) {
return@withLock null
}
cacheFile.inputStream().use {
json.decodeFromStream<CachedSuggestions>(it) json.decodeFromStream<CachedSuggestions>(it)
} }
} catch (ex: Exception) {
Timber.e(ex, "Exception reading from disk cache")
null
} }
} catch (ex: Exception) {
Timber.e(ex, "Exception reading from disk cache")
null
} }
} }
} }

View file

@ -32,6 +32,7 @@ import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Deferred import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@ -207,6 +208,8 @@ class RecommendedMovieViewModel
} }
update(R.string.suggestions, state) update(R.string.suggestions, state)
} }
} catch (ex: CancellationException) {
throw ex
} catch (ex: Exception) { } catch (ex: Exception) {
Timber.e(ex, "Failed to fetch suggestions") Timber.e(ex, "Failed to fetch suggestions")
update( update(

View file

@ -33,6 +33,7 @@ import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Deferred import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async import kotlinx.coroutines.async
@ -254,6 +255,8 @@ class RecommendedTvShowViewModel
} }
update(R.string.suggestions, state) update(R.string.suggestions, state)
} }
} catch (ex: CancellationException) {
throw ex
} catch (ex: Exception) { } catch (ex: Exception) {
Timber.e(ex, "Failed to fetch suggestions") Timber.e(ex, "Failed to fetch suggestions")
update( update(

View file

@ -10,6 +10,7 @@ import com.github.damontecres.wholphin.util.GetItemsRequestHandler
import io.mockk.coEvery import io.mockk.coEvery
import io.mockk.every import io.mockk.every
import io.mockk.mockk import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
@ -150,6 +151,44 @@ class SuggestionServiceTest {
assertEquals(SuggestionsResource.Empty, result) assertEquals(SuggestionsResource.Empty, result)
} }
@Test
fun getSuggestionsFlow_returnsEmpty_whenCachedIdsEmpty_evenIfWorkIsEnqueued() =
runTest {
val userId = UUID.randomUUID()
val parentId = UUID.randomUUID()
val currentUser = MutableLiveData<JellyfinUser?>(mockUser(userId))
every { mockServerRepository.currentUser } returns currentUser
coEvery { mockCache.get(userId, parentId, BaseItemKind.MOVIE) } returns CachedSuggestions(emptyList())
every { mockWorkManager.getWorkInfosForUniqueWorkFlow(any()) } returns
flowOf(listOf(mockWorkInfo(WorkInfo.State.ENQUEUED)))
val service = createService()
val result = service.getSuggestionsFlow(parentId, BaseItemKind.MOVIE).first()
assertEquals(SuggestionsResource.Empty, result)
verify(exactly = 0) { mockWorkManager.getWorkInfosForUniqueWorkFlow(any()) }
}
@Test
fun getSuggestionsFlow_returnsLoading_whenCacheMissing_andWorkIsEnqueued() =
runTest {
val userId = UUID.randomUUID()
val parentId = UUID.randomUUID()
val currentUser = MutableLiveData<JellyfinUser?>(mockUser(userId))
every { mockServerRepository.currentUser } returns currentUser
coEvery { mockCache.get(userId, parentId, BaseItemKind.MOVIE) } returns null
every { mockWorkManager.getWorkInfosForUniqueWorkFlow(any()) } returns
flowOf(listOf(mockWorkInfo(WorkInfo.State.ENQUEUED)))
val service = createService()
val result = service.getSuggestionsFlow(parentId, BaseItemKind.MOVIE).first()
assertEquals(SuggestionsResource.Loading, result)
verify(exactly = 1) { mockWorkManager.getWorkInfosForUniqueWorkFlow(any()) }
}
@Test @Test
fun passes_correct_arguments_to_cache() = fun passes_correct_arguments_to_cache() =
runTest { runTest {