Fix: suggestions get stuck on loading for hours/days (#1364)

## Description

This should fix the issue where suggestions would sometimes get stuck
loading. My best guess is that they were getting stuck because the app
thought it was still loading, even though the background job was just
waiting.

This should fix that by making each library kick off its own suggestions
job when needed.

  ### Related issues

  Fixes #1065

  ### Testing

Emulator and nvidia shield

  ## Screenshots

N/A no ui changes

  ## AI or LLM usage

Used codex to help diagnosis the bug. Also added unit tests and more
debug logging for the suggestions system using ai. Wrote the fix myself.

---------

Co-authored-by: Damontecres <damontecres@gmail.com>
This commit is contained in:
Justin Caveda 2026-05-22 11:19:26 -05:00 committed by GitHub
parent c3ba14557c
commit df2dd3e7ca
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 452 additions and 70 deletions

View file

@ -97,7 +97,7 @@ class LatestNextUpSchedulerService
serverRepository.current.observe(activity) { user -> serverRepository.current.observe(activity) { user ->
Timber.v("New user %s", user?.user?.id) Timber.v("New user %s", user?.user?.id)
if (user == null) { if (user == null) {
workManager.cancelUniqueWork(SuggestionsWorker.WORK_NAME) workManager.cancelUniqueWork(LatestNextUpWorker.WORK_NAME)
} else { } else {
activity.lifecycleScope.launch(dispatcher + ExceptionHandler()) { activity.lifecycleScope.launch(dispatcher + ExceptionHandler()) {
scheduleWork(user.user.id, user.server.id) scheduleWork(user.user.id, user.server.id)

View file

@ -1,10 +1,16 @@
package com.github.damontecres.wholphin.services package com.github.damontecres.wholphin.services
import androidx.lifecycle.asFlow import androidx.lifecycle.asFlow
import androidx.work.Constraints
import androidx.work.ExistingWorkPolicy
import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkInfo import androidx.work.WorkInfo
import androidx.work.WorkManager import androidx.work.WorkManager
import androidx.work.workDataOf
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.data.model.JellyfinUser
import com.github.damontecres.wholphin.util.GetItemsRequestHandler import com.github.damontecres.wholphin.util.GetItemsRequestHandler
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
@ -52,36 +58,106 @@ class SuggestionService
val userId = user?.id ?: return@flatMapLatest flowOf(SuggestionsResource.Empty) val userId = user?.id ?: return@flatMapLatest flowOf(SuggestionsResource.Empty)
val cachedSuggestions = cache.get(userId, parentId, itemKind) val cachedSuggestions = cache.get(userId, parentId, itemKind)
if (cachedSuggestions == null) { if (cachedSuggestions == null) {
val workName = SuggestionsWorker.getOnDemandWorkName(userId, parentId, itemKind)
Timber.d(
"No cached suggestions for parent=%s kind=%s; scheduling one-time suggestions refresh",
parentId,
itemKind,
)
enqueueSuggestionsWork(user, parentId, itemKind, workName)
workManager workManager
.getWorkInfosForUniqueWorkFlow(SuggestionsWorker.WORK_NAME) .getWorkInfosForUniqueWorkFlow(workName)
.map { workInfos -> .map { workInfos ->
cache.get(userId, parentId, itemKind)?.let {
Timber.d(
"Loaded cached suggestions after work update for parent=%s kind=%s count=%d",
parentId,
itemKind,
it.ids.size,
)
return@map it.toResource(parentId, itemKind)
}
val states = workInfos.map { it.state }.distinct()
Timber.v(
"Observed suggestions work states for parent=%s kind=%s states=%s",
parentId,
itemKind,
states,
)
val isActive = val isActive =
workInfos.any { workInfos.any {
it.state == WorkInfo.State.RUNNING || it.state == WorkInfo.State.ENQUEUED it.state == WorkInfo.State.RUNNING || it.state == WorkInfo.State.ENQUEUED
} }
if (isActive) SuggestionsResource.Loading else SuggestionsResource.Empty if (isActive) SuggestionsResource.Loading else SuggestionsResource.Empty
} }
} else if (cachedSuggestions.ids.isEmpty()) {
flowOf(SuggestionsResource.Empty)
} else { } else {
Timber.v(
"Using cached suggestions for parent=%s kind=%s count=%d",
parentId,
itemKind,
cachedSuggestions.ids.size,
)
flow { flow {
try { emit(cachedSuggestions.toResource(parentId, itemKind))
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)
}
} }
} }
} }
} }
private fun enqueueSuggestionsWork(
user: JellyfinUser,
parentId: UUID,
itemKind: BaseItemKind,
workName: String,
) {
val constraints =
Constraints
.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
val request =
OneTimeWorkRequestBuilder<SuggestionsWorker>()
.setConstraints(constraints)
.setInputData(
workDataOf(
SuggestionsWorker.PARAM_USER_ID to user.id.toString(),
SuggestionsWorker.PARAM_SERVER_ID to user.serverId.toString(),
SuggestionsWorker.PARAM_PARENT_ID to parentId.toString(),
SuggestionsWorker.PARAM_ITEM_KIND to itemKind.serialName,
),
).addTag(SuggestionsWorker.getOnDemandWorkTag(user.id, parentId, itemKind))
.build()
workManager.enqueueUniqueWork(
workName,
ExistingWorkPolicy.REPLACE,
request,
)
Timber.d(
"Enqueued on-demand suggestions for parent=%s kind=%s",
parentId,
itemKind,
)
}
private suspend fun CachedSuggestions.toResource(
parentId: UUID,
itemKind: BaseItemKind,
): SuggestionsResource {
if (ids.isEmpty()) {
Timber.d("Cached suggestions are empty for parent=%s kind=%s", parentId, itemKind)
return SuggestionsResource.Empty
}
return try {
SuggestionsResource.Success(fetchItemsByIds(ids, itemKind))
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Timber.e(e, "Failed to fetch items")
SuggestionsResource.Empty
}
}
private suspend fun fetchItemsByIds( private suspend fun fetchItemsByIds(
ids: List<UUID>, ids: List<UUID>,
itemKind: BaseItemKind, itemKind: BaseItemKind,

View file

@ -48,9 +48,19 @@ class SuggestionsWorker
private val cache: SuggestionsCache, private val cache: SuggestionsCache,
) : CoroutineWorker(context, workerParams) { ) : CoroutineWorker(context, workerParams) {
override suspend fun doWork(): Result { override suspend fun doWork(): Result {
Timber.d("Start")
val serverId = inputData.getString(PARAM_SERVER_ID)?.toUUIDOrNull() ?: return Result.failure() val serverId = inputData.getString(PARAM_SERVER_ID)?.toUUIDOrNull() ?: return Result.failure()
val userId = inputData.getString(PARAM_USER_ID)?.toUUIDOrNull() ?: return Result.failure() val userId = inputData.getString(PARAM_USER_ID)?.toUUIDOrNull() ?: return Result.failure()
val requestedParentId = inputData.getString(PARAM_PARENT_ID)?.toUUIDOrNull()
val requestedItemKind =
inputData
.getString(PARAM_ITEM_KIND)
?.let { runCatching { BaseItemKind.fromName(it) }.getOrNull() }
val mode = if (requestedParentId == null && requestedItemKind == null) "periodic" else "on-demand"
Timber.d("Starting SuggestionsWorker mode=%s", mode)
if ((requestedParentId == null) != (requestedItemKind == null)) {
Timber.w("Invalid on-demand suggestions input parent=%s kind=%s", requestedParentId, requestedItemKind)
return Result.failure()
}
if (api.baseUrl.isNullOrBlank() || api.accessToken.isNullOrBlank()) { if (api.baseUrl.isNullOrBlank() || api.accessToken.isNullOrBlank()) {
var currentUser = serverRepository.current.value var currentUser = serverRepository.current.value
@ -71,51 +81,70 @@ class SuggestionsWorker
.takeIf { it > 0 } .takeIf { it > 0 }
?: AppPreference.HomePageItems.defaultValue.toInt() ?: AppPreference.HomePageItems.defaultValue.toInt()
val context = Dispatchers.IO.limitedParallelism(2, "fetchSuggestions")
if (requestedParentId != null && requestedItemKind != null) {
Timber.d(
"Fetching on-demand suggestions for parent=%s kind=%s",
requestedParentId,
requestedItemKind,
)
fetchAndCacheSuggestions(
context,
requestedParentId,
userId,
requestedItemKind,
itemsPerRow,
)
Timber.d(
"Completed on-demand suggestions for parent=%s kind=%s",
requestedParentId,
requestedItemKind,
)
return Result.success()
}
val views = val views =
api.userViewsApi api.userViewsApi
.getUserViews(userId = userId) .getUserViews(userId = userId)
.content.items .content.items
.orEmpty() .orEmpty()
if (views.isEmpty()) { if (views.isEmpty()) {
Timber.d("No user views found for periodic suggestions refresh")
return Result.success() return Result.success()
} }
val supportedViews =
views.mapNotNull { view ->
getTypeForCollection(view.collectionType)?.let {
SuggestionsView(view.id, it)
}
}
val skippedCount = views.size - supportedViews.size
Timber.d(
"Refreshing periodic suggestions for %d supported views; skipped=%d",
supportedViews.size,
skippedCount,
)
val results = val results =
supervisorScope { supervisorScope {
val context = Dispatchers.IO.limitedParallelism(2, "fetchSuggestions") supportedViews
views .map { view ->
.mapNotNull { view ->
val itemKind =
getTypeForCollection(view.collectionType)
?: return@mapNotNull null
async(Dispatchers.IO) { async(Dispatchers.IO) {
runCatching { runCatching {
Timber.v("Fetching suggestions for view %s", view.id) Timber.v("Fetching suggestions for parent=%s kind=%s", view.id, view.itemKind)
val suggestions = fetchAndCacheSuggestions(
fetchSuggestions( context,
context,
view.id,
userId,
itemKind,
itemsPerRow,
)
ensureActive()
val newIds = suggestions.map { it.id }
val cachedIds = cache.get(userId, view.id, itemKind)?.ids
if (cachedIds == newIds) {
Timber.v("Suggestions unchanged for view %s, skipping cache write", view.id)
return@runCatching
}
cache.put(
userId,
view.id, view.id,
itemKind, userId,
newIds, view.itemKind,
itemsPerRow,
) )
ensureActive()
}.onFailure { e -> }.onFailure { e ->
Timber.e( Timber.e(
e, e,
"Failed to fetch suggestions for view %s", "Failed to fetch suggestions for parent=%s kind=%s",
view.id, view.id,
view.itemKind,
) )
} }
} }
@ -130,7 +159,10 @@ class SuggestionsWorker
Timber.d("Completed with $successCount successes and $failureCount failures") Timber.d("Completed with $successCount successes and $failureCount failures")
return Result.success() return Result.success()
} catch (ex: ApiClientException) { } catch (ex: ApiClientException) {
Timber.w(ex, "SuggestionsWorker ApiClientException, will retry") Timber.w(ex, "SuggestionsWorker ApiClientException, mode=%s", mode)
if (mode == "on-demand") {
return Result.failure()
}
return Result.retry() return Result.retry()
} catch (e: Exception) { } catch (e: Exception) {
Timber.e(e, "SuggestionsWorker failed") Timber.e(e, "SuggestionsWorker failed")
@ -138,6 +170,50 @@ class SuggestionsWorker
} }
} }
private suspend fun fetchAndCacheSuggestions(
coroutineContext: CoroutineContext,
parentId: UUID,
userId: UUID,
itemKind: BaseItemKind,
itemsPerRow: Int,
) {
val suggestions =
fetchSuggestions(
coroutineContext,
parentId,
userId,
itemKind,
itemsPerRow,
)
val newIds = suggestions.map { it.id }
val cachedIds = cache.get(userId, parentId, itemKind)?.ids
if (cachedIds == newIds) {
Timber.v(
"Suggestions unchanged for parent=%s kind=%s count=%d; skipping cache write",
parentId,
itemKind,
newIds.size,
)
return
}
cache.put(
userId,
parentId,
itemKind,
newIds,
)
if (newIds.isEmpty()) {
Timber.d("Cached empty suggestions for parent=%s kind=%s", parentId, itemKind)
} else {
Timber.d(
"Cached %d suggestions for parent=%s kind=%s",
newIds.size,
parentId,
itemKind,
)
}
}
private suspend fun fetchSuggestions( private suspend fun fetchSuggestions(
coroutineContext: CoroutineContext, coroutineContext: CoroutineContext,
parentId: UUID, parentId: UUID,
@ -270,6 +346,20 @@ class SuggestionsWorker
const val WORK_NAME = "com.github.damontecres.wholphin.services.SuggestionsWorker" const val WORK_NAME = "com.github.damontecres.wholphin.services.SuggestionsWorker"
const val PARAM_USER_ID = "userId" const val PARAM_USER_ID = "userId"
const val PARAM_SERVER_ID = "serverId" const val PARAM_SERVER_ID = "serverId"
const val PARAM_PARENT_ID = "parentId"
const val PARAM_ITEM_KIND = "itemKind"
fun getOnDemandWorkName(
userId: UUID,
parentId: UUID,
itemKind: BaseItemKind,
): String = "$WORK_NAME.onDemand.$userId.$parentId.${itemKind.serialName}"
fun getOnDemandWorkTag(
userId: UUID,
parentId: UUID,
itemKind: BaseItemKind,
): String = "suggestions:$userId:$parentId:${itemKind.serialName}"
fun getTypeForCollection(collectionType: CollectionType?): BaseItemKind? = fun getTypeForCollection(collectionType: CollectionType?): BaseItemKind? =
when (collectionType) { when (collectionType) {
@ -278,4 +368,9 @@ class SuggestionsWorker
else -> null else -> null
} }
} }
private data class SuggestionsView(
val id: UUID,
val itemKind: BaseItemKind,
)
} }

View file

@ -0,0 +1,68 @@
package com.github.damontecres.wholphin.services
import androidx.appcompat.app.AppCompatActivity
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.LifecycleRegistry
import androidx.lifecycle.MutableLiveData
import androidx.work.WorkManager
import com.github.damontecres.wholphin.data.CurrentUser
import com.github.damontecres.wholphin.data.ServerRepository
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class LatestNextUpSchedulerServiceTest {
@get:Rule val instantTaskExecutorRule = InstantTaskExecutorRule()
private val testDispatcher = StandardTestDispatcher()
private val currentLiveData = MutableLiveData<CurrentUser?>()
private val mockActivity = mockk<AppCompatActivity>(relaxed = true)
private val mockServerRepository = mockk<ServerRepository>(relaxed = true)
private val mockWorkManager = mockk<WorkManager>(relaxed = true)
private val lifecycleRegistry = LifecycleRegistry(mockk<LifecycleOwner>(relaxed = true))
@Before
fun setUp() {
Dispatchers.setMain(testDispatcher)
every { mockActivity.lifecycle } returns lifecycleRegistry
every { mockServerRepository.current } returns currentLiveData
lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_RESUME)
}
@After
fun tearDown() = Dispatchers.resetMain()
private fun createService() =
LatestNextUpSchedulerService(
context = mockActivity,
serverRepository = mockServerRepository,
workManager = mockWorkManager,
).also {
it.dispatcher = testDispatcher
}
@Test
fun cancels_latestNextUp_work_when_user_null() =
runTest {
createService()
currentLiveData.value = null
advanceUntilIdle()
verify { mockWorkManager.cancelUniqueWork(LatestNextUpWorker.WORK_NAME) }
verify(exactly = 0) { mockWorkManager.cancelUniqueWork(SuggestionsWorker.WORK_NAME) }
}
}

View file

@ -2,6 +2,8 @@ package com.github.damontecres.wholphin.services
import androidx.arch.core.executor.testing.InstantTaskExecutorRule import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.work.ExistingWorkPolicy
import androidx.work.OneTimeWorkRequest
import androidx.work.WorkInfo import androidx.work.WorkInfo
import androidx.work.WorkManager import androidx.work.WorkManager
import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.ServerRepository
@ -63,13 +65,15 @@ class SuggestionServiceTest {
workManager = mockWorkManager, workManager = mockWorkManager,
) )
private fun mockQueryResult(items: List<BaseItemDto>): Response<BaseItemDtoQueryResult> = private fun mockQueryResult(items: List<BaseItemDto>): Response<BaseItemDtoQueryResult> {
mockk { val queryResult =
every { content } returns mockk<BaseItemDtoQueryResult> {
mockk { every { this@mockk.items } returns items
every { this@mockk.items } returns items }
} return mockk {
every { content } returns queryResult
} }
}
private fun mockUser(id: UUID = UUID.randomUUID()): JellyfinUser = private fun mockUser(id: UUID = UUID.randomUUID()): JellyfinUser =
JellyfinUser( JellyfinUser(
@ -81,12 +85,21 @@ class SuggestionServiceTest {
private fun mockWorkInfo(state: WorkInfo.State): WorkInfo = mockk<WorkInfo> { every { this@mockk.state } returns state } private fun mockWorkInfo(state: WorkInfo.State): WorkInfo = mockk<WorkInfo> { every { this@mockk.state } returns state }
private fun mockEnqueueOnDemandWork() {
every {
mockWorkManager.enqueueUniqueWork(
any<String>(),
any<ExistingWorkPolicy>(),
any<OneTimeWorkRequest>(),
)
} returns mockk(relaxed = true)
}
@Test @Test
fun getSuggestionsFlow_returnsEmpty_whenNoUserLoggedIn() = fun getSuggestionsFlow_returnsEmpty_whenNoUserLoggedIn() =
runTest { runTest {
val currentUser = MutableLiveData<JellyfinUser?>(null) val currentUser = MutableLiveData<JellyfinUser?>(null)
every { mockServerRepository.currentUser } returns currentUser every { mockServerRepository.currentUser } returns currentUser
every { mockWorkManager.getWorkInfosForUniqueWorkFlow(any()) } returns flowOf(emptyList())
val service = createService() val service = createService()
val result = service.getSuggestionsFlow(UUID.randomUUID(), BaseItemKind.MOVIE).first() val result = service.getSuggestionsFlow(UUID.randomUUID(), BaseItemKind.MOVIE).first()
@ -95,22 +108,31 @@ class SuggestionServiceTest {
} }
@Test @Test
fun maps_active_work_states_to_Loading() = fun maps_active_onDemand_work_states_to_Loading() =
runTest { runTest {
listOf(WorkInfo.State.RUNNING, WorkInfo.State.ENQUEUED).forEach { state -> listOf(WorkInfo.State.RUNNING, WorkInfo.State.ENQUEUED).forEach { state ->
val userId = UUID.randomUUID() val userId = UUID.randomUUID()
val parentId = UUID.randomUUID() val parentId = UUID.randomUUID()
val currentUser = MutableLiveData<JellyfinUser?>(mockUser(userId)) val currentUser = MutableLiveData<JellyfinUser?>(mockUser(userId))
val workName = SuggestionsWorker.getOnDemandWorkName(userId, parentId, BaseItemKind.MOVIE)
every { mockServerRepository.currentUser } returns currentUser every { mockServerRepository.currentUser } returns currentUser
coEvery { mockCache.get(userId, parentId, BaseItemKind.MOVIE) } returns null coEvery { mockCache.get(userId, parentId, BaseItemKind.MOVIE) } returns null
every { mockWorkManager.getWorkInfosForUniqueWorkFlow(any()) } returns mockEnqueueOnDemandWork()
every { mockWorkManager.getWorkInfosForUniqueWorkFlow(workName) } returns
flowOf(listOf(mockWorkInfo(state))) flowOf(listOf(mockWorkInfo(state)))
val service = createService() val service = createService()
val result = service.getSuggestionsFlow(parentId, BaseItemKind.MOVIE).first() val result = service.getSuggestionsFlow(parentId, BaseItemKind.MOVIE).first()
assertEquals(SuggestionsResource.Loading, result) assertEquals(SuggestionsResource.Loading, result)
verify {
mockWorkManager.enqueueUniqueWork(
workName,
ExistingWorkPolicy.REPLACE,
any<OneTimeWorkRequest>(),
)
}
} }
} }
@ -121,10 +143,12 @@ class SuggestionServiceTest {
val userId = UUID.randomUUID() val userId = UUID.randomUUID()
val parentId = UUID.randomUUID() val parentId = UUID.randomUUID()
val currentUser = MutableLiveData<JellyfinUser?>(mockUser(userId)) val currentUser = MutableLiveData<JellyfinUser?>(mockUser(userId))
val workName = SuggestionsWorker.getOnDemandWorkName(userId, parentId, BaseItemKind.MOVIE)
every { mockServerRepository.currentUser } returns currentUser every { mockServerRepository.currentUser } returns currentUser
coEvery { mockCache.get(userId, parentId, BaseItemKind.MOVIE) } returns null coEvery { mockCache.get(userId, parentId, BaseItemKind.MOVIE) } returns null
every { mockWorkManager.getWorkInfosForUniqueWorkFlow(any()) } returns mockEnqueueOnDemandWork()
every { mockWorkManager.getWorkInfosForUniqueWorkFlow(workName) } returns
flowOf(listOf(mockWorkInfo(state))) flowOf(listOf(mockWorkInfo(state)))
val service = createService() val service = createService()
@ -140,15 +164,55 @@ class SuggestionServiceTest {
val userId = UUID.randomUUID() val userId = UUID.randomUUID()
val parentId = UUID.randomUUID() val parentId = UUID.randomUUID()
val currentUser = MutableLiveData<JellyfinUser?>(mockUser(userId)) val currentUser = MutableLiveData<JellyfinUser?>(mockUser(userId))
val workName = SuggestionsWorker.getOnDemandWorkName(userId, parentId, BaseItemKind.MOVIE)
every { mockServerRepository.currentUser } returns currentUser every { mockServerRepository.currentUser } returns currentUser
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()) mockEnqueueOnDemandWork()
every { mockWorkManager.getWorkInfosForUniqueWorkFlow(workName) } returns flowOf(emptyList())
val service = createService() val service = createService()
val result = service.getSuggestionsFlow(parentId, BaseItemKind.MOVIE).first() val result = service.getSuggestionsFlow(parentId, BaseItemKind.MOVIE).first()
assertEquals(SuggestionsResource.Empty, result) assertEquals(SuggestionsResource.Empty, result)
verify {
mockWorkManager.enqueueUniqueWork(
workName,
ExistingWorkPolicy.REPLACE,
any<OneTimeWorkRequest>(),
)
}
}
@Test
fun getSuggestionsFlow_returnsSuccess_whenOnDemandWorkCachesItems() =
runTest {
val userId = UUID.randomUUID()
val parentId = UUID.randomUUID()
val currentUser = MutableLiveData<JellyfinUser?>(mockUser(userId))
val workName = SuggestionsWorker.getOnDemandWorkName(userId, parentId, BaseItemKind.MOVIE)
val cachedId = UUID.randomUUID()
every { mockServerRepository.currentUser } returns currentUser
coEvery { mockCache.get(userId, parentId, BaseItemKind.MOVIE) } returnsMany
listOf(null, CachedSuggestions(listOf(cachedId)))
mockEnqueueOnDemandWork()
every { mockWorkManager.getWorkInfosForUniqueWorkFlow(workName) } returns
flowOf(listOf(mockWorkInfo(WorkInfo.State.SUCCEEDED)))
val dto =
mockk<BaseItemDto>(relaxed = true) {
every { id } returns cachedId
every { type } returns BaseItemKind.MOVIE
}
io.mockk.mockkObject(GetItemsRequestHandler)
coEvery { GetItemsRequestHandler.execute(mockApi, any()) } returns mockQueryResult(listOf(dto))
val service = createService()
val result = service.getSuggestionsFlow(parentId, BaseItemKind.MOVIE).first()
assertTrue(result is SuggestionsResource.Success)
assertEquals(cachedId, (result as SuggestionsResource.Success).items.single().id)
} }
@Test @Test
@ -160,33 +224,42 @@ class SuggestionServiceTest {
every { mockServerRepository.currentUser } returns currentUser every { mockServerRepository.currentUser } returns currentUser
coEvery { mockCache.get(userId, parentId, BaseItemKind.MOVIE) } returns CachedSuggestions(emptyList()) 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 service = createService()
val result = service.getSuggestionsFlow(parentId, BaseItemKind.MOVIE).first() val result = service.getSuggestionsFlow(parentId, BaseItemKind.MOVIE).first()
assertEquals(SuggestionsResource.Empty, result) assertEquals(SuggestionsResource.Empty, result)
verify(exactly = 0) { mockWorkManager.getWorkInfosForUniqueWorkFlow(any()) } verify(exactly = 0) { mockWorkManager.getWorkInfosForUniqueWorkFlow(any()) }
verify(exactly = 0) {
mockWorkManager.enqueueUniqueWork(
any<String>(),
any<ExistingWorkPolicy>(),
any<OneTimeWorkRequest>(),
)
}
} }
@Test @Test
fun getSuggestionsFlow_returnsLoading_whenCacheMissing_andWorkIsEnqueued() = fun getSuggestionsFlow_ignores_periodic_work_whenCacheMissing() =
runTest { runTest {
val userId = UUID.randomUUID() val userId = UUID.randomUUID()
val parentId = UUID.randomUUID() val parentId = UUID.randomUUID()
val currentUser = MutableLiveData<JellyfinUser?>(mockUser(userId)) val currentUser = MutableLiveData<JellyfinUser?>(mockUser(userId))
val workName = SuggestionsWorker.getOnDemandWorkName(userId, parentId, BaseItemKind.MOVIE)
every { mockServerRepository.currentUser } returns currentUser every { mockServerRepository.currentUser } returns currentUser
coEvery { mockCache.get(userId, parentId, BaseItemKind.MOVIE) } returns null coEvery { mockCache.get(userId, parentId, BaseItemKind.MOVIE) } returns null
every { mockWorkManager.getWorkInfosForUniqueWorkFlow(any()) } returns mockEnqueueOnDemandWork()
every { mockWorkManager.getWorkInfosForUniqueWorkFlow(SuggestionsWorker.WORK_NAME) } returns
flowOf(listOf(mockWorkInfo(WorkInfo.State.ENQUEUED))) flowOf(listOf(mockWorkInfo(WorkInfo.State.ENQUEUED)))
every { mockWorkManager.getWorkInfosForUniqueWorkFlow(workName) } returns flowOf(emptyList())
val service = createService() val service = createService()
val result = service.getSuggestionsFlow(parentId, BaseItemKind.MOVIE).first() val result = service.getSuggestionsFlow(parentId, BaseItemKind.MOVIE).first()
assertEquals(SuggestionsResource.Loading, result) assertEquals(SuggestionsResource.Empty, result)
verify(exactly = 1) { mockWorkManager.getWorkInfosForUniqueWorkFlow(any()) } verify(exactly = 0) { mockWorkManager.getWorkInfosForUniqueWorkFlow(SuggestionsWorker.WORK_NAME) }
verify(exactly = 1) { mockWorkManager.getWorkInfosForUniqueWorkFlow(workName) }
} }
@Test @Test
@ -196,9 +269,11 @@ class SuggestionServiceTest {
val libraryId = UUID.randomUUID() val libraryId = UUID.randomUUID()
val otherLibraryId = UUID.randomUUID() val otherLibraryId = UUID.randomUUID()
val currentUser = MutableLiveData<JellyfinUser?>(mockUser(userId)) val currentUser = MutableLiveData<JellyfinUser?>(mockUser(userId))
val workName = SuggestionsWorker.getOnDemandWorkName(userId, libraryId, BaseItemKind.MOVIE)
every { mockServerRepository.currentUser } returns currentUser every { mockServerRepository.currentUser } returns currentUser
every { mockWorkManager.getWorkInfosForUniqueWorkFlow(any()) } returns flowOf(emptyList()) mockEnqueueOnDemandWork()
every { mockWorkManager.getWorkInfosForUniqueWorkFlow(workName) } returns flowOf(emptyList())
coEvery { mockCache.get(userId, libraryId, BaseItemKind.MOVIE) } returns null coEvery { mockCache.get(userId, libraryId, BaseItemKind.MOVIE) } returns null
coEvery { coEvery {
@ -250,8 +325,6 @@ class SuggestionServiceTest {
io.mockk.mockkObject(GetItemsRequestHandler) io.mockk.mockkObject(GetItemsRequestHandler)
coEvery { GetItemsRequestHandler.execute(mockApi, any()) } returns mockQueryResult(listOf(dto)) coEvery { GetItemsRequestHandler.execute(mockApi, any()) } returns mockQueryResult(listOf(dto))
every { mockWorkManager.getWorkInfosForUniqueWorkFlow(any()) } returns flowOf(emptyList())
val service = createService() val service = createService()
val result = service.getSuggestionsFlow(parentId, BaseItemKind.MOVIE).first() val result = service.getSuggestionsFlow(parentId, BaseItemKind.MOVIE).first()
@ -282,8 +355,6 @@ class SuggestionServiceTest {
io.mockk.mockkObject(GetItemsRequestHandler) io.mockk.mockkObject(GetItemsRequestHandler)
coEvery { GetItemsRequestHandler.execute(mockApi, any()) } throws RuntimeException("Network error") coEvery { GetItemsRequestHandler.execute(mockApi, any()) } throws RuntimeException("Network error")
every { mockWorkManager.getWorkInfosForUniqueWorkFlow(any()) } returns flowOf(emptyList())
val service = createService() val service = createService()
val result = service.getSuggestionsFlow(parentId, BaseItemKind.MOVIE).first() val result = service.getSuggestionsFlow(parentId, BaseItemKind.MOVIE).first()

View file

@ -55,6 +55,8 @@ class SuggestionsWorkerTest {
private fun createWorker( private fun createWorker(
userId: UUID? = testUserId, userId: UUID? = testUserId,
serverId: UUID? = testServerId, serverId: UUID? = testServerId,
parentId: UUID? = null,
itemKind: BaseItemKind? = null,
): SuggestionsWorker { ): SuggestionsWorker {
val inputData = val inputData =
Data Data
@ -62,6 +64,8 @@ class SuggestionsWorkerTest {
.apply { .apply {
userId?.let { putString(SuggestionsWorker.PARAM_USER_ID, it.toString()) } userId?.let { putString(SuggestionsWorker.PARAM_USER_ID, it.toString()) }
serverId?.let { putString(SuggestionsWorker.PARAM_SERVER_ID, it.toString()) } serverId?.let { putString(SuggestionsWorker.PARAM_SERVER_ID, it.toString()) }
parentId?.let { putString(SuggestionsWorker.PARAM_PARENT_ID, it.toString()) }
itemKind?.let { putString(SuggestionsWorker.PARAM_ITEM_KIND, it.serialName) }
}.build() }.build()
every { mockWorkerParams.inputData } returns inputData every { mockWorkerParams.inputData } returns inputData
return SuggestionsWorker( return SuggestionsWorker(
@ -90,6 +94,17 @@ class SuggestionsWorkerTest {
} }
} }
@Test
fun returns_failure_on_partial_onDemand_input() =
runTest {
listOf(
createWorker(parentId = UUID.randomUUID()),
createWorker(itemKind = BaseItemKind.MOVIE),
).forEach { worker ->
assertEquals(ListenableWorker.Result.failure(), worker.doWork())
}
}
@Test @Test
fun restores_session_when_api_not_configured() = fun restores_session_when_api_not_configured() =
runTest { runTest {
@ -111,6 +126,21 @@ class SuggestionsWorkerTest {
assertEquals(ListenableWorker.Result.success(), result) assertEquals(ListenableWorker.Result.success(), result)
} }
@Test
fun onDemand_work_caches_only_requested_library() =
runTest {
val parentId = UUID.randomUUID()
every { mockPreferences.data } returns flowOf(mockPrefs())
mockkObject(GetItemsRequestHandler)
coEvery { GetItemsRequestHandler.execute(mockApi, any()) } returns mockQueryResult(emptyList())
val result = createWorker(parentId = parentId, itemKind = BaseItemKind.MOVIE).doWork()
assertEquals(ListenableWorker.Result.success(), result)
coVerify(exactly = 0) { mockUserViewsApi.getUserViews(userId = any()) }
coVerify { mockCache.put(testUserId, parentId, BaseItemKind.MOVIE, emptyList()) }
}
@Test @Test
fun caches_suggestions_for_supported_types() = fun caches_suggestions_for_supported_types() =
runTest { runTest {
@ -136,6 +166,43 @@ class SuggestionsWorkerTest {
} }
} }
@Test
fun caches_empty_suggestions_for_supported_types() =
runTest {
val viewId = UUID.randomUUID()
val view =
mockk<BaseItemDto>(relaxed = true) {
every { id } returns viewId
every { this@mockk.collectionType } returns CollectionType.MOVIES
}
every { mockPreferences.data } returns flowOf(mockPrefs())
coEvery { mockUserViewsApi.getUserViews(userId = testUserId) } returns mockQueryResult(listOf(view))
mockkObject(GetItemsRequestHandler)
coEvery { GetItemsRequestHandler.execute(mockApi, any()) } returns mockQueryResult(emptyList())
val result = createWorker().doWork()
assertEquals(ListenableWorker.Result.success(), result)
coVerify { mockCache.put(testUserId, viewId, BaseItemKind.MOVIE, emptyList()) }
}
@Test
fun skips_unsupported_collection_types() =
runTest {
val view =
mockk<BaseItemDto>(relaxed = true) {
every { id } returns UUID.randomUUID()
every { this@mockk.collectionType } returns CollectionType.MUSIC
}
every { mockPreferences.data } returns flowOf(mockPrefs())
coEvery { mockUserViewsApi.getUserViews(userId = testUserId) } returns mockQueryResult(listOf(view))
val result = createWorker().doWork()
assertEquals(ListenableWorker.Result.success(), result)
coVerify(exactly = 0) { mockCache.put(any(), any(), any(), any()) }
}
@Test @Test
fun fetches_contextual_suggestions_when_genres_available() = fun fetches_contextual_suggestions_when_genres_available() =
runTest { runTest {
@ -227,7 +294,12 @@ class SuggestionsWorkerTest {
} }
} }
fun mockQueryResult(items: List<BaseItemDto> = emptyList()) = fun mockQueryResult(items: List<BaseItemDto> = emptyList()): org.jellyfin.sdk.api.client.Response<BaseItemDtoQueryResult> {
mockk<org.jellyfin.sdk.api.client.Response<BaseItemDtoQueryResult>>(relaxed = true) { val queryResult =
every { content } returns mockk { every { this@mockk.items } returns items } mockk<BaseItemDtoQueryResult> {
every { this@mockk.items } returns items
}
return mockk(relaxed = true) {
every { content } returns queryResult
} }
}