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 ->
Timber.v("New user %s", user?.user?.id)
if (user == null) {
workManager.cancelUniqueWork(SuggestionsWorker.WORK_NAME)
workManager.cancelUniqueWork(LatestNextUpWorker.WORK_NAME)
} else {
activity.lifecycleScope.launch(dispatcher + ExceptionHandler()) {
scheduleWork(user.user.id, user.server.id)

View file

@ -1,10 +1,16 @@
package com.github.damontecres.wholphin.services
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.WorkManager
import androidx.work.workDataOf
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.JellyfinUser
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.ExperimentalCoroutinesApi
@ -52,36 +58,106 @@ class SuggestionService
val userId = user?.id ?: return@flatMapLatest flowOf(SuggestionsResource.Empty)
val cachedSuggestions = cache.get(userId, parentId, itemKind)
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
.getWorkInfosForUniqueWorkFlow(SuggestionsWorker.WORK_NAME)
.getWorkInfosForUniqueWorkFlow(workName)
.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 =
workInfos.any {
it.state == WorkInfo.State.RUNNING || it.state == WorkInfo.State.ENQUEUED
}
if (isActive) SuggestionsResource.Loading else SuggestionsResource.Empty
}
} else if (cachedSuggestions.ids.isEmpty()) {
flowOf(SuggestionsResource.Empty)
} else {
Timber.v(
"Using cached suggestions for parent=%s kind=%s count=%d",
parentId,
itemKind,
cachedSuggestions.ids.size,
)
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)
}
emit(cachedSuggestions.toResource(parentId, itemKind))
}
}
}
}
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(
ids: List<UUID>,
itemKind: BaseItemKind,

View file

@ -48,9 +48,19 @@ class SuggestionsWorker
private val cache: SuggestionsCache,
) : CoroutineWorker(context, workerParams) {
override suspend fun doWork(): Result {
Timber.d("Start")
val serverId = inputData.getString(PARAM_SERVER_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()) {
var currentUser = serverRepository.current.value
@ -71,51 +81,70 @@ class SuggestionsWorker
.takeIf { it > 0 }
?: 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 =
api.userViewsApi
.getUserViews(userId = userId)
.content.items
.orEmpty()
if (views.isEmpty()) {
Timber.d("No user views found for periodic suggestions refresh")
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 =
supervisorScope {
val context = Dispatchers.IO.limitedParallelism(2, "fetchSuggestions")
views
.mapNotNull { view ->
val itemKind =
getTypeForCollection(view.collectionType)
?: return@mapNotNull null
supportedViews
.map { view ->
async(Dispatchers.IO) {
runCatching {
Timber.v("Fetching suggestions for view %s", view.id)
val suggestions =
fetchSuggestions(
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,
Timber.v("Fetching suggestions for parent=%s kind=%s", view.id, view.itemKind)
fetchAndCacheSuggestions(
context,
view.id,
itemKind,
newIds,
userId,
view.itemKind,
itemsPerRow,
)
ensureActive()
}.onFailure { e ->
Timber.e(
e,
"Failed to fetch suggestions for view %s",
"Failed to fetch suggestions for parent=%s kind=%s",
view.id,
view.itemKind,
)
}
}
@ -130,7 +159,10 @@ class SuggestionsWorker
Timber.d("Completed with $successCount successes and $failureCount failures")
return Result.success()
} 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()
} catch (e: Exception) {
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(
coroutineContext: CoroutineContext,
parentId: UUID,
@ -270,6 +346,20 @@ class SuggestionsWorker
const val WORK_NAME = "com.github.damontecres.wholphin.services.SuggestionsWorker"
const val PARAM_USER_ID = "userId"
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? =
when (collectionType) {
@ -278,4 +368,9 @@ class SuggestionsWorker
else -> null
}
}
private data class SuggestionsView(
val id: UUID,
val itemKind: BaseItemKind,
)
}