mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-10 00:21:19 +02:00
Fix suggestions mixing content from different libraries and update recommendation logic (#644)
## Description This PR aims to fix #638 where the "Suggestions" row was mixing up content from different libraries that shared the same library type. For example you were in the Anime library, you'd see regular TV shows showing up, as the TV Shows and Anime libraries would both be "Show" libraries. As @damontecres mentioned in the issue discussion, the `/Items/Suggestions` endpoint Wholphin is using just returns random items from the same library type, with no special logic or recommendation algorithm. I also looked at how the official WebUI handles this. It basically just mixes "Resume," "Next Up," and "Latest Items" together. That felt a bit redundant since we usually have dedicated rows for those things anyway; I wanted to try something that helps discover new content. I replaced the broken endpoint with a few custom `GetItemsRequest` calls. These work correctly with the `ParentId` filter, so we only get results from the library we are actually looking at. Additionally, I added in some logic that takes into account what the user has been watching to tailor the suggestions a little bit. The new logic uses a 40/30/30 mix (this can of course be tweaked as needed) - Tailored (40%): Grabs your recently watched items from this library, checks their genres, and returns items from the same genre. - Random (30%): Random unwatched stuff from this library. - New (30%): Recently added items. This is implemented using only `GetItemsRequest` API calls using filters, rather than any specialized endpoints like the `/Items/Suggestions` endpoint. This means the feature should be predictable and stable. If Jellyfin ever adds an actual recommendation algorithm and endpoint to call it, I imagine we would want to switch to that in the future though. I also added some failsafe logic to make sure there will be diversity in the "recently watched" items that get pulled, in case a user might have recently binged a TV show. We wouldn't want every single recommendation to be based off of that one show. To fix this, I changed the query to fetch the last 20 history items instead of just the top 3. Then, we filter that list client-side to find unique Series IDs. If the user watched 10 episodes of One Piece in a row, the logic collapses them into a single "One Piece" entry and moves on to the next distinct show they watched. This guarantees we get variety in the source material for recommendations. This is a work in progress right now, mainly due to performance. For my server it takes about 10-15 seconds to load the Suggestions row when I tested. This is unacceptable in my opinion, especially if we ever intend to add a Suggestions row like this to the home page like Plex does. I have some ideas on how to improve performance, but I'm open to suggestions. ### Related issues Resolves #638 ### Screenshots Before: <img width="1953" height="1162" alt="suggestions1" src="https://github.com/user-attachments/assets/6e301c67-1a46-46b5-8184-3adb9e52b330" /> After: <img width="1937" height="1108" alt="suggestions3" src="https://github.com/user-attachments/assets/b9563865-4055-40b6-b452-f94c26c8b6e9" /> ### AI/LLM usage I used Claude Code to help write the Kotlin Coroutines so the API calls run at the same time. I also used it to write the tests in `TestSuggestionsLogic.kt`. --------- Co-authored-by: Damontecres <damontecres@gmail.com>
This commit is contained in:
parent
d9d5ab02e8
commit
aeaecc0f59
17 changed files with 1809 additions and 76 deletions
|
|
@ -52,6 +52,7 @@ import com.github.damontecres.wholphin.services.RefreshRateService
|
|||
import com.github.damontecres.wholphin.services.ServerEventListener
|
||||
import com.github.damontecres.wholphin.services.SetupDestination
|
||||
import com.github.damontecres.wholphin.services.SetupNavigationManager
|
||||
import com.github.damontecres.wholphin.services.SuggestionsSchedulerService
|
||||
import com.github.damontecres.wholphin.services.UpdateChecker
|
||||
import com.github.damontecres.wholphin.services.UserSwitchListener
|
||||
import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient
|
||||
|
|
@ -119,6 +120,9 @@ class MainActivity : AppCompatActivity() {
|
|||
@Inject
|
||||
lateinit var tvProviderSchedulerService: TvProviderSchedulerService
|
||||
|
||||
@Inject
|
||||
lateinit var suggestionsSchedulerService: SuggestionsSchedulerService
|
||||
|
||||
// Note: unused but injected to ensure it is created
|
||||
@Inject
|
||||
lateinit var serverEventListener: ServerEventListener
|
||||
|
|
|
|||
|
|
@ -4,6 +4,9 @@ import androidx.lifecycle.DefaultLifecycleObserver
|
|||
import androidx.lifecycle.LifecycleOwner
|
||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||
import dagger.hilt.android.scopes.ActivityRetainedScoped
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
|
|
@ -16,6 +19,7 @@ class PlaybackLifecycleObserver
|
|||
private val navigationManager: NavigationManager,
|
||||
private val playerFactory: PlayerFactory,
|
||||
private val themeSongPlayer: ThemeSongPlayer,
|
||||
private val suggestionsCache: SuggestionsCache,
|
||||
) : DefaultLifecycleObserver {
|
||||
private var wasPlaying: Boolean? = null
|
||||
|
||||
|
|
@ -48,5 +52,6 @@ class PlaybackLifecycleObserver
|
|||
|
||||
override fun onStop(owner: LifecycleOwner) {
|
||||
themeSongPlayer.stop()
|
||||
CoroutineScope(Dispatchers.Main).launch { suggestionsCache.save() }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,97 @@
|
|||
package com.github.damontecres.wholphin.services
|
||||
|
||||
import androidx.lifecycle.asFlow
|
||||
import androidx.work.WorkInfo
|
||||
import androidx.work.WorkManager
|
||||
import com.github.damontecres.wholphin.data.ServerRepository
|
||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.map
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.jellyfin.sdk.model.api.ItemFields
|
||||
import org.jellyfin.sdk.model.api.request.GetItemsRequest
|
||||
import timber.log.Timber
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
sealed interface SuggestionsResource {
|
||||
data object Loading : SuggestionsResource
|
||||
|
||||
data class Success(
|
||||
val items: List<BaseItem>,
|
||||
) : SuggestionsResource
|
||||
|
||||
data object Empty : SuggestionsResource
|
||||
}
|
||||
|
||||
@Singleton
|
||||
class SuggestionService
|
||||
@Inject
|
||||
constructor(
|
||||
private val api: ApiClient,
|
||||
private val serverRepository: ServerRepository,
|
||||
private val cache: SuggestionsCache,
|
||||
private val workManager: WorkManager,
|
||||
) {
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
fun getSuggestionsFlow(
|
||||
parentId: UUID,
|
||||
itemKind: BaseItemKind,
|
||||
): Flow<SuggestionsResource> {
|
||||
return serverRepository.currentUser
|
||||
.asFlow()
|
||||
.flatMapLatest { user ->
|
||||
val userId = user?.id ?: return@flatMapLatest flowOf(SuggestionsResource.Empty)
|
||||
|
||||
cache.cacheVersion
|
||||
.map { cache.get(userId, parentId, itemKind)?.ids.orEmpty() }
|
||||
.distinctUntilChanged()
|
||||
.flatMapLatest { cachedIds ->
|
||||
if (cachedIds.isNotEmpty()) {
|
||||
flow {
|
||||
try {
|
||||
emit(SuggestionsResource.Success(fetchItemsByIds(cachedIds, itemKind)))
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "Failed to fetch items")
|
||||
emit(SuggestionsResource.Empty)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
workManager
|
||||
.getWorkInfosForUniqueWorkFlow(SuggestionsWorker.WORK_NAME)
|
||||
.map { workInfos ->
|
||||
val isActive =
|
||||
workInfos.any {
|
||||
it.state == WorkInfo.State.RUNNING || it.state == WorkInfo.State.ENQUEUED
|
||||
}
|
||||
if (isActive) SuggestionsResource.Loading else SuggestionsResource.Empty
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun fetchItemsByIds(
|
||||
ids: List<UUID>,
|
||||
itemKind: BaseItemKind,
|
||||
): List<BaseItem> {
|
||||
val isSeries = itemKind == BaseItemKind.SERIES
|
||||
val request =
|
||||
GetItemsRequest(
|
||||
ids = ids,
|
||||
fields = listOf(ItemFields.PRIMARY_IMAGE_ASPECT_RATIO, ItemFields.OVERVIEW),
|
||||
)
|
||||
return GetItemsRequestHandler
|
||||
.execute(api, request)
|
||||
.content.items
|
||||
.map { BaseItem.from(it, api, isSeries) }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,216 @@
|
|||
@file:UseSerializers(UUIDSerializer::class)
|
||||
|
||||
package com.github.damontecres.wholphin.services
|
||||
|
||||
import android.content.Context
|
||||
import com.github.damontecres.wholphin.ui.toServerString
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
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.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.ExperimentalSerializationApi
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.UseSerializers
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.decodeFromStream
|
||||
import kotlinx.serialization.json.encodeToStream
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.jellyfin.sdk.model.serializer.UUIDSerializer
|
||||
import timber.log.Timber
|
||||
import java.io.File
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Serializable
|
||||
data class CachedSuggestions(
|
||||
val ids: List<UUID>,
|
||||
)
|
||||
|
||||
@Singleton
|
||||
class SuggestionsCache
|
||||
@Inject
|
||||
constructor(
|
||||
@param:ApplicationContext private val context: Context,
|
||||
) {
|
||||
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()
|
||||
|
||||
@OptIn(ExperimentalSerializationApi::class)
|
||||
private fun writeEntryToDisk(
|
||||
key: String,
|
||||
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(
|
||||
userId: UUID,
|
||||
libraryId: UUID,
|
||||
itemKind: BaseItemKind,
|
||||
) = "${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)
|
||||
suspend fun get(
|
||||
userId: UUID,
|
||||
libraryId: UUID,
|
||||
itemKind: BaseItemKind,
|
||||
): CachedSuggestions? {
|
||||
loadFromDisk(userId)
|
||||
val key = cacheKey(userId, libraryId, itemKind)
|
||||
memoryCache[key]?.let { return it }
|
||||
return withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
File(cacheDir, "$key.json")
|
||||
.takeIf { it.exists() }
|
||||
?.inputStream()
|
||||
?.use {
|
||||
json.decodeFromStream<CachedSuggestions>(it)
|
||||
}?.also { memoryCache[key] = it }
|
||||
}.onFailure { Timber.w(it, "Failed to read cache: $key") }
|
||||
.getOrNull()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun put(
|
||||
userId: UUID,
|
||||
libraryId: UUID,
|
||||
itemKind: BaseItemKind,
|
||||
ids: List<UUID>,
|
||||
) {
|
||||
val key = cacheKey(userId, libraryId, itemKind)
|
||||
val cached = CachedSuggestions(ids)
|
||||
val evictedEntry =
|
||||
mutex.withLock {
|
||||
val evicted = checkForEviction(key)
|
||||
memoryCache[key] = cached
|
||||
dirtyKeys.add(key)
|
||||
_cacheVersion.update { it + 1 }
|
||||
evicted
|
||||
}
|
||||
evictedEntry?.let { (evictedKey, evictedValue) ->
|
||||
withContext(Dispatchers.IO) {
|
||||
writeEntryToDisk(evictedKey, evictedValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun isEmpty(): Boolean =
|
||||
mutex.withLock {
|
||||
if (memoryCache.isNotEmpty() || dirtyKeys.isNotEmpty()) {
|
||||
return@withLock false
|
||||
}
|
||||
withContext(Dispatchers.IO) {
|
||||
val files = cacheDir.listFiles()
|
||||
files == null || files.isEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalSerializationApi::class)
|
||||
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) {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,100 @@
|
|||
package com.github.damontecres.wholphin.services
|
||||
|
||||
import android.content.Context
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.work.BackoffPolicy
|
||||
import androidx.work.Constraints
|
||||
import androidx.work.ExistingPeriodicWorkPolicy
|
||||
import androidx.work.NetworkType
|
||||
import androidx.work.OneTimeWorkRequestBuilder
|
||||
import androidx.work.PeriodicWorkRequestBuilder
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.workDataOf
|
||||
import com.github.damontecres.wholphin.data.ServerRepository
|
||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||
import dagger.hilt.android.qualifiers.ActivityContext
|
||||
import dagger.hilt.android.scopes.ActivityScoped
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
import kotlin.time.Duration.Companion.hours
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
import kotlin.time.toJavaDuration
|
||||
|
||||
@ActivityScoped
|
||||
class SuggestionsSchedulerService
|
||||
@Inject
|
||||
constructor(
|
||||
@param:ActivityContext private val context: Context,
|
||||
private val serverRepository: ServerRepository,
|
||||
private val cache: SuggestionsCache,
|
||||
private val workManager: WorkManager,
|
||||
) {
|
||||
private val activity =
|
||||
(context as? AppCompatActivity)
|
||||
?: throw IllegalStateException(
|
||||
"SuggestionsSchedulerService requires an AppCompatActivity context, but received: ${context::class.java.name}",
|
||||
)
|
||||
|
||||
// Exposed for testing
|
||||
internal var dispatcher: CoroutineDispatcher = Dispatchers.IO
|
||||
|
||||
init {
|
||||
serverRepository.current.observe(activity) { user ->
|
||||
Timber.v("New user %s", user?.user?.id)
|
||||
if (user == null) {
|
||||
workManager.cancelUniqueWork(SuggestionsWorker.WORK_NAME)
|
||||
} else {
|
||||
activity.lifecycleScope.launch(dispatcher + ExceptionHandler()) {
|
||||
scheduleWork(user.user.id, user.server.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun scheduleWork(
|
||||
userId: UUID,
|
||||
serverId: UUID,
|
||||
) {
|
||||
val constraints =
|
||||
Constraints
|
||||
.Builder()
|
||||
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||
.build()
|
||||
|
||||
val inputData =
|
||||
workDataOf(
|
||||
SuggestionsWorker.PARAM_USER_ID to userId.toString(),
|
||||
SuggestionsWorker.PARAM_SERVER_ID to serverId.toString(),
|
||||
)
|
||||
|
||||
if (cache.isEmpty()) {
|
||||
Timber.i("Suggestions cache empty, scheduling immediate fetch")
|
||||
workManager.enqueue(
|
||||
OneTimeWorkRequestBuilder<SuggestionsWorker>()
|
||||
.setConstraints(constraints)
|
||||
.setInputData(inputData)
|
||||
.build(),
|
||||
)
|
||||
}
|
||||
|
||||
Timber.i("Scheduling periodic SuggestionsWorker")
|
||||
workManager.enqueueUniquePeriodicWork(
|
||||
uniqueWorkName = SuggestionsWorker.WORK_NAME,
|
||||
existingPeriodicWorkPolicy = ExistingPeriodicWorkPolicy.UPDATE,
|
||||
request =
|
||||
PeriodicWorkRequestBuilder<SuggestionsWorker>(
|
||||
repeatInterval = 12.hours.toJavaDuration(),
|
||||
).setConstraints(constraints)
|
||||
.setBackoffCriteria(
|
||||
BackoffPolicy.EXPONENTIAL,
|
||||
15.minutes.toJavaDuration(),
|
||||
).setInputData(inputData)
|
||||
.build(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,230 @@
|
|||
package com.github.damontecres.wholphin.services
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.hilt.work.HiltWorker
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.WorkerParameters
|
||||
import com.github.damontecres.wholphin.data.ServerRepository
|
||||
import com.github.damontecres.wholphin.preferences.AppPreference
|
||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedInject
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import kotlinx.coroutines.supervisorScope
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.exception.ApiClientException
|
||||
import org.jellyfin.sdk.api.client.extensions.userViewsApi
|
||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.jellyfin.sdk.model.api.CollectionType
|
||||
import org.jellyfin.sdk.model.api.ItemFields
|
||||
import org.jellyfin.sdk.model.api.ItemSortBy
|
||||
import org.jellyfin.sdk.model.api.SortOrder
|
||||
import org.jellyfin.sdk.model.api.request.GetItemsRequest
|
||||
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
||||
import timber.log.Timber
|
||||
import java.util.UUID
|
||||
|
||||
private val BaseItemDto.relevantId: UUID get() = seriesId ?: id
|
||||
|
||||
@HiltWorker
|
||||
class SuggestionsWorker
|
||||
@AssistedInject
|
||||
constructor(
|
||||
@Assisted private val context: Context,
|
||||
@Assisted workerParams: WorkerParameters,
|
||||
private val serverRepository: ServerRepository,
|
||||
private val preferences: DataStore<AppPreferences>,
|
||||
private val api: ApiClient,
|
||||
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()
|
||||
|
||||
if (api.baseUrl.isNullOrBlank() || api.accessToken.isNullOrBlank()) {
|
||||
var currentUser = serverRepository.current.value
|
||||
if (currentUser == null) {
|
||||
serverRepository.restoreSession(serverId, userId)
|
||||
currentUser = serverRepository.current.value
|
||||
}
|
||||
if (currentUser == null) {
|
||||
Timber.w("No user found during run")
|
||||
return Result.failure()
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
val prefs = preferences.data.firstOrNull() ?: AppPreferences.getDefaultInstance()
|
||||
val itemsPerRow =
|
||||
prefs.homePagePreferences.maxItemsPerRow
|
||||
.takeIf { it > 0 }
|
||||
?: AppPreference.HomePageItems.defaultValue.toInt()
|
||||
|
||||
val views =
|
||||
api.userViewsApi
|
||||
.getUserViews(userId = userId)
|
||||
.content.items
|
||||
.orEmpty()
|
||||
if (views.isEmpty()) {
|
||||
return Result.success()
|
||||
}
|
||||
val results =
|
||||
supervisorScope {
|
||||
views
|
||||
.mapNotNull { view ->
|
||||
val itemKind =
|
||||
when (view.collectionType) {
|
||||
CollectionType.MOVIES -> BaseItemKind.MOVIE
|
||||
CollectionType.TVSHOWS -> BaseItemKind.SERIES
|
||||
else -> return@mapNotNull null
|
||||
}
|
||||
async(Dispatchers.IO) {
|
||||
runCatching {
|
||||
Timber.v("Fetching suggestions for view %s", view.id)
|
||||
val suggestions = fetchSuggestions(view.id, userId, itemKind, itemsPerRow)
|
||||
ensureActive()
|
||||
cache.put(
|
||||
userId,
|
||||
view.id,
|
||||
itemKind,
|
||||
suggestions.map { it.id },
|
||||
)
|
||||
}.onFailure { e ->
|
||||
Timber.e(
|
||||
e,
|
||||
"Failed to fetch suggestions for view %s",
|
||||
view.id,
|
||||
)
|
||||
}
|
||||
}
|
||||
}.awaitAll()
|
||||
}
|
||||
val successCount = results.count { it.isSuccess }
|
||||
val failureCount = results.count { it.isFailure }
|
||||
cache.save()
|
||||
if (failureCount > 0 && successCount == 0) {
|
||||
Timber.w("All attempts failed ($failureCount views), scheduling retry")
|
||||
return Result.retry()
|
||||
}
|
||||
Timber.d("Completed with $successCount successes and $failureCount failures")
|
||||
return Result.success()
|
||||
} catch (ex: ApiClientException) {
|
||||
Timber.w(ex, "SuggestionsWorker ApiClientException, will retry")
|
||||
return Result.retry()
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "SuggestionsWorker failed")
|
||||
return Result.failure()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun fetchSuggestions(
|
||||
parentId: UUID,
|
||||
userId: UUID,
|
||||
itemKind: BaseItemKind,
|
||||
itemsPerRow: Int,
|
||||
): List<BaseItemDto> =
|
||||
coroutineScope {
|
||||
val isSeries = itemKind == BaseItemKind.SERIES
|
||||
val historyItemType = if (isSeries) BaseItemKind.EPISODE else itemKind
|
||||
|
||||
val historyDeferred =
|
||||
async(Dispatchers.IO) {
|
||||
fetchItems(
|
||||
parentId = parentId,
|
||||
userId = userId,
|
||||
itemKind = historyItemType,
|
||||
sortBy = ItemSortBy.DATE_PLAYED,
|
||||
isPlayed = true,
|
||||
limit = 10,
|
||||
extraFields = listOf(ItemFields.GENRES),
|
||||
).distinctBy { it.relevantId }.take(3)
|
||||
}
|
||||
|
||||
val randomDeferred =
|
||||
async(Dispatchers.IO) {
|
||||
fetchItems(
|
||||
parentId = parentId,
|
||||
userId = userId,
|
||||
itemKind = itemKind,
|
||||
sortBy = ItemSortBy.RANDOM,
|
||||
isPlayed = false,
|
||||
limit = itemsPerRow,
|
||||
)
|
||||
}
|
||||
|
||||
val freshDeferred =
|
||||
async(Dispatchers.IO) {
|
||||
fetchItems(
|
||||
parentId = parentId,
|
||||
userId = userId,
|
||||
itemKind = itemKind,
|
||||
sortBy = ItemSortBy.DATE_CREATED,
|
||||
sortOrder = SortOrder.DESCENDING,
|
||||
isPlayed = false,
|
||||
limit = (itemsPerRow * FRESH_CONTENT_RATIO).toInt().coerceAtLeast(1),
|
||||
)
|
||||
}
|
||||
|
||||
val seedItems = historyDeferred.await()
|
||||
val random = randomDeferred.await()
|
||||
val fresh = freshDeferred.await()
|
||||
|
||||
val excludeIds = seedItems.mapTo(HashSet()) { it.relevantId }
|
||||
|
||||
(fresh + random)
|
||||
.asSequence()
|
||||
.distinctBy { it.id }
|
||||
.filterNot { excludeIds.contains(it.relevantId) }
|
||||
.toList()
|
||||
.shuffled()
|
||||
.take(itemsPerRow)
|
||||
}
|
||||
|
||||
private suspend fun fetchItems(
|
||||
parentId: UUID,
|
||||
userId: UUID,
|
||||
itemKind: BaseItemKind,
|
||||
sortBy: ItemSortBy,
|
||||
isPlayed: Boolean,
|
||||
limit: Int,
|
||||
sortOrder: SortOrder? = null,
|
||||
genreIds: List<UUID>? = null,
|
||||
extraFields: List<ItemFields> = emptyList(),
|
||||
): List<BaseItemDto> {
|
||||
val request =
|
||||
GetItemsRequest(
|
||||
parentId = parentId,
|
||||
userId = userId,
|
||||
fields = listOf(ItemFields.PRIMARY_IMAGE_ASPECT_RATIO, ItemFields.OVERVIEW) + extraFields,
|
||||
includeItemTypes = listOf(itemKind),
|
||||
genreIds = genreIds,
|
||||
recursive = true,
|
||||
isPlayed = isPlayed,
|
||||
sortBy = listOf(sortBy),
|
||||
sortOrder = sortOrder?.let { listOf(it) },
|
||||
limit = limit,
|
||||
enableTotalRecordCount = false,
|
||||
imageTypeLimit = 1,
|
||||
)
|
||||
return GetItemsRequestHandler
|
||||
.execute(api, request)
|
||||
.content.items
|
||||
.orEmpty()
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val WORK_NAME = "com.github.damontecres.wholphin.services.SuggestionsWorker"
|
||||
const val PARAM_USER_ID = "userId"
|
||||
const val PARAM_SERVER_ID = "serverId"
|
||||
private const val FRESH_CONTENT_RATIO = 0.4
|
||||
}
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@ package com.github.damontecres.wholphin.services.hilt
|
|||
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.work.WorkManager
|
||||
import com.github.damontecres.wholphin.BuildConfig
|
||||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.data.ServerRepository
|
||||
|
|
@ -176,6 +177,12 @@ object AppModule {
|
|||
@IoCoroutineScope
|
||||
fun ioCoroutineScope(): CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun workManager(
|
||||
@ApplicationContext context: Context,
|
||||
): WorkManager = WorkManager.getInstance(context)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun seerrApi(
|
||||
|
|
|
|||
|
|
@ -29,9 +29,9 @@ class TvProviderSchedulerService
|
|||
constructor(
|
||||
@param:ActivityContext private val context: Context,
|
||||
private val serverRepository: ServerRepository,
|
||||
private val workManager: WorkManager,
|
||||
) {
|
||||
private val activity = (context as AppCompatActivity)
|
||||
private val workManager = WorkManager.getInstance(context)
|
||||
|
||||
private val supportsTvProvider =
|
||||
// TODO <=25 has limited support
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@ import com.github.damontecres.wholphin.preferences.UserPreferences
|
|||
import com.github.damontecres.wholphin.services.BackdropService
|
||||
import com.github.damontecres.wholphin.services.FavoriteWatchManager
|
||||
import com.github.damontecres.wholphin.services.NavigationManager
|
||||
import com.github.damontecres.wholphin.services.SuggestionService
|
||||
import com.github.damontecres.wholphin.services.SuggestionsResource
|
||||
import com.github.damontecres.wholphin.ui.SlimItemFields
|
||||
import com.github.damontecres.wholphin.ui.data.RowColumn
|
||||
import com.github.damontecres.wholphin.ui.setValueOnMain
|
||||
|
|
@ -22,7 +24,6 @@ import com.github.damontecres.wholphin.ui.toBaseItems
|
|||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
|
||||
import com.github.damontecres.wholphin.util.GetResumeItemsRequestHandler
|
||||
import com.github.damontecres.wholphin.util.GetSuggestionsRequestHandler
|
||||
import com.github.damontecres.wholphin.util.HomeRowLoadingState
|
||||
import com.github.damontecres.wholphin.util.LoadingState
|
||||
import dagger.assisted.Assisted
|
||||
|
|
@ -42,7 +43,6 @@ import org.jellyfin.sdk.model.api.ItemSortBy
|
|||
import org.jellyfin.sdk.model.api.SortOrder
|
||||
import org.jellyfin.sdk.model.api.request.GetItemsRequest
|
||||
import org.jellyfin.sdk.model.api.request.GetResumeItemsRequest
|
||||
import org.jellyfin.sdk.model.api.request.GetSuggestionsRequest
|
||||
import timber.log.Timber
|
||||
import java.util.UUID
|
||||
|
||||
|
|
@ -54,6 +54,7 @@ class RecommendedMovieViewModel
|
|||
private val api: ApiClient,
|
||||
private val serverRepository: ServerRepository,
|
||||
private val preferencesDataStore: DataStore<AppPreferences>,
|
||||
private val suggestionService: SuggestionService,
|
||||
@Assisted val parentId: UUID,
|
||||
navigationManager: NavigationManager,
|
||||
favoriteWatchManager: FavoriteWatchManager,
|
||||
|
|
@ -115,7 +116,7 @@ class RecommendedMovieViewModel
|
|||
}
|
||||
|
||||
update(R.string.recently_released) {
|
||||
val recentlyReleasedRequest =
|
||||
val request =
|
||||
GetItemsRequest(
|
||||
parentId = parentId,
|
||||
fields = SlimItemFields,
|
||||
|
|
@ -128,13 +129,11 @@ class RecommendedMovieViewModel
|
|||
limit = itemsPerRow,
|
||||
enableTotalRecordCount = false,
|
||||
)
|
||||
GetItemsRequestHandler
|
||||
.execute(api, recentlyReleasedRequest)
|
||||
.toBaseItems(api, false)
|
||||
GetItemsRequestHandler.execute(api, request).toBaseItems(api, false)
|
||||
}
|
||||
|
||||
update(R.string.recently_added) {
|
||||
val recentlyAddedRequest =
|
||||
val request =
|
||||
GetItemsRequest(
|
||||
parentId = parentId,
|
||||
fields = SlimItemFields,
|
||||
|
|
@ -147,27 +146,11 @@ class RecommendedMovieViewModel
|
|||
limit = itemsPerRow,
|
||||
enableTotalRecordCount = false,
|
||||
)
|
||||
GetItemsRequestHandler
|
||||
.execute(api, recentlyAddedRequest)
|
||||
.toBaseItems(api, false)
|
||||
}
|
||||
|
||||
update(R.string.suggestions) {
|
||||
val suggestionsRequest =
|
||||
GetSuggestionsRequest(
|
||||
userId = serverRepository.currentUser.value?.id,
|
||||
type = listOf(BaseItemKind.MOVIE),
|
||||
startIndex = 0,
|
||||
limit = itemsPerRow,
|
||||
enableTotalRecordCount = false,
|
||||
)
|
||||
GetSuggestionsRequestHandler
|
||||
.execute(api, suggestionsRequest)
|
||||
.toBaseItems(api, false)
|
||||
GetItemsRequestHandler.execute(api, request).toBaseItems(api, false)
|
||||
}
|
||||
|
||||
update(R.string.top_unwatched) {
|
||||
val unwatchedTopRatedRequest =
|
||||
val request =
|
||||
GetItemsRequest(
|
||||
parentId = parentId,
|
||||
fields = SlimItemFields,
|
||||
|
|
@ -181,9 +164,48 @@ class RecommendedMovieViewModel
|
|||
limit = itemsPerRow,
|
||||
enableTotalRecordCount = false,
|
||||
)
|
||||
GetItemsRequestHandler
|
||||
.execute(api, unwatchedTopRatedRequest)
|
||||
.toBaseItems(api, false)
|
||||
GetItemsRequestHandler.execute(api, request).toBaseItems(api, false)
|
||||
}
|
||||
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
suggestionService
|
||||
.getSuggestionsFlow(parentId, BaseItemKind.MOVIE)
|
||||
.collect { resource ->
|
||||
val state =
|
||||
when (resource) {
|
||||
is SuggestionsResource.Loading -> {
|
||||
HomeRowLoadingState.Loading(
|
||||
context.getString(R.string.suggestions),
|
||||
)
|
||||
}
|
||||
|
||||
is SuggestionsResource.Success -> {
|
||||
HomeRowLoadingState.Success(
|
||||
context.getString(R.string.suggestions),
|
||||
resource.items,
|
||||
)
|
||||
}
|
||||
|
||||
is SuggestionsResource.Empty -> {
|
||||
HomeRowLoadingState.Success(
|
||||
context.getString(R.string.suggestions),
|
||||
emptyList(),
|
||||
)
|
||||
}
|
||||
}
|
||||
update(R.string.suggestions, state)
|
||||
}
|
||||
} catch (ex: Exception) {
|
||||
Timber.e(ex, "Failed to fetch suggestions")
|
||||
update(
|
||||
R.string.suggestions,
|
||||
HomeRowLoadingState.Error(
|
||||
title = context.getString(R.string.suggestions),
|
||||
exception = ex,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading.value == LoadingState.Loading || loading.value == LoadingState.Pending) {
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@ import com.github.damontecres.wholphin.services.BackdropService
|
|||
import com.github.damontecres.wholphin.services.FavoriteWatchManager
|
||||
import com.github.damontecres.wholphin.services.LatestNextUpService
|
||||
import com.github.damontecres.wholphin.services.NavigationManager
|
||||
import com.github.damontecres.wholphin.services.SuggestionService
|
||||
import com.github.damontecres.wholphin.services.SuggestionsResource
|
||||
import com.github.damontecres.wholphin.ui.SlimItemFields
|
||||
import com.github.damontecres.wholphin.ui.data.RowColumn
|
||||
import com.github.damontecres.wholphin.ui.setValueOnMain
|
||||
|
|
@ -23,7 +25,6 @@ import com.github.damontecres.wholphin.util.ExceptionHandler
|
|||
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
|
||||
import com.github.damontecres.wholphin.util.GetNextUpRequestHandler
|
||||
import com.github.damontecres.wholphin.util.GetResumeItemsRequestHandler
|
||||
import com.github.damontecres.wholphin.util.GetSuggestionsRequestHandler
|
||||
import com.github.damontecres.wholphin.util.HomeRowLoadingState
|
||||
import com.github.damontecres.wholphin.util.LoadingState
|
||||
import dagger.assisted.Assisted
|
||||
|
|
@ -45,7 +46,6 @@ import org.jellyfin.sdk.model.api.SortOrder
|
|||
import org.jellyfin.sdk.model.api.request.GetItemsRequest
|
||||
import org.jellyfin.sdk.model.api.request.GetNextUpRequest
|
||||
import org.jellyfin.sdk.model.api.request.GetResumeItemsRequest
|
||||
import org.jellyfin.sdk.model.api.request.GetSuggestionsRequest
|
||||
import timber.log.Timber
|
||||
import java.util.UUID
|
||||
|
||||
|
|
@ -58,6 +58,7 @@ class RecommendedTvShowViewModel
|
|||
private val serverRepository: ServerRepository,
|
||||
private val preferencesDataStore: DataStore<AppPreferences>,
|
||||
private val lastestNextUpService: LatestNextUpService,
|
||||
private val suggestionService: SuggestionService,
|
||||
@Assisted val parentId: UUID,
|
||||
navigationManager: NavigationManager,
|
||||
favoriteWatchManager: FavoriteWatchManager,
|
||||
|
|
@ -86,7 +87,7 @@ class RecommendedTvShowViewModel
|
|||
val userId = serverRepository.currentUser.value?.id
|
||||
try {
|
||||
val resumeItemsDeferred =
|
||||
viewModelScope.async(Dispatchers.IO) {
|
||||
async(Dispatchers.IO) {
|
||||
val resumeItemsRequest =
|
||||
GetResumeItemsRequest(
|
||||
userId = userId,
|
||||
|
|
@ -104,7 +105,7 @@ class RecommendedTvShowViewModel
|
|||
}
|
||||
|
||||
val nextUpItemsDeferred =
|
||||
viewModelScope.async(Dispatchers.IO) {
|
||||
async(Dispatchers.IO) {
|
||||
val nextUpRequest =
|
||||
GetNextUpRequest(
|
||||
userId = userId,
|
||||
|
|
@ -116,19 +117,16 @@ class RecommendedTvShowViewModel
|
|||
enableUserData = true,
|
||||
enableRewatching = preferences.homePagePreferences.enableRewatchingNextUp,
|
||||
)
|
||||
|
||||
GetNextUpRequestHandler
|
||||
.execute(api, nextUpRequest)
|
||||
.toBaseItems(api, true)
|
||||
}
|
||||
|
||||
val resumeItems = resumeItemsDeferred.await()
|
||||
val nextUpItems = nextUpItemsDeferred.await()
|
||||
|
||||
if (combineNextUp) {
|
||||
val combined =
|
||||
lastestNextUpService.buildCombined(
|
||||
resumeItems,
|
||||
nextUpItems,
|
||||
)
|
||||
val combined = lastestNextUpService.buildCombined(resumeItems, nextUpItems)
|
||||
update(
|
||||
R.string.continue_watching,
|
||||
HomeRowLoadingState.Success(
|
||||
|
|
@ -138,10 +136,7 @@ class RecommendedTvShowViewModel
|
|||
)
|
||||
update(
|
||||
R.string.next_up,
|
||||
HomeRowLoadingState.Success(
|
||||
context.getString(R.string.next_up),
|
||||
listOf(),
|
||||
),
|
||||
HomeRowLoadingState.Success(context.getString(R.string.next_up), listOf()),
|
||||
)
|
||||
} else {
|
||||
update(
|
||||
|
|
@ -153,10 +148,7 @@ class RecommendedTvShowViewModel
|
|||
)
|
||||
update(
|
||||
R.string.next_up,
|
||||
HomeRowLoadingState.Success(
|
||||
context.getString(R.string.next_up),
|
||||
nextUpItems,
|
||||
),
|
||||
HomeRowLoadingState.Success(context.getString(R.string.next_up), nextUpItems),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -171,7 +163,7 @@ class RecommendedTvShowViewModel
|
|||
}
|
||||
|
||||
update(R.string.recently_released) {
|
||||
val recentlyReleasedRequest =
|
||||
val request =
|
||||
GetItemsRequest(
|
||||
parentId = parentId,
|
||||
fields = SlimItemFields,
|
||||
|
|
@ -184,14 +176,11 @@ class RecommendedTvShowViewModel
|
|||
limit = itemsPerRow,
|
||||
enableTotalRecordCount = false,
|
||||
)
|
||||
|
||||
GetItemsRequestHandler
|
||||
.execute(api, recentlyReleasedRequest)
|
||||
.toBaseItems(api, true)
|
||||
GetItemsRequestHandler.execute(api, request).toBaseItems(api, true)
|
||||
}
|
||||
|
||||
update(R.string.recently_added) {
|
||||
val recentlyAddedRequest =
|
||||
val request =
|
||||
GetItemsRequest(
|
||||
parentId = parentId,
|
||||
fields = SlimItemFields,
|
||||
|
|
@ -204,29 +193,11 @@ class RecommendedTvShowViewModel
|
|||
limit = itemsPerRow,
|
||||
enableTotalRecordCount = false,
|
||||
)
|
||||
|
||||
GetItemsRequestHandler
|
||||
.execute(api, recentlyAddedRequest)
|
||||
.toBaseItems(api, true)
|
||||
}
|
||||
|
||||
update(R.string.suggestions) {
|
||||
val suggestionsRequest =
|
||||
GetSuggestionsRequest(
|
||||
userId = serverRepository.currentUser.value?.id,
|
||||
type = listOf(BaseItemKind.SERIES),
|
||||
startIndex = 0,
|
||||
limit = itemsPerRow,
|
||||
enableTotalRecordCount = false,
|
||||
)
|
||||
|
||||
GetSuggestionsRequestHandler
|
||||
.execute(api, suggestionsRequest)
|
||||
.toBaseItems(api, true)
|
||||
GetItemsRequestHandler.execute(api, request).toBaseItems(api, true)
|
||||
}
|
||||
|
||||
update(R.string.top_unwatched) {
|
||||
val unwatchedTopRatedRequest =
|
||||
val request =
|
||||
GetItemsRequest(
|
||||
parentId = parentId,
|
||||
fields = SlimItemFields,
|
||||
|
|
@ -240,9 +211,48 @@ class RecommendedTvShowViewModel
|
|||
limit = itemsPerRow,
|
||||
enableTotalRecordCount = false,
|
||||
)
|
||||
GetItemsRequestHandler
|
||||
.execute(api, unwatchedTopRatedRequest)
|
||||
.toBaseItems(api, true)
|
||||
GetItemsRequestHandler.execute(api, request).toBaseItems(api, true)
|
||||
}
|
||||
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
suggestionService
|
||||
.getSuggestionsFlow(parentId, BaseItemKind.SERIES)
|
||||
.collect { resource ->
|
||||
val state =
|
||||
when (resource) {
|
||||
is SuggestionsResource.Loading -> {
|
||||
HomeRowLoadingState.Loading(
|
||||
context.getString(R.string.suggestions),
|
||||
)
|
||||
}
|
||||
|
||||
is SuggestionsResource.Success -> {
|
||||
HomeRowLoadingState.Success(
|
||||
context.getString(R.string.suggestions),
|
||||
resource.items,
|
||||
)
|
||||
}
|
||||
|
||||
is SuggestionsResource.Empty -> {
|
||||
HomeRowLoadingState.Success(
|
||||
context.getString(R.string.suggestions),
|
||||
emptyList(),
|
||||
)
|
||||
}
|
||||
}
|
||||
update(R.string.suggestions, state)
|
||||
}
|
||||
} catch (ex: Exception) {
|
||||
Timber.e(ex, "Failed to fetch suggestions")
|
||||
update(
|
||||
R.string.suggestions,
|
||||
HomeRowLoadingState.Error(
|
||||
title = context.getString(R.string.suggestions),
|
||||
exception = ex,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading.value == LoadingState.Loading || loading.value == LoadingState.Pending) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue